Skip to content

Commit 9bd0a50

Browse files
committed
Add download multiple support
1 parent 7e865e8 commit 9bd0a50

17 files changed

+437
-77
lines changed

index.html

+5-2
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" href="/favicon.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<meta name="description" content="A pure front-end avatar generator." />
7+
<meta
8+
name="description"
9+
content="An online avatar generator just for fun. Made with Vue3 and Vite."
10+
/>
811
<meta
912
name="keywords"
10-
content="vector avatar,illustrations,avatar generator,avatar creator,fun avatar,随机头像,卡通头像在线免费生成工具"
13+
content="vector avatar,illustrations,avatar generator,avatar creator,fun avatar,随机头像,卡通头像在线免费生成工具,头像生成器"
1114
/>
1215
<meta content="dark" name="color-scheme" />
1316

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"canvas-confetti": "^1.4.0",
2323
"clipboard": "^2.0.8",
2424
"html2canvas": "^1.3.2",
25+
"jszip": "^3.10.0",
26+
"object-hash": "^3.0.0",
2527
"perfect-scrollbar": "^1.5.2",
2628
"vue": "^3.2.30",
2729
"vue-i18n": "^9.2.0-beta.9",
@@ -33,6 +35,7 @@
3335
"@babel/preset-typescript": "^7.16.7",
3436
"@types/canvas-confetti": "^1.4.2",
3537
"@types/jest": "^27.0.2",
38+
"@types/object-hash": "^2.2.1",
3639
"@typescript-eslint/eslint-plugin": "^5.11.0",
3740
"@typescript-eslint/parser": "^5.11.0",
3841
"@vitejs/plugin-vue": "^2.1.0",

src/App.vue

+81-9
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,17 @@
2020
<ActionBar @action="handleAction" />
2121

2222
<div class="action-group">
23-
<button class="action-randomize" @click="handleGenerate">
23+
<button
24+
type="button"
25+
class="action-btn action-randomize"
26+
@click="handleGenerate"
27+
>
2428
{{ t('action.randomize') }}
2529
</button>
30+
2631
<button
27-
class="action-download"
32+
type="button"
33+
class="action-btn action-download"
2834
:disabled="downloading"
2935
@click="handleDownload"
3036
>
@@ -34,6 +40,14 @@
3440
: t('action.download')
3541
}}
3642
</button>
43+
44+
<button
45+
type="button"
46+
class="action-btn action-multiple"
47+
@click="() => generateMultiple()"
48+
>
49+
{{ t('action.downloadMultiple') }}
50+
</button>
3751
</div>
3852
</div>
3953

@@ -57,20 +71,27 @@
5771
</div>
5872
</Container>
5973

74+
<BatchDownloadModal
75+
:visible="avatarListVisible"
76+
:avatar-list="avatarList"
77+
@close=";(avatarListVisible = false), (avatarList = [])"
78+
/>
79+
6080
<Sider>
6181
<Configurator />
6282
</Sider>
6383
</main>
6484
</template>
6585

6686
<script lang="ts" setup>
67-
import { ref } from 'vue'
87+
import { ref, watchEffect } from 'vue'
6888
import { useI18n } from 'vue-i18n'
6989
7090
import ActionBar from '@/components/ActionBar.vue'
71-
import CodeModal from '@/components/CodeModal.vue'
7291
import Configurator from '@/components/Configurator.vue'
73-
import DownloadModal from '@/components/DownloadModal.vue'
92+
import BatchDownloadModal from '@/components/Modal/BatchDownloadModal.vue'
93+
import CodeModal from '@/components/Modal/CodeModal.vue'
94+
import DownloadModal from '@/components/Modal/DownloadModal.vue'
7495
import VueColorAvatar, {
7596
type VueColorAvatarRef,
7697
} from '@/components/VueColorAvatar.vue'
@@ -94,7 +115,9 @@ import {
94115
} from '@/utils/constant'
95116
import { recordEvent } from '@/utils/ga'
96117
118+
import { name as appName } from '../package.json'
97119
import ConfettiCanvas from './components/ConfettiCanvas.vue'
120+
import type { AvatarOption } from './types'
98121
99122
const store = useStore()
100123
@@ -152,7 +175,7 @@ async function handleDownload() {
152175
} else {
153176
const trigger = document.createElement('a')
154177
trigger.href = dataURL
155-
trigger.download = 'vue-color-avatar.png'
178+
trigger.download = `${appName}.png`
156179
trigger.click()
157180
}
158181
}
@@ -205,6 +228,44 @@ function handleAction(actionType: ActionType) {
205228
break
206229
}
207230
}
231+
232+
const avatarListVisible = ref(false)
233+
const avatarList = ref<AvatarOption[]>([])
234+
235+
watchEffect(() => {
236+
avatarListVisible.value =
237+
Array.isArray(avatarList.value) && avatarList.value.length > 0
238+
})
239+
240+
async function generateMultiple(count = 5 * 6) {
241+
const { default: hash } = await import('object-hash')
242+
243+
const avatarMap = [...Array(count)].reduce<Map<string, AvatarOption>>(
244+
(res) => {
245+
let randomAvatarOption: AvatarOption
246+
let hashKey: string
247+
248+
do {
249+
randomAvatarOption = getRandomAvatarOption(avatarOption.value)
250+
hashKey = hash.sha1(randomAvatarOption)
251+
} while (
252+
randomAvatarOption.background.color === 'transparent' ||
253+
res.has(hashKey)
254+
)
255+
256+
res.set(hashKey, randomAvatarOption)
257+
258+
return res
259+
},
260+
new Map()
261+
)
262+
263+
avatarList.value = Array.from(avatarMap.values())
264+
265+
recordEvent('click_generate_multiple', {
266+
event_category: 'click',
267+
})
268+
}
208269
</script>
209270

210271
<style lang="scss" scoped>
@@ -255,12 +316,17 @@ function handleAction(actionType: ActionType) {
255316
align-items: center;
256317
justify-content: center;
257318
margin-top: 4rem;
319+
column-gap: 1rem;
258320
259-
.action-randomize,
260-
.action-download {
321+
@supports not (column-gap: 1rem) {
322+
.action-btn {
323+
margin: 0 0.5rem;
324+
}
325+
}
326+
327+
.action-btn {
261328
min-width: 6rem;
262329
height: 2.5rem;
263-
margin: 0 1rem;
264330
padding: 0 1rem;
265331
color: var.$color-text;
266332
font-weight: bold;
@@ -280,6 +346,12 @@ function handleAction(actionType: ActionType) {
280346
cursor: default;
281347
}
282348
}
349+
350+
@media screen and (max-width: var.$screen-sm) {
351+
.action-multiple {
352+
display: none;
353+
}
354+
}
283355
}
284356
}
285357

src/components/Configurator.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
:title="t(`wrapperShape.${wrapperShape}`)"
1111
@click="switchWrapperShape(wrapperShape)"
1212
>
13-
<i
13+
<div
1414
class="shape"
1515
:class="[
1616
wrapperShape,
+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
<template>
2+
<ModalWrapper :visible="props.visible" @close="emit('close')">
3+
<div class="container">
4+
<div class="top-bar">
5+
<div>{{ t('text.downloadMultipleTip') }}</div>
6+
<div class="right">
7+
<button
8+
type="button"
9+
:disabled="making"
10+
class="download-btn"
11+
@click="make"
12+
>
13+
{{
14+
making
15+
? `${t('text.downloadingMultiple')}(${madeCount}/${
16+
avatarList?.length
17+
})`
18+
: t(`text.downloadMultiple`)
19+
}}
20+
</button>
21+
</div>
22+
</div>
23+
24+
<div class="content-box">
25+
<PerfectScrollbar
26+
style="height: 100%; overflow: hidden"
27+
:options="{ suppressScrollX: false }"
28+
>
29+
<div class="content">
30+
<template v-for="(opt, i) in props.avatarList" :key="i">
31+
<div
32+
class="avatar-box"
33+
:style="{ opacity: making && i + 1 > madeCount ? 0.5 : 1 }"
34+
>
35+
<VueColorAvatar :id="`avatar-${i}`" :option="opt" :size="280" />
36+
</div>
37+
</template>
38+
</div>
39+
</PerfectScrollbar>
40+
</div>
41+
</div>
42+
</ModalWrapper>
43+
</template>
44+
45+
<script lang="ts" setup>
46+
import { ref } from 'vue'
47+
import { useI18n } from 'vue-i18n'
48+
49+
import PerfectScrollbar from '@/components/PerfectScrollbar.vue'
50+
import VueColorAvatar from '@/components/VueColorAvatar.vue'
51+
import type { AvatarOption } from '@/types'
52+
import { recordEvent } from '@/utils/ga'
53+
54+
import { name as appName } from '../../../package.json'
55+
import ModalWrapper from './ModalWrapper.vue'
56+
57+
const props = defineProps<{ visible?: boolean; avatarList?: AvatarOption[] }>()
58+
59+
const emit = defineEmits<{
60+
(e: 'close'): void
61+
}>()
62+
63+
const { t } = useI18n()
64+
65+
const making = ref(false)
66+
const madeCount = ref(0)
67+
68+
async function make() {
69+
if (props.avatarList && !making.value) {
70+
making.value = true
71+
madeCount.value = 1
72+
73+
const html2canvas = (await import('html2canvas')).default
74+
75+
const { default: JSZip } = await import('jszip')
76+
const jsZip = new JSZip()
77+
78+
for (let i = 0; i <= props.avatarList.length; i += 1) {
79+
const dom = window.document.querySelector(`#avatar-${i}`)
80+
81+
if (dom instanceof HTMLElement) {
82+
const canvas = await html2canvas(dom, {
83+
backgroundColor: null,
84+
})
85+
86+
const dataUrl = canvas.toDataURL().replace('data:image/png;base64,', '')
87+
jsZip.file(`${i + 1}.png`, dataUrl, { base64: true })
88+
madeCount.value = madeCount.value += 1
89+
}
90+
}
91+
92+
const base64 = await jsZip.generateAsync({ type: 'base64' })
93+
94+
making.value = false
95+
madeCount.value = 0
96+
97+
const a = window.document.createElement('a')
98+
a.href = 'data:application/zip;base64,' + base64
99+
a.download = `${appName}.zip`
100+
a.click()
101+
102+
recordEvent('click_download_multiple', {
103+
event_category: 'click',
104+
})
105+
}
106+
}
107+
</script>
108+
109+
<style lang="scss" scoped>
110+
@use 'src/styles/var';
111+
112+
.container {
113+
position: absolute;
114+
top: 50%;
115+
left: 50%;
116+
height: max(90vh, 1000px);
117+
overflow: hidden;
118+
background-color: lighten(var.$color-dark, 3);
119+
border-radius: 1rem;
120+
transform: translate(-50%, -50%);
121+
122+
$top-bar-height: 3.5rem;
123+
124+
.top-bar {
125+
position: absolute;
126+
right: 0;
127+
left: 0;
128+
z-index: 10;
129+
display: flex;
130+
align-items: center;
131+
height: $top-bar-height;
132+
padding: 1rem 2rem;
133+
background-color: lighten(var.$color-dark, 6);
134+
135+
.right {
136+
display: flex;
137+
align-items: center;
138+
margin-left: auto;
139+
140+
.download-btn {
141+
display: flex;
142+
align-items: center;
143+
justify-content: center;
144+
height: 2rem;
145+
margin-left: 1rem;
146+
padding: 0 1rem;
147+
color: #fff;
148+
background-color: var.$color-accent;
149+
border-radius: 0.4rem;
150+
cursor: pointer;
151+
152+
&:disabled,
153+
&[disabled] {
154+
color: rgba(#fff, 0.8);
155+
cursor: default;
156+
}
157+
}
158+
}
159+
}
160+
161+
.content-box {
162+
height: 100%;
163+
padding: $top-bar-height 0rem 0rem 0rem;
164+
}
165+
166+
.content {
167+
z-index: 10;
168+
display: grid;
169+
grid-auto-rows: min-content;
170+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
171+
gap: 2rem;
172+
justify-content: space-between;
173+
width: max(85vw, 1280px);
174+
padding: 2rem;
175+
176+
.avatar-box {
177+
display: flex;
178+
align-items: center;
179+
justify-content: center;
180+
}
181+
}
182+
}
183+
</style>

0 commit comments

Comments
 (0)