✨ feat: 添加社团成员管理功能
This commit is contained in:
parent
bf663be047
commit
63a3222c32
1
.gitignore
vendored
1
.gitignore
vendored
@ -33,3 +33,4 @@ coverage
|
||||
*.lockb
|
||||
|
||||
components.d.ts
|
||||
.VSCodeCounter
|
||||
|
@ -14,7 +14,7 @@ declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
addAuth?: boolean;
|
||||
errorDescription?: string;
|
||||
validationErrorCB?: ValidationErrorCallback;
|
||||
onValidationError?: ValidationErrorCallback;
|
||||
}
|
||||
}
|
||||
// 自动添加token到请求中.
|
||||
@ -73,7 +73,7 @@ export async function request<T extends AnyRespSchema>(
|
||||
schema,
|
||||
await requestRaw(options),
|
||||
options.errorDescription,
|
||||
options.validationErrorCB,
|
||||
options.onValidationError,
|
||||
);
|
||||
}
|
||||
export async function patchRequest<T extends Record<string, ComparablePrimitive>>(
|
||||
|
@ -1,19 +1,26 @@
|
||||
import {
|
||||
clubListRespSchema,
|
||||
clubRespSchema,
|
||||
ordinarySchema,
|
||||
uploadAvatarRespSchema,
|
||||
userInfoListRespSchema,
|
||||
userInfoRespSchema,
|
||||
} from '@/schemas/response';
|
||||
import { type ValidationErrorCallback, request } from '.';
|
||||
import { REQUEST_BASE_URL } from '@/env';
|
||||
import { ElMessage, type UploadRawFile } from 'element-plus';
|
||||
|
||||
export type PagedParams = {
|
||||
/** 页码,从1开始 */
|
||||
page: number;
|
||||
/** 每页数据量 */
|
||||
num: number;
|
||||
};
|
||||
export async function getUserInfo({
|
||||
userID,
|
||||
validationErrorCB,
|
||||
onValidationError,
|
||||
}: {
|
||||
userID?: number;
|
||||
validationErrorCB?: ValidationErrorCallback;
|
||||
onValidationError?: ValidationErrorCallback;
|
||||
}) {
|
||||
let url = '/api/user/info';
|
||||
if (userID !== undefined) {
|
||||
@ -22,7 +29,7 @@ export async function getUserInfo({
|
||||
const resp = await request(userInfoRespSchema, {
|
||||
url,
|
||||
errorDescription: '获取用户信息失败',
|
||||
validationErrorCB,
|
||||
onValidationError,
|
||||
});
|
||||
return resp;
|
||||
}
|
||||
@ -57,6 +64,11 @@ export const getClubById = (id: number) =>
|
||||
url: `/api/club/${id}`,
|
||||
errorDescription: '获取社团信息失败',
|
||||
}).then((r) => r?.data);
|
||||
export const getClubList = (params: PagedParams) =>
|
||||
request(clubListRespSchema, {
|
||||
url: '/api/club/',
|
||||
params: params,
|
||||
});
|
||||
export const changeClubName = (id: number, name: string) =>
|
||||
request(ordinarySchema, {
|
||||
url: '/api/club/name',
|
||||
@ -81,4 +93,19 @@ export const changeClubAvatar = (id: number, code: string) =>
|
||||
},
|
||||
errorDescription: '修改社团头像失败',
|
||||
});
|
||||
export const getUserByClubId = (clubId: number, params: PagedParams) =>
|
||||
request(userInfoListRespSchema, {
|
||||
url: '/api/club/user/list',
|
||||
params: Object.assign(params, { clubId }),
|
||||
errorDescription: '获取社团成员列表失败',
|
||||
});
|
||||
export const removeUserFromClub = (userId: number, clubId: number) =>
|
||||
request(ordinarySchema, {
|
||||
url: '/api/club/user/remove',
|
||||
method: 'delete',
|
||||
params: {
|
||||
userId,
|
||||
clubId,
|
||||
},
|
||||
});
|
||||
// #endregion
|
||||
|
@ -5,6 +5,7 @@
|
||||
}
|
||||
:root {
|
||||
--header-height: 50px;
|
||||
--search-input-gap: 12px;
|
||||
}
|
||||
body {
|
||||
background-color: rgb(244, 246, 249);
|
||||
|
@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'flex justify-center items-center': resp && !resp.data.total,
|
||||
'flex justify-center items-center': !hasData,
|
||||
}"
|
||||
>
|
||||
<template v-if="resp">
|
||||
<template v-if="resp.data.total">
|
||||
<div><slot :data="converter?.(resp.data.data) ?? (resp.data.data as R)"></slot></div>
|
||||
<template v-if="hasData">
|
||||
<div><slot :data="converted"></slot></div>
|
||||
<el-pagination
|
||||
v-if="!hidePager"
|
||||
class="m-t-10px"
|
||||
@ -27,51 +27,52 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<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';
|
||||
<script
|
||||
generic="T extends SucceedRespOf<z.infer<AnyPagedRespSchema>>, R = T['data']['data']"
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import { type PagedParams } from '@/api';
|
||||
import type { AnyPagedRespSchema, SucceedRespOf } from '@/schemas/response';
|
||||
import { computed } from 'vue';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { z } from 'zod';
|
||||
const {
|
||||
schema,
|
||||
requestOptions,
|
||||
handleRequest,
|
||||
num,
|
||||
hidePager = false,
|
||||
converter,
|
||||
} = defineProps<{
|
||||
schema: T;
|
||||
num: number;
|
||||
paginationClass?: string;
|
||||
paginationBackground?: boolean;
|
||||
paginationLayout?: string;
|
||||
hidePager?: boolean;
|
||||
requestOptions: (params: { page: number; num: number }) => AxiosRequestConfig;
|
||||
converter?: (raw: DataList<T>) => R;
|
||||
handleRequest: (params: PagedParams) => Promise<T | undefined>;
|
||||
converter?: (raw: T['data']['data']) => R;
|
||||
}>();
|
||||
defineSlots<{
|
||||
default: (props: { data: R }) => unknown;
|
||||
empty: () => unknown;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
change: [data: DataList<T>];
|
||||
loadComplete: [];
|
||||
change: [data: R];
|
||||
loadComplete: [succeed: boolean];
|
||||
}>();
|
||||
const loading = ref(true);
|
||||
const resp = ref<Succeed<T>>();
|
||||
const resp = ref<T>();
|
||||
const hasData = computed(() => !!resp.value?.data.data.length);
|
||||
const converted = computed(() => {
|
||||
const data = resp.value?.data.data;
|
||||
if (!data) return [] as R;
|
||||
return converter?.(data) ?? (data as R);
|
||||
});
|
||||
const page = ref(1);
|
||||
const total = computed(() => resp.value?.data.total);
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
resp.value = await request(schema, requestOptions({ page: page.value, num }));
|
||||
if (resp.value) {
|
||||
emit('change', resp.value.data.data);
|
||||
}
|
||||
emit('loadComplete');
|
||||
resp.value = await handleRequest({ page: page.value, num });
|
||||
emit('change', converted.value);
|
||||
emit('loadComplete', !!resp.value);
|
||||
loading.value = false;
|
||||
return !!resp.value;
|
||||
}
|
||||
|
26
src/components/SearchInput.vue
Normal file
26
src/components/SearchInput.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="search-input flex gap-$search-input-gap">
|
||||
<el-input v-model="model" class="w-50" :placeholder="placeholder" />
|
||||
<el-button type="primary" plain @click="$emit('submit')">查询</el-button>
|
||||
<el-button type="warning" plain @click="reset">重置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.el-button {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
placeholder?: string;
|
||||
}>();
|
||||
defineEmits<{
|
||||
submit: [];
|
||||
reset: [];
|
||||
}>();
|
||||
const model = defineModel({ default: '' });
|
||||
function reset() {
|
||||
model.value = '';
|
||||
}
|
||||
defineExpose({ reset });
|
||||
</script>
|
@ -76,10 +76,9 @@ const upload: UploadRequestHandler = async ({ file }) => {
|
||||
try {
|
||||
const uploadResp = await uploadAvatar(file);
|
||||
if (!uploadResp) return;
|
||||
const changeResp = await changeClubAvatar(id, uploadResp.data);
|
||||
if (!changeResp) return;
|
||||
await refresh();
|
||||
if (!(await changeClubAvatar(id, uploadResp.data))) return;
|
||||
ElMessage.success('社团头像修改成功');
|
||||
await refresh();
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
|
@ -3,9 +3,8 @@
|
||||
pagination-background
|
||||
pagination-class="w-min"
|
||||
:hide-pager="brief"
|
||||
:schema="clubListRespSchema"
|
||||
:num="10"
|
||||
:request-options="getClubListRequestOptions"
|
||||
:handle-request="getClubList"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="flex gap-10px">
|
||||
@ -23,20 +22,17 @@
|
||||
</paged-wrapper>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getClubList } from '@/api';
|
||||
import PagedWrapper from '@/components/PagedWrapper.vue';
|
||||
import { clubListRespSchema, type Club } from '@/schemas/response';
|
||||
import { type Club } from '@/schemas/response';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import type { ComponentProps } from 'vue-component-type-helpers';
|
||||
import ClubListItem from './ClubListItem.vue';
|
||||
const userStore = useUserStore();
|
||||
const { click = false, brief = false } = defineProps<{
|
||||
brief?: boolean;
|
||||
click?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
click: [club: Club];
|
||||
}>();
|
||||
const getClubListRequestOptions: ComponentProps<typeof PagedWrapper>['requestOptions'] = (
|
||||
params,
|
||||
) => ({ url: '/api/club/', params });
|
||||
</script>
|
||||
|
110
src/components/club/ClubMemberCards.vue
Normal file
110
src/components/club/ClubMemberCards.vue
Normal file
@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5px">
|
||||
<el-card body-class="flex">
|
||||
<el-button type="danger">批量移除</el-button>
|
||||
<el-button type="primary" @click="refresh">刷新</el-button>
|
||||
<search-input class="m-l-auto" placeholder="输入用户名" @submit="refresh" @reset="refresh" />
|
||||
</el-card>
|
||||
<el-card v-loading="refreshing" element-loading-text="正在加载">
|
||||
<paged-wrapper
|
||||
pagination-class="w-min"
|
||||
:num="20"
|
||||
:handle-request="(params) => getUserByClubId(id, params)"
|
||||
:converter="(raw) => raw.map(toUserInfoRender)"
|
||||
@change="onUsersChange"
|
||||
@load-complete="refreshing = false"
|
||||
v-slot="{ data }"
|
||||
ref="memberList"
|
||||
>
|
||||
<el-table :data="data" @selection-change="onUsersSelect">
|
||||
<el-table-column
|
||||
type="selection"
|
||||
:selectable="({ removing }: UserInfoRender) => !removing"
|
||||
/>
|
||||
<el-table-column prop="id" label="序号" />
|
||||
<el-table-column prop="name" label="用户名" />
|
||||
<el-table-column label="权限组" v-slot="{ row }">
|
||||
{{ (row as UserInfoRender).clubAuth?.name ?? '无' }}
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" :min-width="100" v-slot="{ row }">
|
||||
<div class="user-action-outer">
|
||||
<el-button
|
||||
type="danger"
|
||||
:loading="(row as UserInfoRender).removing"
|
||||
@click="remove(row)"
|
||||
>
|
||||
移除成员
|
||||
</el-button>
|
||||
</div>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</paged-wrapper>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.user-action-outer {
|
||||
@apply flex flex-wrap gap-10px;
|
||||
.el-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
type UserInfoRender = UserInfo & {
|
||||
removing: boolean;
|
||||
};
|
||||
const toUserInfoRender = (raw: UserInfo): UserInfoRender => ({ ...raw, removing: false });
|
||||
</script>
|
||||
<script setup lang="ts">
|
||||
import { getUserByClubId, removeUserFromClub } from '@/api';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import PagedWrapper from '@/components/PagedWrapper.vue';
|
||||
import type { UserInfo } from '@/schemas/response';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import SearchInput from '../SearchInput.vue';
|
||||
const { id: clubId } = defineProps<{ id: number }>();
|
||||
const userStore = useUserStore();
|
||||
const refreshing = ref(true);
|
||||
async function refresh() {
|
||||
refreshing.value = true;
|
||||
try {
|
||||
await memberListRef.value!.refresh();
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
const memberListRef = useTemplateRef('memberList');
|
||||
const users = ref<UserInfoRender[]>([]);
|
||||
const selectedUsers = ref<UserInfoRender[]>([]);
|
||||
function onUsersChange(v: UserInfoRender[]) {
|
||||
console.log(v);
|
||||
users.value = v;
|
||||
}
|
||||
function onUsersSelect(v: UserInfoRender[]) {
|
||||
selectedUsers.value = v;
|
||||
}
|
||||
function remove(user: UserInfoRender) {
|
||||
ElMessageBox.confirm('确定移除该成员吗?', '警告', {
|
||||
type: 'warning',
|
||||
}).then(
|
||||
async () => {
|
||||
user.removing = true;
|
||||
try {
|
||||
if (!(await removeUserFromClub(user.id, clubId))) {
|
||||
return;
|
||||
}
|
||||
ElMessage.success('移除成功');
|
||||
await Promise.all([
|
||||
refresh(),
|
||||
userStore.userInfo!.id === user.id && userStore.updateSelfUserInfo(),
|
||||
]);
|
||||
} finally {
|
||||
user.removing = false;
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
</script>
|
@ -189,6 +189,25 @@
|
||||
animation: tile-common-appear 0.2s ease both;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
class FutureMap<T = void> extends Map<number, Future<T>> {
|
||||
add(tileId: number) {
|
||||
const future = Promise.withResolvers<T>();
|
||||
this.set(tileId, future);
|
||||
return future.promise;
|
||||
}
|
||||
|
||||
resolve(tileId: number, value: T) {
|
||||
this.get(tileId)?.resolve(value);
|
||||
this.delete(tileId);
|
||||
}
|
||||
|
||||
reject(tileId: number, reason: unknown) {
|
||||
this.get(tileId)?.reject(reason);
|
||||
this.delete(tileId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
import { useGame2048Store, type GameState, type Tile } from '@/stores/2048';
|
||||
import { chainIterables } from '@/utils';
|
||||
@ -199,10 +218,8 @@ import { useEventListener } from '@vueuse/core';
|
||||
import { ElNotification } from 'element-plus';
|
||||
import { add, pick, sample, shuffle } from 'lodash-es';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, useTemplateRef, watch } from 'vue';
|
||||
const game2048Store = useGame2048Store();
|
||||
|
||||
type SingleTileLine = (Tile | undefined)[];
|
||||
type OverlappedTileLine = Tile[][];
|
||||
/**
|
||||
@ -234,24 +251,6 @@ type TileTransitionFuture = Future;
|
||||
*/
|
||||
type MergedGrid = (LineMergeResult | undefined)[];
|
||||
|
||||
class FutureMap<T = void> extends Map<number, Future<T>> {
|
||||
add(tileId: number) {
|
||||
const future = Promise.withResolvers<T>();
|
||||
this.set(tileId, future);
|
||||
return future.promise;
|
||||
}
|
||||
|
||||
resolve(tileId: number, value: T) {
|
||||
this.get(tileId)?.resolve(value);
|
||||
this.delete(tileId);
|
||||
}
|
||||
|
||||
reject(tileId: number, reason: unknown) {
|
||||
this.get(tileId)?.reject(reason);
|
||||
this.delete(tileId);
|
||||
}
|
||||
}
|
||||
|
||||
const tileWidth = 105;
|
||||
const tileWidthStyle = computed(() => `${tileWidth}px`);
|
||||
const borderWidth = 15;
|
||||
@ -338,24 +337,19 @@ function storeTileGrid() {
|
||||
/**
|
||||
* 获取块的位置.
|
||||
*/
|
||||
function getTilePosition(y: number, x: number) {
|
||||
return {
|
||||
left: `${borderWidth + x * (tileWidth + borderWidth)}px`,
|
||||
top: `${borderWidth + y * (tileWidth + borderWidth)}px`,
|
||||
};
|
||||
}
|
||||
const getTilePosition = (y: number, x: number) => ({
|
||||
left: `${borderWidth + x * (tileWidth + borderWidth)}px`,
|
||||
top: `${borderWidth + y * (tileWidth + borderWidth)}px`,
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取数块除位置以外的样式.
|
||||
*/
|
||||
function getNumberTileStyle(tile: Tile) {
|
||||
const { number } = tile;
|
||||
return {
|
||||
fontSize: `${number < 128 ? 55 : number < 1024 ? 45 : 35}px`,
|
||||
color: number < 8 ? 'rgb(119, 110, 101)' : 'white',
|
||||
backgroundColor: tileBackgroundColorMapping[number] ?? 'rgb(56, 56, 56)',
|
||||
};
|
||||
}
|
||||
const getNumberTileStyle = ({ number }: Tile) => ({
|
||||
fontSize: `${number < 128 ? 55 : number < 1024 ? 45 : 35}px`,
|
||||
color: number < 8 ? 'rgb(119, 110, 101)' : 'white',
|
||||
backgroundColor: tileBackgroundColorMapping[number] ?? 'rgb(56, 56, 56)',
|
||||
});
|
||||
|
||||
/**
|
||||
* 添加随机数块, 空间不够则跳过.
|
||||
@ -398,9 +392,7 @@ function addRandomTiles(randomCount: number, randomNumbers: number[] = [2, 4]) {
|
||||
* 获取数块Div的对应Id.
|
||||
* @param element 数块Div.
|
||||
*/
|
||||
function getElementTileId(element: HTMLDivElement) {
|
||||
return parseInt(element.dataset['tileId']!);
|
||||
}
|
||||
const getElementTileId = (element: HTMLDivElement) => parseInt(element.dataset['tileId']!);
|
||||
|
||||
/**
|
||||
* 合并算法.
|
||||
@ -501,9 +493,9 @@ function mergeLine(line: SingleTileLine): [lineMergeREsult: LineMergeResult, cha
|
||||
* @return 是否有活动空间
|
||||
*/
|
||||
async function mergeTiles(direction: Directions) {
|
||||
function getMergedGrid(
|
||||
const getMergedGrid = (
|
||||
d: Directions = direction,
|
||||
): [MergedGrid, changed: boolean, maxNumber: number] {
|
||||
): [MergedGrid, changed: boolean, maxNumber: number] => {
|
||||
let changed = false;
|
||||
let maxNumber = 0;
|
||||
const mergedGrid: MergedGrid = Array(firstIndices.length).fill(undefined);
|
||||
@ -524,9 +516,11 @@ async function mergeTiles(direction: Directions) {
|
||||
mergedGrid[first] = lineMergeResult;
|
||||
}
|
||||
return [mergedGrid, changed, maxNumber];
|
||||
}
|
||||
};
|
||||
|
||||
function forEachMergedLine(callback: (lineMergeResult: LineMergeResult, first: number) => void) {
|
||||
const forEachMergedLine = (
|
||||
callback: (lineMergeResult: LineMergeResult, first: number) => void,
|
||||
) => {
|
||||
for (const first of firstIndices()) {
|
||||
const mergeResult = mergedGrid[first];
|
||||
if (mergeResult === undefined) {
|
||||
@ -534,9 +528,9 @@ async function mergeTiles(direction: Directions) {
|
||||
}
|
||||
callback(mergeResult, first);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function forEachMergedTiles(
|
||||
const forEachMergedTiles = (
|
||||
callback: (
|
||||
data: LineMergeResult & {
|
||||
tiles: Tile[];
|
||||
@ -545,7 +539,7 @@ async function mergeTiles(direction: Directions) {
|
||||
first: number,
|
||||
second: number,
|
||||
) => void,
|
||||
) {
|
||||
) => {
|
||||
forEachMergedLine((lineMergeResult, first) => {
|
||||
for (const [lineIndex, second] of secondIndices().entries()) {
|
||||
const tiles = get2DArrayItem(tileGrid, first, second, isColFirst());
|
||||
@ -560,10 +554,10 @@ async function mergeTiles(direction: Directions) {
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function firstIndices(d: Directions = direction) {
|
||||
return Array.from(
|
||||
const firstIndices = (d: Directions = direction) =>
|
||||
Array.from(
|
||||
(function* () {
|
||||
switch (d) {
|
||||
case Directions.UP:
|
||||
@ -581,10 +575,9 @@ async function mergeTiles(direction: Directions) {
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
function secondIndices(d: Directions = direction) {
|
||||
return Array.from(
|
||||
const secondIndices = (d: Directions = direction) =>
|
||||
Array.from(
|
||||
(function* () {
|
||||
switch (d) {
|
||||
case Directions.UP:
|
||||
@ -611,11 +604,8 @@ async function mergeTiles(direction: Directions) {
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
function isColFirst(d: Directions = direction) {
|
||||
return d == Directions.LEFT || d == Directions.RIGHT;
|
||||
}
|
||||
const isColFirst = (d: Directions = direction) => d == Directions.LEFT || d == Directions.RIGHT;
|
||||
|
||||
const [mergedGrid, changed, maxNumber] = getMergedGrid(direction);
|
||||
if (maxNumber >= game2048Store.successNumber) {
|
||||
@ -721,13 +711,13 @@ async function move(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const gameContainer = ref<HTMLDivElement>();
|
||||
interface Pos {
|
||||
const gameContainerRef = useTemplateRef('gameContainer');
|
||||
type Pos = {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
};
|
||||
let startPos: Pos = { x: -1, y: -1 };
|
||||
useEventListener(gameContainer, 'touchstart', (e) => {
|
||||
useEventListener(gameContainerRef, 'touchstart', (e) => {
|
||||
const firstTouch = e.changedTouches[0];
|
||||
startPos = { x: firstTouch.clientX, y: firstTouch.clientY };
|
||||
});
|
||||
@ -747,7 +737,7 @@ const triggerMove = (deltaX: number, deltaY: number) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
useEventListener(gameContainer, 'touchend', (e) => {
|
||||
useEventListener(gameContainerRef, 'touchend', (e) => {
|
||||
const firstTouch = e.changedTouches[0];
|
||||
const [x, y] = [firstTouch.clientX - startPos.x, firstTouch.clientY - startPos.y];
|
||||
triggerMove(x, y);
|
||||
|
@ -2,7 +2,12 @@ import { tempErrorRouteName, usePageStore } from '@/stores/page';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import type { MaybeArray } from '@/utils/types';
|
||||
import { PageErrorType, type PageErrorReason } from '@/views/ErrorPage.vue';
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
type RouteLocationNormalizedGeneric,
|
||||
type RouteRecordRaw,
|
||||
} from 'vue-router';
|
||||
import { ClubPermissionId, UserPermissionId } from './permissions';
|
||||
import { ElMessage } from 'element-plus';
|
||||
export type RoutePermissionRelation = 'AND' | 'OR';
|
||||
@ -22,6 +27,13 @@ declare module 'vue-router' {
|
||||
breadcrumb?: string;
|
||||
}
|
||||
}
|
||||
function validateParamId(to: RouteLocationNormalizedGeneric, from: RouteLocationNormalizedGeneric) {
|
||||
const id = Number(to.params.id);
|
||||
if (isNaN(id) || id < 1) {
|
||||
ElMessage.error('参数错误');
|
||||
return from;
|
||||
}
|
||||
}
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
@ -132,18 +144,22 @@ const routes: RouteRecordRaw[] = [
|
||||
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;
|
||||
}
|
||||
},
|
||||
beforeEnter: validateParamId,
|
||||
props: (to) => ({ id: Number(to.params.id) }),
|
||||
meta: {
|
||||
breadcrumb: '编辑社团',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':id/member',
|
||||
name: 'ManageClubMember',
|
||||
component: () => import('@/views/manage/ManageClubMemberPage.vue'),
|
||||
beforeEnter: validateParamId,
|
||||
props: (to) => ({ id: Number(to.params.id) }),
|
||||
meta: {
|
||||
breadcrumb: '社团成员',
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
userPermission: {
|
||||
@ -177,6 +193,9 @@ const router = createRouter({
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.name === tempErrorRouteName) {
|
||||
return true;
|
||||
}
|
||||
const userStore = useUserStore();
|
||||
const pageStore = usePageStore();
|
||||
pageStore.setNewRouteId(to);
|
||||
|
@ -54,15 +54,15 @@ export const clubSchema = idAndNameSchema.extend({
|
||||
export type Club = z.infer<typeof clubSchema>;
|
||||
export const clubRespSchema = createRespSchema(clubSchema);
|
||||
export const clubListRespSchema = createPagedRespSchema(clubSchema);
|
||||
const userInfoDataSchema = idAndNameSchema.extend({
|
||||
const userInfoSchema = idAndNameSchema.extend({
|
||||
avatar: z.optional(z.string()),
|
||||
auth: authSchema,
|
||||
club: z.optional(clubSchema),
|
||||
clubAuth: z.optional(authSchema),
|
||||
});
|
||||
export const userInfoRespSchema = createRespSchema(userInfoDataSchema);
|
||||
export type SucceedUserInfoResp = SucceedRespOf<z.infer<typeof userInfoRespSchema>>;
|
||||
export type UserInfo = SucceedUserInfoResp['data'];
|
||||
export const userInfoRespSchema = createRespSchema(userInfoSchema);
|
||||
export type UserInfo = SucceedRespOf<z.infer<typeof userInfoRespSchema>>['data'];
|
||||
export const userInfoListRespSchema = createPagedRespSchema(userInfoSchema);
|
||||
export const loginRespSchema = userInfoRespSchema;
|
||||
export const registerRespSchema = userInfoRespSchema;
|
||||
export const verifyRespSchema = createRespSchema(
|
||||
|
@ -88,7 +88,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
initializing.value = true;
|
||||
try {
|
||||
const resp = await getUserInfo({
|
||||
validationErrorCB(resp) {
|
||||
onValidationError(resp) {
|
||||
console.log('error');
|
||||
// 验证失败,则清除token
|
||||
if (resp.status === 401) {
|
||||
|
13
src/views/manage/ManageClubMemberPage.vue
Normal file
13
src/views/manage/ManageClubMemberPage.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<el-main class="flex flex-col gap-5px">
|
||||
<manage-breadcrumb />
|
||||
<club-member-cards :id="id" />
|
||||
</el-main>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import ClubMemberCards from '@/components/club/ClubMemberCards.vue';
|
||||
import ManageBreadcrumb from '@/components/manage/ManageBreadcrumb.vue';
|
||||
defineProps<{
|
||||
id: number;
|
||||
}>();
|
||||
</script>
|
@ -5,22 +5,21 @@
|
||||
<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
|
||||
<search-input
|
||||
v-model="searchClubName"
|
||||
class="w-50 m-l-auto m-r-12px"
|
||||
class="m-l-auto"
|
||||
ref="searchInput"
|
||||
placeholder="请输入社团名称"
|
||||
@submit="refresh"
|
||||
@reset="refresh"
|
||||
/>
|
||||
<el-button type="primary" plain>查询</el-button>
|
||||
<el-button type="warning" plain>重置</el-button>
|
||||
</el-card>
|
||||
<el-card v-loading="refreshing" element-loading-text="正在加载" class="min-h-200px">
|
||||
<el-card v-loading="refreshing" element-loading-text="正在加载" class="min-h-100px">
|
||||
<paged-wrapper
|
||||
pagination-class="w-min"
|
||||
:schema="clubListRespSchema"
|
||||
:request-options="getClubListRO"
|
||||
:handle-request="getClubList"
|
||||
:num="20"
|
||||
:converter="(raw) => raw.map(toClubRender)"
|
||||
@change="onClubListChange"
|
||||
@load-complete="refreshing = false"
|
||||
v-slot="{ data }"
|
||||
ref="clubList"
|
||||
@ -48,9 +47,21 @@
|
||||
查看介绍
|
||||
</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-table-column label="操作" v-slot="{ row }" :min-width="100">
|
||||
<div class="club-action-outer">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="(row as ClubRender).deleting"
|
||||
@click="
|
||||
$router.push({
|
||||
name: 'ManageClubMember',
|
||||
params: { id: (row as ClubRender).id },
|
||||
})
|
||||
"
|
||||
>
|
||||
管理成员
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
@ -83,6 +94,7 @@
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.club-action-outer {
|
||||
@apply flex flex-wrap gap-10px;
|
||||
.el-button {
|
||||
margin: 0;
|
||||
}
|
||||
@ -92,41 +104,35 @@
|
||||
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,
|
||||
});
|
||||
const toClubRender = (raw: Club): ClubRender => ({ ...raw, deleting: false });
|
||||
</script>
|
||||
<script setup lang="ts">
|
||||
import { getClubList } from '@/api';
|
||||
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 SearchInput from '@/components/SearchInput.vue';
|
||||
import { type Club } from '@/schemas/response';
|
||||
import { ElMessage, ElMessageBox } 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 searchClubName = ref('');
|
||||
const searchInputRef = useTemplateRef('searchInput');
|
||||
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 onSelect(clubs: ClubRender[]) {
|
||||
selectedClubs.value = clubs;
|
||||
}
|
||||
function onDeleteButtonClick(club: ClubRender) {
|
||||
ElMessageBox.confirm('是否删除该社团,此操作无法恢复!', '警告', {
|
||||
|
Loading…
x
Reference in New Issue
Block a user