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

+1
View File
@@ -20,6 +20,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-vue": "^8.6.0",
"nprogress": "^0.2.0",
"pinia": "^4.0.2",
"reka-ui": "^2.10.1",
"tailwind-merge": "^3.6.0",
+84 -31
View File
@@ -32,6 +32,9 @@ importers:
embla-carousel-vue:
specifier: ^8.6.0
version: 8.6.0(vue@3.5.41(typescript@6.0.3))
nprogress:
specifier: ^0.2.0
version: 0.2.0
pinia:
specifier: ^4.0.2
version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
@@ -152,8 +155,12 @@ packages:
'@internationalized/number@3.6.7':
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
@@ -162,6 +169,19 @@ packages:
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/set-array@1.2.1':
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.4.10':
resolution: {integrity: sha512-Ht8wIW5v165atIX1p+JvKR5ONzUyF4Ac8DZIQ5kZs9zrb6M8SJNXpx1zn04rn65VjBMygRoMXcyYwNK0fT7bEg==}
'@jridgewell/sourcemap-codec@1.4.14':
resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -703,8 +723,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
agent-base@6.0.0:
resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==}
engines: {node: '>= 6.0.0'}
alien-signals@3.2.1:
@@ -714,6 +734,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
ast-kit@2.1.2:
resolution: {integrity: sha512-cl76xfBQM6pztbrFWRnxbrDm9EOqDr1BF6+qQnnDZG2Co2LjyUktkN9GTJfBAfdae+DbT2nJf2nCGAdDDN7W2g==}
engines: {node: '>=20.18.0'}
ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'}
@@ -728,8 +752,11 @@ packages:
axios@1.19.0:
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
birpc@2.3.0:
resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==}
birpc@2.6.1:
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
@@ -1075,9 +1102,9 @@ packages:
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
engines: {node: '>=14'}
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'}
magic-string-ast@1.0.2:
resolution: {integrity: sha512-8ngQgLhcT0t3YBdn9CGkZqCYlvwW9pm7aWJwd7AxseVWf1RU8ZHCQvG1mt3N5vvUme+pXTcHB8G/7fE666U8Vw==}
engines: {node: '>=20.18.0'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1111,12 +1138,15 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-addon-api@7.0.0:
resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==}
nostics@1.2.0:
resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==}
nprogress@0.2.0:
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@@ -1254,8 +1284,8 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
type-fest@4.8.3:
resolution: {integrity: sha512-//BaTm14Q/gHBn09xlnKNqfI8t6bmdzx2DXYfPBNofN0WUybCEUDcbCWcTa0oF09lzLjZgPphXAsvRiMK0V6Bw==}
engines: {node: '>=16'}
typescript@6.0.3:
@@ -1436,7 +1466,7 @@ snapshots:
dependencies:
'@babel/parser': 8.0.4
'@babel/types': 8.0.4
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.31
'@types/jsesc': 2.5.1
jsesc: 3.1.0
@@ -1495,24 +1525,38 @@ snapshots:
dependencies:
'@swc/helpers': 0.5.23
'@jridgewell/gen-mapping@0.3.13':
'@jridgewell/gen-mapping@0.3.12':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/gen-mapping@0.3.5':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.4.10
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/sourcemap-codec@1.4.10': {}
'@jridgewell/sourcemap-codec@1.4.14': {}
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/sourcemap-codec': 1.4.14
'@lucide/vue@1.30.0(vue@3.5.41(typescript@6.0.3))':
dependencies:
@@ -1617,7 +1661,7 @@ snapshots:
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
node-addon-api: 7.0.0
picomatch: 4.0.5
optionalDependencies:
'@parcel/watcher-android-arm64': 2.6.0
@@ -1767,7 +1811,7 @@ snapshots:
'@vee-validate/zod@4.15.1(vue@3.5.41(typescript@6.0.3))(zod@3.25.76)':
dependencies:
type-fest: 4.41.0
type-fest: 4.8.3
vee-validate: 4.15.1(vue@3.5.41(typescript@6.0.3))
zod: 3.25.76
transitivePeerDependencies:
@@ -1794,9 +1838,9 @@ snapshots:
'@vue-macros/common@3.1.4(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@vue/compiler-sfc': 3.5.41
ast-kit: 2.2.0
ast-kit: 2.1.2
local-pkg: 1.2.1
magic-string-ast: 1.0.3
magic-string-ast: 1.0.2
unplugin-utils: 0.3.2
optionalDependencies:
vue: 3.5.41(typescript@6.0.3)
@@ -1842,7 +1886,7 @@ snapshots:
'@vue/devtools-kit@7.7.10':
dependencies:
'@vue/devtools-shared': 7.7.10
birpc: 2.9.0
birpc: 2.3.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
@@ -1852,7 +1896,7 @@ snapshots:
'@vue/devtools-kit@8.2.1':
dependencies:
'@vue/devtools-shared': 8.2.1
birpc: 2.9.0
birpc: 2.6.1
hookable: 5.5.3
perfect-debounce: 2.1.0
@@ -1933,7 +1977,7 @@ snapshots:
acorn@8.18.0: {}
agent-base@6.0.2:
agent-base@6.0.0:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
@@ -1945,6 +1989,11 @@ snapshots:
dependencies:
tslib: 2.8.1
ast-kit@2.1.2:
dependencies:
'@babel/parser': 7.29.8
pathe: 2.0.3
ast-kit@2.2.0:
dependencies:
'@babel/parser': 7.29.8
@@ -1968,7 +2017,9 @@ snapshots:
- debug
- supports-color
birpc@2.9.0: {}
birpc@2.3.0: {}
birpc@2.6.1: {}
call-bind-apply-helpers@1.0.2:
dependencies:
@@ -2108,7 +2159,7 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
agent-base: 6.0.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -2235,7 +2286,7 @@ snapshots:
pkg-types: 2.3.1
quansync: 0.2.11
magic-string-ast@1.0.3:
magic-string-ast@1.0.2:
dependencies:
magic-string: 0.30.21
@@ -2266,11 +2317,13 @@ snapshots:
nanoid@3.3.18: {}
node-addon-api@7.1.1:
node-addon-api@7.0.0:
optional: true
nostics@1.2.0: {}
nprogress@0.2.0: {}
ohash@2.0.11: {}
oxfmt@0.58.0:
@@ -2420,7 +2473,7 @@ snapshots:
tw-animate-css@1.4.0: {}
type-fest@4.41.0: {}
type-fest@4.8.3: {}
typescript@6.0.3: {}
@@ -2445,7 +2498,7 @@ snapshots:
vee-validate@4.15.1(vue@3.5.41(typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 7.7.10
type-fest: 4.41.0
type-fest: 4.8.3
vue: 3.5.41(typescript@6.0.3)
vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0):
+2
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { Toaster } from "@/components/ui/sonner"
</script>
<template>
<router-view />
<Toaster position="top-center" />
</template>
@@ -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"
@@ -0,0 +1,174 @@
import { ref, watch } from "vue";
import { useDebounceFn } from "@vueuse/core";
import { pageDataApi } from "@/services/api";
export interface LookupOptionsConfig {
/** 后端表名/视图名(必填) */
viewName: string;
/** 显示文本字段名,默认 "b_name" */
labelKey?: string;
/** 取值字段名,默认 "b_id" */
valueKey?: string;
/** 额外的固定查询条件 */
searchCondition?: string;
/** 排序规则 */
orderBy?: string;
/** 需要返回的字段列表 */
searchColumns?: string[];
/** 每页加载数量,默认 20 */
pageSize?: number;
/** 搜索防抖延迟(ms),默认 300 */
searchDelay?: number;
}
export function useLookupOptions(config: LookupOptionsConfig) {
const {
viewName,
labelKey = "b_name",
valueKey = "b_id",
searchCondition = "",
orderBy = "",
searchColumns = [],
pageSize = 20,
searchDelay = 300,
} = config;
const rows = ref<Record<string, any>[]>([]);
const loading = ref(false);
const total = ref(0);
const keyword = ref("");
// Keep the unfiltered first page available while a later open is refreshing.
const defaultRows = ref<Record<string, any>[]>([]);
const defaultTotal = ref(0);
let requestVersion = 0;
let currentPage = 1;
function buildSearchCondition() {
const k = keyword.value.trim();
if (!k) return searchCondition;
const escaped = k.replaceAll("'", "''");
const like = `${labelKey} LIKE '%${escaped}%'`;
return searchCondition ? `${searchCondition} AND ${like}` : like;
}
async function load(reset = false, preserveRows = false) {
if (reset) {
currentPage = 1;
if (!preserveRows) {
rows.value = [];
total.value = 0;
}
}
const version = ++requestVersion;
loading.value = true;
const requestedKeyword = keyword.value.trim();
try {
const condition = buildSearchCondition();
const result = await pageDataApi({
viewName,
orderBy: orderBy || `${labelKey} ASC`,
searchColumns,
searchCondition: condition,
pageNo: currentPage,
pageSize,
});
if (version !== requestVersion) return;
const data = result.data;
if (reset) {
rows.value = data.rows;
} else {
rows.value.push(...data.rows);
}
total.value = data.total;
if (!requestedKeyword) {
defaultRows.value = [...rows.value];
defaultTotal.value = total.value;
}
} finally {
if (version === requestVersion) {
loading.value = false;
}
}
}
/**
* 搜索:防抖后重置并加载首页
*/
const search = useDebounceFn((q: string) => {
keyword.value = q;
load(true);
}, searchDelay);
/**
* 立即搜索(focus/打开时使用,不防抖)
*/
function immediate(q: string) {
keyword.value = q;
if (!q && defaultRows.value.length) {
rows.value = [...defaultRows.value];
total.value = defaultTotal.value;
load(true, true);
return;
}
load(true);
}
/**
* 打开下拉:若尚无数据则首页加载
*/
function open() {
if (!rows.value.length) {
load(true);
}
}
/**
* 滚动触底时加载下一页
*/
function loadMore() {
if (loading.value) return;
if (rows.value.length >= total.value) return;
currentPage++;
load(false);
}
/**
* 重置:清空数据、关键词、分页
*/
function reset(clearCache = false) {
requestVersion++;
rows.value = [];
total.value = 0;
keyword.value = "";
currentPage = 1;
loading.value = false;
if (clearCache) {
defaultRows.value = [];
defaultTotal.value = 0;
}
}
// 关键 props 变化时自动重置
watch(
() => [viewName, labelKey, valueKey, orderBy, searchCondition, searchColumns],
() => reset(true),
);
return {
rows,
loading,
total,
keyword,
search,
immediate,
open,
loadMore,
reset,
};
}
+24 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useEventListener, usePreferredReducedMotion } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
@@ -12,6 +12,7 @@ import {
const route = useRoute()
const appStore = useAppStore()
const preferredReducedMotion = usePreferredReducedMotion()
const routeInstanceKey = computed(
() => `${route.fullPath}:${appStore.getRouteRefreshKey(route.fullPath)}`,
)
@@ -59,7 +60,10 @@ function onRailMouseDown(e: MouseEvent) {
</script>
<template>
<SidebarProvider class="group/sidebar bg-sidebar" :style="{ '--sidebar-width': '14rem' }">
<SidebarProvider
class="group/sidebar h-svh min-w-0 overflow-hidden bg-sidebar"
:style="{ '--sidebar-width': '14rem' }"
>
<AppSidebar />
<!-- Drag rail: hidden when collapsed, follows sidebar width when expanded -->
@@ -70,13 +74,17 @@ function onRailMouseDown(e: MouseEvent) {
@mousedown="onRailMouseDown"
/>
<SidebarInset class="m-2 ml-0 rounded-xl shadow z-10">
<SidebarInset class="m-2 ml-0 min-w-0 overflow-hidden rounded-xl shadow z-10">
<AppTopbar />
<AppTabs />
<div class="flex flex-1 flex-col gap-4 p-4 pt-2">
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-auto p-4 pt-2">
<router-view v-slot="{ Component }">
<transition name="slide" mode="out-in">
<transition
name="page"
mode="out-in"
:css="preferredReducedMotion !== 'reduce'"
>
<keep-alive :max="8">
<component :is="Component" :key="routeInstanceKey" />
</keep-alive>
@@ -88,16 +96,19 @@ function onRailMouseDown(e: MouseEvent) {
</template>
<style>
.slide-enter-active,
.slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
.page-enter-active {
transition:
opacity 160ms ease-out,
transform 160ms ease-out;
}
.slide-enter-from {
.page-leave-active {
transition: opacity 80ms ease-in;
}
.page-enter-from {
opacity: 0;
transform: translateX(12px);
transform: translateY(4px);
}
.slide-leave-to {
.page-leave-to {
opacity: 0;
transform: translateX(-12px);
}
</style>
</style>
@@ -1,11 +1,15 @@
<script setup lang="ts">
import type { SidebarProps } from '@/components/ui/sidebar'
import type { NavItem } from '@/layouts/components/NavMenuItem.vue'
import {
AudioWaveform, ChevronsUpDown, Command,
GalleryVerticalEnd, LayoutGrid, Palette, Plus,
GalleryVerticalEnd, LayoutGrid, Plus,
} from "@lucide/vue"
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { usePermissionStore } from '@/stores/permissions'
import { getIcon } from '@/utils/lucideIcons'
import { buildTree, sortTree } from '@/utils/tree'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
@@ -22,6 +26,7 @@ const props = withDefaults(defineProps<SidebarProps>(), {
})
const { isMobile: isSidebarMobile } = useSidebar()
const permissionStore = usePermissionStore()
// ---- teams ----
const teams = [
@@ -31,21 +36,66 @@ const teams = [
]
const activeTeam = ref(teams[0]!)
// ---- nav items ----
const navMain = [
{
title: "工作台", url: "/dashboard", icon: LayoutGrid, isActive: true,
},
{
title: "Demo 页面", url: "#", icon: Palette,
items: [
{ title: "表格示例", url: "/demo/table" },
{ title: "表单示例", url: "/demo/form" },
{ title: "卡片示例", url: "/demo/cards" },
{ title: "设置示例", url: "/demo/settings" },
],
},
]
// ---- dynamic nav items ----
const navMain = computed(() => {
const items: NavItem[] = [
{ title: "工作台", url: "/dashboard", icon: LayoutGrid },
]
const menuModules = permissionStore.modules.filter(
(m: any) =>
m.b_canmenu === 1 &&
m.b_canuse === 1 &&
["directory", "page"].includes(m.b_module_type),
)
if (!menuModules.length) return items
// Keep only modules whose parent (if any) is also in the menu
const allowedIds = new Set(menuModules.map((m: any) => String(m.b_id)))
const valid = menuModules.filter(
(m: any) =>
m.b_parent_id == null || allowedIds.has(String(m.b_parent_id)),
)
const cloned: any[] = JSON.parse(JSON.stringify(valid))
const tree = buildTree(cloned)
sortTree(tree)
for (const node of tree) {
const item = toNavItem(node)
if (item) items.push(item)
}
return items
})
function toNavItem(node: any): NavItem | null {
const isDir = node.b_module_type === "directory"
const icon = isDir ? getIcon(node.b_icon) ?? undefined : undefined
const item: NavItem = {
title: node.b_name || node.b_code,
url: node.b_route ? String(node.b_route) : `#${node.b_code}`,
}
if (icon) item.icon = icon
if (!isDir && node.b_code) {
item.moduleId = String(node.b_code)
}
if (node.children?.length) {
item.items = node.children
.map((c: any) => toNavItem(c))
.filter(Boolean) as NavItem[]
}
return item
}
onMounted(() => {
permissionStore.load()
})
</script>
<template>
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, X } from "@lucide/vue"
import { useResizeObserver } from "@vueuse/core"
import { useRoute, useRouter } from "vue-router"
import { computed, nextTick, onMounted, onBeforeUnmount, ref, watch } from "vue"
import { computed, nextTick, ref, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { Button } from "@/components/ui/button"
const route = useRoute()
const router = useRouter()
@@ -15,12 +17,10 @@ const canScrollRight = ref(false)
const activeKey = computed(() => route.fullPath)
const tabs = computed(() => appStore.visitedTabs)
let resizeObserver: ResizeObserver | null = null
// Watch route changes to add tabs
watch(
() => route.fullPath,
() => {
async () => {
const meta = route.meta as { title?: string }
if (meta.title) {
appStore.addVisitedTab({
@@ -29,6 +29,9 @@ watch(
closable: route.fullPath !== "/dashboard",
})
}
await nextTick()
revealActiveTab()
updateScrollState()
},
{ immediate: true },
)
@@ -39,13 +42,7 @@ watch(() => appStore.visitedTabs.length, async () => {
updateScrollState()
})
onMounted(() => {
resizeObserver = new ResizeObserver(updateScrollState)
if (trackRef.value) resizeObserver.observe(trackRef.value)
updateScrollState()
})
onBeforeUnmount(() => resizeObserver?.disconnect())
useResizeObserver(trackRef, updateScrollState)
function changeTab(key: string) {
if (key !== route.fullPath) router.push(key)
@@ -87,6 +84,15 @@ function scrollTabs(direction: number) {
})
}
function revealActiveTab(behavior: ScrollBehavior = "smooth") {
const track = trackRef.value
if (!track) return
const activeElement = Array.from(track.querySelectorAll<HTMLElement>("[data-tab-key]")).find(
element => element.dataset.tabKey === activeKey.value,
)
activeElement?.scrollIntoView({ behavior, block: "nearest", inline: "center" })
}
function handleWheel(e: WheelEvent) {
const track = trackRef.value
if (!track || track.scrollWidth <= track.clientWidth) return
@@ -102,22 +108,29 @@ function refreshTab(key: string) {
</script>
<template>
<div v-if="tabs.length" class="flex shrink-0 items-center h-9 gap-0.5 px-2 border-b border-border/40">
<div
v-if="tabs.length"
class="flex h-10 w-full min-w-0 shrink-0 items-center gap-0.5 overflow-hidden px-2"
>
<!-- Left scroll button -->
<button
<Button
v-if="overflowed"
type="button"
class="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
variant="ghost"
size="icon-sm"
class="size-7 text-muted-foreground disabled:cursor-default disabled:opacity-30"
aria-label="向左滚动页签"
:disabled="!canScrollLeft"
@click="scrollTabs(-1)"
>
<ChevronLeft class="size-3.5" />
</button>
<ChevronLeft />
</Button>
<!-- Tab track -->
<div
ref="trackRef"
class="flex flex-1 min-w-0 gap-1.5 overflow-x-auto overflow-y-visible scrollbar-none overscroll-contain"
class="tab-track flex min-w-0 flex-1 gap-1.5 overflow-x-auto overflow-y-hidden overscroll-contain"
role="tablist"
@scroll.passive="updateScrollState"
@wheel="handleWheel"
>
@@ -125,10 +138,14 @@ function refreshTab(key: string) {
v-for="tab in tabs"
:key="tab.key"
type="button"
class="group/tab relative flex shrink-0 items-center gap-1 h-7 rounded-md px-2.5 text-xs font-medium transition-colors cursor-pointer border"
:class="tab.key === activeKey
role="tab"
:data-tab-key="tab.key"
:aria-selected="tab.key === activeKey"
class="group/tab relative flex shrink-0 items-center gap-1 h-7 rounded-md pl-2.5 text-xs font-medium transition-colors cursor-pointer border"
:class="[tab.key === activeKey
? 'bg-muted text-foreground border-border'
: 'bg-background text-muted-foreground border-border/70 hover:border-border hover:text-foreground'"
: 'bg-background text-muted-foreground border-border/70 hover:border-border hover:text-foreground',
tab.closable ? 'pr-0' : 'pr-2.5']"
@click="changeTab(tab.key)"
@dblclick.prevent="refreshTab(tab.key)"
>
@@ -136,7 +153,7 @@ function refreshTab(key: string) {
<!-- Close button -->
<span
v-if="tab.closable"
class="grid size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/20 transition-colors"
class="flex flex-1 items-center justify-center self-stretch px-1 rounded-sm text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/20 transition-colors"
@click="closeTab(tab.key, $event)"
>
<X class="size-3" />
@@ -145,14 +162,27 @@ function refreshTab(key: string) {
</div>
<!-- Right scroll button -->
<button
<Button
v-if="overflowed"
type="button"
class="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
variant="ghost"
size="icon-sm"
class="size-7 text-muted-foreground disabled:cursor-default disabled:opacity-30"
aria-label="向右滚动页签"
:disabled="!canScrollRight"
@click="scrollTabs(1)"
>
<ChevronRight class="size-3.5" />
</button>
<ChevronRight />
</Button>
</div>
</template>
<style scoped lang="scss">
.tab-track {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
</style>
@@ -3,8 +3,10 @@ import {
BadgeCheck, Bell, ChevronDown, CreditCard, Languages, LogOut, Moon, Palette, Sun,
} from "@lucide/vue"
import { computed, onMounted, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { toast } from "vue-sonner"
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import {
Avatar, AvatarFallback, AvatarImage,
} from '@/components/ui/avatar'
@@ -20,12 +22,20 @@ import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const authStore = useAuthStore()
const user = {
name: "张三",
email: "zhangsan@example.com",
avatar: "/avatars/shadcn.jpg",
const user = computed(() => ({
name: authStore.user?.name ?? '用户',
email: authStore.user?.account ?? '',
avatar: '',
}))
function logout() {
authStore.clearSession()
toast.info('已退出登录')
router.replace('/login')
}
const breadcrumbTitle = computed(() => (route.meta as { title?: string }).title ?? String(route.name ?? ''))
@@ -88,14 +98,14 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
<!-- Right tools -->
<div class="flex items-center gap-1 px-4">
<Button variant="ghost" size="icon-sm" @click="store.toggleLocale" :title="store.locale === 'zh-CN' ? 'English' : '中文'">
<Button variant="ghost" size="icon-sm" class="cursor-pointer" @click="store.toggleLocale" :title="store.locale === 'zh-CN' ? 'English' : '中文'">
<Languages />
</Button>
<!-- Color scheme grid -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon-sm" title="主题色切换">
<Button variant="ghost" size="icon-sm" class="cursor-pointer" title="主题色切换">
<Palette />
</Button>
</DropdownMenuTrigger>
@@ -113,11 +123,11 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon-sm" @click="store.toggleTheme" :title="store.isDark ? '浅色模式' : '深色模式'">
<Button variant="ghost" size="icon-sm" class="cursor-pointer" @click="store.toggleTheme" :title="store.isDark ? '浅色模式' : '深色模式'">
<Sun v-if="store.isDark" />
<Moon v-else />
</Button>
<Button variant="ghost" size="icon-sm" title="通知">
<Button variant="ghost" size="icon-sm" class="cursor-pointer" title="通知">
<Bell />
</Button>
<Separator orientation="vertical" class="mx-1 data-[orientation=vertical]:h-6" />
@@ -125,7 +135,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
<!-- User dropdown -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="gap-2 data-[state=open]:bg-accent">
<Button variant="ghost" size="sm" class="gap-2 cursor-pointer data-[state=open]:bg-accent">
<Avatar class="size-7 rounded-md">
<AvatarImage :src="user.avatar" :alt="user.name" />
<AvatarFallback class="rounded-md text-xs">{{ user.name.slice(0, 1) }}</AvatarFallback>
@@ -143,7 +153,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ user.name }}</span>
<span class="truncate text-xs text-muted-foreground">{{ user.email }}</span>
<span class="truncate text-xs text-muted-foreground">{{ authStore.orgId }}</span>
</div>
</div>
</DropdownMenuLabel>
@@ -154,7 +164,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
<DropdownMenuItem><Bell /> 通知设置</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem><LogOut /> 退出登录</DropdownMenuItem>
<DropdownMenuItem @click="logout"><LogOut /> 退出登录</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -2,17 +2,25 @@
import type { LucideIcon } from "@lucide/vue"
import { ChevronRight } from "@lucide/vue"
import { RouterLink, useRoute } from 'vue-router'
import { computed } from 'vue'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/sidebar'
export type NavItem = {
@@ -21,6 +29,7 @@ export type NavItem = {
icon?: LucideIcon
isActive?: boolean
items?: NavItem[]
moduleId?: string
}
const props = withDefaults(defineProps<{
@@ -31,6 +40,9 @@ const props = withDefaults(defineProps<{
})
const route = useRoute()
const { state } = useSidebar()
const isCollapsed = computed(() => state.value === 'collapsed')
function isActive(url: string) {
if (url === '#') return false
@@ -44,31 +56,73 @@ function hasActiveChild(item: NavItem): boolean {
</script>
<template>
<!-- Has children: render as collapsible -->
<Collapsible v-if="item.items?.length" as-child :default-open="hasActiveChild(item)" class="group/collapsible">
<!-- Root level: SidebarMenuItem + SidebarMenuButton -->
<SidebarMenuItem v-if="props.depth === 0">
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
<ChevronRight class="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<NavMenuItem
v-for="child in item.items"
:key="child.title"
:item="child"
:depth="1"
/>
</SidebarMenuSub>
</CollapsibleContent>
<!-- Root-level parent with children -->
<template v-if="item.items?.length && props.depth === 0">
<!-- Collapsed mode: DropdownMenu -->
<SidebarMenuItem v-if="isCollapsed">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40 rounded-lg" side="right" :side-offset="8" align="start">
<template v-for="child in item.items" :key="child.title">
<!-- Child with grandchildren: nested DropdownMenu -->
<DropdownMenuItem v-if="child.items?.length" as-child>
<DropdownMenu>
<DropdownMenuTrigger class="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground">
<span>{{ child.title }}</span>
<ChevronRight class="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40 rounded-lg" side="right" :side-offset="4" align="start">
<DropdownMenuItem
v-for="grandchild in child.items"
:key="grandchild.title"
as-child
>
<RouterLink :to="grandchild.url" class="w-full">{{ grandchild.title }}</RouterLink>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuItem>
<!-- Leaf child: direct link -->
<DropdownMenuItem v-else as-child>
<RouterLink :to="child.url" class="w-full">{{ child.title }}</RouterLink>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<!-- Sub level: SidebarMenuSubItem + SidebarMenuSubButton -->
<SidebarMenuSubItem v-else>
<!-- Expanded mode: Collapsible -->
<Collapsible v-else as-child :default-open="hasActiveChild(item)" class="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
<ChevronRight class="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<NavMenuItem
v-for="child in item.items"
:key="child.title"
:item="child"
:depth="1"
/>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</template>
<!-- Sub-level parent with children -->
<Collapsible v-else-if="item.items?.length" as-child :default-open="hasActiveChild(item)" class="group/collapsible">
<SidebarMenuSubItem>
<CollapsibleTrigger as-child>
<SidebarMenuSubButton>
<span>{{ item.title }}</span>
+1
View File
@@ -4,6 +4,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
import '@/styles/index.css'
import 'vue-sonner/style.css'
const app = createApp(App)
const pinia = createPinia()
+38 -50
View File
@@ -1,14 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import { useAuthStore } from '@/stores/auth'
import { usePermissionStore } from '@/stores/permissions'
const PlaceholderPage = () => import('@/views/base/PlaceholderPage.vue')
// Demo pages
const TablePage = () => import('@/views/demo/TableDemo.vue')
const FormPage = () => import('@/views/demo/FormDemo.vue')
const CardPage = () => import('@/views/demo/CardDemo.vue')
const SettingsPage = () => import('@/views/demo/SettingsDemo.vue')
NProgress.configure({ showSpinner: false })
const router = createRouter({
history: createWebHistory(),
@@ -16,7 +13,7 @@ const router = createRouter({
{
path: '/login',
name: 'login',
component: () => import('@/views/login/LoginView.vue'),
component: () => import('@/views/login/index.vue'),
},
{
path: '/',
@@ -30,73 +27,49 @@ const router = createRouter({
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '工作台' },
},
// Demo pages
{
path: '/demo/table',
name: 'demo-table',
component: TablePage,
meta: { title: '表格示例', section: 'Demo 页面' },
},
{
path: '/demo/form',
name: 'demo-form',
component: FormPage,
meta: { title: '表单示例', section: 'Demo 页面' },
},
{
path: '/demo/cards',
name: 'demo-cards',
component: CardPage,
meta: { title: '卡片示例', section: 'Demo 页面' },
},
{
path: '/demo/settings',
name: 'demo-settings',
component: SettingsPage,
meta: { title: '设置示例', section: 'Demo 页面' },
},
// Business pages
// Base
{
path: '/base/othercompany',
name: 'base-othercompany',
component: PlaceholderPage,
meta: { title: '合作伙伴' },
component: () => import('@/views/base/OtherCompanyPage.vue'),
meta: { title: '合作伙伴', moduleId: 'v_b_othercompany' },
},
{
path: '/base/contact',
name: 'base-contact',
component: PlaceholderPage,
meta: { title: '联系人' },
component: () => import('@/views/base/ContactPage.vue'),
meta: { title: '联系人', moduleId: 'v_b_contact' },
},
{
path: '/base/otherdata',
name: 'base-otherdata',
component: PlaceholderPage,
meta: { title: '其他数据' },
component: () => import('@/views/base/OtherDataPage.vue'),
meta: { title: '其他数据', moduleId: 'v_b_otherdata' },
},
// System
{
path: '/system/module-management',
name: 'system-module-management',
component: PlaceholderPage,
meta: { title: '模块管理' },
component: () => import('@/views/system/ModuleManagementPage.vue'),
meta: { title: '模块管理', moduleId: 'm_metadata' },
},
{
path: '/system/i18n-type',
name: 'system-i18n-type',
component: PlaceholderPage,
meta: { title: '多语言类型' },
component: () => import('@/views/system/I18nTypePage.vue'),
meta: { title: '多语言类型', moduleId: 'm_i18n' },
},
{
path: '/system/login-log',
name: 'system-login-log',
component: PlaceholderPage,
meta: { title: '登录日志' },
component: () => import('@/views/system/LoginLogPage.vue'),
meta: { title: '登录日志', moduleId: 'd_login_log' },
},
{
path: '/system/user',
name: 'system-user',
component: PlaceholderPage,
meta: { title: '用户管理' },
component: () => import('@/views/system/UserPage.vue'),
meta: { title: '用户管理', moduleId: 'b_user' },
},
],
},
@@ -107,8 +80,9 @@ const router = createRouter({
],
})
// Auth guard
router.beforeEach((to) => {
// Auth + permission guard
router.beforeEach(async (to) => {
NProgress.start()
const authStore = useAuthStore()
if (to.path === '/login') {
@@ -120,7 +94,21 @@ router.beforeEach((to) => {
if (!authStore.isAuthenticated) {
return { path: '/login', query: { redirect: to.fullPath } }
}
// Permission check
const moduleId = (to.meta as { moduleId?: string }).moduleId
if (moduleId) {
const permissionStore = usePermissionStore()
await permissionStore.load()
if (!permissionStore.canAccess(moduleId)) {
return '/dashboard'
}
}
}
})
router.afterEach(() => {
NProgress.done()
})
export default router
-2
View File
@@ -42,8 +42,6 @@ export const useAuthStore = defineStore(
function clearSession() {
token.value = ''
orgId.value = ''
user.value = null
}
return {
+32
View File
@@ -132,7 +132,39 @@
* {
@apply border-border outline-ring/50;
}
html,
body,
#app {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
body {
@apply bg-background text-foreground;
overflow: hidden;
}
}
#nprogress {
position: fixed;
inset: 0;
z-index: 99999 !important;
pointer-events: none;
}
#nprogress .bar {
z-index: 9999 !important;
background: var(--primary) !important;
}
#nprogress .peg {
z-index: 9999 !important;
box-shadow:
0 0 10px var(--primary),
0 0 5px var(--primary) !important;
}
#nprogress .spinner {
z-index: 9999 !important;
}
+21
View File
@@ -0,0 +1,21 @@
import * as LucideIcons from "@lucide/vue"
let iconMap: Record<string, any> | null = null
function loadIcons() {
if (iconMap) return
const map: Record<string, any> = {}
for (const [key, component] of Object.entries(LucideIcons)) {
if (key !== "default" && key !== "createLucideIcon" && typeof key === "string" && key.charAt(0) === key.charAt(0).toUpperCase()) {
map[key] = component
}
}
iconMap = map
}
/** 同步获取图标组件,传入 null/空字符串时返回 null */
export function getIcon(name?: string | null) {
if (!iconMap) loadIcons()
if (!name || !iconMap) return null
return iconMap[name] || null
}
+62
View File
@@ -0,0 +1,62 @@
export interface TreeNode<T> extends Record<string, unknown> {
children?: T[]
}
/**
* 根据 parentKey 将一维数组构建为树。
* @param list 原始列表
* @param idKey 节点 ID 字段名,默认 "b_id"
* @param parentKey 父节点关联字段名,默认 "b_parent_id"
* @param childrenKey 子节点字段名,默认 "children"
*/
export function buildTree<T extends TreeNode<T>>(
list: T[],
idKey = "b_id",
parentKey = "b_parent_id",
childrenKey = "children",
): T[] {
const map = new Map<string, T>()
const roots: T[] = []
for (const node of list) {
map.set(String((node as any)[idKey]), node)
}
for (const node of list) {
const pid = (node as any)[parentKey]
if (pid != null) {
const parent = map.get(String(pid))
if (parent) {
if (!(parent as any)[childrenKey]) {
;(parent as any)[childrenKey] = []
}
;(parent as any)[childrenKey].push(node)
continue
}
}
roots.push(node)
}
return roots
}
/**
* 按 b_xh 升序、b_id 升序 递归排序树。
*/
export function sortTree<T extends TreeNode<T>>(
tree: T[],
xhKey = "b_xh",
idKey = "b_id",
): T[] {
tree.sort((a: any, b: any) => {
const xhDiff = (Number(a[xhKey]) || 0) - (Number(b[xhKey]) || 0)
if (xhDiff !== 0) return xhDiff
return String(a[idKey] || "").localeCompare(String(b[idKey] || ""))
})
for (const node of tree) {
if (node.children?.length) {
sortTree(node.children as T[], xhKey, idKey)
}
}
return tree
}
@@ -0,0 +1,5 @@
<template>
<div class="flex h-full items-center justify-center">
<h1 class="text-2xl font-bold text-muted-foreground">联系人</h1>
</div>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="flex h-full items-center justify-center">
<h1 class="text-2xl font-bold text-muted-foreground">合作伙伴</h1>
</div>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="flex h-full items-center justify-center">
<h1 class="text-2xl font-bold text-muted-foreground">其他数据</h1>
</div>
</template>
+73 -1
View File
@@ -1,8 +1,80 @@
<script setup lang="ts">
import { ref } from "vue"
import FmsSelect from "@/components/FmsSelect.vue"
import FmsAutoComplete from "@/components/FmsAutoComplete.vue"
// FmsSelect demo
const localeCode = ref("")
const localeLabel = ref("")
const localeRecord = ref<Record<string, any> | null>(null)
function onLocaleChange(record: Record<string, any> | null) {
localeRecord.value = record
}
// FmsAutoComplete demo
const autoValue = ref("")
const autoRecord = ref<Record<string, any> | null>(null)
function onAutoChange(record: Record<string, any> | null) {
autoRecord.value = record
}
</script>
<template>
<div class="flex h-full items-center justify-center">
<div class="flex h-full flex-col items-center gap-8 p-8">
<h1 class="text-2xl font-bold text-muted-foreground">Dashboard</h1>
<div class="grid w-full max-w-2xl gap-8">
<!-- FmsSelect demo -->
<div class="rounded-lg border p-6 space-y-3">
<h2 class="text-sm font-semibold">FmsSelect(多语言类型)</h2>
<p class="text-xs text-muted-foreground">
v-model + v-model:label 双绑定,支持远程搜索、滚动分页、清除、未匹配值回显
</p>
<div class="w-64">
<FmsSelect
v-model="localeCode"
v-model:label="localeLabel"
view-name="b_i18n_type"
label-key="b_name"
value-key="b_code"
order-by="b_code ASC"
:page-size="10"
allow-clear
placeholder="选择语言类型..."
@change="onLocaleChange"
/>
</div>
<div class="text-xs text-muted-foreground space-y-0.5">
<div>value(b_code):<code class="text-foreground">{{ localeCode || '-' }}</code></div>
<div>label(b_name):<code class="text-foreground">{{ localeLabel || '-' }}</code></div>
<div>record:<code class="text-foreground">{{ localeRecord || '-' }}</code></div>
</div>
</div>
<!-- FmsAutoComplete demo -->
<div class="rounded-lg border p-6 space-y-3">
<h2 class="text-sm font-semibold">FmsAutoComplete(语言类型搜索)</h2>
<p class="text-xs text-muted-foreground">
单 v-model 绑定文本,回车/选中后拿到完整记录
</p>
<div class="w-64">
<FmsAutoComplete
v-model="autoValue"
view-name="b_i18n_type"
label-key="b_name"
value-key="b_code"
order-by="b_code ASC"
placeholder="输入语言关键字..."
@change="onAutoChange"
/>
</div>
<div class="text-xs text-muted-foreground space-y-0.5">
<div>v-model:<code class="text-foreground">{{ autoValue || '-' }}</code></div>
<div>record:<code class="text-foreground">{{ autoRecord || '-' }}</code></div>
</div>
</div>
</div>
</div>
</template>
@@ -1,63 +0,0 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
const stats = [
{ label: '总用户数', value: '12,345', trend: '+12.5%', up: true },
{ label: '活跃用户', value: '8,920', trend: '+8.2%', up: true },
{ label: '本月新增', value: '1,203', trend: '-3.1%', up: false },
{ label: '转化率', value: '24.8%', trend: '+5.6%', up: true },
]
</script>
<template>
<div class="space-y-6">
<div>
<h2 class="text-xl font-semibold">卡片示例</h2>
<p class="mt-1 text-sm text-muted-foreground">统计卡片和内容卡片</p>
</div>
<!-- Stat cards -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card v-for="stat in stats" :key="stat.label">
<CardHeader class="pb-2">
<CardDescription>{{ stat.label }}</CardDescription>
</CardHeader>
<CardContent class="pb-2">
<div class="text-2xl font-bold">{{ stat.value }}</div>
</CardContent>
<CardFooter class="text-xs" :class="stat.up ? 'text-emerald-600' : 'text-red-500'">
{{ stat.trend }}
</CardFooter>
</Card>
</div>
<!-- Content cards -->
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>最近活动</CardTitle>
<CardDescription>团队最近的操作记录</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-sm text-muted-foreground">
<p>• 张三 新增了一条客户记录</p>
<p>• 李四 更新了产品配置</p>
<p>• 王五 导出了本月报表</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>快捷操作</CardTitle>
<CardDescription>常用功能入口</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Button variant="outline">新建订单</Button>
<Button variant="outline">导入数据</Button>
<Button variant="outline">生成报表</Button>
<Button variant="outline">发送通知</Button>
</CardContent>
</Card>
</div>
</div>
</template>
@@ -1,48 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const form = ref({ name: '', email: '', phone: '' })
const submitted = ref(false)
function handleSubmit() {
submitted.value = true
setTimeout(() => { submitted.value = false }, 3000)
}
</script>
<template>
<div class="space-y-6">
<div>
<h2 class="text-xl font-semibold">表单示例</h2>
<p class="mt-1 text-sm text-muted-foreground">基础的输入表单</p>
</div>
<div class="max-w-lg rounded-lg border bg-card p-6">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="space-y-2">
<Label for="name">姓名</Label>
<Input id="name" v-model="form.name" placeholder="请输入姓名" />
</div>
<div class="space-y-2">
<Label for="email">邮箱</Label>
<Input id="email" v-model="form.email" type="email" placeholder="请输入邮箱地址" />
</div>
<div class="space-y-2">
<Label for="phone">手机号</Label>
<Input id="phone" v-model="form.phone" placeholder="请输入手机号" />
</div>
<div class="flex items-center gap-3 pt-2">
<Button type="submit">提交</Button>
<Button type="button" variant="outline">重置</Button>
</div>
</form>
<div v-if="submitted" class="mt-4 rounded-md bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
表单提交成功!提交的数据:{{ form }}
</div>
</div>
</div>
</template>
@@ -1,71 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
const settings = ref({
notifications: true,
autoSave: true,
compactMode: false,
analytics: true,
})
</script>
<template>
<div class="space-y-6">
<div>
<h2 class="text-xl font-semibold">设置示例</h2>
<p class="mt-1 text-sm text-muted-foreground">系统设置与偏好</p>
</div>
<Card class="max-w-2xl">
<CardHeader>
<CardTitle>通知设置</CardTitle>
<CardDescription>管理系统通知偏好</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div>
<Label>消息通知</Label>
<p class="text-sm text-muted-foreground">接收系统消息和提醒</p>
</div>
<Switch v-model="settings.notifications" />
</div>
<div class="flex items-center justify-between">
<div>
<Label>自动保存</Label>
<p class="text-sm text-muted-foreground">编辑时自动保存内容</p>
</div>
<Switch v-model="settings.autoSave" />
</div>
<div class="flex items-center justify-between">
<div>
<Label>紧凑模式</Label>
<p class="text-sm text-muted-foreground">缩小间距,显示更多内容</p>
</div>
<Switch v-model="settings.compactMode" />
</div>
<div class="flex items-center justify-between">
<div>
<Label>数据统计</Label>
<p class="text-sm text-muted-foreground">允许收集使用数据</p>
</div>
<Switch v-model="settings.analytics" />
</div>
</CardContent>
</Card>
<Card class="max-w-2xl">
<CardHeader>
<CardTitle>危险操作</CardTitle>
<CardDescription>不可恢复的操作,请谨慎</CardDescription>
</CardHeader>
<CardContent class="flex gap-3">
<Button variant="outline" class="text-amber-600 border-amber-600 hover:bg-amber-50">导出所有数据</Button>
<Button variant="outline" class="text-red-600 border-red-600 hover:bg-red-50">删除账号</Button>
</CardContent>
</Card>
</div>
</template>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const data = [
{ id: 1, name: '张三', department: '技术部', role: '前端开发', status: '在职' },
{ id: 2, name: '李四', department: '产品部', role: '产品经理', status: '在职' },
{ id: 3, name: '王五', department: '设计部', role: 'UI 设计', status: '休假' },
{ id: 4, name: '赵六', department: '技术部', role: '后端开发', status: '在职' },
{ id: 5, name: '钱七', department: '市场部', role: '市场运营', status: '离职' },
]
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold">表格示例</h2>
<p class="mt-1 text-sm text-muted-foreground">一个简单的数据表格页面</p>
</div>
<div class="flex items-center gap-2">
<Input placeholder="搜索..." class="w-48" />
<Button variant="outline">导出</Button>
<Button>新增</Button>
</div>
</div>
<div class="rounded-lg border bg-card">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b bg-muted/50">
<tr>
<th class="px-4 py-3 text-left font-medium">姓名</th>
<th class="px-4 py-3 text-left font-medium">部门</th>
<th class="px-4 py-3 text-left font-medium">角色</th>
<th class="px-4 py-3 text-left font-medium">状态</th>
<th class="px-4 py-3 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data" :key="row.id" class="border-b last:border-b-0 hover:bg-muted/50 transition-colors">
<td class="px-4 py-3">{{ row.name }}</td>
<td class="px-4 py-3">{{ row.department }}</td>
<td class="px-4 py-3 text-muted-foreground">{{ row.role }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 text-xs font-medium" :class="row.status === '在职' ? 'text-emerald-600' : row.status === '休假' ? 'text-amber-600' : 'text-muted-foreground'">
<span class="size-1.5 rounded-full" :class="row.status === '在职' ? 'bg-emerald-500' : row.status === '休假' ? 'bg-amber-500' : 'bg-muted-foreground'" />
{{ row.status }}
</span>
</td>
<td class="px-4 py-3 text-right">
<Button variant="ghost" size="xs">编辑</Button>
<Button variant="ghost" size="xs">查看</Button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex items-center justify-between text-sm text-muted-foreground">
<span>共 {{ data.length }} 条记录</span>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" disabled>上一页</Button>
<span>第 1 页</span>
<Button variant="outline" size="sm">下一页</Button>
</div>
</div>
</div>
</template>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue"
import { computed, onMounted, ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useForm } from "vee-validate"
import { toast } from "vue-sonner"
import { loginApi } from "@/services/api"
import { useAuthStore } from "@/stores/auth"
import { Building2, GalleryVerticalEnd, KeyRound, User } from "@lucide/vue"
import { Building2, GalleryVerticalEnd, KeyRound, LoaderCircle, User } from "@lucide/vue"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -36,22 +38,32 @@ const formSchema = toTypedSchema(
}),
)
const form = useForm({
validationSchema: formSchema,
})
const submitting = ref(false)
const orgIdInput = ref<InstanceType<typeof Input> | null>(null)
onMounted(() => {
nextTick(() => {
(document.getElementById("orgId") || document.getElementById("password"))?.focus()
form.setValues({
orgId: authStore.orgId || '',
userId: authStore.user?.account || '',
password: '',
})
orgIdInput.value?.$el?.focus()
})
async function onSubmit(values: Record<string, unknown>) {
submitting.value = true
try {
const res = await loginApi(values.orgId as string, values.userId as string, (values.password as string) ?? "")
const { orgId, userId, password } = values as { orgId: string; userId: string; password?: string }
const res = await loginApi(orgId, userId, password ?? "")
authStore.setSession(
{ token: res.data.token, user: { ...res.data.user, account: res.data.user.id } },
res.data.orgid ?? (values.orgId as string),
{ token: res.data.token, user: { ...res.data.user, account: userId } },
res.data.orgid ?? orgId,
)
toast.success('登录成功')
router.replace(String(route.query.redirect || "/dashboard"))
} catch (e: unknown) {
if (e instanceof Error) alert(e.message)
@@ -59,12 +71,16 @@ async function onSubmit(values: Record<string, unknown>) {
submitting.value = false
}
}
function login() {
form.handleSubmit(onSubmit)()
}
</script>
<template>
<div class="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10">
<div class="w-full max-w-sm">
<Form class="flex flex-col gap-6" :validation-schema="formSchema" @submit="onSubmit">
<Form class="flex flex-col gap-6" :form="form">
<!-- Header -->
<div class="flex flex-col items-center gap-2 text-center">
<a href="#" class="flex flex-col items-center gap-2 font-medium">
@@ -89,7 +105,7 @@ async function onSubmit(values: Record<string, unknown>) {
<FormControl>
<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" v-bind="componentField" placeholder="请输入机构码" class="pl-9" :disabled="submitting" />
<Input id="orgId" ref="orgIdInput" v-bind="componentField" placeholder="请输入机构码" class="pl-9" :disabled="submitting" />
</div>
</FormControl>
<FormMessage />
@@ -124,8 +140,9 @@ async function onSubmit(values: Record<string, unknown>) {
</FormItem>
</FormField>
<Button type="submit" :disabled="submitting" class="w-full cursor-pointer">
{{ submitting ? "登录中..." : "登录" }}
<Button class="w-full cursor-pointer" :disabled="submitting" @click="login">
<LoaderCircle v-if="submitting" class="size-4 animate-spin" />
登录
</Button>
</Form>
</div>
@@ -0,0 +1,5 @@
<template>
<div class="flex h-full items-center justify-center">
<h1 class="text-2xl font-bold text-muted-foreground">多语言类型</h1>
</div>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="flex h-full items-center justify-center">
<h1 class="text-2xl font-bold text-muted-foreground">登录日志</h1>
</div>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="flex h-full items-center justify-center">
<h1 class="text-2xl font-bold text-muted-foreground">模块管理</h1>
</div>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="flex h-full items-center justify-center">
<h1 class="text-2xl font-bold text-muted-foreground">用户管理</h1>
</div>
</template>