feat: signature (#194)
Some checks failed
Spell Check / Spell Check with Typos (push) Failing after 8s

* refactor: enable profile signature option in ProfileMenu

* feat: add signature api function

* refactor: add new translation keys for 'Draw' and 'New Upload' in English and Thai

* refactor: update image URL variable and improve translation keys in CanvasComponent and MainLayout

* refactor: get function

* feat: add delete signature function

* feat: add canvas manipulation functions and integrate signature submission in MainLayout (unfinished)

* chore(deps): update

---------

Co-authored-by: puriphatt <puriphat@frappet.com>
Co-authored-by: Methapon2001 <61303214+Methapon2001@users.noreply.github.com>
This commit is contained in:
Methapon Metanipat 2025-03-27 09:01:42 +07:00 committed by GitHub
parent 3646956038
commit 0e685a99f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 378 additions and 149 deletions

View file

@ -4,7 +4,7 @@ import { useQuasar } from 'quasar';
import SignaturePad from 'signature_pad';
import Cropper from 'cropperjs';
defineExpose({ clearCanvas, clearUpload });
defineExpose({ setCanvas, getCanvas, clearCanvas, clearUpload });
const $q = useQuasar();
const isDarkActive = computed(() => $q.dark.isActive);
@ -18,7 +18,7 @@ const cropper = ref();
const tab = ref('draw');
const uploadFile = ref<File | undefined>(undefined);
const profileUrl = ref<string | null>('');
const imgUrl = ref<string | null>('');
const inputFile = (() => {
const element = document.createElement('input');
element.type = 'file';
@ -26,7 +26,7 @@ const inputFile = (() => {
const reader = new FileReader();
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') profileUrl.value = reader.result;
if (typeof reader.result === 'string') imgUrl.value = reader.result;
});
element.addEventListener('change', () => {
@ -39,12 +39,11 @@ const inputFile = (() => {
return element;
})();
async function initializeSignaturePad(canva?: HTMLCanvasElement) {
if (canva) {
signaturePad.value = new SignaturePad(canva, {
backgroundColor: isDarkActive.value
? 'rgb(21,25,29)'
: 'rgb(248,249,250)',
async function initializeSignaturePad() {
const canvas = canvasRef.value;
if (canvas) {
signaturePad.value = new SignaturePad(canvas, {
penColor: 'blue',
});
} else {
@ -77,34 +76,41 @@ function changeColor(color: string) {
currentColor.value = color;
}
function setCanvas() {
const data = signaturePad.value.toDataURL('image/png');
return data;
}
function getCanvas(signature: string) {
signaturePad.value.fromDataURL(signature);
}
function clearCanvas() {
signaturePad.value.clear();
}
function clearUpload() {
profileUrl.value = '';
imgUrl.value = '';
}
watch(
() => tab.value,
async () => {
await initializeSignaturePad(canvasRef.value);
await initializeCropper(imageRef.value);
await initializeSignaturePad();
},
);
onMounted(async () => {
await initializeSignaturePad(canvasRef.value);
await initializeCropper(imageRef.value);
await initializeSignaturePad();
});
</script>
<template>
<div class="surface-1 bordered rounded full-width">
<div class="surface-1 column full-width full-height">
<q-tabs
v-model="tab"
dense
align="left"
class="text-grey"
class="text-grey surface-2"
active-color="primary"
indicator-color="primary"
>
@ -112,18 +118,18 @@ onMounted(async () => {
<div class="row">
<q-tab
name="draw"
label="Draw"
:label="$t('general.draw')"
style="border-top-left-radius: var(--radius-2)"
/>
<q-tab name="upload" label="Upload" />
<q-tab name="upload" :label="$t('general.upload')" />
</div>
<div class="q-pr-md">
<q-btn
v-if="tab === 'upload'"
dense
flat
v-if="tab === 'upload'"
:label="$t('newUpload')"
:label="$t('general.newUpload')"
color="info"
@click="inputFile.click()"
/>
@ -132,89 +138,66 @@ onMounted(async () => {
</q-tabs>
<q-separator />
<div v-show="tab === 'draw'" class="q-pa-md">
<section v-show="tab === 'draw'" class="q-pa-md col">
<div class="column relative-position">
<div class="absolute-top-right q-ma-md q-gutter-x-md row items-center">
<article
class="absolute-top-right q-ma-md q-gutter-x-md row items-center"
>
<span
v-for="color in ['black', 'red', 'blue']"
:key="color"
:class="{ active: currentColor === color }"
class="dot"
:class="{ active: currentColor === 'black' }"
style="background-color: black"
@click="changeColor('black')"
:style="`background-color: ${color}`"
@click="changeColor(color)"
>
<q-icon
v-if="currentColor === 'black'"
v-if="currentColor === color"
name="mdi-check"
color="white"
size="sm"
/>
</span>
<span
:class="{ active: currentColor === 'red' }"
class="dot"
style="background-color: red"
@click="changeColor('red')"
>
<q-icon
v-if="currentColor === 'red'"
name="mdi-check"
color="white"
size="sm"
/>
</span>
<span
:class="{ active: currentColor === 'blue' }"
class="dot"
style="background-color: blue"
@click="changeColor('blue')"
>
<q-icon
v-if="currentColor === 'blue'"
name="mdi-check"
color="white"
size="sm"
/>
</span>
</div>
</article>
<canvas
class="signature-canvas"
ref="canvasRef"
id="signature-pad"
width="700"
height="310"
width="766"
height="364"
></canvas>
</div>
</div>
</section>
<div v-show="tab === 'upload'" class="q-pa-md">
<section v-show="tab === 'upload'" class="q-pa-md col">
<div
class="bordered upload-border rounded column items-center justify-center"
style="height: 312px"
class="bordered upload-border rounded column items-center justify-center full-height"
>
<q-img
v-show="profileUrl"
v-show="imgUrl"
ref="imageRef"
:src="profileUrl ?? ''"
:src="imgUrl ?? ''"
style="object-fit: cover; width: 100%; height: 100%"
/>
<div v-if="!profileUrl">
<div v-if="!imgUrl">
<q-icon
name="mdi-cloud-upload"
size="10rem"
style="color: hsla(var(--text-mute) / 0.2)"
/>
<div>
<div class="text-center">
<q-btn
unelevated
color="info"
:label="$t('uploadFile')"
:label="$t('general.upload')"
icon="mdi-plus"
@click="inputFile.click()"
/>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped lang="scss">

View file

@ -151,6 +151,8 @@ export default {
dueDate: 'Due date',
year: 'year',
tableOfContent: 'Table of Contents',
draw: 'Draw',
newUpload: 'New Upload',
},
menu: {

View file

@ -151,6 +151,8 @@ export default {
dueDate: 'วันครบกำหนด',
year: 'ปี',
tableOfContent: 'สารบัญ',
draw: 'วาด',
newUpload: 'อัปโหลดใหม่',
},
menu: {

View file

@ -50,7 +50,6 @@ const leftDrawerMini = ref(false);
const unread = computed<number>(
() => notificationData.value.filter((v) => !v.read).length || 0,
);
// const filterRole = ref<string[]>();
const userImage = ref<string>();
const userGender = ref('');
const canvasRef = ref();
@ -124,6 +123,17 @@ function readNoti(id: string) {
}
}
function signatureSubmit() {
const signature = canvasRef.value.setCanvas();
userStore.setSignature(signature);
canvasModal.value = false;
}
async function signatureFetch() {
const ret = await userStore.getSignature();
if (ret) canvasRef.value.getCanvas(ret);
}
onMounted(async () => {
initTheme();
initLang();
@ -502,13 +512,15 @@ onMounted(async () => {
no-app-box
:title="$t('menu.profile.addSignature')"
:close="() => (canvasModal = false)"
:submit="signatureSubmit"
:show="signatureFetch"
>
<CanvasComponent ref="canvasRef" v-model:modal="canvasModal" />
<template #footer>
<q-btn
flat
dense
:label="$t('clear')"
:label="$t('general.clear')"
@click="
() => {
canvasRef.clearCanvas(), canvasRef.clearUpload();

View file

@ -31,7 +31,7 @@ const options = [
label: 'menu.profile.signature',
value: 'signature',
color: 'grey',
disabled: true,
disabled: false,
},
{
icon: 'mdi-brightness-6',

View file

@ -15,6 +15,7 @@ import {
import axios from 'axios';
import useBranchStore from '../branch';
import { Branch } from '../branch/types';
import { getSignature, setSignature } from './signature';
const branchStore = useBranchStore();
@ -327,6 +328,9 @@ const useUserStore = defineStore('api-user', () => {
addAttachment,
deleteAttachment,
getSignature,
setSignature,
typeStats,
};
});

View file

@ -0,0 +1,50 @@
import axios from 'axios';
import { api } from 'src/boot/axios';
import { getUserId } from 'src/services/keycloak';
export async function getSignature() {
const userId = getUserId();
if (!userId) return;
const responseSignature = await api.get<string>(
'/user/' + userId + '/signature',
);
if (!responseSignature.data) return '';
const responseBlob = await axios.get<Blob>(responseSignature.data, {
responseType: 'blob',
});
if (responseBlob.status < 400) {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('error', reject);
reader.addEventListener('load', () => resolve(reader.result as string));
reader.readAsDataURL(responseBlob.data);
});
} else {
return '';
}
}
export async function setSignature(image: string) {
const userId = getUserId();
if (!userId) return;
if (image === '') {
return await deleteSignature();
} else {
return await api.put('/user/' + userId + '/signature', { data: image });
}
}
export async function deleteSignature() {
const userId = getUserId();
if (!userId) return;
return await api.delete('/user/' + userId + '/signature');
}