This commit is contained in:
owenchen committed 2026-08-25 15:31:16 +08:00
1 parent 1404640f16
commit 2bbf016cbf
167 files changed
+18941 -589

No files matched your search

@@ -0,0 +1,604 @@
<template>
<!-- ============================================================
Biometric login enrollment card
Shown on MyProfile.vue below the personal-details card.
Two related toggles:
1. Biometric login — enrolls / removes this device's
fingerprint / face / device PIN for signing in.
2. Biometric for epayslip / taxation — once biometric login
is enabled, lets the user skip re-typing the epayslip
password on the enquiry pages. Requires the user to prove
they know the epayslip password at enable-time.
The "credential id" of the WebAuthn registration is stashed in
localStorage under `biometric_<username>` so the login page can
show the "Sign in with fingerprint" button.
The card hides itself entirely on devices that have a browser
but no platform authenticator (e.g. a desktop without Windows
Hello / Touch ID configured).
============================================================ -->
<section
v-if="showCard"
class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft sm:p-6"
>
<header class="mb-4 flex items-center justify-between gap-3">
<div>
<h3
class="text-sm font-semibold uppercase tracking-wider text-slate-500"
>
{{ "Biometric login" }}
</h3>
<p class="mt-1 text-xs text-slate-400">
{{ statusText }}
</p>
</div>
<a-spin v-if="enrolling || removing" :spinning="true" size="small" />
</header>
<div
v-if="!isSupported"
class="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
{{
"This browser does not support biometric login. Try the latest Chrome, Edge, Safari, or Firefox."
}}
</div>
<div
v-else-if="hasPlatformAuthenticator === false"
class="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
<p class="font-medium">
{{ "No biometric authenticator on this device" }}
</p>
<p class="mt-1 text-xs text-amber-700">
{{
"Enable Windows Hello (Settings → Accounts → Sign-in options), Touch ID, or a device PIN, then refresh this page."
}}
</p>
</div>
<div
v-else-if="hasPlatformAuthenticator === null"
class="rounded-lg bg-slate-50 px-4 py-3 text-sm text-slate-500"
>
{{ "Checking biometric support…" }}
</div>
<div
v-else
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex flex-col gap-1">
<p class="text-sm text-slate-700">
{{
enrolled
? `Enrolled on ${enrolledDeviceName || "this device"}`
: "Sign in with fingerprint, face, or device PIN on this device."
}}
</p>
<p v-if="enrolled && enrolledAt" class="text-xs text-slate-400">
{{ "Enrolled on" }} {{ formatDate(enrolledAt) }}
</p>
</div>
<div class="flex items-center gap-3">
<a-switch
:checked="enrolled"
:loading="enrolling || removing"
@change="onToggle"
/>
</div>
</div>
<!-- Second toggle — biometric for epayslip / taxation.
Only visible when biometric login itself is enrolled (the local
`enrolled` state is set from localStorage on mount, so the second
toggle appears as soon as the user has a stored credential id).
The server-side guard in EnableBiometricForEpayslip also re-checks
that biometric_credential_id is not null. -->
<!-- Confirm-remove modal — kept inline for compactness -->
<a-modal
v-model:open="confirmRemoveOpen"
:title="'Remove biometric login?'"
:ok-text="'Remove'"
:ok-type="'danger'"
:cancel-text="'Cancel'"
@ok="onConfirmRemove"
>
<p>
{{
"You will need to type your password to sign in next time. You can re-enroll anytime."
}}
</p>
</a-modal>
<!-- Password re-verification modal — shown when the user toggles
biometric login ON. Requires the user to prove they know the
account password before WebAuthn enrollment starts. Closes
silently on Cancel / X / ESC; clears `passwordInput` via the
watcher when closed. -->
<a-modal
v-model:open="confirmPasswordOpen"
:title="'Verify your password'"
:ok-text="'Confirm'"
:cancel-text="'Cancel'"
:confirm-loading="verifying"
:ok-button-props="{ disabled: !passwordInput }"
@ok="onConfirmPassword"
>
<p class="mb-4 text-sm text-slate-600">
{{
"Enter your password to enable biometric login on this device. This prevents someone from enrolling their fingerprint on a stolen or unattended device without your knowledge."
}}
</p>
<a-form layout="vertical">
<a-form-item
:label="'Password'"
:rules="[{ required: true, message: 'Please enter your password' }]"
>
<a-input-password
v-model:value="passwordInput"
:placeholder="'Password'"
size="large"
@press-enter="onConfirmPassword"
/>
</a-form-item>
</a-form>
</a-modal>
</section>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, watch } from "vue";
import { startRegistration } from "@simplewebauthn/browser";
import { notification, message } from "ant-design-vue";
import enUS from "ant-design-vue/es/locale/en_US";
import zhCN from "ant-design-vue/es/locale/zh_CN";
import i18n from "../../locales";
import authService from "@/service/security/authService";
import { v4 as uuidv4 } from "uuid";
import { JSEncrypt } from "jsencrypt";
import CryptoJS from "crypto-js";
import { FinishBiometricRegistrationData } from "@/data/auto/finishBiometricRegistrationData";
// import userProfileService from '@/service/UserProfileService'
const props = defineProps<{
/** HR+ username — used as the localStorage key prefix. */
username: string;
}>();
const { t } = i18n.global;
// ===== State =====
const enrolling = ref(false);
const removing = ref(false);
const enrolled = ref(false);
const enrolledDeviceName = ref<string | null>(null);
const enrolledAt = ref<Date | null>(null);
const confirmRemoveOpen = ref(false);
// Password re-verification before enabling biometric login. This prevents
// someone from enrolling their fingerprint on a stolen / unattended device
// without the user explicitly proving they know the account password.
// confirmPasswordOpen — controls the modal visibility
// passwordInput — bound to the password input field
// verifying — disables the OK button + shows a spinner while
// useLogin is in flight
const confirmPasswordOpen = ref(false);
const passwordInput = ref("");
const verifying = ref(false);
// ===== Epayslip / taxation biometric sub-toggle =====
// Mirrors security_user.biometric_for_epayslip. The initial value is
// passed in as a prop from MyProfile (which reads it from
// UserProfileDisplayData); we keep a local copy so the switch can
// optimistically flip when the user toggles, then commit to the server.
const epayslipSaving = ref(false);
// Clear the password field whenever either modal closes (cancel, X, OK, ESC,
// or backdrop click). Otherwise the next open would pre-fill with the
// previous value.
watch(confirmPasswordOpen, (open) => {
if (!open) passwordInput.value = "";
});
const isSupported = computed(
() =>
typeof window !== "undefined" &&
typeof window.PublicKeyCredential === "function",
);
// Tri-state probe for the device's biometric capability.
// null → still loading (probe in flight or not started)
// true → the device has a user-verifying platform authenticator
// false → the browser supports WebAuthn but no biometric is available
//
// isUserVerifyingPlatformAuthenticatorAvailable() is the W3C-standard
// check: it returns true only when the device has a biometric / PIN
// authenticator the browser can use to satisfy "user verification".
// Older / non-biometric devices return false, which means we should
// hide the toggle entirely instead of letting the user click into
// a failing flow.
const hasPlatformAuthenticator = ref<boolean | null>(
isSupported.value ? null : false,
);
if (
isSupported.value &&
typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
"function"
) {
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then((available) => {
hasPlatformAuthenticator.value = !!available;
})
.catch(() => {
// Probe can fail on locked-down browsers. Default to "show the
// toggle" — the call-time catch in enroll() will still surface
// a friendly error if the user actually tries.
hasPlatformAuthenticator.value = true;
});
}
// Only show the card if either:
// - the device DOES have a platform authenticator, or
// - the user has already enrolled on this device (so we need the
// card to render the "remove" toggle even if the current
// authenticator is no longer available, e.g. the user replaced
// their fingerprint scanner but the credential still works).
const showCard = computed(() => {
// console.log(
// "showCard",
// isSupported.value,
// enrolled.value,
// hasPlatformAuthenticator.value,
// );
if (!isSupported.value) return false;
if (enrolled.value) return true;
return hasPlatformAuthenticator.value === true;
});
// localStorage key — the login page reads this same key to decide
// whether to show the fingerprint button.
const storageKey = computed(() => `biometric_${props.username}`);
// Best-effort error message extraction. Handles three error shapes:
// 1. Axios: error.response.data is the server body (string or object)
// 2. WebAuthn/JS: err is a DOMException / SyntaxError with .name + .message
// 3. Plain string thrown by our own service
// Returns a single non-empty human-readable string.
const normalizeError = (err: any): string => {
if (err == null) return "Unknown error";
if (typeof err === "string") return err;
// Axios error: prefer the server's body when present
if (err.response?.data != null) {
const data = err.response.data;
if (typeof data === "string") return data;
if (typeof data === "object") {
// Common ASP.NET ValidationException shape: { message, ... } or
// a string that came from WebMessages.GetString.
if (typeof data.message === "string" && data.message) return data.message;
if (typeof data.error === "string" && data.error) return data.error;
if (typeof data.title === "string" && data.title) return data.title;
// Last resort: stringify the body. Try JSON first (avoids
// "[object Object]" for plain objects) then fall back to String.
try {
return JSON.stringify(data);
} catch {
return String(data);
}
}
}
// WebAuthn / DOMException / Error
if (err.name && err.message) return `${err.name}: ${err.message}`;
if (err.message) return err.message;
return String(err);
};
// Coerce the BeginBiometricRegistration response into a
// PublicKeyCredentialCreationOptionsJSON object that
// @simplewebauthn/browser can consume.
//
// The C# method returns a JSON *string* (the output of Fido2NetLib's
// options.ToJson()), which ASP.NET serializes as a JSON-encoded string.
// axios's default transformResponse JSON.parses that, so resp.data is
// normally a JS string. But we've seen this flow break in three ways:
//
// 1. resp.data is an empty string (server-side exception, the catch
// logged it and rethrew, ASP.NET emitted a 500 with no body, or
// a proxy stripped the body). Bare JSON.parse("") throws
// "Unexpected end of JSON input" — the cryptic error the user
// was seeing.
// 2. resp.data is an object (axios mis-classified the response, or
// the body was a JSON object like {message: "..."} with a 200).
// JSON.parse({message: "..."}) throws
// "Unexpected token o in JSON at position 1".
// 3. resp.data is a valid object already (some intermediate layer
// double-parsed). Just pass it through.
//
// This helper normalises all three cases and throws a readable error
// for (1) and (2) so the user sees "the server returned …" instead of
// a raw SyntaxError.
const parseCreationOptions = (raw: unknown): any => {
if (raw == null || raw === "") {
throw new Error(
"Server returned an empty response for BeginBiometricRegistration. " +
"The backend probably threw an exception. Check the API log.",
);
}
if (typeof raw === "object") {
return raw;
}
if (typeof raw === "string") {
try {
return JSON.parse(raw);
} catch (e: any) {
// Truncate the raw value so we don't dump a giant HTML error page
// into the notification toast.
const preview = raw.length > 200 ? raw.slice(0, 200) + "…" : raw;
throw new Error(
`Server returned invalid JSON for BeginBiometricRegistration: ` +
`${e.message}. Raw response: ${preview}`,
);
}
}
throw new Error(
`Unexpected BeginBiometricRegistration response type: ${typeof raw}`,
);
};
const statusText = computed(() => {
if (!isSupported.value) return "Not supported in this browser";
if (enrolled.value) return "Enabled on this device";
return "Disabled";
});
// ===== Lifecycle =====
onMounted(() => {
if (!props.username) return;
try {
const stored = localStorage.getItem(storageKey.value);
if (stored) {
const parsed = JSON.parse(stored) as {
deviceName?: string;
enrolledAt?: string;
};
enrolled.value = true;
enrolledDeviceName.value = parsed.deviceName ?? null;
enrolledAt.value = parsed.enrolledAt ? new Date(parsed.enrolledAt) : null;
}
} catch {
// corrupted localStorage entry — treat as not enrolled
localStorage.removeItem(storageKey.value);
}
});
// ===== Actions =====
const formatDate = (d: Date): string => d.toLocaleDateString();
const onToggle = async (checked: boolean | string | Event) => {
const next = typeof checked === "boolean" ? checked : !!checked;
if (next) {
// Don't enroll straight away — require the user to re-enter their
// account password first. This is the standard "step-up auth" pattern
// for sensitive enrollment changes (FIDO2 / WebAuthn best practice
// §6.1.1 — "user verification prior to credential creation").
confirmPasswordOpen.value = true;
} else {
confirmRemoveOpen.value = true;
}
};
// Verify the password by calling UserProfileProcess.ConfirmPassword
// (auto-generated webmethod). The username is taken from the JWT on
// the server side via loginCredential, not from the request body, so
// the user can only verify their own password. No new session is
// minted — the existing one is preserved.
//
// IMPORTANT: the backend returns `false` (not throws) on wrong password.
// So we MUST check the boolean return value — only checking for thrown
// errors (the catch block) would let a wrong password fall through to
// the success path and start WebAuthn enrollment. That would defeat the
// entire purpose of the step-up auth gate.
const onConfirmPassword = async () => {
if (verifying.value) return;
if (!passwordInput.value) {
notification.warning({
message: "Please enter your password",
placement: "topRight",
});
return;
}
verifying.value = true;
let submitted = passwordInput.value;
try {
let key = await authService.GetRsaPublicKey();
let encrypt = new JSEncrypt();
encrypt.setPublicKey(key);
submitted=uuidv4() + CryptoJS.MD5(submitted);
submitted = encodeURIComponent(encrypt.encrypt(submitted).toString());
const ok = await authService.ConfirmPassword(submitted);
if (!ok) {
// Wrong password. Keep the dialog open so the user can retry.
notification.error({
message: "Incorrect password",
description: "Please re-enter your password to enable biometric login.",
placement: "topRight",
});
return;
}
// Password is correct. Close the dialog and kick off WebAuthn
// enrollment. Use the captured `submitted` value, not the cleared
// `passwordInput` (the modal reset it on close).
confirmPasswordOpen.value = false;
await enroll();
} catch (err: any) {
// Network / 500 / other transport errors. Wrong-password itself
// does NOT throw — it returns false (handled above).
notification.error({
message: "Could not verify password",
description: err?.message ?? String(err),
placement: "topRight",
});
} finally {
verifying.value = false;
}
};
const enroll = async () => {
if (enrolling.value) return
enrolling.value = true
try {
// 1. Server returns the PublicKeyCredentialCreationOptions as a JSON
// string (the auto-generated UserProfileService stub types it as
// `Promise<string>` because the C# method returns `string`).
// @simplewebauthn/browser@13 wants a parsed object, so parse it
// here before handing it to startRegistration().
//
// Be defensive about the shape: the backend could legitimately
// return the body as a JSON string (axios auto-parses to a JS
// string), as an already-parsed object (if a proxy/CDN unwrapped
// the JSON string), or as an error object (which axios rejects
// on, but a 200 with an error payload would land here). Treat
// all three cases and surface a clear error if parsing fails —
// a bare `JSON.parse` on an empty string throws the cryptic
// "Unexpected end of JSON input" the user was seeing.
const raw = await authService.BeginBiometricRegistration(
deriveDeviceName(),
)
// eslint-disable-next-line no-console
// console.log('[biometric] beginBiometricRegistration raw:', typeof raw, raw)
const optionsJSON = parseCreationOptions(raw)
// 2. Browser drives navigator.credentials.create(). @simplewebauthn/browser
// validates the object and shows the OS prompt.
const attestation = await startRegistration({ optionsJSON })
// eslint-disable-next-line no-console
// console.log('[biometric] attestation received:', JSON.stringify({
// id: attestation.id,
// rawId: attestation.rawId,
// type: attestation.type,
// hasResponse: !!attestation.response,
// hasAttestationObject: !!attestation.response?.attestationObject,
// attestationObjectLen: attestation.response?.attestationObject?.length,
// hasClientDataJSON: !!attestation.response?.clientDataJSON,
// clientDataJSONLen: attestation.response?.clientDataJSON?.length,
// }))
// 3. Server verifies the attestation and persists the credential.
// Use camelCase keys — that matches the TypeGen-generated
// FinishBiometricRegistrationData class exactly, so TS type-checks
// and ASP.NET's case-insensitive JSON binder maps to the C#
// AttestationJson / CreationOptionsJson properties without 400s.
let fbrd: FinishBiometricRegistrationData=new FinishBiometricRegistrationData();
fbrd.AttestationJson = JSON.stringify(attestation);
fbrd.CreationOptionsJson = JSON.stringify(optionsJSON);
const result = await authService.FinishBiometricRegistration(fbrd)
// 4. Stash the credential id locally so the login page can use it
// as allowCredentials[0].id in navigator.credentials.get().
localStorage.setItem(
storageKey.value,
JSON.stringify({
credentialId: result.CredentialIdBase64Url,
deviceName: deriveDeviceName(),
enrolledAt: new Date().toISOString(),
}),
)
// 5. Remember the username separately so the login page can
// auto-fill the field on next visit (if the user opens a fresh
// tab and the credential id is the only thing in localStorage,
// they still have to type the username before the fingerprint
// button shows). Keyed by username so multiple enrolled
// accounts on the same device don't clobber each other.
localStorage.setItem('last_biometric_username', props.username)
enrolled.value = true
enrolledDeviceName.value = deriveDeviceName()
enrolledAt.value = new Date()
notification.success({
message: 'Biometric login enabled',
placement: 'topRight',
})
} catch (err: any) {
// @simplewebauthn/browser throws on user cancel, no authenticator,
// or verification failure. Normalize the error to a readable string
// (axios errors have a nested response.data; WebAuthn errors have
// .name/.message). The previous "err?.message ?? String(err)" would
// produce "[object Object]" when err is a structured axios error.
const msg = normalizeError(err)
if (/user cancelled|user canceled/i.test(msg)) {
message.info( 'Cancelled')
} else if (/not allowed|security error|notallowed/i.test(msg)) {
// Common on desktop without a biometric reader: the browser
// has no platform authenticator. Show a friendlier message.
notification.warning({
message: 'No biometric authenticator',
description: 'This device has no fingerprint reader, face camera, or PIN configured. Try on a phone or laptop with Windows Hello / Touch ID.',
placement: 'topRight',
})
} else {
notification.error({
message: 'Could not enable biometric login',
description: msg,
placement: 'topRight',
})
}
} finally {
enrolling.value = false
}
};
const onConfirmRemove = async () => {
if (removing.value) return
removing.value = true
try {
await authService.RemoveBiometric()
localStorage.removeItem(storageKey.value)
enrolled.value = false
enrolledDeviceName.value = null
enrolledAt.value = null
notification.success({
message: 'Biometric login removed',
placement: 'topRight',
})
} catch (err: any) {
notification.error({
message: 'Could not remove biometric login',
description: err?.message ?? String(err),
placement: 'topRight',
})
} finally {
removing.value = false
confirmRemoveOpen.value = false
}
};
// Best-effort friendly name from the user agent. We use a coarse
// platform detection — the user can override by re-enrolling on a
// different device. The server-side column is the source of truth.
const deriveDeviceName = (): string => {
const ua = navigator.userAgent;
if (/iPhone/.test(ua)) return "iPhone";
if (/iPad/.test(ua)) return "iPad";
if (/Android/.test(ua)) {
const m = ua.match(/Android[^;]+;\s*([^)]+)/);
return m ? `Android (${m[1].trim()})` : "Android device";
}
if (/Mac OS X/.test(ua)) return "Mac";
if (/Windows/.test(ua)) return "Windows PC";
if (/Linux/.test(ua)) return "Linux";
return "Unnamed device";
};
</script>
+182
View File
@@ -0,0 +1,182 @@
<template>
<dragModal v-model:open="props.showModal" title="表格列设置" :mask-closable="false" @cancel="handleClose">
<hr />
<br />
<a-row :wrap="false">
<a-col :lg="24">
<a-input flex="none" readonly value="视图" class="site-input-split" style="
width: 80px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
<a-select style="min-width: 200px" v-model:value="viewOption.Value" @select="onSelect">
<a-select-option v-for="o in viewOption.SelectOptions" :key="o.DisplayName" :value="o.BindValue">{{
o.DisplayName }}</a-select-option>
</a-select>
</a-col>
</a-row>
<a-row class="column" :wrap="false" style="margin-top: 4px; margin-bottom: 4px">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly value="新视图" class="site-input-split" style="
width: 80px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto" style="border-left: 0; height: 32px">
<a-input v-model:value="newSelectOption.DisplayName"
style="border-radius: 0; border-left: 0; text-align: left" />
</a-col>
<a-col flex="none">
<a-button type="dashed" @click="addViewOption">添加</a-button>
</a-col>
</a-row>
<draggList v-model:list="columns" @on-change-list="onChangeList" />
<template #footer>
<a-button type="primary" @click="handleClose">关闭</a-button>
</template>
</dragModal>
</template>
<script setup lang="ts">
import {
reactive,
useSlots,
inject,
computed,
onMounted,
defineComponent,
h,
watch,
ref,
unref,
provide,
} from "vue";
import dragModal from "@/components/basic/DragModal.vue";
import draggList from "@/components/basic/DraggList.vue";
import { BindOption } from "@/data/custom/bindOption";
import { SelectOption } from "@/data/auto/selectOption";
import { notification } from "ant-design-vue";
import { clone } from "lodash-es";
import { enquiryMethodKey } from "@/injections";
const props = defineProps({
showModal: {
type: Boolean,
default: false,
},
});
const enquiry = inject(enquiryMethodKey);
const viewOption = ref(
enquiry ? enquiry.getViewOption() : {
Value: "default",
SelectOptions: [{ BindValue: "default", DisplayName: "默认" }],
Columns: [{ BindValue: "default", BindColumn: [] }],
}
);
let fs: Column[] = viewOption.value.Columns.filter(
(x: any) => x.BindValue == viewOption.value.Value
)[0].BindColumn as Column[];
const columns = ref(fs);
const onSelect = () => {
//触发事件后要重新绑定columns
columns.value = viewOption.value.Columns.filter(
(x: any) => x.BindValue == viewOption.value.Value
)[0].BindColumn as Column[];
emit("onChangeColumns", unref(columns));
};
const onChangeList = async (list: any) => {
//拖动后要重新赋值
viewOption.value.Columns.filter(
(x: any) => x.BindValue == viewOption.value.Value
)[0].BindColumn = clone(list);
emit("onChangeColumns", list);
emit("onChangeViewOption", unref(viewOption));
};
const addViewOption = async () => {
if (newSelectOption.value.DisplayName.length > 0) {
if (
viewOption.value.SelectOptions.filter(
(x: any) => x.BindValue == newSelectOption.value.DisplayName
).length == 1
) {
notification.info({
message: "系统提示:",
description: "新视图已存在",
placement: "topRight",
});
return;
}
newSelectOption.value.BindValue = newSelectOption.value.DisplayName;
let unNewSelectOption = Object.assign({}, unref(newSelectOption));
newSelectOption.value.BindValue = newSelectOption.value.DisplayName = "";
viewOption.value.SelectOptions.push(unNewSelectOption);
let newColumns = clone(unref(columns));
viewOption.value.Columns.push({
BindValue: unNewSelectOption.BindValue,
BindColumn: newColumns,
});
viewOption.value.Value = unNewSelectOption.BindValue;
emit("onChangeViewOption", unref(viewOption));
} else {
notification.info({
message: "系统提示:",
description: "请输入新视图",
placement: "topRight",
});
}
};
const newSelectOption = ref<SelectOption>({ BindValue: "", DisplayName: "" });
onMounted(() => { });
const emit = defineEmits(["onChangeColumns", "onChangeViewOption", "handleClose"]);
const handleClose = async () => {
emit("handleClose");
};
</script>
<style lang="less">
.ant-modal-body .ant-select-single .ant-select-selector {
border-top-left-radius: 0pt 0pt;
border-bottom-left-radius: 0pt 0pt;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0 !important;
}
.ant-modal-body .ant-select-multiple .ant-select-selector {
border-top-left-radius: 0pt 0pt;
border-bottom-left-radius: 0pt 0pt;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0 !important;
}
.column .ant-btn {
padding-top: 4px;
padding-bottom: 4px;
border-radius: 0;
}
</style>
+359
View File
@@ -0,0 +1,359 @@
<template>
<teleport :to="getContainer()">
<div ref="modalWrapRef" class="draggable-modal" :class="{ fullscreen: fullscreenModel }">
<Modal
v-bind="omit(props, ['open', 'onCancel', 'onOk', 'onUpdate:open'])"
v-model:open="visibleModel"
:destroyOnClose="true"
:mask-closable="false"
:get-container="() => modalWrapRef"
:width="innerWidth || width"
@ok="emit('ok')"
@cancel="emit('cancel')"
>
<template #title>
<slot name="title">{{ $attrs.title || '标题' }}</slot>
</template>
<template #closeIcon>
<slot name="closeIcon">
<Space class="ant-modal-operate" @click.stop>
<FullscreenOutlined v-if="!fullscreenModel" @click="fullscreenModel = true" />
<FullscreenExitOutlined v-else @click="restore" />
<CloseOutlined @click="closeModal" />
</Space>
</slot>
</template>
<slot>
① 窗口可以拖动;<br />
② 窗口可以通过八个方向改变大小;<br />
③ 窗口可以最小化、最大化、还原、关闭;<br />
④ 限制窗口最小宽度/高度。
</slot>
<template v-if="$slots.footer" #footer>
<slot name="footer"></slot>
</template>
</Modal>
</div>
</teleport>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { modalProps } from 'ant-design-vue/es/modal/Modal';
import { CloseOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons-vue';
import { useVModel } from '@vueuse/core';
import { throttle, omit } from 'lodash-es';
import { Modal, Space } from 'ant-design-vue';
const props = defineProps({
...modalProps(),
fullscreen: {
type: Boolean,
default: false,
},
getContainer: {
type: Function,
default: () => document.body,
},
});
const emit = defineEmits(['update:open', 'update:fullscreen', 'ok', 'cancel']);
const route = useRoute();
const visibleModel = useVModel(props, 'open');
const fullscreenModel = ref(props.fullscreen);
const innerWidth = ref('');
const cursorStyle = {
top: 'n-resize',
left: 'w-resize',
right: 'e-resize',
bottom: 's-resize',
topLeft: 'nw-resize',
topright: 'ne-resize',
bottomLeft: 'sw-resize',
bottomRight: 'se-resize',
auto: 'auto',
} as const;
// 是否已经初始化过了
let inited = false;
const modalWrapRef = ref<HTMLDivElement>();
const closeModal = () => {
visibleModel.value = false;
emit('cancel');
};
// 居中弹窗
const centerModal = async () => {
await nextTick();
const modalEl = modalWrapRef.value?.querySelector<HTMLDivElement>('.ant-modal');
if (modalEl && modalEl.getBoundingClientRect().left < 1) {
modalEl.style.left = `${(document.documentElement.clientWidth - modalEl.offsetWidth) / 2}px`;
}
};
const restore = async () => {
fullscreenModel.value = false;
centerModal();
};
const registerDragTitle = (dragEl: HTMLDivElement, handleEl: HTMLDivElement) => {
handleEl.style.cursor = 'move';
handleEl.onmousedown = throttle((e: MouseEvent) => {
if (fullscreenModel.value) return;
document.body.style.userSelect = 'none';
const disX = e.clientX - dragEl.getBoundingClientRect().left;
const disY = e.clientY - dragEl.getBoundingClientRect().top;
const mousemove = (event: MouseEvent) => {
if (fullscreenModel.value) return;
let iL = event.clientX - disX;
let iT = event.clientY - disY;
const maxL = document.documentElement.clientWidth - dragEl.offsetWidth;
const maxT = document.documentElement.clientHeight - dragEl.offsetHeight;
iL <= 0 && (iL = 0);
iT <= 0 && (iT = 0);
iL >= maxL && (iL = maxL);
iT >= maxT && (iT = maxT);
dragEl.style.left = `${iL}px`;
dragEl.style.top = `${iT}px`;
};
const mouseup = () => {
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mouseup', mouseup);
document.body.style.userSelect = 'auto';
};
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);
}, 20);
};
const initDrag = async () => {
await nextTick();
const modalWrapRefEl = modalWrapRef.value!;
const modalWrapEl = modalWrapRefEl.querySelector<HTMLDivElement>('.ant-modal-wrap');
const modalEl = modalWrapRefEl.querySelector<HTMLDivElement>('.ant-modal');
if (modalWrapEl && modalEl) {
centerModal();
const headerEl = modalEl.querySelector<HTMLDivElement>('.ant-modal-header');
headerEl && registerDragTitle(modalEl, headerEl);
modalWrapEl.onmousemove = throttle((event: MouseEvent) => {
if (fullscreenModel.value) return;
const left = event.clientX - modalEl.offsetLeft;
const top = event.clientY - modalEl.offsetTop;
const right = event.clientX - modalEl.offsetWidth - modalEl.offsetLeft;
const bottom = event.clientY - modalEl.offsetHeight - modalEl.offsetTop;
const isLeft = left <= 0 && left > -8;
const isTop = top < 5 && top > -8;
const isRight = right >= 0 && right < 8;
const isBottom = bottom > -5 && bottom < 8;
// 向左
if (isLeft && top > 5 && bottom < -5) {
modalWrapEl.style.cursor = cursorStyle.left;
// 向上
} else if (isTop && left > 5 && right < -5) {
modalWrapEl.style.cursor = cursorStyle.top;
// 向右
} else if (isRight && top > 5 && bottom < -5) {
modalWrapEl.style.cursor = cursorStyle.right;
// 向下
} else if (isBottom && left > 5 && right < -5) {
modalWrapEl.style.cursor = cursorStyle.bottom;
// 左上角
} else if (left > -8 && left <= 5 && top <= 5 && top > -8) {
modalWrapEl.style.cursor = cursorStyle.topLeft;
// 左下角
} else if (left > -8 && left <= 5 && bottom <= 5 && bottom > -8) {
modalWrapEl.style.cursor = cursorStyle.bottomLeft;
// 右上角
} else if (right < 8 && right >= -5 && top <= 5 && top > -8) {
modalWrapEl.style.cursor = cursorStyle.topright;
// 右下角
} else if (right < 8 && right >= -5 && bottom <= 5 && bottom > -8) {
modalWrapEl.style.cursor = cursorStyle.bottomRight;
} else {
modalWrapEl.style.cursor = cursorStyle.auto;
}
}, 20);
modalWrapEl.onmousedown = (e: MouseEvent) => {
if (fullscreenModel.value) return;
const {
top: iParentTop,
bottom: iParentBottom,
left: iParentLeft,
right: iParentRight,
} = modalEl.getBoundingClientRect();
const disX = e.clientX - iParentLeft;
const disY = e.clientY - iParentTop;
const iParentWidth = modalEl.offsetWidth;
const iParentHeight = modalEl.offsetHeight;
const cursor = modalWrapEl.style.cursor;
const mousemove = throttle((event: MouseEvent) => {
if (fullscreenModel.value) return;
if (cursor !== cursorStyle.auto) {
document.body.style.userSelect = 'none';
}
const mLeft = `${Math.max(0, event.clientX - disX)}px`;
const mTop = `${Math.max(0, event.clientY - disY)}px`;
const mLeftWidth = `${Math.min(
iParentRight,
iParentWidth + iParentLeft - event.clientX,
)}px`;
const mRightWidth = `${Math.min(
window.innerWidth - iParentLeft,
event.clientX - iParentLeft,
)}px`;
const mTopHeight = `${Math.min(
iParentBottom,
iParentHeight + iParentTop - event.clientY,
)}px`;
const mBottomHeight = `${Math.min(
window.innerHeight - iParentTop,
event.clientY - iParentTop,
)}px`;
// 向左边拖拽
if (cursor === cursorStyle.left) {
modalEl.style.left = mLeft;
modalEl.style.width = mLeftWidth;
// 向上边拖拽
} else if (cursor === cursorStyle.top) {
modalEl.style.top = mTop;
modalEl.style.height = mTopHeight;
// 向右边拖拽
} else if (cursor === cursorStyle.right) {
modalEl.style.width = mRightWidth;
// 向下拖拽
} else if (cursor === cursorStyle.bottom) {
modalEl.style.height = mBottomHeight;
// 左上角拖拽
} else if (cursor === cursorStyle.topLeft) {
modalEl.style.left = mLeft;
modalEl.style.top = mTop;
modalEl.style.height = mTopHeight;
modalEl.style.width = mLeftWidth;
// 右上角拖拽
} else if (cursor === cursorStyle.topright) {
modalEl.style.top = mTop;
modalEl.style.width = mRightWidth;
modalEl.style.height = mTopHeight;
// 左下角拖拽
} else if (cursor === cursorStyle.bottomLeft) {
modalEl.style.left = mLeft;
modalEl.style.width = mLeftWidth;
modalEl.style.height = mBottomHeight;
// 右下角拖拽
} else if (cursor === cursorStyle.bottomRight) {
modalEl.style.width = mRightWidth;
modalEl.style.height = mBottomHeight;
}
innerWidth.value = modalEl.style.width;
}, 20);
const mouseup = () => {
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mouseup', mouseup);
document.body.style.userSelect = 'auto';
modalWrapEl.style.cursor = cursorStyle.auto;
};
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);
};
}
inited = false;
};
watch(visibleModel, async (val) => {
if ((val && Object.is(inited, false)) || props.destroyOnClose) {
initDrag();
}
});
watch(() => route.fullPath, closeModal);
</script>
<style lang="less">
.draggable-modal {
&.fullscreen {
.ant-modal {
inset: 0 !important;
width: 100% !important;
max-width: 100vw !important;
height: 100% !important;
}
.ant-modal-content {
width: 100% !important;
height: 100% !important;
}
}
.ant-modal {
position: fixed;
min-width: 200px;
min-height: 200px;
padding: 0;
.ant-modal-header {
user-select: none;
}
.ant-modal-close {
top: 6px;
right: 30px;
background-color: transparent;
cursor: inherit;
&:hover,
&:focus {
color: rgb(0 0 0 / 45%);
}
.ant-space-item:hover .anticon,
.ant-space-item:focus .anticon {
color: rgb(0 0 0 / 75%);
text-decoration: none;
}
.ant-modal-close-x {
width: 50px;
height: 50px;
line-height: 44px;
.ant-space {
width: 100%;
height: 100%;
}
}
}
.ant-modal-content {
/* width: ~'v-bind("props.width")px'; */
display: flex;
flex-direction: column;
width: 100%;
min-width: 200px;
height: 100%;
min-height: 200px;
overflow: hidden;
.ant-modal-body {
flex: auto;
height: 100%;
overflow: auto;
}
}
}
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div ref="draggableContainer" class="draggableContainer">
<main v-for="item in list" :key="item.title" style="margin:2px;">
<section class="draggable-item">
<section class="my-handle r-2"
style="width: 100%;display:flex;justify-content:space-between;line-height:32px;">
<div>
<a-input flex="none" readonly :value="item.sequence as number + 1" class="site-input-split" style="width: 40px;border-radius:0; border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt; border-radius: 0;pointer-events: none" />
<a-input flex="none" readonly :value="item.title" class="site-input-split" style="width: 100px;border-radius:0; border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;pointer-events: none" />
</div>
<div style="margin-left: 4px;">
<a-radio-group v-model:value="item.hidden" @change="onChange">
<a-radio-button value="N">显示</a-radio-button>
<a-radio-button value="Y">隐藏</a-radio-button>
</a-radio-group>
</div>
<div style="margin-left: 4px;">
<a-input v-model:value="item.width" @change="onChange" style="width:60px; border-radius:0; border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt; text-align: right;" />
<a-select style="width:60px; border-radius: 5pt 5pt;" v-model:value="item.fixed" @select="onChange">
<a-select-option value=""></a-select-option>
<a-select-option value="left">左</a-select-option>
<a-select-option value="right">右</a-select-option>
</a-select>
</div>
</section>
</section>
</main>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue';
import Sortable from 'sortablejs';
// interface Item {
// label: string;
// value: string; // 可能是会需要value值
// }
interface Props {
list: Array<Column>;
options?: Sortable.Options;
}
const draggableContainer = ref<HTMLDivElement | null>(null);
const props = defineProps<Props>();
interface EmitsType {
(e: 'update:list', value: any[]): void;
(e: 'onChangeList', value: any[]): void;
}
const emit = defineEmits<EmitsType>();
const sortable = ref<Sortable | null>(null);
onMounted(() => {
initDraggable();
});
const onChange = () => {
emit('onChangeList', props.list);
};
const initDraggable = () => {
if (!draggableContainer.value) {
console.warn('容器不能为空');
return;
}
sortable.value = Sortable.create(draggableContainer.value, {
handle: '.my-handle',
chosenClass: 'chosen',
dragClass: 'drag',
direction: 'horizontal',
forceFallback: true,
animation: 300,
onUpdate(e: any) {
if (e.oldIndex !== undefined && e.newIndex !== undefined) {
// 删除拖拽的元素
const list = [...props.list];
const item = list.splice(e.oldIndex, 1)[0];
// 把删除的元素放到新的位置
list.splice(e.newIndex, 0, item);
let i: number = 0;
list.forEach((element: Column) => {
element.sequence = i++;
});
emit('update:list', list);
emit('onChangeList', list);
};
},
...props.options
});
};
onUnmounted(() => {
sortable.value?.destroy();
});
</script>
<style lang="less" scoped>
.draggableContainer {
max-width: 30rem;
background: #ffffff;
color: #495057;
border: 0 none;
border-radius: 6px;
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
}
.r-2 {
margin-right: 2rem;
}
.draggable-item {
margin: 0;
padding: 0.75rem 1.25rem;
border-width: 1px;
border-style: solid;
border-color: #d9d9d9;
color: #495057;
background: transparent;
transition: box-shadow 0.2s;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: normal;
white-space: nowrap;
position: relative;
overflow: hidden;
}
//.draggable-item:hover {
// background: #e9ecef;
//}
.my-handle {
cursor: move;
cursor: -webkit-grabbing;
}
.drag {
// 正在拖拽中幽灵图的样式
display: none;
cursor: move;
cursor: -webkit-grabbing;
}
.chosen {
cursor: move;
cursor: -webkit-grabbing;
border-radius: 6px;
box-shadow: 0 2px 10px 0 rgb(0 0 0 / 20%);
}
</style>
+339
View File
@@ -0,0 +1,339 @@
<template>
<div ref="searchRef" style="margin-left: 4px;padding-top: 4px; margin-right: 12px">
<searchCriteria @submit="onSubmit" @reset="onReset" @handle-change-bind-value="handleChangeBindValue">
<template #control>
<slot name="control"></slot>
</template>
</searchCriteria>
<hr />
</div>
<div v-if="!isModal && !isSM" class="setting">
<a-row :wrap="false">
<a-col :lg="4"></a-col>
<a-col style="display: flex; justify-content: flex-end" :lg="20" :sm="24">
<a-input flex="none" readonly value="视图" class="site-input-split" style="
width: 80px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
<a-select style="min-width: 200px" v-model:value="viewOption.Value" @select="onSelectColumn">
<a-select-option v-for="o in viewOption.SelectOptions" :key="o.DisplayName" :value="o.BindValue">{{
o.DisplayName }}</a-select-option>
</a-select>
<a-button type="dashed" :icon="h(SettingTwoTone)" @click.prevent="onViewResult"></a-button>
<a-button type="dashed" :icon="h(PrinterTwoTone)" @click.prevent="onPrint"></a-button>
</a-col>
</a-row>
<columnModal :show-modal="showColumnModal" @on-change-columns="onChangeColumns" @handle-close="onHandleClose"
@on-change-view-option="onChangeViewOption" />
</div>
<a-table style="margin-right: 6px" :row-key="(index: any) => { return index }" :columns="columns" :data-source="datas"
:row-selection="rowSelection" :pagination="pagination" @change="handleTableChange"
:scroll="{ x: scrollx, y: resultH }" :custom-row="customRow" size="small" bordered>
headerCell
<template #headerCell="datas">
<slot name="headerCell" v-bind="datas"> </slot>
</template>
<template #bodyCell="datas">
<template v-if="datas.column.dataIndex === 'index'">
<span>{{ ((pagination.current as number - 1) * pagination.pageSize) + datas.index + 1 }}</span>
</template>
<slot name="bodyCell" v-bind="datas"> </slot>
</template>
</a-table>
<slot name="footer" style="padding-left: 4px;"></slot>
</template>
<script setup lang="ts">
import { reactive, useSlots, watch, inject, computed, onMounted, defineComponent, h, ref, unref, provide } from "vue";
import { usePagination, type PaginationQueryResult } from 'vue-request'
import type { TablePaginationConfig, TableProps } from 'ant-design-vue'
import searchCriteria from "@/components/basic/SearchCriteria.vue";
import { SearchFilter } from "@/data/auto/searchFilter"
import { SearchCriteriaResultData } from "@/data/auto/searchCriteriaResultData";
import { BindOption } from "@/data/custom/bindOption";
import { SelectOption } from "@/data/auto/selectOption";
import { SettingTwoTone, PrinterTwoTone } from '@ant-design/icons-vue';
import columnModal from "./ColumnModal.vue";
import { useWindowSize } from '@vueuse/core'
import { bsInfo } from "@/utils/browser"
import { on } from "events";
import { enquiryMethodKey } from "@/injections";
//================props================
const props = defineProps({
isModal: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
scrollx: {
type: Number,
default: 1024,
},
scrolly: {
type: Number,
default: 0,
},
pagination: {
type: SearchCriteriaResultData,
default: new SearchCriteriaResultData(),
},
rowSelectionType: {
type: String,
default: 'radio',
},
});
const isModal = ref(props['isModal']);
const paginationJ = ref(props['pagination']);
const rowSelectionType = ref(props['rowSelectionType']);
const tableY = ref(props['scrolly']);
const enquiry = inject(enquiryMethodKey);
const datas = enquiry?.getDatas()!;
const viewOption = ref(
enquiry ? enquiry.getViewOption() : {
Value: "default",
SelectOptions: [{ BindValue: "default", DisplayName: "默认" }],
Columns: [{ BindValue: "default", BindColumn: [] }],
}
);
//================inject================
const isSM = ref<boolean>(false);
const { width, height } = useWindowSize()
if (bsInfo.size.sm) {
isSM.value = true;
}
else {
isSM.value = false;
}
const resultH = ref('');
const showColumnModal = ref<boolean>(false);
const selected = ref<any[]>([])
const emit = defineEmits(['onReset', 'onSubmit', 'handleChangeBindValue', 'usePagination', 'onRowDblclick', 'onclick', 'onSelectChange', 'onViewResult', 'onPrint', 'updateColumns']);
const onViewResult = (() => {
showColumnModal.value = true;
emit('onViewResult');
});
const onHandleClose = (() => {
showColumnModal.value = false;
});
const onPrint = (() => {
emit('onPrint', viewOption.value);
});
const searchRef = ref<HTMLElement | null>()
onMounted(() => {
computResultTableHeight()
})
watch(isModal, () => {
computResultTableHeight();
});
const computResultTableHeight = () => {
if (width.value > 640) {
if (isModal.value) {
resultH.value = 'calc(100vh - ' + searchRef.value?.offsetHeight + 'px - 560px )'
}
else {
resultH.value = 'calc(100vh - ' + searchRef.value?.offsetHeight + 'px - 330px )'
}
}
else {
if (isModal.value) {
resultH.value = 'calc(100vh - ' + searchRef.value?.offsetHeight + 'px - 560px )'
}
else {
if (tableY.value > 0) {
resultH.value = 'calc(100vh - ' + searchRef.value?.offsetHeight + 'px - ' + tableY.value + 'px)';
}
else {
resultH.value = 'calc(100vh - ' + searchRef.value?.offsetHeight + 'px - 380px )'
}
}
}
}
defineOptions({
name: "search",
});
const columns = ref(viewOption.value.Columns[0].BindColumn?.filter((x: any) => x.hidden != 'Y'));
//接收拖动变化
const onChangeColumns = ((cls: Column[]) => {
columns.value = cls?.filter(x => x.hidden != 'Y');
});
const onChangeViewOption = ((vop: ViewOption) => {
viewOption.value = vop;
});
const onSelectColumn = (() => {
columns.value = viewOption.value.Columns.filter((x: any) => x.BindValue == viewOption.value.Value)[0].BindColumn?.filter((x: any) => x.hidden != 'Y') as Column[];
});
const onSubmit = (async (filters: SearchFilter[]) => {
emit('onSubmit', filters);
});
const handleChangeBindValue = ((bindValue: string, value: string) => {
emit('handleChangeBindValue', bindValue, value)
});
const onReset = (async () => {
datas.value = [];
emit('onReset');
});
const customRow = (record: any) => {
return {
onDblclick: (event: any) => {
if (selected.value.includes(record)) {
selected.value = selected.value.filter(item => item !== record);
}
else {
selected.value.push(record);
}
emit('onRowDblclick', event, record);
emit('onSelectChange', selected.value);
},
onclick: (event: any) => {
if (unref(rowSelectionType) == 'radio') {
selected.value = [record];
}
else {
if (selected.value.includes(record)) {
selected.value = selected.value.filter(item => item !== record);
}
else {
selected.value.push(record);
}
}
emit('onclick', event, [record]);
emit('onSelectChange', selected.value);
}
}
}
const onSelectChange = (changableRowKeys: any[]) => {
selected.value = changableRowKeys;
emit('onSelectChange', selected.value);
};
const rowSelection = computed(() => {
return {
columnWidth: '4%',
type: rowSelectionType.value,
selectedRowKeys: unref(selected),
onChange: onSelectChange,
}
});
const pagination = computed(() => ({
total: paginationJ.value.Total,
current: paginationJ.value.Page,
pageSize: paginationJ.value.Limit
}))
const handleTableChange: TableProps['onChange'] = (
pag: TablePaginationConfig,
filters: any,
sorter: any
) => {
run({
pageSize: pag.pageSize,
page: pag.current,
sortField: sorter.field,
sortOrder: sorter.order,
...filters
})
}
const queryData = async (params: PagingParams): Promise<any> => {
try {
emit('usePagination', params);
} catch (err) {
}
return datas.value;
}
const { run, current, pageSize } = usePagination(queryData, {
pagination: {
currentKey: 'page',
pageSizeKey: 'pageSize',
totalKey: 'totalCount'
}
})
</script>
<style lang="less">
.search-criteria .ant-select-single .ant-select-selector {
border-top-left-radius: 0pt 0pt;
border-bottom-left-radius: 0pt 0pt;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0 !important;
}
.search-criteria .ant-select-multiple .ant-select-selector {
border-top-left-radius: 0pt 0pt;
border-bottom-left-radius: 0pt 0pt;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0 !important;
}
.search-criteria .ant-picker {
width: 100%;
}
.setting {
margin-right: 12px;
margin-bottom: 4px;
}
.setting .ant-select-selector {
border-radius: 0;
border-left: 0 !important;
}
.setting .ant-btn {
padding-top: 4px;
padding-bottom: 4px;
border-radius: 0;
}
.page-footer {
z-index: 999;
padding-left: 8px;
padding-right: 18px;
padding-top: 8px;
width: 100%;
position: fixed;
bottom: 8px;
border-top: 1px solid silver;
}
</style>
@@ -0,0 +1,640 @@
<template>
<a-form layout="horizontal" labelAlign="left" class="search-criteria">
<a-row :gutter="[16, 8]">
<a-col :lg="layoutSetting.controlLeft" :sm="24">
<a-row :gutter="[16, 8]">
<template v-for="(bindOption, idx) in bindOptions">
<a-col v-if="bindOption.Type == 'Number'" class="gutter-row" :lg="bindOption.lg" :sm="bindOption.sm">
<a-row v-if="bindOption.Operator == 'between'" :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-input v-model:value="bindOption.NumberValue" :readonly="bindOption.disabled" flex="auto" style="
border-radius: 0;
border-left: 0;
border-right: 0;
text-align: right;
" />
</a-col>
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" value="~" class="site-input-split" style="
width: 30px;
border-radius: 0;
background-color: rgb(250, 250, 250);
pointer-events: none;
" placeholder="~" />
</div>
</a-col>
<a-col flex="auto">
<a-input v-model:value="bindOption.NumberValue1" :readonly="bindOption.disabled" flex="auto" style="
border-radius: 0;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0;
text-align: right;
" />
</a-col>
</a-row>
<a-row v-else :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-input v-model:value="bindOption.NumberValue" :readonly="bindOption.disabled" flex="auto" style="
border-radius: 0;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0;
text-align: right;
" />
</a-col>
</a-row>
</a-col>
<a-col v-if="bindOption.Type == 'Text' || bindOption.Type == 'TextContain'" class="gutter-row"
:lg="bindOption.lg" :sm="bindOption.sm">
<a-row :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-input :readonly="bindOption.disabled" v-model:value="bindOption.Value" flex="auto" style="
border-radius: 0;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0;
text-align: left;
" />
</a-col>
</a-row>
</a-col>
<a-col v-if="bindOption.Type == 'Date'" class="gutter-row" :lg="bindOption.lg" :sm="bindOption.sm">
<a-row v-if="bindOption.Operator == 'date_between'" :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-date-picker :disabled="bindOption.disabled" v-model:value="bindOption.DayjsValue" flex="auto"
style="border-radius: 0; border-left: 0; text-align: left" />
</a-col>
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" value="~" class="site-input-split" style="
border-left: 0;
width: 30px;
border-radius: 0;
background-color: rgb(250, 250, 250);
pointer-events: none;
" placeholder="~" />
</div>
</a-col>
<a-col flex="auto">
<a-date-picker :disabled="bindOption.disabled" v-model:value="bindOption.DayjsValue1" flex="auto"
style="
border-radius: 0;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0;
text-align: left;
" />
</a-col>
</a-row>
<a-row v-else :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-date-picker :disabled="bindOption.disabled" v-model:value="bindOption.DayjsValue" flex="auto"
style="
border-radius: 0;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0;
text-align: left;
" />
</a-col>
</a-row>
</a-col>
<a-col v-if="bindOption.Type == 'DateTime'" class="gutter-row" :lg="bindOption.lg" :sm="bindOption.sm">
<a-row v-if="bindOption.Operator == 'datetime_between'" :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-date-picker :disabled="bindOption.disabled" show-time v-model:value="bindOption.DayjsValue"
flex="auto" style="border-radius: 0; border-left: 0; text-align: left" />
</a-col>
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" value="~" class="site-input-split" style="
border-left: 0;
width: 30px;
border-radius: 0;
background-color: rgb(250, 250, 250);
pointer-events: none;
" placeholder="~" />
</div>
</a-col>
<a-col flex="auto">
<a-date-picker :disabled="bindOption.disabled" show-time v-model:value="bindOption.DayjsValue1"
flex="auto" style="
border-radius: 0;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0;
text-align: left;
" />
</a-col>
</a-row>
<a-row v-else :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-date-picker :disabled="bindOption.disabled" show-time v-model:value="bindOption.DayjsValue"
flex="auto" style="
border-radius: 0;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0;
text-align: left;
" />
</a-col>
</a-row>
</a-col>
<a-col v-if="bindOption.Type == 'SingleSelection'" class="gutter-row" :lg="bindOption.lg"
:sm="bindOption.sm">
<a-row :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-select @change="handleChange(bindOption.Value, bindOption.BindValue)"
:disabled="bindOption.disabled" v-model:value="bindOption.Value"
style="width: 100%; min-width: 124px">
<a-select-option v-for="o in bindOption.SelectOptions" :value="o.BindValue">{{ o.DisplayName
}}</a-select-option>
</a-select>
</a-col>
</a-row>
</a-col>
<a-col v-if="bindOption.Type == 'SearchSingleSelection'" class="gutter-row" :lg="bindOption.lg"
:sm="bindOption.sm">
<a-row :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-select :disabled="bindOption.disabled" show-search :filter-option="filterOptionHandle"
v-model:value="bindOption.Value" style="width: 100%; min-width: 124px">
<a-select-option v-for="o in bindOption.SelectOptions" :key="o.DisplayName" :value="o.BindValue">{{
o.DisplayName }}</a-select-option>
</a-select>
</a-col>
</a-row>
</a-col>
<a-col v-if="bindOption.Type == 'MultipleSelection'" class="gutter-row" :lg="bindOption.lg"
:sm="bindOption.sm">
<a-row :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-select :disabled="bindOption.disabled" show-search :filter-option="filterOptionMultipleHandle"
v-model:value="bindOption.Values" mode="multiple" style="width: 100%; min-width: 124px">
<a-select-option v-for="o in bindOption.SelectOptions" :key="o.DisplayName" :value="o.BindValue">{{
o.DisplayName }}</a-select-option>
</a-select>
</a-col>
</a-row>
</a-col>
<a-col v-if="bindOption.Type == 'MenuSingleSelection'" class="gutter-row" :lg="bindOption.lg"
:sm="bindOption.sm">
<a-row :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto">
<a-select :disabled="bindOption.disabled" v-model:value="bindOption.Value"
style="width: 100%; min-width: 160px" :options="bindOption.SelectOptions.map((item) => ({
name: item.DisplayName,
value: item.BindValue + ' - ' + item.DisplayName,
}))
">
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<a-space style="padding: 4px 8px">
<a-input v-model:value="menuMultipleSelectionItem" placeholder="Please enter item" />
<a-button type="text" @click="addItem(bindOption.SelectOptions)">
<template #icon>
<plus-outlined />
</template>
Add item
</a-button>
</a-space>
</template>
</a-select>
</a-col>
</a-row>
</a-col>
<a-col v-if="bindOption.Type == 'Checkbox'" style="line-height: 32px" class="gutter-row" :lg="bindOption.lg"
:sm="bindOption.sm">
<a-checkbox :disabled="bindOption.disabled" v-model:checked="bindOption.BooleanValue">{{
bindOption.DisplayName
}}</a-checkbox>
</a-col>
<a-col v-if="bindOption.Type == 'CheckboxGroup'" style="line-height: 32px" class="gutter-row"
:lg="bindOption.lg" :sm="bindOption.sm">
<a-row :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto" style="
padding-left: 8px;
border: 1px solid rgb(204, 201, 201);
border-left: 0;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
height: 32px;
">
<a-checkbox-group :disabled="bindOption.disabled" v-model:value="bindOption.BooleanValues"
:name="bindOption.BindValue" :options="bindOption.SelectOptions.map((item) => ({
label: item.DisplayName,
value: item.BindValue,
}))
" />
</a-col>
</a-row>
</a-col>
<a-col v-if="bindOption.Type == 'RadioGroup'" style="line-height: 32px" class="gutter-row"
:lg="bindOption.lg" :sm="bindOption.sm">
<a-row :wrap="false">
<a-col flex="none">
<div style="padding: 0">
<a-input flex="none" readonly :value="bindOption.DisplayName" class="site-input-split" style="
width: 160px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
" />
</div>
</a-col>
<a-col flex="auto" style="
padding-left: 8px;
border: 1px solid rgb(204, 201, 201);
border-left: 0;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
height: 32px;
">
<a-radio-group :disabled="bindOption.disabled" v-model:value="bindOption.BooleanValue"
:name="bindOption.BindValue">
<a-radio v-for="o in bindOption.SelectOptions" :key="o.DisplayName" :value="o.BindValue">{{
o.DisplayName
}}</a-radio>
</a-radio-group>
</a-col>
</a-row>
</a-col>
</template>
</a-row>
</a-col>
<a-col :lg="layoutSetting.controlRight" :sm="24">
<a-row v-if="!layoutSetting.space" :gutter="[16, 8]">
<a-col style="display: flex; justify-content: flex-end" :lg="layoutSetting.soltLeft" :sm="24">
<a-button type="primary" :icon="h(FileSearchOutlined)" style="max-width: 160px; margin-right: 12px"
@click.prevent="onSubmit">查询</a-button>
</a-col>
<a-col style="display: flex; justify-content: flex-end" :lg="layoutSetting.soltRight" :sm="24">
<a-button type="primary" :icon="h(ClearOutlined)" style="max-width: 160px; margin-right: 12px"
@click.prevent="onReset" flex="auto">重置</a-button>
</a-col>
<slot name="control"></slot>
</a-row>
<a-space style="display: flex; justify-content: flex-end" v-else>
<a-button type="primary" :icon="h(FileSearchOutlined)" @click.prevent="onSubmit">查询</a-button>
<a-button type="primary" :icon="h(ClearOutlined)" style="max-width: 160px" @click.prevent="onReset"
flex="auto">重置</a-button>
<slot name="control"></slot>
</a-space>
</a-col>
</a-row>
</a-form>
</template>
<script setup lang="ts">
import { reactive, defineComponent, inject, ref, onMounted, h, computed } from "vue";
import { BindOption } from "@/data/custom/bindOption";
import { SelectOption } from "@/data/auto/selectOption";
import { SearchFilter } from "@/data/auto/searchFilter";
import i18n from "@/locales";
import { PlusOutlined, FileSearchOutlined, ClearOutlined } from "@ant-design/icons-vue";
import { enquiryMethodKey } from "@/injections";
import { useSearchCriteriaProvide } from "@/provides";
import { on } from "events";
import { nextTick } from "process";
defineOptions({
name: "searchCriteria",
});
const { t } = i18n.global;
const VNodes = defineComponent({
props: {
vnodes: {
type: Object,
required: true,
},
},
render() {
return this.vnodes;
},
});
const menuMultipleSelectionItem = ref("");
const addItem = (e: SelectOption[]) => {
if (!menuMultipleSelectionItem.value || menuMultipleSelectionItem.value.length == 0)
return;
e.push({
BindValue: menuMultipleSelectionItem.value,
DisplayName: menuMultipleSelectionItem.value,
});
};
const filterOptionHandle = (input: string, option: any) => {
return (
option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0 ||
option.key.toLowerCase().indexOf(input.toLowerCase()) >= 0
);
};
const filterOptionMultipleHandle = (input: string, option: any) => {
return (
option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0 ||
option.key.toLowerCase().indexOf(input.toLowerCase()) >= 0
);
};
const emit = defineEmits(["reset", "submit", "handleChangeBindValue"]);
let filters: SearchFilter[] = [];
const getFilters = () => {
filters = [];
let filter: SearchFilter;
bindOptions.value.forEach((element: BindOption) => {
filter = new SearchFilter();
filter.Column = element.BindValue;
filter.Type = element.Type;
filter.Operator = element.Operator;
filter.Value = element.Value;
filter.Value1 = element.Value1;
filter.Values = element.Values;
switch (filter.Type) {
case "Text":
case "TextContain":
if (element.Value && element.Value.length > 0) filters.push(filter);
break;
case "Number":
if (element.NumberValue) filter.Value = element.NumberValue.toString();
if (element.Operator == "between" && element.NumberValue1) {
filter.Value1 = element.NumberValue1.toString();
}
if (
(filter.Value && filter.Value.length > 0) ||
(filter.Value1 && filter.Value1.length > 0)
)
filters.push(filter);
break;
case "Date":
if (element.DayjsValue) filter.Value = element.DayjsValue.format("YYYY-MM-DD");
if (element.Operator == "date_between" && element.DayjsValue1) {
filter.Value1 = element.DayjsValue1.format("YYYY-MM-DD");
}
if (
(filter.Value && filter.Value.length > 0) ||
(filter.Value1 && filter.Value1.length > 0)
)
filters.push(filter);
break;
case "DateTime":
if (element.DayjsValue)
filter.Value = element.DayjsValue.format("YYYY-MM-DD HH:mm:ss");
if (element.Operator == "date_between" && element.DayjsValue1) {
filter.Value1 = element.DayjsValue1.format("YYYY-MM-DD HH:mm:ss");
}
if (
(filter.Value && filter.Value.length > 0) ||
(filter.Value1 && filter.Value1.length > 0)
)
filters.push(filter);
break;
case "RadioGroup":
case "Checkbox":
filter.Value = element.BooleanValue.toString();
if (element.BooleanValue) filters.push(filter);
break;
case "CheckboxGroup":
if (element.BooleanValues && element.BooleanValues.length > 0) {
filter.Values = [];
element.BooleanValues.forEach((b: boolean) => {
filter.Values.push(b.toString());
});
}
if (filter.Values.length > 0) filters.push(filter);
break;
case "MenuSingleSelection":
if (filter.Value && filter.Value.length > 0) {
filter.Value = filter.Value.split(" - ")[0];
filters.push(filter);
}
break;
default:
if (
(filter.Value && filter.Value.length > 0) ||
(filter.Value1 && filter.Value1.length > 0) ||
(filter.Values && filter.Values.length > 0)
)
filters.push(filter);
break;
}
});
return filters;
};
const onSubmit = () => {
filters = getFilters();
emit("submit", filters);
};
const handleChange = (bindValue: string, value: string) => {
emit("handleChangeBindValue", bindValue, value);
};
const onReset = () => {
bindOptions.value.forEach((element: BindOption) => {
element.Value = "";
element.Value1 = "";
element.Values = [];
element.NumberValue = null!;
element.NumberValue1 = null!;
element.NumberValues = [];
element.BooleanValue = false;
element.BooleanValue1 = false;
element.BooleanValues = [];
element.DayjsValue = null!;
element.DayjsValue1 = null!;
element.DayjsValues = [];
});
emit("reset");
};
const enquiry = inject(enquiryMethodKey);
const layoutSetting: LayoutSetting = enquiry ? (enquiry.getLayoutSetting() as LayoutSetting) :
{ controlLeft: 18, controlRight: 6, space: true, soltLeft: 24, soltRight: 24 };
const bindOptions = computed(() => { return enquiry ? (enquiry.getBindOptions()!) : []; });
useSearchCriteriaProvide(getFilters);
</script>
<style lang="less">
.search-criteria .ant-select-single .ant-select-selector {
border-top-left-radius: 0pt 0pt;
border-bottom-left-radius: 0pt 0pt;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0 !important;
}
.search-criteria .ant-select-multiple .ant-select-selector {
border-top-left-radius: 0pt 0pt;
border-bottom-left-radius: 0pt 0pt;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0 !important;
}
.search-criteria .ant-picker {
width: 100%;
}
.search-criteria .ant-picker-input input[readonly] {
background-color: #fff !important;
}
</style>
@@ -0,0 +1,65 @@
<template>
<Checkbox v-bind="getProps" v-model:checked="checkedModel" @change="handleChange">
<slot></slot>
</Checkbox>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { checkboxProps } from 'ant-design-vue/es/checkbox';
import { omit } from 'lodash-es';
import { Checkbox } from 'ant-design-vue';
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
...checkboxProps(),
Value: {
type: [Number, Boolean, String],
default: false,
},
trueValue: {
type: [Number, Boolean, String],
default: true,
},
falseValue: {
type: [Number, Boolean, String],
default: false,
},
});
const emit = defineEmits(['update:checked', 'update:value', 'change']);
const getProps = computed(() => {
return omit(props, ['onUpdate:checked', 'onChange']);
});
computed({
get() {
return props.value;
},
set(value) {
emit('update:value', value);
}
});
const checkedModel = ref<boolean>(props.value == props.trueValue);
watch(() => props.value, (value) => {
checkedModel.value = value == props.trueValue;
});
const handleChange = (e: any) => {
const evt = {
...e,
target: {
...e.target,
checked: e.target.checked ? props.trueValue : props.falseValue,
},
};
emit('update:value', e.target.checked ? props.trueValue : props.falseValue);
emit('change', evt);
};
</script>
@@ -0,0 +1,72 @@
import { defineComponent, unref, computed } from 'vue';
import { createFromIconfontCN } from '@ant-design/icons-vue';
import type { PropType } from 'vue';
import { isString } from '@/utils/is';
let scriptUrls = [`/iconfont.js`];
let MyIconFont = createFromIconfontCN({
// scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js',
// scriptUrl: '//at.alicdn.com/t/font_2184398_zflo1kjcemp.js',
// iconfont字体图标本地化,详见:/public/iconfont.js
scriptUrl: scriptUrls,
});
export default defineComponent({
name: 'CustIconFont',
props: {
type: {
type: String as PropType<string>,
default: '',
},
prefix: {
type: String,
default: 'icon-',
},
color: {
type: String as PropType<string>,
default: 'unset',
},
size: {
type: [Number, String] as PropType<number | string>,
default: 14,
},
scriptUrl: {
// 阿里图库字体图标路径
type: String as PropType<string | string[]>,
default: '',
},
},
setup(props, { attrs }) {
// 如果外部传进来字体图标路径,则覆盖默认的
if (props.scriptUrl) {
scriptUrls = [...new Set(scriptUrls.concat(props.scriptUrl))];
MyIconFont = createFromIconfontCN({
scriptUrl: scriptUrls,
});
}
const wrapStyleRef = computed(() => {
const { color, size } = props;
const fs = isString(size) ? parseFloat(size) : size;
return {
color,
fontSize: `${fs}px`,
};
});
return () => {
const { type, prefix } = props;
return type ? (
<MyIconFont
type={type.startsWith(prefix) ? type : `${prefix}${type}`}
{...attrs}
style={unref(wrapStyleRef)}
/>
) : null;
};
},
});
@@ -0,0 +1,2 @@
import CustIconFont from './icon-font';
export { CustIconFont };
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,78 @@
<template>
<Popover v-model:open="visible" placement="bottomLeft" trigger="focus">
<template #content>
<div class="select-box">
<template v-for="iconItem in glyphs" :key="iconItem.font_class">
<div :title="iconItem.name" class="select-box-item"
:class="{ active: modelValue?.replace('icon-', '') == iconItem.font_class }" @click="selectIcon(iconItem)">
<cust-icon-font :type="iconItem.font_class" size="20" />
</div>
</template>
</div>
</template>
<a-input :disabled="disabled" v-model:value="modelValue" :placeholder="placeholder">
<template v-if="modelValue" #prefix>
<cust-icon-font :type="modelValue" size="22" />
</template>
</a-input>
</Popover>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useVModel } from '@vueuse/core';
import { Popover } from 'ant-design-vue';
import icons from './icons.json';
import { CustIconFont } from '@/components/basic/iconfont';
const { glyphs } = icons;
interface Props {
value: string;
placeholder?: string;
disabled:boolean;
}
interface Emits {
(e: 'update:value', val: string): void;
}
const props = withDefaults(defineProps<Props>(), {
value: '',
placeholder: '请选择',
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const modelValue = useVModel(props, 'value', emit);
const selectIcon = (iconItem: (typeof glyphs)[number]) => {
modelValue.value = iconItem.font_class;
visible.value = false;
};
</script>
<style lang="less" scoped>
.select-box {
display: grid;
grid-template-columns: repeat(9,minmax(0,1fr));
height: 300px;
width: 600px;
overflow: auto;
justify-content:flex-start;
&-item {
display: flex;
margin: -2px;
padding: -6px;
height: 30px;
width: 30px;
border: 1px solid #e5e7eb;
&:hover,
&.active {
border: 1px solid #e5e7eb;
}
}
}
</style>
@@ -0,0 +1,3 @@
import SvgIcon from './svg-icon.vue';
export { SvgIcon };
@@ -0,0 +1,44 @@
<template>
<svg v-bind="$attrs" class="svg-icon" :style="getStyle" aria-hidden="true">
<use :xlink:href="symbolId" />
</svg>
</template>
<script lang="ts" setup>
import { computed, type CSSProperties } from 'vue';
defineOptions({
name: 'SvgIcon',
});
const props = defineProps({
prefix: {
type: String,
default: 'svg-icon',
},
name: {
type: String,
required: true,
},
size: {
type: [Number, String],
default: 16,
},
});
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
const getStyle = computed((): CSSProperties => {
const { size } = props;
const s = `${size}`.replace('px', '').concat('px');
return {
width: s,
height: s,
};
});
</script>
<style lang="less">
.svg-icon {
overflow: hidden;
fill: currentcolor;
vertical-align: -0.15em;
}
</style>