✨ feat: 新增编辑社团信息
This commit is contained in:
parent
d0f6c76984
commit
bf663be047
@ -1,14 +1,11 @@
|
||||
import { REQUEST_BASE_URL } from '@/env';
|
||||
import {
|
||||
userInfoRespSchema,
|
||||
type AnyRespSchema,
|
||||
type ErrorResp,
|
||||
type SucceedRespOf,
|
||||
} from '@/schemas/response';
|
||||
import { type AnyRespSchema, type ErrorResp, type SucceedRespOf } from '@/schemas/response';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import type { ComparablePrimitive } from '@/utils/types';
|
||||
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { z } from 'zod';
|
||||
export * from './request';
|
||||
export const axiosInstance = axios.create({
|
||||
baseURL: REQUEST_BASE_URL,
|
||||
});
|
||||
@ -79,23 +76,36 @@ export async function request<T extends AnyRespSchema>(
|
||||
options.validationErrorCB,
|
||||
);
|
||||
}
|
||||
export async function getUserInfo({
|
||||
userID,
|
||||
validationErrorCB,
|
||||
}: {
|
||||
userID?: number;
|
||||
validationErrorCB?: ValidationErrorCallback;
|
||||
}) {
|
||||
let url = '/api/user/info';
|
||||
if (userID !== undefined) {
|
||||
url += `/${userID}`;
|
||||
export async function patchRequest<T extends Record<string, ComparablePrimitive>>(
|
||||
obj: T,
|
||||
old: NoInfer<T>,
|
||||
mapping: {
|
||||
[P in keyof T]?: (
|
||||
v: T[P],
|
||||
) => Promise<SucceedRespOf<z.infer<AnyRespSchema>> | boolean | undefined>;
|
||||
},
|
||||
) {
|
||||
let changed = false,
|
||||
succeed = true;
|
||||
let k: keyof T;
|
||||
const promises: Promise<unknown>[] = [];
|
||||
for (k in obj) {
|
||||
const v = obj[k];
|
||||
if (v !== old[k] && mapping[k]) {
|
||||
changed = true;
|
||||
const p = mapping[k]!(v);
|
||||
if (p)
|
||||
promises.push(
|
||||
p.then((resp) => {
|
||||
if (!resp) succeed = false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
const resp = await request(userInfoRespSchema, {
|
||||
url,
|
||||
errorDescription: '获取用户信息失败',
|
||||
validationErrorCB,
|
||||
});
|
||||
return resp;
|
||||
await Promise.all(promises);
|
||||
const res = !changed ? 'unchanged' : succeed ? 'succeed' : 'error';
|
||||
if (res === 'unchanged') {
|
||||
ElMessage.info('没有要修改的信息');
|
||||
}
|
||||
return res;
|
||||
}
|
||||
export const getAvatarURL = (avatar: string | undefined) =>
|
||||
avatar && `${REQUEST_BASE_URL}/api/avatar/${avatar}`;
|
||||
|
84
src/api/request.ts
Normal file
84
src/api/request.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import {
|
||||
clubRespSchema,
|
||||
ordinarySchema,
|
||||
uploadAvatarRespSchema,
|
||||
userInfoRespSchema,
|
||||
} from '@/schemas/response';
|
||||
import { type ValidationErrorCallback, request } from '.';
|
||||
import { REQUEST_BASE_URL } from '@/env';
|
||||
import { ElMessage, type UploadRawFile } from 'element-plus';
|
||||
|
||||
export async function getUserInfo({
|
||||
userID,
|
||||
validationErrorCB,
|
||||
}: {
|
||||
userID?: number;
|
||||
validationErrorCB?: ValidationErrorCallback;
|
||||
}) {
|
||||
let url = '/api/user/info';
|
||||
if (userID !== undefined) {
|
||||
url += `/${userID}`;
|
||||
}
|
||||
const resp = await request(userInfoRespSchema, {
|
||||
url,
|
||||
errorDescription: '获取用户信息失败',
|
||||
validationErrorCB,
|
||||
});
|
||||
return resp;
|
||||
}
|
||||
// #region Avatar
|
||||
export const getAvatarURL = (avatar: string | undefined) =>
|
||||
avatar && `${REQUEST_BASE_URL}/api/avatar/${avatar}`;
|
||||
export function validateAvatarFile(file: UploadRawFile) {
|
||||
if (!file.type.startsWith('image')) {
|
||||
ElMessage.error('只能上传图片');
|
||||
return false;
|
||||
}
|
||||
if (file.size > 1024 * 1024) {
|
||||
ElMessage.error('文件大小不能超过1MB');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
export function uploadAvatar(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return request(uploadAvatarRespSchema, {
|
||||
url: '/api/avatar/upload',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
errorDescription: '上传头像失败',
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
// #region 社团
|
||||
export const getClubById = (id: number) =>
|
||||
request(clubRespSchema, {
|
||||
url: `/api/club/${id}`,
|
||||
errorDescription: '获取社团信息失败',
|
||||
}).then((r) => r?.data);
|
||||
export const changeClubName = (id: number, name: string) =>
|
||||
request(ordinarySchema, {
|
||||
url: '/api/club/name',
|
||||
method: 'put',
|
||||
data: { clubId: id, name },
|
||||
errorDescription: '修改社团名称失败',
|
||||
});
|
||||
export const changeClubCommit = (id: number, commit: string) =>
|
||||
request(ordinarySchema, {
|
||||
url: '/api/club/commit',
|
||||
method: 'put',
|
||||
data: { clubId: id, commit },
|
||||
errorDescription: '修改社团简介失败',
|
||||
});
|
||||
export const changeClubAvatar = (id: number, code: string) =>
|
||||
request(ordinarySchema, {
|
||||
url: '/api/club/avatar',
|
||||
method: 'put',
|
||||
params: {
|
||||
code,
|
||||
clubId: id,
|
||||
},
|
||||
errorDescription: '修改社团头像失败',
|
||||
});
|
||||
// #endregion
|
@ -6,7 +6,7 @@
|
||||
>
|
||||
<template v-if="resp">
|
||||
<template v-if="resp.data.total">
|
||||
<div><slot :data="resp.data.data"></slot></div>
|
||||
<div><slot :data="converter?.(resp.data.data) ?? (resp.data.data as R)"></slot></div>
|
||||
<el-pagination
|
||||
v-if="!hidePager"
|
||||
class="m-t-10px"
|
||||
@ -27,18 +27,20 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script generic="T extends AnyPagedRespSchema" setup lang="ts">
|
||||
<script lang="ts">
|
||||
type Succeed<T extends z.ZodTypeAny> = SucceedRespOf<z.infer<T>>;
|
||||
type DataList<T extends z.ZodTypeAny> = Succeed<T>['data']['data'];
|
||||
</script>
|
||||
<script generic="T extends AnyPagedRespSchema, R extends DataList<T> = DataList<T>" setup lang="ts">
|
||||
import { request } from '@/api';
|
||||
import type { AnyPagedRespSchema, SucceedRespOf } from '@/schemas/response';
|
||||
import { computed } from 'vue';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { ref } from 'vue';
|
||||
import type { z } from 'zod';
|
||||
type Succeed = SucceedRespOf<z.infer<T>>;
|
||||
type DataList = Succeed['data']['data'];
|
||||
const {
|
||||
schema,
|
||||
requestOptions: getRequestOptions,
|
||||
requestOptions,
|
||||
num,
|
||||
hidePager = false,
|
||||
} = defineProps<{
|
||||
@ -48,20 +50,30 @@ const {
|
||||
paginationBackground?: boolean;
|
||||
paginationLayout?: string;
|
||||
hidePager?: boolean;
|
||||
requestOptions: (page: number, num: number) => AxiosRequestConfig;
|
||||
requestOptions: (params: { page: number; num: number }) => AxiosRequestConfig;
|
||||
converter?: (raw: DataList<T>) => R;
|
||||
}>();
|
||||
defineSlots<{
|
||||
default: (props: { data: DataList }) => unknown;
|
||||
default: (props: { data: R }) => unknown;
|
||||
empty: () => unknown;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
change: [data: DataList<T>];
|
||||
loadComplete: [];
|
||||
}>();
|
||||
const loading = ref(true);
|
||||
const resp = ref<Succeed>();
|
||||
const resp = ref<Succeed<T>>();
|
||||
const page = ref(1);
|
||||
const total = computed(() => resp.value?.data.total);
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
resp.value = await request(schema, getRequestOptions(page.value, num));
|
||||
resp.value = await request(schema, requestOptions({ page: page.value, num }));
|
||||
if (resp.value) {
|
||||
emit('change', resp.value.data.data);
|
||||
}
|
||||
emit('loadComplete');
|
||||
loading.value = false;
|
||||
return !!resp.value;
|
||||
}
|
||||
defineExpose({ refresh });
|
||||
refresh();
|
||||
|
@ -151,8 +151,8 @@ export interface LoginRegisterDialogProps {
|
||||
handleLogin: (params: LoginParams) => Promise<boolean>;
|
||||
handleRegister: (params: RegisterParams) => Promise<boolean>;
|
||||
}
|
||||
export const usernameLength = 3;
|
||||
export const passwordLength = usernameLength;
|
||||
export const minUsernameLength = 3;
|
||||
export const minPasswordLength = minUsernameLength;
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
import VerifyInput, { type VerifyImagePath } from '@/components/app/VerifyInput.vue';
|
||||
@ -180,11 +180,11 @@ watch(show, (show) => {
|
||||
const loginFormRules = reactive<FormRules<typeof loginFormData>>({
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: usernameLength, message: `用户名长度不能小于${usernameLength}位` },
|
||||
{ min: minUsernameLength, message: `用户名长度不能小于${minUsernameLength}位` },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: passwordLength, message: `密码长度不能小于${passwordLength}位` },
|
||||
{ min: minPasswordLength, message: `密码长度不能小于${minPasswordLength}位` },
|
||||
],
|
||||
verifyCode: [
|
||||
{ required: !DEV, message: '请输入验证码' },
|
||||
@ -238,8 +238,8 @@ const registerFormRules = reactive<FormRules<typeof registerFormData>>({
|
||||
callback('请输入密码');
|
||||
} else if (value !== registerFormData.password) {
|
||||
callback('密码不一致');
|
||||
} else if (value.length < passwordLength) {
|
||||
callback(`密码长度不能小于${passwordLength}位`);
|
||||
} else if (value.length < minPasswordLength) {
|
||||
callback(`密码长度不能小于${minPasswordLength}位`);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
|
12
src/components/club/ClubAvatar.vue
Normal file
12
src/components/club/ClubAvatar.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<el-avatar shape="square" class="select-none" :size="size" :src="getAvatarURL(avatar)">
|
||||
<slot><div class="text-black">暂无图片</div></slot>
|
||||
</el-avatar>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getAvatarURL } from '@/api';
|
||||
defineProps<{
|
||||
avatar: string | undefined;
|
||||
size: number;
|
||||
}>();
|
||||
</script>
|
35
src/components/club/ClubDetailDialog.vue
Normal file
35
src/components/club/ClubDetailDialog.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<el-dialog v-model="show" :class="$style['el-dialog']" align-center>
|
||||
<template #header><b>社团信息</b></template>
|
||||
<div class="flex flex-col" v-if="club">
|
||||
<club-list-item :club="club" mode="h" />
|
||||
<div>简介:{{ club.commit }}</div>
|
||||
<template v-if="userStore.logined && userInfo">
|
||||
<el-button
|
||||
v-if="userInfo.club && userInfo.club.id === club.id"
|
||||
class="self-end"
|
||||
type="primary"
|
||||
>
|
||||
进入主页
|
||||
</el-button>
|
||||
<el-button v-else class="self-end" type="success">加入社团</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style lang="scss" module>
|
||||
.el-dialog {
|
||||
position: relative;
|
||||
--el-text-color-regular: black;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import ClubListItem from '@/components/club/ClubListItem.vue';
|
||||
import type { Club } from '@/schemas/response';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { storeToRefs } from 'pinia';
|
||||
const userStore = useUserStore();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
const { club } = defineProps<{ club: Club | undefined }>();
|
||||
const show = defineModel<boolean>({ required: true });
|
||||
</script>
|
111
src/components/club/ClubEditCard.vue
Normal file
111
src/components/club/ClubEditCard.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<el-card v-loading="!club" element-loading-text="正在加载" class="min-h-200px">
|
||||
<template #header>
|
||||
<h4>编辑社团资料</h4>
|
||||
</template>
|
||||
<template v-if="club">
|
||||
<div class="flex justify-center">
|
||||
<div class="grid gap-15px">
|
||||
<el-form :model="data" :rules="rules" ref="form">
|
||||
<el-form-item prop="name" label="社团名称">
|
||||
<el-input v-model="data.name" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="commit" label="社团简介">
|
||||
<el-input type="textarea" v-model="data.commit" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="flex flex-col gap-15px items-center">
|
||||
<club-avatar :avatar="club.avatar" :size="100" />
|
||||
<el-upload :show-file-list="false" :http-request="upload">
|
||||
<template #trigger>
|
||||
<el-link type="primary" :icon="Upload" :disabled="uploading" :underline="false">
|
||||
上传头像
|
||||
</el-link>
|
||||
</template>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div class="col-span-2 text-center">
|
||||
<el-button type="primary" @click="submit" :loading="submitting">修改信息</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.el-divider {
|
||||
margin: 15px 0;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
export const minClubNameLength = 2;
|
||||
</script>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
changeClubAvatar,
|
||||
changeClubCommit,
|
||||
changeClubName,
|
||||
getClubById,
|
||||
patchRequest,
|
||||
uploadAvatar,
|
||||
validateAvatarFile,
|
||||
} from '@/api';
|
||||
import type { Club } from '@/schemas/response';
|
||||
import { Upload } from '@element-plus/icons-vue';
|
||||
import { ElMessage, type FormRules, type UploadRequestHandler } from 'element-plus';
|
||||
import { pick } from 'lodash-es';
|
||||
import { reactive, ref, useTemplateRef } from 'vue';
|
||||
import ClubAvatar from './ClubAvatar.vue';
|
||||
const { id } = defineProps<{ id: number }>();
|
||||
const club = ref<Club>();
|
||||
const data = ref({
|
||||
name: '',
|
||||
commit: '',
|
||||
});
|
||||
const rules = reactive<FormRules<typeof data>>({
|
||||
name: [
|
||||
{ required: true, message: '请输入社团名' },
|
||||
{ min: minClubNameLength, message: `社团名长度不能小于${minClubNameLength}位` },
|
||||
],
|
||||
});
|
||||
const formRef = useTemplateRef('form');
|
||||
const uploading = ref(false);
|
||||
const upload: UploadRequestHandler = async ({ file }) => {
|
||||
if (!validateAvatarFile(file)) return;
|
||||
uploading.value = true;
|
||||
try {
|
||||
const uploadResp = await uploadAvatar(file);
|
||||
if (!uploadResp) return;
|
||||
const changeResp = await changeClubAvatar(id, uploadResp.data);
|
||||
if (!changeResp) return;
|
||||
await refresh();
|
||||
ElMessage.success('社团头像修改成功');
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitting = ref(false);
|
||||
async function submit() {
|
||||
try {
|
||||
await formRef.value!.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
const res = await patchRequest(data.value, club.value!, {
|
||||
name: (name) => changeClubName(id, name),
|
||||
commit: (commit) => changeClubCommit(id, commit),
|
||||
});
|
||||
if (res === 'succeed') {
|
||||
await refresh();
|
||||
ElMessage.success('修改成功');
|
||||
}
|
||||
submitting.value = false;
|
||||
}
|
||||
async function refresh() {
|
||||
club.value = await getClubById(id);
|
||||
if (!club.value) return;
|
||||
data.value = pick(club.value, ['name', 'commit']);
|
||||
}
|
||||
refresh();
|
||||
</script>
|
@ -37,10 +37,6 @@ const emit = defineEmits<{
|
||||
click: [club: Club];
|
||||
}>();
|
||||
const getClubListRequestOptions: ComponentProps<typeof PagedWrapper>['requestOptions'] = (
|
||||
page,
|
||||
num,
|
||||
) => ({
|
||||
url: '/api/club/',
|
||||
params: { page, num },
|
||||
});
|
||||
params,
|
||||
) => ({ url: '/api/club/', params });
|
||||
</script>
|
||||
|
@ -9,10 +9,8 @@
|
||||
:key="club.id"
|
||||
@click="click && $emit('click', club)"
|
||||
>
|
||||
<el-avatar shape="square" class="select-none" :size="100" :src="getAvatarURL(club.avatar)">
|
||||
<div class="text-black">暂无图片</div>
|
||||
</el-avatar>
|
||||
<div class="flex flex-col" :class="{ 'items-center': mode === 'v' }">
|
||||
<club-avatar :size="100" :avatar="club.avatar" />
|
||||
<div class="flex flex-col" :class="[{ 'items-center': mode === 'v' }, textOuterClass]">
|
||||
<div class="text-black font-bold" :class="mode === 'v' ? 'text-5' : 'text-7'">
|
||||
{{ club.name }}
|
||||
</div>
|
||||
@ -31,14 +29,15 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getAvatarURL } from '@/api';
|
||||
import type { Club } from '@/schemas/response';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import ClubAvatar from './ClubAvatar.vue';
|
||||
const userStore = useUserStore();
|
||||
const { click = false, mode = 'v' } = defineProps<{
|
||||
club: Club;
|
||||
click?: boolean;
|
||||
mode?: 'h' | 'v';
|
||||
textOuterClass?: string;
|
||||
}>();
|
||||
defineEmits<{ click: [club: Club] }>();
|
||||
</script>
|
||||
|
0
src/components/club/ClubMemberCard.vue
Normal file
0
src/components/club/ClubMemberCard.vue
Normal file
@ -4,6 +4,7 @@ import type { MaybeArray } from '@/utils/types';
|
||||
import { PageErrorType, type PageErrorReason } from '@/views/ErrorPage.vue';
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import { ClubPermissionId, UserPermissionId } from './permissions';
|
||||
import { ElMessage } from 'element-plus';
|
||||
export type RoutePermissionRelation = 'AND' | 'OR';
|
||||
export type RoutePermission<T extends number> =
|
||||
| MaybeArray<T>
|
||||
@ -121,8 +122,29 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: 'club',
|
||||
name: 'ManageClub',
|
||||
component: () => import('@/views/manage/ManageClubPage.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ManageClub',
|
||||
component: () => import('@/views/manage/ManageClubPage.vue'),
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'ManageClubEdit',
|
||||
component: () => import('@/views/manage/ManageClubEditPage.vue'),
|
||||
beforeEnter(to, from) {
|
||||
const id = Number(to.params.id);
|
||||
if (isNaN(id) || id < 1) {
|
||||
ElMessage.error('社团ID无效');
|
||||
return from;
|
||||
}
|
||||
},
|
||||
props: (to) => ({ id: Number(to.params.id) }),
|
||||
meta: {
|
||||
breadcrumb: '编辑社团',
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
userPermission: {
|
||||
id: UserPermissionId.MANAGE_PAGE,
|
||||
|
@ -26,6 +26,9 @@ const createRespSchema = <T extends z.ZodTypeAny>(data: T) =>
|
||||
}),
|
||||
),
|
||||
]);
|
||||
export type AnyRespSchema = ReturnType<typeof createRespSchema>;
|
||||
export type ErrorResp = Extract<z.infer<AnyRespSchema>, { type: 'error' }>;
|
||||
export type SucceedRespOf<T extends z.infer<AnyRespSchema>> = Extract<T, { type: 'success' }>;
|
||||
export const createPagedRespSchema = <T extends z.ZodTypeAny>(data: T) =>
|
||||
createRespSchema(
|
||||
z.object({
|
||||
@ -33,9 +36,7 @@ export const createPagedRespSchema = <T extends z.ZodTypeAny>(data: T) =>
|
||||
total: z.number(),
|
||||
}),
|
||||
);
|
||||
export type AnyRespSchema = ReturnType<typeof createRespSchema>;
|
||||
export type ErrorResp = Extract<z.infer<AnyRespSchema>, { type: 'error' }>;
|
||||
export type SucceedRespOf<T> = Extract<T, { type: 'success' }>;
|
||||
export type SucceedPagedDataOf<T extends z.infer<AnyPagedRespSchema>> = SucceedRespOf<T>['data'];
|
||||
export type AnyPagedRespSchema = ReturnType<typeof createPagedRespSchema>;
|
||||
export const ordinarySchema = createRespSchema(z.literal(true));
|
||||
export const idAndNameSchema = z.object({
|
||||
@ -51,6 +52,7 @@ export const clubSchema = idAndNameSchema.extend({
|
||||
commit: z.string(),
|
||||
});
|
||||
export type Club = z.infer<typeof clubSchema>;
|
||||
export const clubRespSchema = createRespSchema(clubSchema);
|
||||
export const clubListRespSchema = createPagedRespSchema(clubSchema);
|
||||
const userInfoDataSchema = idAndNameSchema.extend({
|
||||
avatar: z.optional(z.string()),
|
||||
|
@ -99,7 +99,12 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!resp) return false;
|
||||
if (!resp) {
|
||||
if (!token.value) {
|
||||
userInfo.value = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
userInfo.value = resp.data;
|
||||
return true;
|
||||
} finally {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import type { Primitive } from 'zod';
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
export type MaybeArray<T, R extends boolean = false> = T | (R extends false ? T[] : readonly T[]);
|
||||
export type Future<T = void> = PromiseWithResolvers<T>;
|
||||
@ -9,3 +11,4 @@ export type Override<T extends object, R extends Partial<T> = {}, O extends keyo
|
||||
R;
|
||||
export type Nullable<T = never> = T | null | undefined;
|
||||
export type StrictOmit<T, K extends keyof T> = Omit<T, K>;
|
||||
export type ComparablePrimitive = Exclude<Primitive, symbol>;
|
||||
|
@ -8,45 +8,19 @@
|
||||
<club-list click @click="onClubClick" />
|
||||
</el-card>
|
||||
</div>
|
||||
<el-dialog :class="$style['el-dialog']" v-model="showClubDialog" align-center>
|
||||
<template #title><b>社团信息</b></template>
|
||||
<div class="flex flex-col" v-if="showingClub">
|
||||
<club-list-item :club="showingClub" mode="h" />
|
||||
<div>简介:{{ showingClub.commit }}</div>
|
||||
<template v-if="userStore.logined && userInfo">
|
||||
<el-button
|
||||
v-if="userInfo.club && userInfo.club.id === showingClub.id"
|
||||
class="self-end"
|
||||
type="primary"
|
||||
>
|
||||
进入主页
|
||||
</el-button>
|
||||
<el-button v-else class="self-end" type="success">加入社团</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<club-detail-dialog v-model="showClubDialog" :club="showingClub" />
|
||||
</el-main>
|
||||
</template>
|
||||
<style lang="scss" module>
|
||||
.el-dialog {
|
||||
position: relative;
|
||||
--el-text-color-regular: black;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" scoped>
|
||||
.el-card {
|
||||
--el-card-padding: 15px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts" setup>
|
||||
import ClubDetailDialog from '@/components/club/ClubDetailDialog.vue';
|
||||
import ClubList from '@/components/club/ClubList.vue';
|
||||
import ClubListItem from '@/components/club/ClubListItem.vue';
|
||||
import type { Club } from '@/schemas/response';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
const userStore = useUserStore();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
const showingClub = ref<Club>();
|
||||
const showClubDialog = ref(false);
|
||||
function onClubClick(club: Club) {
|
||||
|
11
src/views/manage/ManageClubEditPage.vue
Normal file
11
src/views/manage/ManageClubEditPage.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<el-main class="flex flex-col gap-5px">
|
||||
<manage-breadcrumb />
|
||||
<club-edit-card :id="id" />
|
||||
</el-main>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{ id: number }>();
|
||||
import ClubEditCard from '@/components/club/ClubEditCard.vue';
|
||||
import ManageBreadcrumb from '@/components/manage/ManageBreadcrumb.vue';
|
||||
</script>
|
@ -2,34 +2,151 @@
|
||||
<el-main class="flex flex-col gap-5px">
|
||||
<manage-breadcrumb />
|
||||
<el-card body-class="flex">
|
||||
<el-input v-model="searchClubName" class="w-50 m-r-12px" placeholder="请输入社团名称" />
|
||||
<el-button type="success">新建社团</el-button>
|
||||
<el-button type="danger" :disabled="!selectedClubs.length">批量删除</el-button>
|
||||
<el-button type="primary" @click="refresh">刷新</el-button>
|
||||
<el-input
|
||||
v-model="searchClubName"
|
||||
class="w-50 m-l-auto m-r-12px"
|
||||
placeholder="请输入社团名称"
|
||||
/>
|
||||
<el-button type="primary" plain>查询</el-button>
|
||||
<el-button type="warning" plain>重置</el-button>
|
||||
</el-card>
|
||||
<el-card>
|
||||
<el-card v-loading="refreshing" element-loading-text="正在加载" class="min-h-200px">
|
||||
<paged-wrapper
|
||||
pagination-class="w-min"
|
||||
:schema="clubListRespSchema"
|
||||
:request-options="options"
|
||||
:request-options="getClubListRO"
|
||||
:num="20"
|
||||
:converter="(raw) => raw.map(toClubRender)"
|
||||
@change="onClubListChange"
|
||||
@load-complete="refreshing = false"
|
||||
v-slot="{ data }"
|
||||
ref="clubList"
|
||||
>
|
||||
<el-table :data="data"></el-table>
|
||||
<el-table :data="data" @selection-change="onSelect">
|
||||
<el-table-column type="selection" :selectable="({ deleting }: ClubRender) => !deleting" />
|
||||
<el-table-column prop="id" label="序号" />
|
||||
<el-table-column prop="name" label="社团名称" />
|
||||
<el-table-column label="社团封面" v-slot="{ row }">
|
||||
<club-avatar :avatar="(row as ClubRender).avatar" :size="50">
|
||||
<div class="text-black text-2.5 lh-4">
|
||||
暂无
|
||||
<br />
|
||||
图片
|
||||
</div>
|
||||
</club-avatar>
|
||||
</el-table-column>
|
||||
<el-table-column label="社团介绍" v-slot="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="(row as ClubRender).deleting"
|
||||
@click="onClubClick(row)"
|
||||
>
|
||||
查看介绍
|
||||
</el-button>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" v-slot="{ row }" :width="300" align="right">
|
||||
<div class="club-action-outer flex flex-wrap gap-10px justify-end">
|
||||
<el-button type="primary" plain>管理成员</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
:disabled="(row as ClubRender).deleting"
|
||||
@click="
|
||||
$router.push({
|
||||
name: 'ManageClubEdit',
|
||||
params: {
|
||||
id: (row as ClubRender).id,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:loading="(row as ClubRender).deleting"
|
||||
@click="onDeleteButtonClick(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</paged-wrapper>
|
||||
</el-card>
|
||||
<club-detail-dialog v-model="showClubDialog" :club="showingClub" />
|
||||
</el-main>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import ManageBreadcrumb from '@/components/manage/ManageBreadcrumb.vue';
|
||||
import PagedWrapper from '@/components/PagedWrapper.vue';
|
||||
import { clubListRespSchema, type SucceedRespOf } from '@/schemas/response';
|
||||
import { ref } from 'vue';
|
||||
import type { ComponentProps } from 'vue-component-type-helpers';
|
||||
import type { z } from 'zod';
|
||||
const searchClubName = ref('');
|
||||
const loading = ref(true);
|
||||
const clubList = ref<SucceedRespOf<z.infer<typeof clubListRespSchema>>['data']['data']>();
|
||||
const options: ComponentProps<typeof PagedWrapper>['requestOptions'] = (page, num) => ({
|
||||
<style lang="scss" scoped>
|
||||
.club-action-outer {
|
||||
.el-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
type ClubRender = Club & {
|
||||
deleting: boolean;
|
||||
};
|
||||
const toClubRender = (club: Club): ClubRender => ({ ...club, deleting: false });
|
||||
export const getClubListRO: ComponentProps<typeof PagedWrapper>['requestOptions'] = (params) => ({
|
||||
url: '/api/club/',
|
||||
params: { page, num },
|
||||
params,
|
||||
});
|
||||
</script>
|
||||
<script setup lang="ts">
|
||||
import ClubAvatar from '@/components/club/ClubAvatar.vue';
|
||||
import ClubDetailDialog from '@/components/club/ClubDetailDialog.vue';
|
||||
import ManageBreadcrumb from '@/components/manage/ManageBreadcrumb.vue';
|
||||
import PagedWrapper from '@/components/PagedWrapper.vue';
|
||||
import { clubListRespSchema, type Club } from '@/schemas/response';
|
||||
import { ElMessageBox, ElMessage } from 'element-plus';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import type { ComponentProps } from 'vue-component-type-helpers';
|
||||
const searchClubName = ref('');
|
||||
const showingClub = ref<ClubRender>();
|
||||
const showClubDialog = ref(false);
|
||||
const clubListRef = useTemplateRef('clubList');
|
||||
const refreshing = ref(true);
|
||||
const clubs = ref<ClubRender[]>([]);
|
||||
const selectedClubs = ref<ClubRender[]>([]);
|
||||
function refresh() {
|
||||
refreshing.value = true;
|
||||
clubListRef.value!.refresh();
|
||||
}
|
||||
function onClubListChange(data: ClubRender[]) {
|
||||
clubs.value = data;
|
||||
}
|
||||
function onClubClick(club: ClubRender) {
|
||||
showingClub.value = club;
|
||||
showClubDialog.value = true;
|
||||
}
|
||||
function onSelect(v: ClubRender[]) {
|
||||
selectedClubs.value = v;
|
||||
}
|
||||
function onDeleteButtonClick(club: ClubRender) {
|
||||
ElMessageBox.confirm('是否删除该社团,此操作无法恢复!', '警告', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
}).then(
|
||||
async () => {
|
||||
// TODO 删除社团
|
||||
club.deleting = true;
|
||||
try {
|
||||
if (!(await clubListRef.value!.refresh())) {
|
||||
return;
|
||||
}
|
||||
ElMessage.success('删除成功');
|
||||
} finally {
|
||||
club.deleting = false;
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-main class="flex flex-col gap-10px">
|
||||
<el-main class="flex flex-col gap-5px">
|
||||
<manage-breadcrumb />
|
||||
<el-card body-class="flex">
|
||||
你好,
|
||||
|
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="userStore.userInfo">
|
||||
<template v-if="!smLess">
|
||||
<h4 class="p">编辑资料</h4>
|
||||
<el-divider />
|
||||
</template>
|
||||
<div class="p outer flex">
|
||||
<el-avatar class="self-center" :size="100" :src="getAvatarURL(userStore.userInfo!.avatar)" />
|
||||
<el-avatar class="self-center" :size="100" :src="getAvatarURL(userStore.userInfo.avatar)" />
|
||||
<div class="self-center flex gap-2">
|
||||
<el-upload :show-file-list="false" :http-request="upload">
|
||||
<template #trigger>
|
||||
@ -26,8 +26,8 @@
|
||||
:model="data"
|
||||
:rules="rules"
|
||||
>
|
||||
<el-form-item prop="username" label="用户名">
|
||||
<el-input v-model="data.username" />
|
||||
<el-form-item prop="name" label="用户名">
|
||||
<el-input v-model="data.name" />
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:class="{
|
||||
@ -59,9 +59,9 @@
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import { getAvatarURL, request } from '@/api';
|
||||
import { usernameLength } from '@/components/app/LoginRegisterDialog.vue';
|
||||
import { ordinarySchema, uploadAvatarRespSchema } from '@/schemas/response';
|
||||
import { getAvatarURL, patchRequest, request, uploadAvatar, validateAvatarFile } from '@/api';
|
||||
import { minUsernameLength } from '@/components/app/LoginRegisterDialog.vue';
|
||||
import { ordinarySchema } from '@/schemas/response';
|
||||
import { useMediaStore } from '@/stores/media';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { Upload } from '@element-plus/icons-vue';
|
||||
@ -90,24 +90,10 @@ const userStore = useUserStore();
|
||||
const uploading = ref(false);
|
||||
const { smLess } = storeToRefs(useMediaStore());
|
||||
const upload: UploadRequestHandler = async ({ file }) => {
|
||||
if (!file.type.startsWith('image')) {
|
||||
ElMessage.error('只能上传图片');
|
||||
return;
|
||||
}
|
||||
if (file.size > 1024 * 1024) {
|
||||
ElMessage.error('文件大小不能超过1MB');
|
||||
return;
|
||||
}
|
||||
if (!validateAvatarFile(file)) return;
|
||||
uploading.value = true;
|
||||
try {
|
||||
const formdata = new FormData();
|
||||
formdata.append('file', file);
|
||||
const uploadResp = await request(uploadAvatarRespSchema, {
|
||||
url: '/api/avatar/upload',
|
||||
method: 'post',
|
||||
data: formdata,
|
||||
errorDescription: '上传头像失败',
|
||||
});
|
||||
const uploadResp = await uploadAvatar(file);
|
||||
if (!uploadResp) return;
|
||||
const changeResp = await request(ordinarySchema, {
|
||||
url: '/api/user/avatar',
|
||||
@ -124,52 +110,38 @@ const upload: UploadRequestHandler = async ({ file }) => {
|
||||
};
|
||||
const formRef = ref<FormInstance>();
|
||||
const data = reactive({
|
||||
username: userStore.userInfo!.name,
|
||||
name: userStore.userInfo!.name,
|
||||
});
|
||||
const rules = reactive<FormRules<typeof data>>({
|
||||
username: [
|
||||
name: [
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: usernameLength, message: `用户名长度不能小于${usernameLength}位` },
|
||||
{ min: minUsernameLength, message: `用户名长度不能小于${minUsernameLength}位` },
|
||||
],
|
||||
});
|
||||
const submitting = ref(false);
|
||||
async function submit() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
await formRef.value!.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
const { username } = data;
|
||||
let changed = false,
|
||||
allSucceed = true;
|
||||
try {
|
||||
if (username !== userStore.userInfo!.name) {
|
||||
const resp = await request(ordinarySchema, {
|
||||
const res = await patchRequest(data, userStore.userInfo!, {
|
||||
name: (name) =>
|
||||
request(ordinarySchema, {
|
||||
url: '/api/user/rename',
|
||||
method: 'put',
|
||||
data: {
|
||||
id: userStore.userInfo!.id,
|
||||
newName: username,
|
||||
newName: name,
|
||||
},
|
||||
errorDescription: '修改用户名失败',
|
||||
});
|
||||
if (resp) {
|
||||
changed = true;
|
||||
} else {
|
||||
allSucceed = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (changed) {
|
||||
await userStore.updateSelfUserInfo();
|
||||
if (allSucceed) {
|
||||
ElMessage.success('修改成功');
|
||||
}
|
||||
} else if (allSucceed) {
|
||||
ElMessage.info('没有要修改的信息');
|
||||
}
|
||||
submitting.value = false;
|
||||
}),
|
||||
});
|
||||
if (res === 'succeed') {
|
||||
await userStore.updateSelfUserInfo();
|
||||
ElMessage.success('修改成功');
|
||||
}
|
||||
submitting.value = false;
|
||||
}
|
||||
</script>
|
||||
|
Loading…
x
Reference in New Issue
Block a user