This commit is contained in:
Li1304553726 2025-02-25 10:03:08 +08:00
commit 5b313df33c
31 changed files with 908 additions and 335 deletions

View File

@ -155,6 +155,7 @@ export class UserProfileService {
where: { id }, where: { id },
select: { select: {
id: true, id: true,
avatar:true,
deptId: true, deptId: true,
department: true, department: true,
domainId: true, domainId: true,

View File

@ -40,7 +40,11 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
id: postId, id: postId,
visitType: args.data.type, // 直接复用传入的类型 visitType: args.data.type, // 直接复用传入的类型
}); });
EventBus.emit('updateTotalCourseViewCount', {
visitType: args.data.type, // 直接复用传入的类型
});
} }
return result; return result;
} }
async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) { async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) {
@ -138,6 +142,9 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
id: args?.where?.postId as string, id: args?.where?.postId as string,
visitType: args.where.type as any, // 直接复用传入的类型 visitType: args.where.type as any, // 直接复用传入的类型
}); });
EventBus.emit('updateTotalCourseViewCount', {
visitType: args.where.type as any, // 直接复用传入的类型
});
} }
} }
return superDetele; return superDetele;

View File

@ -22,6 +22,12 @@ export class PostQueueService implements OnModuleInit {
EventBus.on('updatePostState', ({ id }) => { EventBus.on('updatePostState', ({ id }) => {
this.addUpdatePostState({ id }); this.addUpdatePostState({ id });
}); });
EventBus.on('updatePostState', ({ id }) => {
this.addUpdatePostState({ id });
});
EventBus.on('updateTotalCourseViewCount', ({ visitType }) => {
this.addUpdateTotalCourseViewCount({ visitType });
});
} }
async addUpdateVisitCountJob(data: updateVisitCountJobData) { async addUpdateVisitCountJob(data: updateVisitCountJobData) {
this.logger.log(`update post view count ${data.id}`); 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}` }, 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}` },
},
);
}
} }

View File

@ -1,4 +1,67 @@
import { db, VisitType } from '@nice/common'; import {
AppConfigSlug,
BaseSetting,
db,
PostType,
TaxonomySlug,
VisitType,
} from '@nice/common';
export async function updateTotalCourseViewCount(type: VisitType) {
const posts = await db.post.findMany({
where: {
type: { in: [PostType.COURSE, PostType.LECTURE] },
deletedAt: null,
},
select: { id: true, type: true },
});
const courseIds = posts
.filter((post) => post.type === PostType.COURSE)
.map((course) => course.id);
const lectures = posts.filter((post) => post.type === PostType.LECTURE);
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 staffs = await db.staff.count({
where: { deletedAt: null },
});
const baseSeting = appConfig.meta as BaseSetting;
await db.appConfig.update({
where: {
slug: AppConfigSlug.BASE_SETTING,
},
data: {
meta: {
...baseSeting,
appConfig: {
...(baseSeting?.appConfig || {}),
statistics: {
reads: totalViews._sum.views || 0,
courses: courseIds?.length || 0,
staffs: staffs || 0,
lectures: lectures?.length || 0,
},
},
},
},
});
}
export async function updatePostViewCount(id: string, type: VisitType) { export async function updatePostViewCount(id: string, type: VisitType) {
const post = await db.post.findFirst({ const post = await db.post.findFirst({
where: { id }, where: { id },

View File

@ -4,6 +4,7 @@ export enum QueueJobType {
FILE_PROCESS = 'file_process', FILE_PROCESS = 'file_process',
UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount', UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount',
UPDATE_POST_STATE = 'updatePostState', UPDATE_POST_STATE = 'updatePostState',
UPDATE_TOTAL_COURSE_VIEW_COUNT = 'updateTotalCourseViewCount',
} }
export type updateVisitCountJobData = { export type updateVisitCountJobData = {
id: string; id: string;

View File

@ -11,7 +11,10 @@ import {
updateCourseReviewStats, updateCourseReviewStats,
updateParentLectureStats, updateParentLectureStats,
} from '@server/models/post/utils'; } from '@server/models/post/utils';
import { updatePostViewCount } from '../models/post/utils'; import {
updatePostViewCount,
updateTotalCourseViewCount,
} from '../models/post/utils';
const logger = new Logger('QueueWorker'); const logger = new Logger('QueueWorker');
export default async function processJob(job: Job<any, any, QueueJobType>) { export default async function processJob(job: Job<any, any, QueueJobType>) {
try { try {
@ -51,6 +54,9 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
if (job.name === QueueJobType.UPDATE_POST_STATE) { if (job.name === QueueJobType.UPDATE_POST_STATE) {
await updatePostViewCount(job.data.id, job.data.type); 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) { } catch (error: any) {
logger.error( logger.error(
`Error processing stats update job: ${error.message}`, `Error processing stats update job: ${error.message}`,

View File

@ -21,6 +21,9 @@ type Events = {
updatePostState: { updatePostState: {
id: string; id: string;
}; };
updateTotalCourseViewCount: {
visitType: VisitType | string;
};
onMessageCreated: { data: Partial<MessageDto> }; onMessageCreated: { data: Partial<MessageDto> };
dataChanged: { type: string; operation: CrudOperation; data: any }; dataChanged: { type: string; operation: CrudOperation; data: any };
}; };

View File

@ -24,7 +24,8 @@ export default function BaseSettingPage() {
const [isFormChanged, setIsFormChanged] = useState(false); const [isFormChanged, setIsFormChanged] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { user, hasSomePermissions } = useAuth(); const { user, hasSomePermissions } = useAuth();
const { pageWidth } = useContext?.(MainLayoutContext); const context = useContext(MainLayoutContext);
const pageWidth = context?.pageWidth;
function handleFieldsChange() { function handleFieldsChange() {
setIsFormChanged(true); setIsFormChanged(true);
} }
@ -43,7 +44,6 @@ export default function BaseSettingPage() {
} }
async function onSubmit(values: BaseSetting) { async function onSubmit(values: BaseSetting) {
setLoading(true); setLoading(true);
try { try {
await update.mutateAsync({ await update.mutateAsync({
where: { where: {
@ -116,6 +116,13 @@ export default function BaseSettingPage() {
<Input></Input> <Input></Input>
</Form.Item> </Form.Item>
</div> </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 {/* <div
className="p-2 border-b flex items-center justify-between" className="p-2 border-b flex items-center justify-between"
style={{ style={{

View File

@ -156,14 +156,13 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
color={selectedCategory === category ? 'blue' : 'default'} color={selectedCategory === category ? 'blue' : 'default'}
onClick={() => { onClick={() => {
setSelectedCategory(category) setSelectedCategory(category)
// console.log(gateGory) console.log(category)
} }}
}
className={`px-6 py-2 text-base cursor-pointer rounded-full transition-all duration-300 ${selectedCategory === category className={`px-6 py-2 text-base cursor-pointer rounded-full transition-all duration-300 ${selectedCategory === category
? 'bg-blue-600 text-white shadow-lg' ? 'bg-blue-600 text-white shadow-lg'
: 'bg-white text-gray-600 hover:bg-gray-100' : 'bg-white text-gray-600 hover:bg-gray-100'
}`} }`}
> >
{category} {category}
</Tag> </Tag>

View File

@ -10,6 +10,7 @@ import {
EyeOutlined, EyeOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import type { CarouselRef } from "antd/es/carousel"; import type { CarouselRef } from "antd/es/carousel";
import { useAppConfig } from "@nice/client";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -44,16 +45,16 @@ const carouselItems: CarouselItem[] = [
}, },
]; ];
const platformStats: PlatformStat[] = [
{ icon: <TeamOutlined />, value: "50,000+", label: "注册学员" },
{ icon: <BookOutlined />, value: "1,000+", label: "精品课程" },
// { icon: <StarOutlined />, value: '98%', label: '好评度' },
{ icon: <EyeOutlined />, value: "100万+", label: "观看次数" },
];
const HeroSection = () => { const HeroSection = () => {
const carouselRef = useRef<CarouselRef>(null); const carouselRef = useRef<CarouselRef>(null);
const { statistics, baseSetting } = useAppConfig();
const platformStats: PlatformStat[] = [
{ icon: <TeamOutlined />, value: "50,000+", label: "注册学员" },
{ icon: <BookOutlined />, value: "1,000+", label: "精品课程" },
// { icon: <StarOutlined />, value: '98%', label: '好评度' },
{ icon: <EyeOutlined />, value: "4552", label: "观看次数" },
];
const handlePrev = useCallback(() => { const handlePrev = useCallback(() => {
carouselRef.current?.prev(); carouselRef.current?.prev();
}, []); }, []);
@ -61,7 +62,7 @@ const HeroSection = () => {
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
carouselRef.current?.next(); carouselRef.current?.next();
}, []); }, []);
//const {slides:carouselItems} = useAppConfig()
return ( return (
<section className="relative "> <section className="relative ">
<div className="group"> <div className="group">
@ -73,24 +74,29 @@ const HeroSection = () => {
dots={{ dots={{
className: "carousel-dots !bottom-32 !z-20", className: "carousel-dots !bottom-32 !z-20",
}}> }}>
{carouselItems.map((item, index) => ( {Array.isArray(carouselItems) ? (
<div key={index} className="relative h-[600px]"> carouselItems.map((item, index) => (
<div <div key={index} className="relative h-[600px]">
className="absolute inset-0 bg-cover bg-center transform transition-[transform,filter] duration-[2000ms] group-hover:scale-105 group-hover:brightness-110 will-change-[transform,filter]" <div
style={{ className="absolute inset-0 bg-cover bg-center transform transition-[transform,filter] duration-[2000ms] group-hover:scale-105 group-hover:brightness-110 will-change-[transform,filter]"
backgroundImage: `url(${item.image})`, style={{
backfaceVisibility: "hidden", //backgroundImage: `url(https://s.cn.bing.net/th?id=OHR.GiantCuttlefish_ZH-CN0670915878_1920x1080.webp&qlt=50)`,
}} backgroundImage: `url(${item.image})`,
/> backfaceVisibility: "hidden",
<div }}
className={`absolute inset-0 bg-gradient-to-r ${item.color} to-transparent opacity-90 mix-blend-overlay transition-opacity duration-500`} />
/> <div
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" /> className={`absolute inset-0 bg-gradient-to-r ${item.color} to-transparent opacity-90 mix-blend-overlay transition-opacity duration-500`}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
{/* Content Container */} {/* Content Container */}
<div className="relative h-full max-w-7xl mx-auto px-6 lg:px-8"></div> <div className="relative h-full max-w-7xl mx-auto px-6 lg:px-8"></div>
</div> </div>
))} ))
) : (
<div></div>
)}
</Carousel> </Carousel>
{/* Navigation Buttons */} {/* Navigation Buttons */}

View File

@ -8,7 +8,7 @@ export function MainFooter() {
{/* 开发组织信息 */} {/* 开发组织信息 */}
<div className="text-center md:text-left space-y-2"> <div className="text-center md:text-left space-y-2">
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start"> <h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
</h3> </h3>
<p className="text-gray-400 text-xs italic"> <p className="text-gray-400 text-xs italic">

View File

@ -3,7 +3,7 @@ import { Input, Layout, Avatar, Button, Dropdown } from "antd";
import { EditFilled, SearchOutlined, UserOutlined } from "@ant-design/icons"; import { EditFilled, SearchOutlined, UserOutlined } from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { UserMenu } from "./UserMenu"; import { UserMenu } from "./UserMenu/UserMenu";
import { NavigationMenu } from "./NavigationMenu"; import { NavigationMenu } from "./NavigationMenu";
const { Header } = Layout; const { Header } = Layout;
@ -35,10 +35,12 @@ export function MainHeader() {
className="w-72 rounded-full" className="w-72 rounded-full"
value={searchValue} value={searchValue}
onChange={(e) => setSearchValue(e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
onPressEnter={(e)=>{ onPressEnter={(e) => {
//console.log(e) //console.log(e)
setSearchValue('') setSearchValue("");
navigate(`/courses/?searchValue=${searchValue}`) navigate(
`/courses/?searchValue=${searchValue}`
);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
}} }}
/> />
@ -54,18 +56,7 @@ export function MainHeader() {
</> </>
)} )}
{isAuthenticated ? ( {isAuthenticated ? (
<Dropdown <UserMenu />
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>
) : ( ) : (
<Button <Button
onClick={() => navigate("/login")} onClick={() => navigate("/login")}

View File

@ -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>
);
};

View File

@ -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>
);
}

View File

@ -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("avatar", 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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,6 @@
import React, { ReactNode } from "react";
export interface MenuItemType {
icon: ReactNode;
label: string;
action: () => void;
}

View File

@ -11,6 +11,7 @@ export interface TusUploaderProps {
value?: string[]; value?: string[];
onChange?: (value: string[]) => void; onChange?: (value: string[]) => void;
multiple?: boolean; multiple?: boolean;
allowTypes?: string[];
} }
interface UploadingFile { interface UploadingFile {
@ -25,8 +26,8 @@ export const TusUploader = ({
value = [], value = [],
onChange, onChange,
multiple = true, multiple = true,
allowTypes = undefined,
}: TusUploaderProps) => { }: TusUploaderProps) => {
const { handleFileUpload, uploadProgress } = useTusUpload(); const { handleFileUpload, uploadProgress } = useTusUpload();
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]); const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
const [completedFiles, setCompletedFiles] = useState<UploadingFile[]>( const [completedFiles, setCompletedFiles] = useState<UploadingFile[]>(
@ -61,7 +62,10 @@ export const TusUploader = ({
const handleBeforeUpload = useCallback( const handleBeforeUpload = useCallback(
(file: File) => { (file: File) => {
if (allowTypes && !allowTypes.includes(file.type)) {
toast.error(`文件类型 ${file.type} 不在允许范围内`);
return Upload.LIST_IGNORE; // 使用 antd 的官方阻止方式
}
const fileKey = `${file.name}-${Date.now()}`; const fileKey = `${file.name}-${Date.now()}`;
setUploadingFiles((prev) => [ setUploadingFiles((prev) => [
@ -136,10 +140,10 @@ export const TusUploader = ({
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<Upload.Dragger <Upload.Dragger
accept={allowTypes?.join(",")}
name="files" name="files"
multiple={multiple} multiple={multiple}
showUploadList={false} showUploadList={false}
beforeUpload={handleBeforeUpload}> beforeUpload={handleBeforeUpload}>
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
<UploadOutlined /> <UploadOutlined />
@ -149,6 +153,11 @@ export const TusUploader = ({
</p> </p>
<p className="ant-upload-hint"> <p className="ant-upload-hint">
{multiple ? "支持单个或批量上传文件" : "仅支持上传单个文件"} {multiple ? "支持单个或批量上传文件" : "仅支持上传单个文件"}
{allowTypes && (
<span className="block text-xs text-gray-500">
: {allowTypes.join(", ")}
</span>
)}
</p> </p>
<div className="px-2 py-0 rounded mt-1"> <div className="px-2 py-0 rounded mt-1">
@ -165,10 +174,10 @@ export const TusUploader = ({
file.status === "done" file.status === "done"
? 100 ? 100
: Math.round( : Math.round(
uploadProgress?.[ uploadProgress?.[
file.fileKey! file.fileKey!
] || 0 ] || 0
) )
} }
status={ status={
file.status === "error" file.status === "error"

View File

@ -58,6 +58,7 @@ export function CourseDetailProvider({
); );
useEffect(() => { useEffect(() => {
if (course) { if (course) {
console.log("read");
read.mutateAsync({ read.mutateAsync({
data: { data: {
visitorId: user?.id || null, visitorId: user?.id || null,

View File

@ -47,7 +47,7 @@ export const CourseDetailDescription: React.FC = () => {
<div>{course?.subTitle}</div> <div>{course?.subTitle}</div>
<div className="flex gap-1"> <div className="flex gap-1">
<EyeOutlined></EyeOutlined> <EyeOutlined></EyeOutlined>
<div>{course?.meta?.views}</div> <div>{course?.meta?.views || 0}</div>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<CalendarOutlined></CalendarOutlined> <CalendarOutlined></CalendarOutlined>

View File

@ -8,7 +8,7 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { useNavigate } from "react-router-dom"; 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"; import { CourseDetailContext } from "../CourseDetailContext";
const { Header } = Layout; const { Header } = Layout;
@ -21,7 +21,7 @@ export function CourseDetailHeader() {
return ( return (
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30"> <Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
<div className="w-full max-w-screen-2xl px-4 md:px-6 mx-auto flex items-center justify-between h-full"> <div className="w-full flex items-center justify-between h-full">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<HomeOutlined <HomeOutlined
onClick={() => { onClick={() => {

View File

@ -2,6 +2,7 @@ import {
SkeletonItem, SkeletonItem,
SkeletonSection, SkeletonSection,
} from "@web/src/components/presentation/Skeleton"; } from "@web/src/components/presentation/Skeleton";
import { api } from "packages/client/dist";
export const CourseDetailSkeleton = () => { export const CourseDetailSkeleton = () => {
return ( return (

View File

@ -10,7 +10,13 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor"; import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
import { TusUploader } from "@web/src/components/common/uploader/TusUploader"; import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
import { Lecture, LectureType, LessonTypeLabel, PostType } from "@nice/common"; import {
Lecture,
LectureType,
LessonTypeLabel,
PostType,
videoMimeTypes,
} from "@nice/common";
import { usePost } from "@nice/client"; import { usePost } from "@nice/client";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@ -134,7 +140,9 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
name="title" name="title"
initialValue={field?.title} initialValue={field?.title}
className="mb-0 flex-1" className="mb-0 flex-1"
rules={[{ required: true }]}> rules={[
{ required: true, message: "请输入课时标题" },
]}>
<Input placeholder="课时标题" /> <Input placeholder="课时标题" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@ -158,14 +166,24 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
<Form.Item <Form.Item
name={["meta", "videoIds"]} name={["meta", "videoIds"]}
className="mb-0 flex-1" className="mb-0 flex-1"
rules={[{ required: true }]}> rules={[
<TusUploader multiple={false} /> {
required: true,
message: "请传入视频",
},
]}>
<TusUploader
allowTypes={videoMimeTypes}
multiple={false}
/>
</Form.Item> </Form.Item>
) : ( ) : (
<Form.Item <Form.Item
name="content" name="content"
className="mb-0 flex-1" className="mb-0 flex-1"
rules={[{ required: true }]}> rules={[
{ required: true, message: "请输入内容" },
]}>
<QuillEditor /> <QuillEditor />
</Form.Item> </Form.Item>
)} )}

View File

@ -1,10 +1,11 @@
import { Button, Form, Input, Spin, Switch, message } from "antd"; import { Button, Form, Input, Spin, Switch, message } from "antd";
import { useContext, useEffect} from "react"; import { useContext, useEffect } from "react";
import { useStaff } from "@nice/client"; import { useStaff } from "@nice/client";
import DepartmentSelect from "../department/department-select"; import DepartmentSelect from "../department/department-select";
import { api } from "@nice/client" import { api } from "@nice/client";
import { StaffEditorContext } from "./staff-editor"; import { StaffEditorContext } from "./staff-editor";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import AvatarUploader from "../../common/uploader/AvatarUploader";
export default function StaffForm() { export default function StaffForm() {
const { create, update } = useStaff(); // Ensure you have these methods in your hooks const { create, update } = useStaff(); // Ensure you have these methods in your hooks
const { const {
@ -21,6 +22,7 @@ export default function StaffForm() {
{ where: { id: editId } }, { where: { id: editId } },
{ enabled: !!editId } { enabled: !!editId }
); );
const { isRoot } = useAuth(); const { isRoot } = useAuth();
async function handleFinish(values: any) { async function handleFinish(values: any) {
const { const {
@ -31,8 +33,9 @@ export default function StaffForm() {
password, password,
phoneNumber, phoneNumber,
officerId, officerId,
enabled enabled,
} = values avatar,
} = values;
setFormLoading(true); setFormLoading(true);
try { try {
if (data && editId) { if (data && editId) {
@ -46,8 +49,9 @@ export default function StaffForm() {
password, password,
phoneNumber, phoneNumber,
officerId, officerId,
enabled enabled,
} avatar,
},
}); });
} else { } else {
await create.mutateAsync({ await create.mutateAsync({
@ -58,8 +62,9 @@ export default function StaffForm() {
domainId: fieldDomainId ? fieldDomainId : domainId, domainId: fieldDomainId ? fieldDomainId : domainId,
password, password,
officerId, officerId,
phoneNumber phoneNumber,
} avatar,
},
}); });
form.resetFields(); form.resetFields();
if (deptId) form.setFieldValue("deptId", deptId); if (deptId) form.setFieldValue("deptId", deptId);
@ -77,13 +82,14 @@ export default function StaffForm() {
useEffect(() => { useEffect(() => {
form.resetFields(); form.resetFields();
if (data && editId) { if (data && editId) {
form.setFieldValue("username", data.username); form.setFieldValue("username", data?.username);
form.setFieldValue("showname", data.showname); form.setFieldValue("showname", data?.showname);
form.setFieldValue("domainId", data.domainId); form.setFieldValue("domainId", data?.domainId);
form.setFieldValue("deptId", data.deptId); form.setFieldValue("deptId", data?.deptId);
form.setFieldValue("officerId", data.officerId); form.setFieldValue("officerId", data?.officerId);
form.setFieldValue("phoneNumber", data.phoneNumber); form.setFieldValue("phoneNumber", data?.phoneNumber);
form.setFieldValue("enabled", data.enabled) form.setFieldValue("enabled", data?.enabled);
form.setFieldValue("avatar", data?.avatar);
} }
}, [data]); }, [data]);
useEffect(() => { useEffect(() => {
@ -99,6 +105,7 @@ export default function StaffForm() {
<Spin /> <Spin />
</div> </div>
)} )}
<Form <Form
disabled={isLoading} disabled={isLoading}
form={form} form={form}
@ -106,6 +113,9 @@ export default function StaffForm() {
requiredMark="optional" requiredMark="optional"
autoComplete="off" autoComplete="off"
onFinish={handleFinish}> onFinish={handleFinish}>
<Form.Item name={"avatar"} label="头像">
<AvatarUploader></AvatarUploader>
</Form.Item>
{canManageAnyStaff && ( {canManageAnyStaff && (
<Form.Item <Form.Item
name={"domainId"} name={"domainId"}
@ -127,7 +137,8 @@ export default function StaffForm() {
rules={[{ required: true }]} rules={[{ required: true }]}
name={"username"} name={"username"}
label="帐号"> label="帐号">
<Input allowClear <Input
allowClear
autoComplete="new-username" // 使用非标准的自动完成值 autoComplete="new-username" // 使用非标准的自动完成值
spellCheck={false} spellCheck={false}
/> />
@ -136,7 +147,8 @@ export default function StaffForm() {
rules={[{ required: true }]} rules={[{ required: true }]}
name={"showname"} name={"showname"}
label="姓名"> label="姓名">
<Input allowClear <Input
allowClear
autoComplete="new-name" // 使用非标准的自动完成值 autoComplete="new-name" // 使用非标准的自动完成值
spellCheck={false} spellCheck={false}
/> />
@ -146,8 +158,8 @@ export default function StaffForm() {
{ {
required: false, required: false,
pattern: /^\d{5,18}$/, pattern: /^\d{5,18}$/,
message: "请输入正确的证件号(数字)" message: "请输入正确的证件号(数字)",
} },
]} ]}
name={"officerId"} name={"officerId"}
label="证件号"> label="证件号">
@ -158,20 +170,29 @@ export default function StaffForm() {
{ {
required: false, required: false,
pattern: /^\d{6,11}$/, pattern: /^\d{6,11}$/,
message: "请输入正确的手机号(数字)" message: "请输入正确的手机号(数字)",
} },
]} ]}
name={"phoneNumber"} name={"phoneNumber"}
label="手机号"> label="手机号">
<Input autoComplete="new-phone" // 使用非标准的自动完成值 <Input
spellCheck={false} allowClear /> autoComplete="new-phone" // 使用非标准的自动完成值
spellCheck={false}
allowClear
/>
</Form.Item> </Form.Item>
<Form.Item label="密码" name={"password"}> <Form.Item label="密码" name={"password"}>
<Input.Password spellCheck={false} visibilityToggle autoComplete="new-password" /> <Input.Password
spellCheck={false}
visibilityToggle
autoComplete="new-password"
/>
</Form.Item> </Form.Item>
{editId && <Form.Item label="是否启用" name={"enabled"}> {editId && (
<Switch></Switch> <Form.Item label="是否启用" name={"enabled"}>
</Form.Item>} <Switch></Switch>
</Form.Item>
)}
</Form> </Form>
</div> </div>
); );

View File

@ -15,7 +15,7 @@ export function useTusUpload() {
>({}); >({});
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const getFileId = (url: string) => { const getFileId = (url: string) => {
const parts = url.split("/"); const parts = url.split("/");
const uploadIndex = parts.findIndex((part) => part === "upload"); const uploadIndex = parts.findIndex((part) => part === "upload");

View File

@ -1,135 +1,136 @@
version: "3.8" version: "3.8"
services: services:
db: db:
image: postgres:latest image: postgres:latest
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
- POSTGRES_DB=app - POSTGRES_DB=app
- POSTGRES_USER=root - POSTGRES_USER=root
- POSTGRES_PASSWORD=Letusdoit000 - POSTGRES_PASSWORD=Letusdoit000
volumes: volumes:
- ./volumes/postgres:/var/lib/postgresql/data - ./volumes/postgres:/var/lib/postgresql/data
# minio: # minio:
# image: minio/minio # image: minio/minio
# ports: # ports:
# - "9000:9000" # - "9000:9000"
# - "9001:9001" # - "9001:9001"
# volumes: # volumes:
# - ./volumes/minio:/minio_data # - ./volumes/minio:/minio_data
# environment: # environment:
# - MINIO_ACCESS_KEY=minioadmin # - MINIO_ACCESS_KEY=minioadmin
# - MINIO_SECRET_KEY=minioadmin # - MINIO_SECRET_KEY=minioadmin
# command: minio server /minio_data --console-address ":9001" -address ":9000" # command: minio server /minio_data --console-address ":9001" -address ":9000"
# healthcheck: # healthcheck:
# test: # test:
# [ # [
# "CMD", # "CMD",
# "curl", # "curl",
# "-f", # "-f",
# "http://192.168.2.1:9001/minio/health/live" # "http://192.168.2.1:9001/minio/health/live"
# ] # ]
# interval: 30s # interval: 30s
# timeout: 20s # timeout: 20s
# retries: 3 # retries: 3
pgadmin: pgadmin:
image: dpage/pgadmin4 image: dpage/pgadmin4
ports: ports:
- "8082:80" - "8082:80"
environment: environment:
- PGADMIN_DEFAULT_EMAIL=insiinc@outlook.com - PGADMIN_DEFAULT_EMAIL=insiinc@outlook.com
- PGADMIN_DEFAULT_PASSWORD=Letusdoit000 - PGADMIN_DEFAULT_PASSWORD=Letusdoit000
# tusd: # tusd:
# image: tusproject/tusd # image: tusproject/tusd
# ports: # ports:
# - "8080:8080" # - "8080:8080"
# environment: # environment:
# - AWS_REGION=cn-north-1 # - AWS_REGION=cn-north-1
# - AWS_ACCESS_KEY_ID=minioadmin # - AWS_ACCESS_KEY_ID=minioadmin
# - AWS_SECRET_ACCESS_KEY=minioadmin # - AWS_SECRET_ACCESS_KEY=minioadmin
# command: -verbose -s3-bucket app -s3-endpoint http://minio:9000 -hooks-http http://host.docker.internal:3000/upload/hook # command: -verbose -s3-bucket app -s3-endpoint http://minio:9000 -hooks-http http://host.docker.internal:3000/upload/hook
# volumes: # volumes:
# - ./volumes/tusd:/data # - ./volumes/tusd:/data
# extra_hosts: # extra_hosts:
# - "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
# depends_on: # depends_on:
# - minio # - minio
# tusd: # tusd:
# image: tusproject/tusd # image: tusproject/tusd
# ports: # ports:
# - "8080:8080" # - "8080:8080"
# command: -verbose -upload-dir /data -hooks-http http://host.docker.internal:3000/upload/hook # command: -verbose -upload-dir /data -hooks-http http://host.docker.internal:3000/upload/hook
# volumes: # volumes:
# - ./uploads:/data # - ./uploads:/data
# extra_hosts: # extra_hosts:
# - "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
nginx: nginx:
image: nice-nginx:latest image: nice-nginx:2.0
ports: ports:
- "80:80" - "80:80"
volumes: volumes:
- ./config/nginx/conf.d:/etc/nginx/conf.d - ./config/nginx/conf.d:/etc/nginx/conf.d
- ./config/nginx/nginx.conf:/etc/nginx/nginx.conf - ./config/nginx/nginx.conf:/etc/nginx/nginx.conf
- ./uploads:/data/uploads # tusd 上传目录 - ./uploads:/data/uploads # tusd 上传目录
- ./web-dist:/usr/share/nginx/html # 添加前端构建文件的挂载 - ./web-dist:/usr/share/nginx/html # 添加前端构建文件的挂载
- ./config/nginx/entrypoint.sh:/docker-entrypoint.sh - ./config/nginx/entrypoint.sh:/docker-entrypoint.sh
environment: environment:
- SERVER_IP=host.docker.internal - SERVER_IP=host.docker.internal
- SERVER_PORT=3000 - SERVER_PORT=3000
entrypoint: ["/docker-entrypoint.sh"] entrypoint: ["/docker-entrypoint.sh"]
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
redis:
image: redis:latest redis:
ports: image: redis:latest
- "6379:6379" ports:
volumes: - "6379:6379"
- ./config/redis.conf:/usr/local/etc/redis/redis.conf volumes:
- ./volumes/redis:/data - ./config/redis.conf:/usr/local/etc/redis/redis.conf
command: ["redis-server", "/usr/local/etc/redis/redis.conf"] - ./volumes/redis:/data
# restic: command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
# image: restic/restic:latest # restic:
# environment: # image: restic/restic:latest
# - RESTIC_REPOSITORY=/backup # environment:
# - RESTIC_PASSWORD=Letusdoit000 # - RESTIC_REPOSITORY=/backup
# volumes: # - RESTIC_PASSWORD=Letusdoit000
# - ./volumes/postgres:/data # volumes:
# - ./volumes/restic-cache:/root/.cache/restic # - ./volumes/postgres:/data
# - ./backup:/backup # 本地目录挂载到容器内的 /backup # - ./volumes/restic-cache:/root/.cache/restic
# - ./config/backup.sh:/usr/local/bin/backup.sh # Mount your script inside the container # - ./backup:/backup # 本地目录挂载到容器内的 /backup
# entrypoint: /usr/local/bin/backup.sh # - ./config/backup.sh:/usr/local/bin/backup.sh # Mount your script inside the container
# depends_on: # entrypoint: /usr/local/bin/backup.sh
# - db # depends_on:
# web: # - db
# image: td-web:latest # web:
# ports: # image: td-web:latest
# - "80:80" # ports:
# environment: # - "80:80"
# - VITE_APP_SERVER_IP=192.168.79.77 # environment:
# - VITE_APP_VERSION=0.3.0 # - VITE_APP_SERVER_IP=192.168.79.77
# - VITE_APP_APP_NAME=两道防线管理后台 # - VITE_APP_VERSION=0.3.0
# server: # - VITE_APP_APP_NAME=两道防线管理后台
# image: td-server:latest # server:
# ports: # image: td-server:latest
# - "3000:3000" # ports:
# - "3001:3001" # - "3000:3000"
# environment: # - "3001:3001"
# - DATABASE_URL=postgresql://root:Letusdoit000@db:5432/app?schema=public # environment:
# - REDIS_HOST=redis # - DATABASE_URL=postgresql://root:Letusdoit000@db:5432/app?schema=public
# - REDIS_PORT=6379 # - REDIS_HOST=redis
# - REDIS_PASSWORD=Letusdoit000 # - REDIS_PORT=6379
# - TUS_URL=http://192.168.2.1:8080 # - REDIS_PASSWORD=Letusdoit000
# - JWT_SECRET=/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA= # - TUS_URL=http://192.168.2.1:8080
# - PUSH_URL=http://dns:9092 # - JWT_SECRET=/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=
# - PUSH_APPID=123 # - PUSH_URL=http://dns:9092
# - PUSH_APPSECRET=123 # - PUSH_APPID=123
# - MINIO_HOST=minio # - PUSH_APPSECRET=123
# - ADMIN_PHONE_NUMBER=13258117304 # - MINIO_HOST=minio
# - DEADLINE_CRON=0 0 8 * * * # - ADMIN_PHONE_NUMBER=13258117304
# depends_on: # - DEADLINE_CRON=0 0 8 * * *
# - db # depends_on:
# - redis # - db
# - redis
networks: networks:
default: default:
name: remooc name: remooc

View File

@ -3,15 +3,16 @@ import { AppConfigSlug, BaseSetting } from "@nice/common";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
export function useAppConfig() { export function useAppConfig() {
const utils = api.useUtils() const utils = api.useUtils();
const [baseSetting, setBaseSetting] = useState<BaseSetting | undefined>(); const [baseSetting, setBaseSetting] = useState<BaseSetting | undefined>();
const { data, isLoading }: { data: any; isLoading: boolean } = const { data, isLoading }: { data: any; isLoading: boolean } =
api.app_config.findFirst.useQuery({ api.app_config.findFirst.useQuery({
where: { slug: AppConfigSlug.BASE_SETTING } where: { slug: AppConfigSlug.BASE_SETTING },
}); });
const handleMutationSuccess = useCallback(() => { const handleMutationSuccess = useCallback(() => {
utils.app_config.invalidate() utils.app_config.invalidate();
}, [utils]); }, [utils]);
// Use the generic success handler in mutations // Use the generic success handler in mutations
@ -26,9 +27,9 @@ export function useAppConfig() {
}); });
useEffect(() => { useEffect(() => {
if (data?.meta) { if (data?.meta) {
setBaseSetting(JSON.parse(data?.meta)); // console.log(JSON.parse(data?.meta));
setBaseSetting(data?.meta);
} }
}, [data, isLoading]); }, [data, isLoading]);
const splashScreen = useMemo(() => { const splashScreen = useMemo(() => {
return baseSetting?.appConfig?.splashScreen; return baseSetting?.appConfig?.splashScreen;
@ -36,8 +37,20 @@ export function useAppConfig() {
const devDept = useMemo(() => { const devDept = useMemo(() => {
return baseSetting?.appConfig?.devDept; return baseSetting?.appConfig?.devDept;
}, [baseSetting]); }, [baseSetting]);
const slides = useMemo(() => {
return baseSetting?.appConfig?.slides || [];
}, [baseSetting]);
const statistics = useMemo(() => {
return (
baseSetting?.appConfig?.statistics || {
reads: 0,
staffs: 0,
courses: 0,
lectures: 0,
}
);
}, [baseSetting]);
return { return {
create, create,
deleteMany, deleteMany,
update, update,
@ -45,5 +58,7 @@ export function useAppConfig() {
splashScreen, splashScreen,
devDept, devDept,
isLoading, isLoading,
slides,
statistics,
}; };
} }

View File

@ -81,3 +81,17 @@ export const InitAppConfigs: Prisma.AppConfigCreateInput[] = [
description: "", description: "",
}, },
]; ];
export const videoMimeTypes = [
"video/*", // 通配符 (部分浏览器可能不支持)
"video/mp4", // .mp4
"video/quicktime", // .mov
"video/x-msvideo", // .avi
"video/x-matroska", // .mkv
"video/webm", // .webm
"video/ogg", // .ogv
"video/mpeg", // .mpeg
"video/3gpp", // .3gp
"video/3gpp2", // .3g2
"video/x-flv", // .flv
"video/x-ms-wmv", // .wmv
];

View File

@ -70,21 +70,22 @@ export const courseDetailSelect: Prisma.PostSelect = {
title: true, title: true,
subTitle: true, subTitle: true,
content: true, content: true,
depts: true,
// isFeatured: true, // isFeatured: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
// 关联表选择 // 关联表选择
terms:{ terms: {
select:{ select: {
id:true, id: true,
name:true, name: true,
taxonomy:{ taxonomy: {
select:{ select: {
id:true, id: true,
slug:true slug: true,
} },
} },
} },
}, },
enrollments: { enrollments: {
select: { select: {

View File

@ -2,37 +2,37 @@ import { Staff, Department } from "@prisma/client";
import { RolePerms } from "../enum"; import { RolePerms } from "../enum";
export type StaffRowModel = { export type StaffRowModel = {
avatar: string; avatar: string;
dept_name: string; dept_name: string;
officer_id: string; officer_id: string;
phone_number: string; phone_number: string;
showname: string; showname: string;
username: string; username: string;
}; };
export type UserProfile = Staff & { export type UserProfile = Staff & {
permissions: RolePerms[]; permissions: RolePerms[];
deptIds: string[]; deptIds: string[];
parentDeptIds: string[]; parentDeptIds: string[];
domain: Department; domain: Department;
department: Department; department: Department;
}; };
export type StaffDto = Staff & { export type StaffDto = Staff & {
domain?: Department; domain?: Department;
department?: Department; department?: Department;
}; };
export interface AuthDto { export interface AuthDto {
token: string; token: string;
staff: StaffDto; staff: StaffDto;
refreshToken: string; refreshToken: string;
perms: string[]; perms: string[];
} }
export interface JwtPayload { export interface JwtPayload {
sub: string; sub: string;
username: string; username: string;
} }
export interface TokenPayload { export interface TokenPayload {
id: string; id: string;
phoneNumber: string; phoneNumber: string;
name: string; name: string;
} }

View File

@ -43,6 +43,13 @@ export interface BaseSetting {
appConfig?: { appConfig?: {
splashScreen?: string; splashScreen?: string;
devDept?: string; devDept?: string;
slides?: [];
statistics?: {
reads?: number;
courses?: number;
lectures?: number;
staffs?: number;
};
}; };
} }
export type RowModelResult = { export type RowModelResult = {