ow
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user