This commit is contained in:
Rao 2025-02-26 10:20:10 +08:00
commit 3027dae3a6
15 changed files with 139 additions and 431 deletions

View File

@ -352,7 +352,9 @@ export class TermService extends BaseTreeService<Prisma.TermDelegate> {
staff: UserProfile,
data: z.infer<typeof TermMethodSchema.getSimpleTree>,
) {
const { domainId = null, permissions } = staff;
// const { domainId = null, permissions } = staff;
const permissions = staff?.permissions || [];
const domainId = staff?.domainId || null;
const hasAnyPerms =
permissions.includes(RolePerms.READ_ANY_TERM) ||
permissions.includes(RolePerms.MANAGE_ANY_TERM);

View File

@ -3,7 +3,7 @@ import { useContext, useEffect, useState } from "react";
import { Button, Form, Input, message, theme } from "antd";
import { useAppConfig } from "@nice/client";
import { useAuth } from "@web/src/providers/auth-provider";
import MultiImageUploader from "@web/src/components/common/uploader/MultiImageUploader";
import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader";
import FixedHeader from "@web/src/components/layout/fix-header";
import { useForm } from "antd/es/form/Form";
@ -56,7 +56,7 @@ export default function BaseSettingPage() {
meta: {
...baseSetting,
appConfig: {
...baseSetting.appConfig,
...(baseSetting?.appConfig || {}),
...appConfig,
},
},
@ -129,9 +129,9 @@ export default function BaseSettingPage() {
</div>
<div className="p-2 grid grid-cols-8 gap-2 border-b">
<Form.Item
label="运维单位"
label="首页轮播图"
name={["appConfig", "slides"]}>
<MultiImageUploader></MultiImageUploader>
<MultiAvatarUploader></MultiAvatarUploader>
</Form.Item>
</div>
{/* <div

View File

@ -34,6 +34,7 @@ export default function CourseCard({ course }: CourseCardProps) {
backgroundImage: `url(${course?.meta?.thumbnail})`,
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" />
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-6xl text-white/90 opacity-0 group-hover:opacity-100 transition-all duration-300" />
</div>
@ -78,6 +79,7 @@ export default function CourseCard({ course }: CourseCardProps) {
{course.terms?.[1].name}
</Tag> */}
</div>
<Title
level={4}
className="mb-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
@ -88,12 +90,17 @@ export default function CourseCard({ course }: CourseCardProps) {
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
<div className="ml-2 flex items-center flex-grow">
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
{course?.depts?.map((depts) => depts.name)}
{/* {course?.depts?.[0]?.name} */}
{course?.depts?.length > 1
? `${course.depts[0].name}`
: course?.depts?.[0]?.name}
{/* {course?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */}
{/* {course?.depts?.map((dept)=>{return dept.name})} */}
</Text>
</div>
<span className="text-xs font-medium text-gray-500">
{course?.meta?.views}
{course?.meta?.views
? `观看次数 ${course?.meta?.views}`
: null}
</span>
</div>

View File

@ -52,6 +52,7 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
className="font-bold text-5xl mb-6 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
{title}
</Title>
<Text
type="secondary"
className="text-xl font-light text-gray-600">

View File

@ -222,8 +222,7 @@ export function UserMenu() {
focus:ring-2 focus:ring-[#00538E]/20
group relative overflow-hidden
active:scale-[0.99]
${
item.label === "注销"
${item.label === "注销"
? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
}`}>
@ -231,8 +230,7 @@ export function UserMenu() {
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:translate-x-0.5 ${item.label === "注销"
? "group-hover:text-red-600"
: "group-hover:text-[#003F6A]"
}`}>

View File

@ -12,6 +12,8 @@ export interface AvatarUploaderProps {
onChange?: (value: string) => void;
compressed?: boolean;
style?: React.CSSProperties; // 添加style属性
successText?: string;
showCover?: boolean;
}
interface UploadingFile {
@ -31,6 +33,8 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
className,
placeholder = "点击上传",
style, // 解构style属性
successText = "上传成功",
showCover = true,
}) => {
const { handleFileUpload, uploadProgress } = useTusUpload();
const [file, setFile] = useState<UploadingFile | null>(null);
@ -92,12 +96,12 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
// 使用 resolved 的最新值调用 onChange
// 强制刷新 Avatar 组件
setAvatarKey((prev) => prev + 1); // 修改 key 强制重新挂载
onChange?.(uploadedUrl);
console.log(uploadedUrl);
toast.success("头像上传成功");
onChange?.(uploadedUrl);
toast.success(successText);
} catch (error) {
console.error("上传错误:", error);
toast.error("头像上传失败");
toast.error("上传失败");
setFile((prev) => ({ ...prev!, status: "error" }));
} finally {
setUploading(false);
@ -124,7 +128,7 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
accept="image/*"
style={{ display: "none" }}
/>
{previewUrl ? (
{(previewUrl && showCover) ? (
<Avatar
key={avatarKey}
ref={avatarRef}

View File

@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
// import UncoverAvatarUploader from "../uploader/UncoverAvatarUploader ";
import { Upload, Progress, Button, Image, Form } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import AvatarUploader from "./AvatarUploader";
import { isEqual } from "lodash";
interface MultiAvatarUploaderProps {
value?: string[];
onChange?: (value: string[]) => void;
}
export function MultiAvatarUploader({
value,
onChange,
}: MultiAvatarUploaderProps) {
const [imageList, setImageList] = useState<string[]>(value || []);
const [previewImage, setPreviewImage] = useState<string>("");
useEffect(() => {
if (!isEqual(value, imageList)) {
setImageList(value || []);
}
}, [value]);
useEffect(() => {
onChange?.(imageList);
}, [imageList]);
return (
<>
<div className="flex gap-2 mb-2" style={{ width: "1200px" }}>
{(imageList || [])?.map((image, index) => {
return (
<div
className="mr-2px relative"
key={index}
style={{ width: "200px", height: "100px" }}>
<Image
alt=""
style={{ width: "200px", height: "100px" }}
src={image}
preview={{
visible: previewImage === image,
onVisibleChange: (visible) =>
setPreviewImage(
visible ? image || "" : ""
),
}}></Image>
<Button
type="text"
danger
icon={<DeleteOutlined className="text-red" />}
onClick={() =>
image &&
setImageList(
imageList.filter((_, i) => i !== index)
)
}
style={{
position: "absolute", // 绝对定位
top: "0", // 顶部对齐
right: "0", // 右侧对齐
zIndex: 1, // 确保按钮在图片上方
padding: "4px", // 调整按钮内边距
backgroundColor: "rgba(255, 255, 255, 0.2)", // 半透明背景
borderRadius: "50%", // 圆形按钮
}}
/>
</div>
);
})}
</div>
<div className="flex">
<AvatarUploader
showCover={false}
successText={"轮播图上传成功"}
onChange={(value) => {
console.log(value);
setImageList([...imageList, value]);
}}></AvatarUploader>
</div>
</>
);
}
export default MultiAvatarUploader;

View File

@ -1,173 +0,0 @@
import React, { useState } from 'react';
import { Upload, Modal, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useTusUpload } from "@web/src/hooks/useTusUpload";
import toast from "react-hot-toast";
export interface MultiImageUploadProps {
value?: string;
placeholder?: string;
className?: string;
onChange?: (value: string) => void;
compressed?: boolean;
style?: React.CSSProperties; // 添加style属性
}
interface UploadFile {
name: string;
progress: number;
status: "uploading" | "done" | "error";
url?: string;
fileKey?: string;
uid: string;
}
const MultiImageUpload: React.FC<MultiImageUploadProps> = ({
value,
onChange,
compressed = false,
className,
placeholder = "点击上传",
style, // 解构style属性
}) => {
const [fileList, setFileList] = useState<UploadFile[] | null>(null); // 存储已上传的文件列表
const [previewVisible, setPreviewVisible] = useState(false); // 控制预览模态框的显示
const [previewImage, setPreviewImage] = useState(''); // 当前预览的图片URL
const { handleFileUpload, uploadProgress } = useTusUpload();
const [compressedUrl, setCompressedUrl] = useState<string>(value || "");
const [url, setUrl] = useState<string>(value || "");
const [uploading, setUploading] = useState(false);
// 处理文件上传前的校验
const beforeUpload = (file) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件!');
}
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
message.error('图片大小不能超过10MB');
}
return isImage && isLt10M;
};
// 处理文件列表变化
const handleChange = async ({ fileList }) => {
//setFileList(fileList);
console.log(fileList);
const imageUrls = fileList.map(file => {
return URL.createObjectURL(file.originFileObj)
});
console.log("imageUrls", imageUrls);
const newFileList = fileList.map(file => {
return {
name: file.name,
progress: 0,
status: "uploading",
//uid: file.uid,
fileKey: `${file.name}-${Date.now()}`,
//url: file.url,
}
});
console.log("newFileList", newFileList);
setUploading(true);
try {
const resFileList = newFileList.map(async (file, index) => {
const uploadedUrl = await new Promise<string>((resolve, reject) => {
handleFileUpload(
fileList[index].originFileObj,
(result) => {
() => {
return {
...newFileList[index],
progress: 100,
status: "done",
fileId: result.fileId,
url: result.url,
compressedUrl: result.compressedUrl,
}
}
// setFile((prev) => ({
// ...prev!,
// progress: 100,
// status: "done",
// fileId: result.fileId,
// url: result.url,
// compressedUrl: result.compressedUrl,
// }));
setUrl(result.url);
setCompressedUrl(result.compressedUrl);
// 直接使用 result 中的最新值
resolve(compressed ? result.compressedUrl : result.url);
},
(error) => {
reject(error);
},
newFileList[index]?.fileKey
);
});
})
// await new Promise((resolve) => setTimeout(resolve,4999)); // 方法1使用 await 暂停执行
// 使用 resolved 的最新值调用 onChange
// 强制刷新 Avatar 组件
// setAvatarKey((prev) => prev + 1); // 修改 key 强制重新挂载
onChange?.(resFileList);
console.log(resFileList);
toast.success("图片上传成功");
} catch (error) {
console.error("上传错误:", error);
toast.error("图片上传失败");
// setFile((prev) => ({ ...prev!, status: "error" }));
} finally {
setUploading(false);
}
};uploadProgress
// 处理预览
const handlePreview = async (file) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj);
}
setPreviewImage(file.url || file.preview);
setPreviewVisible(true);
};
// 关闭预览模态框
const handleCancel = () => setPreviewVisible(false);
// 将文件转换为Base64格式用于预览
const getBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
});
};
return (
<div>
<Upload.Dragger
listType="picture-card" // 卡片样式
fileList={fileList} // 已上传的文件列表
beforeUpload={beforeUpload} // 上传前的校验
onChange={handleChange} // 文件列表变化时的回调
onPreview={handlePreview} // 预览回调
multiple // 支持多选
// style={{ width: '200px' }}
>
<p className="ant-upload-drag-icon">
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"></p>
</Upload.Dragger>
<Modal visible={previewVisible} footer={null} onCancel={handleCancel}>
<img alt="预览" style={{ width: '100%' }} src={previewImage} />
</Modal>
</div>
);
};
export default MultiImageUpload;

View File

@ -1,18 +0,0 @@
import AvatarUploader from "./AvatarUploader"
interface TestUploaderProps {
value?: string[],
onChange?: (value: string[]) => void
}
export function TestUploader({
value,
onChange,
}: TestUploaderProps) {
return <>
<AvatarUploader></AvatarUploader>
</>
}

View File

@ -229,8 +229,7 @@ export function UserMenu() {
focus:ring-2 focus:ring-[#00538E]/20
group relative overflow-hidden
active:scale-[0.99]
${
item.label === "注销"
${item.label === "注销"
? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
}`}>
@ -238,8 +237,7 @@ export function UserMenu() {
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:translate-x-0.5 ${item.label === "注销"
? "group-hover:text-red-600"
: "group-hover:text-[#003F6A]"
}`}>

View File

@ -83,6 +83,7 @@ export function CourseFormProvider({
const onSubmit = async (values: any) => {
console.log(values);
const sections = values?.sections || [];
const deptIds = values?.deptIds || [];
const termIds = taxonomies
.map((tax) => values[tax.id]) // 获取每个 taxonomy 对应的选中值
.filter((id) => id); // 过滤掉空值
@ -95,12 +96,16 @@ export function CourseFormProvider({
terms: {
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式
},
depts: {
connect: deptIds.map((id) => ({ id })),
},
};
// 删除原始的 taxonomy 字段
taxonomies.forEach((tax) => {
delete formattedValues[tax.id];
});
delete formattedValues.sections;
delete formattedValues.deptIds;
if (course) {
formattedValues.meta = {
...(course?.meta as CourseMeta),

View File

@ -4,6 +4,7 @@ import { convertToOptions } from "@nice/client";
import TermSelect from "../../../term/term-select";
import { useCourseEditor } from "../context/CourseEditorContext";
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
import DepartmentSelect from "../../../department/department-select";
const { TextArea } = Input;
@ -48,6 +49,9 @@ export function CourseBasicForm() {
autoSize={{ minRows: 3, maxRows: 6 }}
/>
</Form.Item>
<Form.Item name="deptIds" label="参与单位">
<DepartmentSelect multiple />
</Form.Item>
{taxonomies &&
taxonomies.map((tax, index) => (
<Form.Item

View File

@ -54,6 +54,9 @@ export default function CourseList({
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: "smooth" });
}
if (isLoading) {
return <Skeleton paragraph={{ rows: 10 }}></Skeleton>;
}
return (
<div className="space-y-6">
{courses.length > 0 ? (

View File

@ -15,11 +15,9 @@ interface TermSelectProps {
defaultValue?: string | string[];
value?: string | string[];
onChange?: (value: string | string[]) => void;
placeholder?: string;
multiple?: boolean;
taxonomyId?: string;
disabled?: boolean;
className?: string;
domainId?: string;
dropdownStyle?: React.CSSProperties;
style?: React.CSSProperties;
@ -34,7 +32,6 @@ export default function TermSelect({
defaultValue,
value,
onChange,
className,
placeholder = "选择分类",
multiple = false,
taxonomyId,
@ -45,7 +42,8 @@ export default function TermSelect({
style,
disabled = false,
dropdownRender,
}: TermSelectProps) {
...treeSelectProps
}: TermSelectProps & TreeSelectProps) {
const utils = api.useUtils();
const [listTreeData, setListTreeData] = useState<
Omit<DefaultOptionType, "label">[]
@ -179,9 +177,6 @@ export default function TermSelect({
<TreeSelect
treeDataSimpleMode
disabled={disabled}
showSearch={showSearch}
allowClear
style={style}
// ref={selectRef}
dropdownStyle={{
width: "300px", // 固定宽度
@ -189,11 +184,8 @@ export default function TermSelect({
maxWidth: "600px", // 最大宽度
...dropdownStyle,
}}
dropdownRender={dropdownRender}
defaultValue={defaultValue}
value={value}
open={open}
className={className}
placeholder={placeholder}
onChange={handleChange}
loadData={onLoadData}
@ -204,6 +196,7 @@ export default function TermSelect({
onClear={() => handleChange(multiple ? [] : undefined)}
onTreeExpand={handleExpand}
onDropdownVisibleChange={handleDropdownVisibleChange}
{...treeSelectProps}
/>
);
}

View File

@ -1,199 +0,0 @@
import React, { useEffect, useState, useCallback } from "react";
import { Tree } from "antd";
import type { DataNode, TreeProps } from "antd/es/tree";
import { getUniqueItems } from "@nice/common";
import { api } from "@nice/client";
interface TermData {
value?: string;
children?: TermData[];
key?: string;
hasChildren?: boolean;
isLeaf?: boolean;
pId?: string;
title?: React.ReactNode;
data?: any;
order?: string;
id?: string;
}
interface TermTreeProps {
defaultValue?: string | string[];
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
taxonomyId?: string;
disabled?: boolean;
className?: string;
domainId?: string;
style?: React.CSSProperties;
}
const TermTree: React.FC<TermTreeProps> = ({
defaultValue,
value,
onChange,
className,
multiple = false,
taxonomyId,
domainId,
disabled = false,
style,
}) => {
const utils = api.useUtils();
const [treeData, setTreeData] = useState<TermData[]>([]);
const processTermData = (terms: TermData[]): TermData[] => {
return terms.map((term) => ({
...term,
key: term.key || term.id || "",
title: term.title || term.value,
children: term.children
? processTermData(term.children)
: undefined,
}));
};
const fetchParentTerms = useCallback(
async (termIds: string | string[], taxonomyId?: string) => {
const idsArray = Array.isArray(termIds)
? termIds
: [termIds].filter(Boolean);
try {
const result = await utils.term.getParentSimpleTree.fetch({
termIds: idsArray,
taxonomyId,
domainId,
});
return processTermData(result);
} catch (error) {
console.error(
"Error fetching parent terms for termIds",
idsArray,
":",
error
);
throw error;
}
},
[utils, domainId]
);
const fetchTerms = useCallback(async () => {
try {
const rootTerms = await utils.term.getChildSimpleTree.fetch({
taxonomyId,
domainId,
});
let combinedTerms = processTermData(rootTerms);
if (defaultValue) {
const defaultTerms = await fetchParentTerms(
defaultValue,
taxonomyId
);
combinedTerms = getUniqueItems(
[...treeData, ...combinedTerms, ...defaultTerms],
"key"
);
}
if (value) {
const valueTerms = await fetchParentTerms(value, taxonomyId);
combinedTerms = getUniqueItems(
[...treeData, ...combinedTerms, ...valueTerms],
"key"
);
}
setTreeData(combinedTerms);
} catch (error) {
console.error("Error fetching terms:", error);
}
}, [
defaultValue,
value,
taxonomyId,
utils,
fetchParentTerms,
domainId,
treeData,
]);
useEffect(() => {
fetchTerms();
}, [fetchTerms]);
const onLoadData = async ({ key }: any) => {
try {
const result = await utils.term.getChildSimpleTree.fetch({
termIds: [key],
taxonomyId,
domainId,
});
const processedResult = processTermData(result);
const newItems = getUniqueItems(
[...treeData, ...processedResult],
"key"
);
setTreeData(newItems);
} catch (error) {
console.error(
"Error loading data for node with key",
key,
":",
error
);
}
};
const handleCheck: TreeProps["onCheck"] = (checkedKeys, info) => {
if (onChange) {
if (multiple) {
onChange(checkedKeys as string[]);
} else {
onChange((checkedKeys as string[])[0] || "");
}
}
};
const handleExpand = async (expandedKeys: React.Key[]) => {
try {
const allKeyIds = expandedKeys
.map((key) => key.toString())
.filter(Boolean);
const expandedNodes = await utils.term.getChildSimpleTree.fetch({
termIds: allKeyIds,
taxonomyId,
domainId,
});
const processedNodes = processTermData(expandedNodes);
const newItems = getUniqueItems(
[...treeData, ...processedNodes],
"key"
);
setTreeData(newItems);
} catch (error) {
console.error(
"Error expanding nodes with keys",
expandedKeys,
":",
error
);
}
};
return (
<Tree
checkable={multiple}
disabled={disabled}
className={className}
style={style}
treeData={treeData as DataNode[]}
checkedKeys={Array.isArray(value) ? value : value ? [value] : []}
onCheck={handleCheck}
loadData={onLoadData}
onExpand={handleExpand}
/>
);
};
export default TermTree;