diff --git a/package.json b/package.json index b6f3705..f2b5440 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "vee-validate": "^4.12.6", "viewerjs": "^1.11.6", "vue": "^3.4.21", + "vue-advanced-cropper": "^2.8.8", "vue-i18n": "^9.10.2", "vue-router": "^4.3.0", "vuetify": "^3.5.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d89fb8b..fddbecd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ dependencies: vue: specifier: ^3.4.21 version: 3.4.21(typescript@5.4.3) + vue-advanced-cropper: + specifier: ^2.8.8 + version: 2.8.8(vue@3.4.21) vue-i18n: specifier: ^9.10.2 version: 9.10.2(vue@3.4.21) @@ -2111,6 +2114,10 @@ packages: optionalDependencies: fsevents: 2.3.3 + /classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + dev: false + /cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -2699,6 +2706,10 @@ packages: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} dev: true + /debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + dev: false + /debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -2871,6 +2882,10 @@ packages: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true + /easy-bem@1.1.1: + resolution: {integrity: sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==} + dev: false + /editorconfig@1.0.4: resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} engines: {node: '>=14'} @@ -5995,6 +6010,18 @@ packages: - terser dev: true + /vue-advanced-cropper@2.8.8(vue@3.4.21): + resolution: {integrity: sha512-yDM7Jb/gnxcs//JdbOogBUoHr1bhCQSto7/ohgETKAe4wvRpmqIkKSppMm1huVQr+GP1YoVlX/fkjKxvYzwwDQ==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + vue: ^3.0.0 + dependencies: + classnames: 2.5.1 + debounce: 1.2.1 + easy-bem: 1.1.1 + vue: 3.4.21(typescript@5.4.3) + dev: false + /vue-component-type-helpers@2.0.7: resolution: {integrity: sha512-7e12Evdll7JcTIocojgnCgwocX4WzIYStGClBQ+QuWPinZo/vQolv2EMq4a3lg16TKfwWafLimG77bxb56UauA==} dev: true diff --git a/src/components/answer/AnswerCard.vue b/src/components/answer/AnswerCard.vue index d3cab98..77bc212 100644 --- a/src/components/answer/AnswerCard.vue +++ b/src/components/answer/AnswerCard.vue @@ -5,7 +5,7 @@ {{ answer.author.nickname }} {{ answer.author.intro }} - + diff --git a/src/components/user/UserCard.vue b/src/components/user/UserCard.vue index 8b2f0e4..fa1323d 100644 --- a/src/components/user/UserCard.vue +++ b/src/components/user/UserCard.vue @@ -10,7 +10,13 @@ cover /> - + @@ -30,7 +36,7 @@ - + mdi-cog @@ -40,48 +46,76 @@ - - + + 昵称 - + 个人简介 - - 头像 - - - - 预览 - - - + + + 头像预览 + + + mdi-upload + + + + + + + 提交 - 重置 + 重置 关闭 + + + + + + 确认 + + 取消 + + + + @@ -104,7 +138,7 @@ color="black" :to="{ name: 'UserFollowing', params: { id: $route.params.id } }" > - {{ profile.fans_count }} 关注 + {{ profile.fans_count }}关注 | import { ref, onMounted, computed, inject, watch, Ref } from 'vue' +import { toast } from 'vuetify-sonner' import { toTypedSchema } from '@vee-validate/zod' -import { z } from 'zod' +import { number, z } from 'zod' import { User } from '@/types/users' import { UserApi } from '@/network/api/users' -import { ImageApi } from '@/network/api/image' +import { AvatarApi } from '@/network/api/avatars' import { useForm } from 'vee-validate' import { vuetifyConfig } from '@/utils/form' +import { Cropper } from 'vue-advanced-cropper' +import { getUrlByAvatarId } from '@/utils/user' import AccountService from '@/services/account' import UserAvatar from '../common/UserAvatar.vue' +import 'vue-advanced-cropper/dist/style.css' +import { getErrorMessage } from '@/utils/errors' const profile = inject>('userData', ref({} as User)) - -const hasAvatar = computed(() => { - return !!profile?.value.avatar && profile?.value.avatar != 'default.jpg' && profile?.value.avatar != 'deafult.jpg' +const userRefresh = inject('userRefresh', () => { + toast.error('userRefresh not provided.') }) const myId = computed(() => AccountService._user.value?.id as number) @@ -149,6 +187,16 @@ const isActivatedUser = computed(() => { const isFollowLoading = ref(false) const isFollowing = ref(true) +const previewUrl = ref('') +const croppedUrl = ref('') +const croppedBlob = ref({} as Blob) +const overlay = ref(false) +const isCropperDialogVisible = ref(false) +const isEditDialogVisible = ref(false) +const avatarInput = ref(null) +const avatarCropper = ref(null) +const nicknameField = ref(null) + const followUser = async () => { const result = await UserApi.followUser(profile?.value.id as number) isFollowing.value = true @@ -179,7 +227,7 @@ const { handleSubmit, defineField, handleReset, resetForm } = useForm({ nickname: z.string().min(4).max(16), intro: z.string().max(60), avatar: z - .array(z.instanceof(File).refine((v) => v.size < 2 * 1024 * 1024, { message: '文件大小不能超过 2MB' })) + .array(z.instanceof(File).refine((v) => v.size < 1024 * 1024, { message: '文件大小不能超过 2MB' })) .optional(), }) ), @@ -197,48 +245,83 @@ watch( intro: newVal.intro, }, }) + croppedUrl.value = '' }, { immediate: true } ) const [selectedNickname, nicknameProps] = defineField('nickname', vuetifyConfig) const [selectedIntro, introProps] = defineField('intro', vuetifyConfig) -const [selectedAvatar, avatarProps] = defineField('avatar', vuetifyConfig) -const previewUrl = ref('') -const handleFileChange = () => { - previewUrl.value = '' - const length = selectedAvatar.value?.length ?? 0 - if (length > 0) { - readAvatar() - } +// const [selectedAvatar, avatarProps] = defineField('avatar', vuetifyConfig) + +const reset = () => { + croppedUrl.value = '' + if (avatarInput.value) avatarInput.value.value = '' + handleReset() } -const readAvatar = () => { - if (!selectedAvatar.value) return - if (selectedAvatar.value.length === 0) return - const file = selectedAvatar.value[0] + +const previewAvatar = (event: Event) => { + const fileInput = event.target as HTMLInputElement + const file = fileInput.files?.[0] + + if (!file) { + return + } + const reader = new FileReader() + reader.onload = (e) => { + const target = e.target as FileReader + previewUrl.value = target.result as string + isCropperDialogVisible.value = true + } reader.readAsDataURL(file) - reader.onload = (event) => { - previewUrl.value = event.target?.result as string +} + +const cropAvatar = () => { + if (avatarCropper.value) { + croppedUrl.value = avatarCropper.value.getResult().canvas.toDataURL() + avatarCropper.value.getResult().canvas.toBlob((blob: Blob) => { + croppedBlob.value = blob + }) + isCropperDialogVisible.value = false } } -const uploadAvatar = () => { - if (!selectedAvatar.value) return - if (selectedAvatar.value.length === 0) return - const file = selectedAvatar.value[0] + +watch(isCropperDialogVisible, () => { + if (isCropperDialogVisible.value == false) { + nicknameField.value?.focus() + } +}) + +const uploadAvatar = async () => { const formData = new FormData() - formData.append('file', file) - const result = ImageApi.upload(formData) - return result + const blob = croppedBlob.value + formData.append('avatar', blob) + try { + const { data: result } = await AvatarApi.uploadAvatar(formData) + return result.avatarid + } catch (e) { + toast.error('头像大小不能超过1MB,请重新调整') + } + return -1 } -const submit = handleSubmit((values) => { - // uploadAvatar() + +const submit = handleSubmit(async (values) => { + const avatarId = croppedUrl.value == '' ? profile.value.avatarId : await uploadAvatar() + if (avatarId == -1) return const submitData = { nickname: values.nickname, intro: values.intro, - avatar: 'default.jpg', + avatarId: avatarId, } - alert(JSON.stringify(submitData, null, 2)) + try { + await UserApi.updateUserInfo(profile.value.id, submitData) + } catch (e) { + toast.error(getErrorMessage(e)) + } + toast.success('用户信息更新成功') + isEditDialogVisible.value = false + userRefresh() }) @@ -264,4 +347,19 @@ const submit = handleSubmit((values) => { padding-top: 0; padding-bottom: 0; } +.overlay { + position: absolute; + z-index: 10; + width: 175px; + height: 175px; + background-color: rgba(0, 0, 0, 0); + display: flex; + justify-content: center; + align-items: center; + transition: background-color 0.3s; +} + +.overlay:hover { + background-color: rgba(0, 0, 0, 0.5); +} diff --git a/src/layouts/user/User.vue b/src/layouts/user/User.vue index e579b35..84afe3f 100644 --- a/src/layouts/user/User.vue +++ b/src/layouts/user/User.vue @@ -42,6 +42,7 @@ const fetchData = async (userId: number) => { data: { user }, } = await UserApi.getUserInfo(userId) userData.value = user + // console.log(user) loaded.value = true } @@ -54,9 +55,14 @@ onBeforeRouteUpdate(async (to, from) => { } }) +const userRefresh = async () => { + await fetchData(userId.value) +} + console.log(route) provide('userData', userData) +provide('userRefresh', userRefresh) const links = [ { diff --git a/src/network/api/avatars/index.ts b/src/network/api/avatars/index.ts new file mode 100644 index 0000000..cde3704 --- /dev/null +++ b/src/network/api/avatars/index.ts @@ -0,0 +1,11 @@ +import ApiInstance from '../index' +import { UploadAvatarResponseDataType } from './types' + +export namespace AvatarApi { + export const uploadAvatar = (data: FormData) => + ApiInstance.request({ + url: '/avatars', + method: 'POST', + data, + }) +} diff --git a/src/network/api/avatars/types.ts b/src/network/api/avatars/types.ts new file mode 100644 index 0000000..e207687 --- /dev/null +++ b/src/network/api/avatars/types.ts @@ -0,0 +1,3 @@ +export type UploadAvatarResponseDataType = { + avatarid: number +} diff --git a/src/network/api/users/index.ts b/src/network/api/users/index.ts index 0f3871c..099c3d8 100644 --- a/src/network/api/users/index.ts +++ b/src/network/api/users/index.ts @@ -5,6 +5,7 @@ import { GetAnswerListResponse, GetQuestionListResponse, GetUserInfoResponse, + UpdateUserInfoResponse, UserList, } from './types' @@ -131,4 +132,12 @@ export namespace UserApi { url: `/users/${userId}/followers`, method: 'DELETE', }) + + export const updateUserInfo = (userId: number, data: { nickname: string; intro: string; avatarId: number }) => + ApiInstance.request({ + url: `/users/${userId}`, + method: 'PUT', + data, + withCredentials: true, + }) } diff --git a/src/network/api/users/types.ts b/src/network/api/users/types.ts index b752796..f11fd4b 100644 --- a/src/network/api/users/types.ts +++ b/src/network/api/users/types.ts @@ -22,3 +22,8 @@ export type GetUserInfoResponse = { export type FollowUserResponse = { follow_count: number } + +export type UpdateUserInfoResponse = { + code: number + message: string +} diff --git a/src/types/users.ts b/src/types/users.ts index 13d3db6..095fb90 100644 --- a/src/types/users.ts +++ b/src/types/users.ts @@ -2,7 +2,7 @@ export type User = { id: number username: string nickname: string - avatar: string + avatarId: number intro: string follow_count: number fans_count: number diff --git a/src/utils/user.ts b/src/utils/user.ts new file mode 100644 index 0000000..6b37baf --- /dev/null +++ b/src/utils/user.ts @@ -0,0 +1,6 @@ +// import { AvatarApi } from '@/network/api/avatars' +import { API_BASE_URL } from '@/network/utils' + +export const getUrlByAvatarId = (avatarId: number) => { + return API_BASE_URL + '/avatars/' + avatarId +} diff --git a/src/views/user/Answer.vue b/src/views/user/Answer.vue index fea9f64..62c2e9d 100644 --- a/src/views/user/Answer.vue +++ b/src/views/user/Answer.vue @@ -1,31 +1,39 @@ - - {{ item.id }} - - {{ item.author.nickname }}:{{ item.content }} - - - - mdi-menu-up - {{ item.attitudes.difference }} - - - mdi-menu-down - - - mdi-comment-outline - {{ item.comment_count }}评论 - - - mdi-star-outline - {{ item.favorite_count }}收藏 - - - mdi-dots-horizontal - - + + + + + + {{ item.id }} + + {{ item.author.nickname }}:{{ item.content }} + + + + mdi-menu-up + {{ item.attitudes.difference }} + + + mdi-menu-down + + + mdi-comment-outline + {{ item.comment_count }}评论 + + + mdi-star-outline + {{ item.favorite_count }}收藏 + + + mdi-dots-horizontal + + + + + + @@ -33,28 +41,42 @@ import { ref, onMounted, computed } from 'vue' import { UserApi } from '@/network/api/users' import { useRoute } from 'vue-router' -import { Answer } from '@/types' -import { Page } from '@/types' +import { usePaging } from '@/utils/paging' +import BlankPage from '@/components/common/BlankPage.vue' const route = useRoute() const userID = computed(() => parseInt(route.params.id[0], 10)) -const answerList = ref([]) -const pageData = ref() -const loaded = ref(false) +const isEmpty = ref(true) -onMounted(async () => { - await fetchData() -}) - -async function fetchData() { +const { + data: answerList, + refresh, + refreshing, +} = usePaging(async () => { const { - data: { answers, page }, + data: { answers: data, page }, } = await UserApi.getAnswerList(userID.value, { pageStart: 0, pageSize: 10, }) - answerList.value = answers - pageData.value = page - loaded.value = true -} + isEmpty.value = data.length === 0 + return { data, page } +}) + +onMounted(refresh) +// onMounted(async () => { +// await fetchData() +// }) + +// async function fetchData() { +// const { +// data: { answers, page }, +// } = await UserApi.getAnswerList(userID.value, { +// pageStart: 0, +// pageSize: 10, +// }) +// answerList.value = answers +// pageData.value = page +// isEmpty.value = answers.length === 0 +// } diff --git a/src/views/user/Question.vue b/src/views/user/Question.vue index 3770aee..f1c949c 100644 --- a/src/views/user/Question.vue +++ b/src/views/user/Question.vue @@ -5,18 +5,24 @@ + + +
{{ item.author.nickname }}:{{ item.content }}