This commit is contained in:
Rao 2025-04-09 11:40:44 +08:00
parent 5ab23e4a42
commit 853303a726
13 changed files with 579 additions and 22 deletions

View File

@ -1,13 +1,15 @@
import { z } from "zod"; import { z, ZodType } from "zod";
import { ShareCodeService } from "./share-code.service"; import { ShareCodeService } from "./share-code.service";
import { TrpcService } from "@server/trpc/trpc.service"; import { TrpcService } from "@server/trpc/trpc.service";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { Prisma } from "@nice/common";
const ShareCodeWhereInputSchema: ZodType<Prisma.ShareCodeWhereInput> = z.any();
@Injectable() @Injectable()
export class ShareCodeRouter { export class ShareCodeRouter {
constructor( constructor(
private readonly shareCodeService: ShareCodeService, private readonly shareCodeService: ShareCodeService,
private readonly trpc: TrpcService private readonly trpc: TrpcService
) {} ) { }
router = this.trpc.router({ router = this.trpc.router({
generateShareCode: this.trpc.procedure generateShareCode: this.trpc.procedure
@ -45,5 +47,26 @@ export class ShareCodeRouter {
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return this.shareCodeService.generateShareCodeByFileId(input.fileId, input.expiresAt, input.canUseTimes); return this.shareCodeService.generateShareCodeByFileId(input.fileId, input.expiresAt, input.canUseTimes);
}), }),
getShareCodesWithResources: this.trpc.procedure
.input(z.object({ page: z.number(), pageSize: z.number(), where: ShareCodeWhereInputSchema.optional() }))
.query(async ({ input }) => {
return this.shareCodeService.getShareCodesWithResources(input);
}),
softDeleteShareCodes: this.trpc.procedure
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
return this.shareCodeService.softDeleteShareCodes(input.ids);
}),
updateShareCode: this.trpc.procedure
.input(z.object({
id: z.string(),
data: z.object({
expiresAt: z.date().optional(),
canUseTimes: z.number().optional(),
})
}))
.mutation(async ({ input }) => {
return this.shareCodeService.updateShareCode(input.id, input.data);
}),
}); });
} }

View File

@ -1,6 +1,6 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { customAlphabet } from 'nanoid-cjs'; import { customAlphabet } from 'nanoid-cjs';
import { db } from '@nice/common'; import { db, ObjectType, Prisma, Resource } from '@nice/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { ResourceService } from '@server/models/resource/resource.service'; import { ResourceService } from '@server/models/resource/resource.service';
import * as fs from 'fs' import * as fs from 'fs'
@ -8,6 +8,7 @@ import * as path from 'path'
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import { BaseService } from '../base/base.service';
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
export interface ShareCode { export interface ShareCode {
@ -33,7 +34,7 @@ interface ResourceMeta {
} }
@Injectable() @Injectable()
export class ShareCodeService { export class ShareCodeService extends BaseService<Prisma.ShareCodeDelegate> {
private readonly logger = new Logger(ShareCodeService.name); private readonly logger = new Logger(ShareCodeService.name);
// 生成8位分享码使用易读的字符 // 生成8位分享码使用易读的字符
private readonly generateCode = customAlphabet( private readonly generateCode = customAlphabet(
@ -41,7 +42,9 @@ export class ShareCodeService {
8, 8,
); );
constructor(private readonly resourceService: ResourceService) { } constructor(private readonly resourceService: ResourceService) {
super(db, ObjectType.SHARE_CODE, false);
}
async generateShareCode( async generateShareCode(
fileId: string, fileId: string,
@ -54,42 +57,40 @@ export class ShareCodeService {
const resource = await this.resourceService.findUnique({ const resource = await this.resourceService.findUnique({
where: { fileId }, where: { fileId },
}); });
this.logger.log('完整 fileId:', fileId); // 确保与前端一致 this.logger.log('完整 resource:', resource);
if (!resource) { if (!resource) {
throw new NotFoundException('文件不存在'); throw new NotFoundException('文件不存在');
} }
const { filename } = resource.meta as any as ResourceMeta
// 生成分享码 // 生成分享码
const code = this.generateCode(); const code = this.generateCode();
// 查找是否已有分享码记录 // 查找是否已有分享码记录
const existingShareCode = await db.shareCode.findUnique({ const existingShareCode = await super.findUnique({
where: { fileId }, where: { fileId },
}); });
if (existingShareCode) { if (existingShareCode) {
// 更新现有记录,但保留原有文件名 // 更新现有记录,但保留原有文件名
await db.shareCode.update({ await super.update({
where: { fileId }, where: { fileId },
data: { data: {
code, code,
expiresAt, expiresAt,
canUseTimes, canUseTimes,
isUsed: false, isUsed: false,
// 只在没有现有文件名且提供了新文件名时才更新文件名 fileName: filename|| "downloaded_file",
...(fileName && !existingShareCode.fileName ? { fileName } : {}),
}, },
}); });
} else { } else {
// 创建新记录 // 创建新记录
await db.shareCode.create({ await super.create({
data: { data: {
code, code,
fileId, fileId,
expiresAt, expiresAt,
canUseTimes, canUseTimes,
isUsed: false, isUsed: false,
fileName: fileName || null, fileName: filename || "downloaded_file",
}, },
}); });
} }
@ -304,6 +305,68 @@ export class ShareCodeService {
this.logger.error('生成分享码错误:', error); this.logger.error('生成分享码错误:', error);
return error return error
} }
}
async getShareCodesWithResources(args: {
page?: number;
pageSize?: number;
where?: Prisma.ShareCodeWhereInput;
}): Promise<{
items: Array<ShareCode & { resource?: Resource }>;
totalPages: number;
}> {
try {
console.log('args:', args.where.OR);
// 使用include直接关联查询Resource
const { items, totalPages } = await super.findManyWithPagination({
...args,
select:{
id: true,
code: true,
fileId: true,
expiresAt: true,
fileName: true,
canUseTimes: true,
resource: {
select: {
id: true,
type: true,
url: true,
meta: true,
}
}
}
});
this.logger.log('search result:', items);
return {
items,
totalPages
};
} catch (error) {
this.logger.error('Failed to get share codes with resources', error);
throw error;
}
}
async softDeleteShareCodes(ids: string[]): Promise<any> {
try {
this.logger.log(`尝试软删除分享码IDs: ${ids.join(', ')}`);
const result = await super.softDeleteByIds(ids);
this.logger.log(`软删除分享码成功,数量: ${result.length}`);
return result;
} catch (error) {
this.logger.error('软删除分享码失败', error);
throw error;
}
}
async updateShareCode(id: string, data: Partial<ShareCode>): Promise<any> {
try {
this.logger.log(`尝试更新分享码ID: ${id},数据:`, data);
const result = await super.updateById(id, data);
this.logger.log(`更新分享码成功:`, result);
return result;
} catch (error) {
this.logger.error('更新分享码失败', error);
throw error;
}
} }
} }

View File

@ -0,0 +1,126 @@
import { Form, FormInstance, message } from "antd";
import { api } from "@nice/client";
import { createContext, useContext, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getQueryKey } from "@trpc/react-query";
interface CodeManageContextType {
editForm: FormInstance<any>;
isLoading: boolean;
currentShareCodes: ShareCodeWithResource;
currentPage: number;
setCurrentPage: (page: number) => void;
pageSize: number;
deletShareCode: (id: string) => void;
updateCode: (expiresAt: Date, canUseTimes: number) => void
setCurrentCodeId: (id: string) => void,
currentCodeId: string | null,
searchRefetch: () => void,
setSearchKeyword: (keyword: string) => void,
currentCode: string | null,
setCurrentCode: (code: string) => void
}
interface ShareCodeWithResource {
items: {
id: string;
code: string;
fileName: string;
fileSize: number;
expiresAt: string;
createdAt: string;
canUseTimes: number;
resource: {
id: string;
type: string;
url: string;
meta: any;
}
}[],
totalPages: number
}
export const CodeManageContext = createContext<CodeManageContextType | null>(null);
export const CodeManageProvider = ({ children }: { children: React.ReactNode }) => {
const [editForm] = Form.useForm();
const [currentPage, setCurrentPage] = useState(1);
const [currentCodeId, setCurrentCodeId] = useState<string | null>()
const [currentCode, setCurrentCode] = useState<string | null>()
const queryClient = useQueryClient();
const pageSize = 8;
// 在组件顶部添加
const [searchKeyword, setSearchKeyword] = useState('');
// 构建查询条件
const whereCondition = {
deletedAt: null,
...(searchKeyword ? {
OR: [
{ fileName: { contains: searchKeyword } },
{ code: { contains: searchKeyword } }
]
} : {})
};
const { data: currentShareCodes, refetch: searchRefetch }: { data: ShareCodeWithResource, refetch: () => void } = api.shareCode.getShareCodesWithResources.useQuery(
{
page: currentPage,
pageSize: pageSize,
where: whereCondition,
},
{
enabled: true,
refetchOnWindowFocus: false,
}
)
const { mutate: softDeleteShareCode } = api.shareCode.softDeleteShareCodes.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getQueryKey(api.shareCode) });
},
onError: () => {
message.error('删除失败')
}
})
const { mutate: updateShareCode } = api.shareCode.updateShareCode.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getQueryKey(api.shareCode) });
},
onError: () => {
message.error('更新失败')
}
})
const deletShareCode = (id: string) => {
softDeleteShareCode({ ids: [id] })
}
const updateCode = (expiresAt: Date, canUseTimes: number) => {
if (currentCodeId) updateShareCode({ id: currentCodeId, data: { expiresAt, canUseTimes } })
}
const [isLoading, setIsLoading] = useState(false);
return <>
<CodeManageContext.Provider value={{
editForm,
isLoading,
currentShareCodes,
currentPage,
setCurrentPage,
pageSize,
deletShareCode,
updateCode,
currentCodeId,
setCurrentCodeId,
searchRefetch,
setSearchKeyword,
currentCode,
setCurrentCode
}}>
{children}
</CodeManageContext.Provider>
</>
};
export const useCodeManageContext = () => {
const context = useContext(CodeManageContext);
if (!context) {
throw new Error("useCodeManageContext must be used within a CodeManageProvider");
}
return context;
};

View File

@ -0,0 +1,72 @@
import { useCodeManageContext } from "./CodeManageContext";
import { Form, DatePicker, Input, Button } from "antd";
import dayjs from "dayjs";
import { useState } from "react";
export default function CodeManageEdit() {
const { editForm } = useCodeManageContext();
// 验证数字输入只能是大于等于0的整数
const validatePositiveInteger = (_: any, value: string) => {
const num = parseInt(value, 10);
if (isNaN(num) || num < 0 || num !== parseFloat(value)) {
return Promise.reject("请输入大于等于0的整数");
}
return Promise.resolve();
};
return (
<div className="w-full max-w-md mx-auto bg-white p-6 rounded-lg">
<Form
form={editForm}
layout="vertical"
className="space-y-4"
>
<Form.Item
label={<span className="text-gray-700 font-medium"></span>}
name="expiresAt"
rules={[{ required: true, message: "请选择有效期" }]}
className="mb-5"
>
<DatePicker
className="w-full"
showTime
placeholder="选择日期和时间"
disabledDate={(current) => current && current < dayjs().startOf('day')}
disabledTime={(current) => {
if (current && current.isSame(dayjs(), 'day')) {
return {
disabledHours: () => [...Array(dayjs().hour()).keys()],
disabledMinutes: (selectedHour) => {
if (selectedHour === dayjs().hour()) {
return [...Array(dayjs().minute()).keys()];
}
return [];
}
};
}
return {};
}}
/>
</Form.Item>
<Form.Item
label={<span className="text-gray-700 font-medium">使</span>}
name="canUseTimes"
rules={[
{ required: true, message: "请输入使用次数" },
{ validator: validatePositiveInteger }
]}
className="mb-5"
>
<Input
type="number"
min={0}
step={1}
placeholder="请输入使用次数"
className="w-full"
/>
</Form.Item>
</Form>
</div>
);
}

View File

@ -0,0 +1,10 @@
import CodeManageSearchBase from "./CodeManageSearchBase";
import CodeManageDisplay from "./CodeMangeDisplay";
export default function CodeManageLayout() {
return (
<div className="max-w-[1100px] mx-auto h-[100vh]">
<CodeManageSearchBase></CodeManageSearchBase>
<CodeManageDisplay></CodeManageDisplay>
</div>
)
}

View File

@ -0,0 +1,42 @@
import { Button, Form, Input } from 'antd';
import { useCodeManageContext } from './CodeManageContext';
import { ChangeEvent, useRef } from 'react';
export default function CodeManageSearchBase() {
const { setCurrentPage, searchRefetch, setSearchKeyword } = useCodeManageContext();
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
const onSearch = (value: string) => {
console.log(value);
setSearchKeyword(value);
setCurrentPage(1)
searchRefetch()
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// 设置表单值
setSearchKeyword(e.target.value);
// 设置页码为1确保从第一页开始显示搜索结果
setCurrentPage(1);
// 使用防抖处理,避免频繁发送请求
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
// 触发查询
searchRefetch();
}, 300); // 300毫秒的防抖延迟
};
return <>
<div className="py-4 mt-10 w-2/3 mx-auto">
<Form>
<Form.Item name="search" label="关键字搜索">
<Input.Search
placeholder="输入分享码或文件名"
enterButton
onSearch={onSearch}
onChange={onChange}
/>
</Form.Item>
</Form>
</div>
</>
}

View File

@ -0,0 +1,87 @@
import { message, Modal, Pagination } from "antd";
import ShareCodeList from "./ShareCodeList";
import { useCodeManageContext } from "./CodeManageContext";
import { useEffect, useState } from "react";
import { ExclamationCircleFilled } from "@ant-design/icons";
import CodeManageEdit from "./CodeManageEdit";
import dayjs from "dayjs";
export default function CodeMangeDisplay() {
const { isLoading, currentShareCodes, pageSize, currentPage,
setCurrentPage, deletShareCode, editForm,
updateCode, setCurrentCodeId, setCurrentCode, currentCode
} = useCodeManageContext();
const [modalOpen, setModalOpen] = useState(false);
const [formLoading, setFormLoading] = useState(false);
const { confirm } = Modal;
const handleEdit = (id: string, expiresAt: Date, canUseTimes: number, code: string) => {
console.log('编辑分享码:', id);
setCurrentCodeId(id)
setModalOpen(true);
setCurrentCode(code)
editForm.setFieldsValue({
expiresAt: dayjs(expiresAt),
canUseTimes: canUseTimes
});
};
const handleEditOk = () => {
const expiresAt = editForm.getFieldsValue().expiresAt.tz('Asia/Shanghai').toDate()
const canUseTimes = Number(editForm.getFieldsValue().canUseTimes)
updateCode(expiresAt, canUseTimes)
message.success('分享码已更新')
setModalOpen(false)
}
const handleDelete = (id: string) => {
console.log('删除分享码:', id);
confirm({
title: '确定删除该分享码吗',
icon: <ExclamationCircleFilled />,
content: '',
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
deletShareCode(id)
message.success('分享码已删除')
},
onCancel() {
},
});
};
useEffect(() => {
console.log('currentShareCodes:', currentShareCodes);
}, [currentShareCodes]);
return <>
<div className="w-full min-h-[550px] mx-auto">
<ShareCodeList
data={currentShareCodes?.items}
loading={isLoading}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
<div className="py-4 mt-10 w-2/3 mx-auto flex justify-center">
<Pagination
defaultCurrent={currentPage}
total={currentShareCodes?.totalPages * pageSize}
pageSize={pageSize}
onChange={(page, pageSize) => {
setCurrentPage(page);
}}
/>
</div>
<Modal
width={550}
onOk={() => {
handleEditOk()
}}
centered
open={modalOpen}
confirmLoading={formLoading}
onCancel={() => {
setModalOpen(false);
}}
title={`编辑分享码:${currentCode}`}>
<CodeManageEdit></CodeManageEdit>
</Modal>
</>
}

View File

@ -0,0 +1,56 @@
import React from 'react';
import { List,} from 'antd';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import ShareCodeListCard from './ShareCodeListCard';
dayjs.extend(utc);
dayjs.extend(timezone);
export interface ShareCodeItem {
id: string;
code: string;
expiresAt: string;
fileName: string;
canUseTimes: number;
resource: {
id: string;
type: string;
url: string;
meta: {
size: number;
filename: string;
};
}
}
interface ShareCodeListProps {
data: ShareCodeItem[];
loading?: boolean;
onEdit?: (id: string,expiresAt:Date,canUseTimes:number,code:string) => void;
onDelete?: (id: string) => void;
}
const ShareCodeList: React.FC<ShareCodeListProps> = ({
data,
loading,
onEdit,
onDelete
}) => {
return (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 4, lg: 4 }}
dataSource={data}
loading={loading}
renderItem={(item) => (
<List.Item>
<ShareCodeListCard item={item} onEdit={onEdit} onDelete={onDelete} />
</List.Item>
)}
/>
);
};
export default ShareCodeList;

View File

@ -0,0 +1,54 @@
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { Button, Card, Typography } from "antd";
import dayjs from "dayjs";
import { ShareCodeItem } from "./ShareCodeList";
import { useEffect } from "react";
export default function ShareCodeListCard({ item, onEdit, onDelete }: { item: ShareCodeItem, onEdit: (id: string,expiresAt:Date,canUseTimes:number,code:string) => void, onDelete: (id: string) => void }) {
useEffect(() => {
console.log('item:', item);
}, [item]);
return <div>
<Card
className="shadow-md hover:shadow-lg transition-shadow duration-300 space-x-4"
title={
<Typography.Text
strong
className="text-lg font-semibold text-blue-600"
>
{item.code}
</Typography.Text>
}
hoverable
actions={[
<Button
type="text"
icon={<EditOutlined />}
onClick={() => onEdit?.(item.id,dayjs(item.expiresAt).toDate(),item.canUseTimes,item.code)}
className="text-blue-500 hover:text-blue-700"
/>,
<Button
type="text"
icon={<DeleteOutlined />}
onClick={() => onDelete?.(item.id)}
className="text-red-500 hover:text-red-700"
/>
]}
>
<div className="space-y-2">
<p className="text-gray-700">
<span className="font-medium">:</span> {item.fileName}
</p>
<p className="text-gray-700">
<span className="font-medium">:</span> {Math.max(0.01, (item?.resource?.meta?.size / 1024 / 1024)).toFixed(2)} MB
</p>
<p className="text-gray-700">
<span className="font-medium">:</span> {dayjs(item.expiresAt).format('YYYY-MM-DD HH:mm:ss')}
</p>
<p className="text-gray-700">
<span className="font-medium">使:</span> {item.canUseTimes}
</p>
</div>
</Card>
</div>
}

View File

@ -56,13 +56,13 @@ const LoginPage: React.FC = () => {
}> }>
<div <div
className="flex items-center transition-all hover:bg-white overflow-hidden border-2 border-white bg-gray-50 shadow-elegant rounded-xl " className="flex items-center transition-all hover:bg-white overflow-hidden border-2 border-white bg-gray-50 shadow-elegant rounded-xl "
style={{ width: 800, height: 600 }}> style={{ width: 900, height: 600 }}>
<div <div
className={`transition-all h-full flex-1 text-white p-10 flex items-center justify-center bg-primary`}> className={`transition-all h-full flex-1 text-white p-10 flex items-center justify-center bg-primary`}>
{showLogin ? ( {showLogin ? (
<div className="flex flex-col"> <div className="flex flex-col">
<SineWave width={300} height={200} /> <SineWave width={350} height={400} />
<div className="text-2xl my-4"></div> {/* <div className="text-2xl my-4"></div>
<div className="my-4 font-thin text-sm"> <div className="my-4 font-thin text-sm">
</div> </div>
@ -70,7 +70,7 @@ const LoginPage: React.FC = () => {
onClick={() => setShowLogin(false)} onClick={() => setShowLogin(false)}
className="hover:translate-y-1 my-8 p-2 text-center rounded-xl border-white border hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all"> className="hover:translate-y-1 my-8 p-2 text-center rounded-xl border-white border hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all">
</div> </div> */}
</div> </div>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
@ -122,8 +122,8 @@ const LoginPage: React.FC = () => {
]}> ]}>
<Input.Password /> <Input.Password />
</Form.Item> </Form.Item>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center mt-4">
<Button type="primary" htmlType="submit"> <Button type="primary" className="w-full" htmlType="submit">
</Button> </Button>
</div> </div>

View File

@ -7,6 +7,10 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import ErrorPage from "../app/error"; import ErrorPage from "../app/error";
import DeptSettingPage from "../app/admin/deptsettingpage/page"; import DeptSettingPage from "../app/admin/deptsettingpage/page";
import LoginPage from "../app/login";
import WithAuth from "../components/utils/with-auth";
import { CodeManageProvider } from "../app/admin/code-manage/CodeManageContext";
import CodeManageLayout from "../app/admin/code-manage/CodeManageLayout";
interface CustomIndexRouteObject extends IndexRouteObject { interface CustomIndexRouteObject extends IndexRouteObject {
name?: string; name?: string;
breadcrumb?: string; breadcrumb?: string;
@ -33,6 +37,21 @@ export const routes: CustomRouteObject[] = [
element: <DeptSettingPage></DeptSettingPage>, element: <DeptSettingPage></DeptSettingPage>,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
}, },
{
path: "/login",
breadcrumb: "登录",
element: <LoginPage></LoginPage>,
},
{
path: "/code-manage",
element: <>
<WithAuth>
<CodeManageProvider>
<CodeManageLayout></CodeManageLayout>
</CodeManageProvider>
</WithAuth>
</>
},
]; ];
export const router = createBrowserRouter(routes); export const router = createBrowserRouter(routes);

View File

@ -360,6 +360,7 @@ model Resource {
ownerId String? @map("owner_id") ownerId String? @map("owner_id")
post Post? @relation(fields: [postId], references: [id]) post Post? @relation(fields: [postId], references: [id])
postId String? @map("post_id") postId String? @map("post_id")
shareCode ShareCode?
// 索引 // 索引
@@index([type]) @@index([type])
@ -431,7 +432,10 @@ model ShareCode {
isUsed Boolean? @default(false) isUsed Boolean? @default(false)
fileName String? @map("file_name") fileName String? @map("file_name")
canUseTimes Int? canUseTimes Int?
resource Resource? @relation(fields: [fileId], references: [fileId])
deletedAt DateTime? @map("deleted_at")
@@index([code]) @@index([code])
@@index([fileId]) @@index([fileId])
@@index([expiresAt]) @@index([expiresAt])
@@map("share_code")
} }

View File

@ -59,6 +59,7 @@ export enum ObjectType {
LECTURE = "lecture", LECTURE = "lecture",
ENROLLMENT = "enrollment", ENROLLMENT = "enrollment",
RESOURCE = "resource", RESOURCE = "resource",
SHARE_CODE = "shareCode",
} }
export enum RolePerms { export enum RolePerms {
// Create Permissions 创建权限 // Create Permissions 创建权限