Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
5b313df33c
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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}`,
|
||||||
|
|
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
|
@ -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={{
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 */}
|
||||||
|
|
|
@ -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">
|
||||||
提供技术支持
|
提供技术支持
|
||||||
|
|
|
@ -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")}
|
||||||
|
|
|
@ -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("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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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,6 @@
|
||||||
|
import React, { ReactNode } from "react";
|
||||||
|
export interface MenuItemType {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
action: () => void;
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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={() => {
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
];
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
Loading…
Reference in New Issue