20260810172235
This commit is contained in:
1 parent
c4891538a7
commit
ed5ab3885d
35 files changed
+1314
-653
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+84
-31
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -42,8 +42,6 @@ export const useAuthStore = defineStore(
|
||||
|
||||
function clearSession() {
|
||||
token.value = ''
|
||||
orgId.value = ''
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+28
-11
@@ -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>
|
||||
Reference in new issue
Block a user