Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
b3c1f82c4b
|
@ -22,6 +22,12 @@ export class PostQueueService implements OnModuleInit {
|
|||
EventBus.on('updatePostState', ({ id }) => {
|
||||
this.addUpdatePostState({ id });
|
||||
});
|
||||
EventBus.on('updatePostState', ({ id }) => {
|
||||
this.addUpdatePostState({ id });
|
||||
});
|
||||
EventBus.on('updateTotalCourseViewCount', ({ visitType }) => {
|
||||
this.addUpdateTotalCourseViewCount({ visitType });
|
||||
});
|
||||
}
|
||||
async addUpdateVisitCountJob(data: updateVisitCountJobData) {
|
||||
this.logger.log(`update post view count ${data.id}`);
|
||||
|
@ -37,4 +43,14 @@ export class PostQueueService implements OnModuleInit {
|
|||
debounce: { id: `${QueueJobType.UPDATE_POST_STATE}_${data.id}` },
|
||||
});
|
||||
}
|
||||
async addUpdateTotalCourseViewCount({ visitType }) {
|
||||
this.logger.log(`update post state ${visitType}`);
|
||||
await this.generalQueue.add(
|
||||
QueueJobType.UPDATE_TOTAL_COURSE_VIEW_COUNT,
|
||||
{ type: visitType },
|
||||
{
|
||||
debounce: { id: `${QueueJobType.UPDATE_POST_STATE}_${visitType}` },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,48 @@
|
|||
import { db, VisitType } from '@nice/common';
|
||||
import {
|
||||
AppConfigSlug,
|
||||
BaseSetting,
|
||||
db,
|
||||
PostType,
|
||||
TaxonomySlug,
|
||||
VisitType,
|
||||
} from '@nice/common';
|
||||
export async function updateTotalCourseViewCount(type: VisitType) {
|
||||
const courses = await db.post.findMany({
|
||||
where: { type: PostType.COURSE },
|
||||
select: { id: true },
|
||||
});
|
||||
const courseIds = courses.map((course) => course.id);
|
||||
const totalViews = await db.visit.aggregate({
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
where: {
|
||||
postId: { in: courseIds },
|
||||
type: type,
|
||||
},
|
||||
});
|
||||
const appConfig = await db.appConfig.findFirst({
|
||||
where: {
|
||||
slug: AppConfigSlug.BASE_SETTING,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
const baseSeting = appConfig.meta as BaseSetting;
|
||||
await db.appConfig.update({
|
||||
where: {
|
||||
slug: AppConfigSlug.BASE_SETTING,
|
||||
},
|
||||
data: {
|
||||
meta: {
|
||||
...baseSeting,
|
||||
reads: totalViews,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
export async function updatePostViewCount(id: string, type: VisitType) {
|
||||
const post = await db.post.findFirst({
|
||||
where: { id },
|
||||
|
|
|
@ -4,6 +4,7 @@ export enum QueueJobType {
|
|||
FILE_PROCESS = 'file_process',
|
||||
UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount',
|
||||
UPDATE_POST_STATE = 'updatePostState',
|
||||
UPDATE_TOTAL_COURSE_VIEW_COUNT = 'updateTotalCourseViewCount',
|
||||
}
|
||||
export type updateVisitCountJobData = {
|
||||
id: string;
|
||||
|
|
|
@ -11,7 +11,10 @@ import {
|
|||
updateCourseReviewStats,
|
||||
updateParentLectureStats,
|
||||
} from '@server/models/post/utils';
|
||||
import { updatePostViewCount } from '../models/post/utils';
|
||||
import {
|
||||
updatePostViewCount,
|
||||
updateTotalCourseViewCount,
|
||||
} from '../models/post/utils';
|
||||
const logger = new Logger('QueueWorker');
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
try {
|
||||
|
@ -51,6 +54,9 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
|
|||
if (job.name === QueueJobType.UPDATE_POST_STATE) {
|
||||
await updatePostViewCount(job.data.id, job.data.type);
|
||||
}
|
||||
if (job.name === QueueJobType.UPDATE_TOTAL_COURSE_VIEW_COUNT) {
|
||||
await updateTotalCourseViewCount(job.data.type);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Error processing stats update job: ${error.message}`,
|
||||
|
|
|
@ -21,6 +21,9 @@ type Events = {
|
|||
updatePostState: {
|
||||
id: string;
|
||||
};
|
||||
updateTotalCourseViewCount: {
|
||||
visitType: VisitType | string;
|
||||
};
|
||||
onMessageCreated: { data: Partial<MessageDto> };
|
||||
dataChanged: { type: string; operation: CrudOperation; data: any };
|
||||
};
|
||||
|
|
|
@ -116,6 +116,13 @@ export default function BaseSettingPage() {
|
|||
<Input></Input>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="p-2 grid grid-cols-8 gap-2 border-b">
|
||||
<Form.Item
|
||||
label="运维单位"
|
||||
name={["appConfig", "slides"]}>
|
||||
<Input></Input>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* <div
|
||||
className="p-2 border-b flex items-center justify-between"
|
||||
style={{
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
|||
import { EditFilled, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
import { UserMenu } from "./UserMenu/UserMenu";
|
||||
import { NavigationMenu } from "./NavigationMenu";
|
||||
|
||||
const { Header } = Layout;
|
||||
|
@ -35,10 +35,12 @@ export function MainHeader() {
|
|||
className="w-72 rounded-full"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onPressEnter={(e)=>{
|
||||
onPressEnter={(e) => {
|
||||
//console.log(e)
|
||||
setSearchValue('')
|
||||
navigate(`/courses/?searchValue=${searchValue}`)
|
||||
setSearchValue("");
|
||||
navigate(
|
||||
`/courses/?searchValue=${searchValue}`
|
||||
);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
/>
|
||||
|
@ -54,18 +56,7 @@ export function MainHeader() {
|
|||
</>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<Dropdown
|
||||
overlay={<UserMenu />}
|
||||
trigger={["click"]}
|
||||
placement="bottomRight">
|
||||
<Avatar
|
||||
size="large"
|
||||
className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
|
||||
{(user?.showname ||
|
||||
user?.username ||
|
||||
"")[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Dropdown>
|
||||
<UserMenu />
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => navigate("/login")}
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
import { Avatar, Menu, Dropdown } from "antd";
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserAddOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const UserMenu = () => {
|
||||
const { isAuthenticated, logout, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Menu className="w-48">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Menu.Item key="profile" className="px-4 py-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
|
||||
{(user?.showname ||
|
||||
user?.username ||
|
||||
"")[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{user?.department?.name || user?.officerId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
key="user-settings"
|
||||
icon={<UserSwitchOutlined />}
|
||||
className="px-4">
|
||||
个人设置
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="settings"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => {
|
||||
navigate("/admin/staff");
|
||||
}}
|
||||
className="px-4">
|
||||
后台管理
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="logout"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={async () => await logout()}
|
||||
className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50">
|
||||
退出登录
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : (
|
||||
<Menu.Item
|
||||
key="login"
|
||||
onClick={() => navigate("/login")}
|
||||
className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50">
|
||||
登录/注册
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import { Button, Drawer, Modal } from "antd";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
|
||||
import { UserEditorContext } from "./UserMenu";
|
||||
import UserForm from "./UserForm";
|
||||
|
||||
export default function UserEditModal() {
|
||||
const { formLoading, modalOpen, setModalOpen, form } =
|
||||
useContext(UserEditorContext);
|
||||
const handleOk = () => {
|
||||
form.submit();
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
width={400}
|
||||
onOk={handleOk}
|
||||
centered
|
||||
open={modalOpen}
|
||||
confirmLoading={formLoading}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
title={"编辑个人信息"}>
|
||||
<UserForm />
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
import { Button, Form, Input, Spin, Switch, message } from "antd";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { useStaff } from "@nice/client";
|
||||
import DepartmentSelect from "@web/src/components/models/department/department-select";
|
||||
import { api } from "@nice/client";
|
||||
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
|
||||
import { StaffDto } from "@nice/common";
|
||||
import { UserEditorContext } from "./UserMenu";
|
||||
import toast from "react-hot-toast";
|
||||
export default function StaffForm() {
|
||||
const { user } = useAuth();
|
||||
const { create, update } = useStaff(); // Ensure you have these methods in your hooks
|
||||
const {
|
||||
formLoading,
|
||||
modalOpen,
|
||||
setModalOpen,
|
||||
domainId,
|
||||
setDomainId,
|
||||
form,
|
||||
setFormLoading,
|
||||
} = useContext(UserEditorContext);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
}: {
|
||||
data: StaffDto;
|
||||
isLoading: boolean;
|
||||
} = api.staff.findFirst.useQuery(
|
||||
{ where: { id: user?.id } },
|
||||
{ enabled: !!user?.id }
|
||||
);
|
||||
const { isRoot } = useAuth();
|
||||
async function handleFinish(values: any) {
|
||||
const {
|
||||
username,
|
||||
showname,
|
||||
deptId,
|
||||
domainId,
|
||||
password,
|
||||
phoneNumber,
|
||||
officerId,
|
||||
enabled,
|
||||
avatar,
|
||||
photoUrl,
|
||||
email,
|
||||
rank,
|
||||
office,
|
||||
} = values;
|
||||
setFormLoading(true);
|
||||
try {
|
||||
if (data && user?.id) {
|
||||
await update.mutateAsync({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
username,
|
||||
deptId,
|
||||
showname,
|
||||
domainId,
|
||||
password,
|
||||
phoneNumber,
|
||||
officerId,
|
||||
enabled,
|
||||
avatar,
|
||||
},
|
||||
});
|
||||
}
|
||||
toast.success("提交成功");
|
||||
setModalOpen(false);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
form.resetFields();
|
||||
if (data) {
|
||||
form.setFieldValue("username", data.username);
|
||||
form.setFieldValue("showname", data.showname);
|
||||
form.setFieldValue("domainId", data.domainId);
|
||||
form.setFieldValue("deptId", data.deptId);
|
||||
form.setFieldValue("officerId", data.officerId);
|
||||
form.setFieldValue("phoneNumber", data.phoneNumber);
|
||||
form.setFieldValue("enabled", data.enabled);
|
||||
form.setFieldValue("enabled", data.avatar);
|
||||
}
|
||||
}, [data]);
|
||||
// useEffect(() => {
|
||||
// if (!data && domainId) {
|
||||
// form.setFieldValue("domainId", domainId);
|
||||
// form.setFieldValue("deptId", domainId);
|
||||
// }
|
||||
// }, [domainId, data as any]);
|
||||
return (
|
||||
<div className="relative">
|
||||
{isLoading && (
|
||||
<div className="absolute h-full inset-0 flex items-center justify-center bg-white bg-opacity-50 z-10">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
<Form
|
||||
disabled={isLoading}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark="optional"
|
||||
autoComplete="off"
|
||||
onFinish={handleFinish}>
|
||||
<div className=" flex items-center gap-4 mb-2">
|
||||
<div>
|
||||
<Form.Item name={"avatar"} label="头像" noStyle>
|
||||
<AvatarUploader
|
||||
placeholder="点击上传头像"
|
||||
className="rounded-lg"
|
||||
style={{
|
||||
width: "120px",
|
||||
height: "150px",
|
||||
}}></AvatarUploader>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 flex-1">
|
||||
<Form.Item
|
||||
noStyle
|
||||
rules={[{ required: true }]}
|
||||
name={"showname"}
|
||||
label="名称">
|
||||
<Input
|
||||
placeholder="请输入名称"
|
||||
allowClear
|
||||
autoComplete="new-name" // 使用非标准的自动完成值
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={"domainId"}
|
||||
label="所属域"
|
||||
noStyle
|
||||
rules={[{ required: true }]}>
|
||||
<DepartmentSelect
|
||||
placeholder="选择域"
|
||||
onChange={(value) => {
|
||||
setDomainId(value as string);
|
||||
}}
|
||||
domain={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
name={"deptId"}
|
||||
label="所属单位"
|
||||
rules={[{ required: true }]}>
|
||||
<DepartmentSelect rootId={domainId} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle label="密码" name={"password"}>
|
||||
<Input.Password
|
||||
placeholder="修改密码"
|
||||
spellCheck={false}
|
||||
visibilityToggle
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,252 @@
|
|||
import { useClickOutside } from "@web/src/hooks/useClickOutside";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
createContext,
|
||||
} from "react";
|
||||
import { Avatar } from "@web/src/components/common/element/Avatar";
|
||||
import {
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { FormInstance, Spin } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MenuItemType } from "./types";
|
||||
import { RolePerms } from "@nice/common";
|
||||
import { useForm } from "antd/es/form/Form";
|
||||
import UserEditModal from "./UserEditModal";
|
||||
const menuVariants = {
|
||||
hidden: { opacity: 0, scale: 0.95, y: -10 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -10,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UserEditorContext = createContext<{
|
||||
domainId: string;
|
||||
setDomainId: React.Dispatch<React.SetStateAction<string>>;
|
||||
modalOpen: boolean;
|
||||
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
form: FormInstance<any>;
|
||||
formLoading: boolean;
|
||||
setFormLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}>({
|
||||
modalOpen: false,
|
||||
domainId: undefined,
|
||||
setDomainId: undefined,
|
||||
setModalOpen: undefined,
|
||||
form: undefined,
|
||||
formLoading: undefined,
|
||||
setFormLoading: undefined,
|
||||
});
|
||||
|
||||
export function UserMenu() {
|
||||
const [form] = useForm();
|
||||
const [formLoading, setFormLoading] = useState<boolean>();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout, isLoading, hasSomePermissions } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [domainId, setDomainId] = useState<string>();
|
||||
const toggleMenu = useCallback(() => {
|
||||
setShowMenu((prev) => !prev);
|
||||
}, []);
|
||||
const canManageAnyStaff = useMemo(() => {
|
||||
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF);
|
||||
}, [user]);
|
||||
const menuItems: MenuItemType[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
icon: <UserOutlined className="text-lg" />,
|
||||
label: "个人信息",
|
||||
action: () => {
|
||||
setModalOpen(true);
|
||||
},
|
||||
},
|
||||
canManageAnyStaff && {
|
||||
icon: <SettingOutlined className="text-lg" />,
|
||||
label: "设置",
|
||||
action: () => {
|
||||
navigate("/admin/staff");
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
icon: <LogoutOutlined className="text-lg" />,
|
||||
label: "注销",
|
||||
action: () => logout(),
|
||||
},
|
||||
].filter(Boolean),
|
||||
[logout]
|
||||
);
|
||||
|
||||
const handleMenuItemClick = useCallback((action: () => void) => {
|
||||
action();
|
||||
setShowMenu(false);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-10 h-10">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserEditorContext.Provider
|
||||
value={{
|
||||
formLoading,
|
||||
setFormLoading,
|
||||
form,
|
||||
domainId,
|
||||
modalOpen,
|
||||
setDomainId,
|
||||
setModalOpen,
|
||||
}}>
|
||||
<div ref={menuRef} className="relative">
|
||||
<motion.button
|
||||
aria-label="用户菜单"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showMenu}
|
||||
aria-controls="user-menu"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={toggleMenu}
|
||||
className="flex items-center rounded-full transition-all duration-200 ease-in-out">
|
||||
{/* Avatar 容器,相对定位 */}
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white hover:ring-[#00538E]/90
|
||||
transition-all duration-200 ease-in-out shadow-md
|
||||
hover:shadow-lg focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
|
||||
focus:ring-offset-white "
|
||||
/>
|
||||
{/* 小绿点 */}
|
||||
<span
|
||||
className="absolute bottom-0 right-0 h-3 w-3
|
||||
rounded-full bg-emerald-500 ring-2 ring-white
|
||||
shadow-sm transition-transform duration-200
|
||||
ease-in-out hover:scale-110"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 用户信息,显示在 Avatar 右侧 */}
|
||||
<div className="flex flex-col space-y-0.5 ml-3 items-start">
|
||||
<span className="text-base text-primary flex items-center gap-1.5">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showMenu && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={menuVariants}
|
||||
role="menu"
|
||||
id="user-menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu-button"
|
||||
style={{ zIndex: 100 }}
|
||||
className="absolute right-0 mt-3 w-64 origin-top-right
|
||||
bg-white rounded-xl overflow-hidden shadow-lg
|
||||
border border-[#E5EDF5]">
|
||||
{/* User Profile Section */}
|
||||
<div
|
||||
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
|
||||
border-b border-[#E5EDF5] ">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white shadow-sm"
|
||||
/>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="text-sm font-semibold text-[#00538E]">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-[#718096] flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
{menuItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
role="menuitem"
|
||||
tabIndex={showMenu ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuItemClick(item.action);
|
||||
}}
|
||||
className={`flex items-center gap-3 w-full px-4 py-3
|
||||
text-sm font-medium rounded-lg transition-all
|
||||
focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/20
|
||||
group relative overflow-hidden
|
||||
active:scale-[0.99]
|
||||
${
|
||||
item.label === "注销"
|
||||
? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
|
||||
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
|
||||
}`}>
|
||||
<span
|
||||
className={`w-5 h-5 flex items-center justify-center
|
||||
transition-all duration-200 ease-in-out
|
||||
group-hover:scale-110 group-hover:rotate-6
|
||||
group-hover:translate-x-0.5 ${
|
||||
item.label === "注销"
|
||||
? "group-hover:text-red-600"
|
||||
: "group-hover:text-[#003F6A]"
|
||||
}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<UserEditModal></UserEditModal>
|
||||
</UserEditorContext.Provider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export interface MenuItemType {
|
||||
icon: JSX.Element;
|
||||
label: string;
|
||||
action: () => void;
|
||||
}
|
|
@ -58,6 +58,7 @@ export function CourseDetailProvider({
|
|||
);
|
||||
useEffect(() => {
|
||||
if (course) {
|
||||
console.log("read");
|
||||
read.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from "@ant-design/icons";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { UserMenu } from "@web/src/app/main/layout/UserMenu";
|
||||
import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu";
|
||||
import { CourseDetailContext } from "../CourseDetailContext";
|
||||
|
||||
const { Header } = Layout;
|
||||
|
|
|
@ -70,21 +70,22 @@ export const courseDetailSelect: Prisma.PostSelect = {
|
|||
title: true,
|
||||
subTitle: true,
|
||||
content: true,
|
||||
depts: true,
|
||||
// isFeatured: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
// 关联表选择
|
||||
terms:{
|
||||
select:{
|
||||
id:true,
|
||||
name:true,
|
||||
taxonomy:{
|
||||
select:{
|
||||
id:true,
|
||||
slug:true
|
||||
}
|
||||
}
|
||||
}
|
||||
terms: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
taxonomy: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
enrollments: {
|
||||
select: {
|
||||
|
|
|
@ -2,37 +2,37 @@ import { Staff, Department } from "@prisma/client";
|
|||
import { RolePerms } from "../enum";
|
||||
|
||||
export type StaffRowModel = {
|
||||
avatar: string;
|
||||
dept_name: string;
|
||||
officer_id: string;
|
||||
phone_number: string;
|
||||
showname: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
dept_name: string;
|
||||
officer_id: string;
|
||||
phone_number: string;
|
||||
showname: string;
|
||||
username: string;
|
||||
};
|
||||
export type UserProfile = Staff & {
|
||||
permissions: RolePerms[];
|
||||
deptIds: string[];
|
||||
parentDeptIds: string[];
|
||||
domain: Department;
|
||||
department: Department;
|
||||
permissions: RolePerms[];
|
||||
deptIds: string[];
|
||||
parentDeptIds: string[];
|
||||
domain: Department;
|
||||
department: Department;
|
||||
};
|
||||
|
||||
export type StaffDto = Staff & {
|
||||
domain?: Department;
|
||||
department?: Department;
|
||||
domain?: Department;
|
||||
department?: Department;
|
||||
};
|
||||
export interface AuthDto {
|
||||
token: string;
|
||||
staff: StaffDto;
|
||||
refreshToken: string;
|
||||
perms: string[];
|
||||
token: string;
|
||||
staff: StaffDto;
|
||||
refreshToken: string;
|
||||
perms: string[];
|
||||
}
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
sub: string;
|
||||
username: string;
|
||||
}
|
||||
export interface TokenPayload {
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
name: string;
|
||||
}
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
name: string;
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ export interface BaseSetting {
|
|||
splashScreen?: string;
|
||||
devDept?: string;
|
||||
slides?: [];
|
||||
reads?: number;
|
||||
};
|
||||
}
|
||||
export type RowModelResult = {
|
||||
|
|
Loading…
Reference in New Issue