feat: enhance modal functionality and integrate new components

- Added keyframes and animation for a smooth scale-in effect in the Tailwind configuration.
- Updated Image component to improve quality handling and style definitions.
- Introduced WordViewer and WordViewerModal components for document viewing capabilities.
- Enhanced Modal component to support embedded mode with customizable position and dimensions.
- Integrated IframeModal across various pages for user assistance and content display.
- Updated Content and Menu pages to utilize new components and improve user interaction.
- Added utility function to retrieve public paths for resources.
This commit is contained in:
zhongyidian committed 2025-10-29 00:18:06 +08:00
1 parent 13dd84a8a7
commit 4ce7520066
44 files changed
+1209 -100

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
# Alert 组件使用说明
## 简介
`Alert` 是一个通用的提示框组件,类似于 Ant Design 的 Alert,用于向用户显示重要的提示信息。
## 特性
- 🎨 支持 4 种类型:`warning`、`info`、`success`、`error`
- 🎭 可选显示/隐藏图标
- ❌ 可选是否可关闭
- 🎯 居中模态显示
- ✨ 优雅的动画效果
## 基本用法
```tsx
import { Alert } from "@/components";
function MyComponent() {
const [showAlert, setShowAlert] = useState(false);
return (
<Alert
isOpen={showAlert}
onClose={() => setShowAlert(false)}
message="这是一条提示信息"
type="warning"
/>
);
}
```
## API
### Props
| 参数 | 说明 | 类型 | 默认值 | 必填 |
| -------- | -------------- | --------------------------------------------- | ----------- | ---- |
| isOpen | 是否显示 Alert | `boolean` | - | ✅ |
| onClose | 关闭回调函数 | `() => void` | - | ✅ |
| message | 提示内容 | `string \| ReactNode` | - | ✅ |
| type | 提示类型 | `'warning' \| 'info' \| 'success' \| 'error'` | `'warning'` | ❌ |
| showIcon | 是否显示图标 | `boolean` | `true` | ❌ |
| closable | 是否可关闭 | `boolean` | `true` | ❌ |
## 示例
### 警告提示
```tsx
<Alert
isOpen={showWarning}
onClose={() => setShowWarning(false)}
message="该名师工作室数字睿教正在筹划中"
type="warning"
/>
```
### 成功提示
```tsx
<Alert
isOpen={showSuccess}
onClose={() => setShowSuccess(false)}
message="操作成功!"
type="success"
/>
```
### 错误提示
```tsx
<Alert
isOpen={showError}
onClose={() => setShowError(false)}
message="操作失败,请稍后重试"
type="error"
/>
```
### 信息提示
```tsx
<Alert
isOpen={showInfo}
onClose={() => setShowInfo(false)}
message="这是一条普通信息"
type="info"
/>
```
### 自定义内容
```tsx
<Alert
isOpen={showCustom}
onClose={() => setShowCustom(false)}
message={
<div>
<h3 className="text-lg font-bold">自定义标题</h3>
<p className="text-sm mt-1">这是自定义的内容</p>
</div>
}
type="info"
/>
```
### 不可关闭的提示
```tsx
<Alert
isOpen={showPermanent}
onClose={() => {}}
message="这是一条不可关闭的提示"
type="info"
closable={false}
/>
```
### 无图标提示
```tsx
<Alert
isOpen={showNoIcon}
onClose={() => setShowNoIcon(false)}
message="这是一条没有图标的提示"
type="info"
showIcon={false}
/>
```
## 样式说明
每种类型都有对应的颜色主题:
- **warning**: 黄色系(适用于警告信息)
- **info**: 蓝色系(适用于一般信息)
- **success**: 绿色系(适用于成功提示)
- **error**: 红色系(适用于错误提示)
## 注意事项
1. Alert 组件会创建一个全屏半透明遮罩层
2. 点击遮罩层可以关闭 Alert(当 `closable` 为 `true` 时)
3. Alert 的 z-index 为 50,确保在大多数元素之上
4. 支持动画效果,通过 `animate-scale-in` 类实现
+152
View File
@@ -0,0 +1,152 @@
import { ReactNode } from "react";
type AlertType = "warning" | "info" | "success" | "error";
interface AlertProps {
isOpen: boolean;
onClose: () => void;
message: string | ReactNode;
type?: AlertType;
showIcon?: boolean;
closable?: boolean;
}
const alertStyles: Record<
AlertType,
{
bg: string;
border: string;
text: string;
icon: string;
}
> = {
warning: {
bg: "bg-yellow-50",
border: "border-yellow-400",
text: "text-yellow-800",
icon: "text-yellow-400",
},
info: {
bg: "bg-blue-50",
border: "border-blue-400",
text: "text-blue-800",
icon: "text-blue-400",
},
success: {
bg: "bg-green-50",
border: "border-green-400",
text: "text-green-800",
icon: "text-green-400",
},
error: {
bg: "bg-red-50",
border: "border-red-400",
text: "text-red-800",
icon: "text-red-400",
},
};
const icons = {
warning: (
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
),
info: (
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
success: (
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
),
error: (
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
),
};
export const Alert = ({
isOpen,
onClose,
message,
type = "warning",
showIcon = true,
closable = true,
}: AlertProps) => {
if (!isOpen) return null;
const styles = alertStyles[type];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 半透明背景 */}
<div
className="absolute inset-0 bg-black/50"
onClick={closable ? onClose : undefined}
/>
{/* Alert 提示框 */}
<div
className={`relative ${styles.bg} border-l-4 ${styles.border} px-6 py-4 rounded-md shadow-lg max-w-md mx-4 animate-scale-in`}
>
<div className="flex items-center">
{/* 图标 */}
{showIcon && (
<div className="flex-shrink-0">
<div className={styles.icon}>{icons[type]}</div>
</div>
)}
{/* 内容 */}
<div className={`${showIcon ? "ml-3" : ""} flex-1`}>
{typeof message === "string" ? (
<p className={`text-lg font-medium ${styles.text}`}>{message}</p>
) : (
message
)}
</div>
{/* 关闭按钮 */}
{closable && (
<button
onClick={onClose}
className={`ml-4 flex-shrink-0 ${styles.icon
.replace("text-", "text-")
.replace("400", "600")} hover:${styles.icon
.replace("text-", "text-")
.replace("400", "800")} transition-colors`}
>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</div>
</div>
);
};
+3 -3
View File
@@ -40,7 +40,7 @@ export default function Image({
onError,
onClick,
draggable = false,
quality = 80,
quality: _quality = 80,
blur = false,
button = false,
}: ImageProps) {
@@ -98,7 +98,7 @@ export default function Image({
}
}, [loading, src]);
const imageStyle = {
const imageStyle: React.CSSProperties = {
width: width,
height: height,
objectFit,
@@ -110,7 +110,7 @@ export default function Image({
return (
<div
className={clsx(
"inline-block",
width === "100%" ? "block" : "inline-block",
button &&
"cursor-pointer transition-all duration-300 hover:scale-105 hover:brightness-110",
className
+1
View File
@@ -0,0 +1 @@
+112 -10
View File
@@ -1,27 +1,110 @@
import clsx from "clsx";
import { useState } from "react";
type DisplayMode = "modal" | "embedded";
type EmbeddedPosition =
| "bottom-right"
| "bottom-left"
| "top-right"
| "top-left";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children?: React.ReactNode;
className?: string;
mode?: DisplayMode;
position?: EmbeddedPosition;
width?: string | number;
height?: string | number;
showMask?: boolean;
}
export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => {
export const Modal = ({
isOpen,
onClose,
children,
className,
mode = "modal",
position = "bottom-right",
width,
height,
showMask = true,
}: ModalProps) => {
if (!isOpen) return null;
// 嵌入式客服对话框模式
if (mode === "embedded") {
const getPosition = () => {
switch (position) {
case "bottom-right":
return "bottom-6 right-6";
case "bottom-left":
return "bottom-6 left-6";
case "top-right":
return "top-6 right-6";
case "top-left":
return "top-6 left-6";
}
};
const defaultWidth = width || "400px";
const defaultHeight = height || "600px";
return (
<div
className={clsx(
"fixed z-[9999] bg-transparent overflow-hidden border border-transparent",
"transition-all duration-300 ease-out",
"animate-scale-in",
getPosition(),
className
)}
style={{
width: defaultWidth,
height: defaultHeight,
maxHeight: "calc(100vh - 3rem)",
maxWidth: "calc(100vw - 3rem)",
}}
>
{/* 关闭/最小化按钮 */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 w-7 h-7 flex items-center justify-center bg-white hover:bg-gray-100 rounded-full transition-colors shadow-sm border border-gray-200"
title="最小化"
>
<span className="text-gray-500 text-lg font-bold leading-none">
−
</span>
</button>
{/* 内容 */}
<div className="h-full w-full">{children}</div>
</div>
);
}
// 传统弹窗模式
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩 */}
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
{showMask && (
<div
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={onClose}
/>
)}
{/* 弹窗内容 */}
<div
className={clsx(
"relative bg-white rounded-lg shadow-xl max-w-7xl max-h-[100vh] w-full mx-4",
"relative bg-white rounded-lg shadow-xl max-w-7xl max-h-[100vh] w-full mx-4",
className
)}
style={{
width: width,
height: height,
}}
>
{/* 关闭按钮 */}
<button
@@ -43,6 +126,11 @@ interface IframeModalProps {
onClose: () => void;
url: string;
title?: string;
mode?: DisplayMode;
position?: EmbeddedPosition;
width?: string | number;
height?: string | number;
showMask?: boolean;
}
export const IframeModal = ({
@@ -50,6 +138,11 @@ export const IframeModal = ({
onClose,
url,
title,
mode = "modal",
position = "bottom-right",
width,
height,
showMask = true,
}: IframeModalProps) => {
const [hasError, setHasError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -88,16 +181,25 @@ export const IframeModal = ({
};
return (
<Modal isOpen={isOpen} onClose={onClose} className="max-w-5xl">
<div className="h-[90vh] flex flex-col">
{title && (
<div className="px-6 py-4 border-b border-gray-200">
<Modal
isOpen={isOpen}
onClose={onClose}
mode={mode}
position={position}
width={width}
height={height}
showMask={showMask}
className={mode === "modal" ? "max-w-5xl" : ""}
>
<div className="h-full flex flex-col">
{title && mode === "modal" && (
<div className="px-6 py-4 border-b border-transparent">
<h3 className="text-lg font-semibold text-gray-800">{title}</h3>
</div>
)}
<div className="flex-1 relative">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-50">
<div className="absolute inset-0 flex items-center justify-center bg-transparent">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-gray-600">正在加载...</p>
@@ -106,7 +208,7 @@ export const IframeModal = ({
)}
{hasError ? (
<div className="absolute inset-0 flex items-center justify-center bg-gray-50">
<div className="absolute inset-0 flex items-center justify-center bg-transparent">
<div className="text-center">
<div className="text-red-500 text-6xl mb-4">⚠️</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">
@@ -127,7 +229,7 @@ export const IframeModal = ({
) : (
<iframe
src={url}
className="w-full h-full border-0"
className="w-full h-full border-0 bg-transparent"
title={title || "嵌入页面"}
allow="microphone; camera; autoplay; fullscreen; encrypted-media; picture-in-picture; web-share; accelerometer; gyroscope; magnetometer; payment; usb; serial; bluetooth; xr-spatial-tracking"
referrerPolicy="no-referrer-when-downgrade"
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import mammoth from "mammoth";
interface WordViewerProps {
/** Word 文档的路径 */
docPath: string;
/** 容器的类名 */
className?: string;
}
export const WordViewer = ({ docPath, className = "" }: WordViewerProps) => {
const [htmlContent, setHtmlContent] = useState<string>("");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
useEffect(() => {
const loadDocument = async () => {
try {
setLoading(true);
setError("");
// 获取 Word 文档
const response = await fetch(docPath);
if (!response.ok) {
throw new Error("无法加载文档");
}
// 转换为 ArrayBuffer
const arrayBuffer = await response.arrayBuffer();
// 使用 mammoth 转换为 HTML
const result = await mammoth.convertToHtml({
arrayBuffer: arrayBuffer,
});
setHtmlContent(result.value);
// 如果有警告信息,可以在控制台输出
if (result.messages.length > 0) {
console.warn("文档转换警告:", result.messages);
}
} catch (err) {
console.error("加载文档失败:", err);
setError(err instanceof Error ? err.message : "加载文档失败");
} finally {
setLoading(false);
}
};
loadDocument();
}, [docPath]);
if (loading) {
return (
<div className={`flex justify-center items-center ${className}`}>
<div className="text-[#82B2D6] text-2xl font-bold">加载中...</div>
</div>
);
}
if (error) {
return (
<div className={`flex justify-center items-center ${className}`}>
<div className="text-red-500 text-2xl font-bold">加载失败: {error}</div>
</div>
);
}
return (
<div className={`h-full flex flex-col p-4 ${className}`}>
{/* 头部工具栏 */}
{/* <div className="flex justify-between items-center mb-4">
<h3 className="text-[#82B2D6] text-2xl font-bold">工作室活动文稿</h3>
<a
href={docPath}
download="工作室活动文稿.docx"
className="px-6 py-2 bg-[#82B2D6] text-white rounded-lg hover:bg-[#6a9bc4] transition-colors text-sm font-medium"
>
下载文档
</a>
</div> */}
{/* 文档内容区域 custom-scrollbar*/}
<div className="flex-1 overflow-y-auto bg-white rounded-lg px-4 py-0 ">
<div
className="word-content"
dangerouslySetInnerHTML={{ __html: htmlContent }}
style={{
maxWidth: "100%",
color: "#333",
lineHeight: "1.8",
}}
/>
</div>
{/* 内联样式 - 美化文档内容 */}
<style>{`
.word-content h1 {
font-size: 2em;
font-weight: bold;
margin: 1em 0 0.5em;
color: #1a1a1a;
border-bottom: 2px solid #82B2D6;
padding-bottom: 0.3em;
}
.word-content h2 {
font-size: 1.5em;
font-weight: bold;
margin: 0.8em 0 0.4em;
color: #2a2a2a;
}
.word-content h3 {
font-size: 1.3em;
font-weight: bold;
margin: 0.6em 0 0.3em;
color: #3a3a3a;
}
.word-content p {
margin: 0.8em 0;
text-align: justify;
text-indent: 2em;
}
/* 表格内的段落不需要缩进 */
.word-content table p {
text-indent: 0;
margin: 0.4em 0;
}
.word-content img {
max-width: 100%;
height: auto;
display: block;
margin: 1.5em auto;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.word-content ul,
.word-content ol {
margin: 0.8em 0;
padding-left: 2em;
}
.word-content li {
margin: 0.4em 0;
}
.word-content table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
table-layout: fixed;
}
.word-content table th,
.word-content table td {
border: 1px solid #ddd;
padding: 0.8em;
text-align: left;
vertical-align: top;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
}
.word-content table th {
background-color: #f5f5f5;
font-weight: bold;
}
/* 表格第一列(标题列)固定宽度 */
.word-content table tr td:first-child,
.word-content table tr th:first-child {
width: 200px;
min-width: 200px;
white-space: normal;
font-weight: 600;
background-color: #f8f9fa;
}
.word-content strong,
.word-content b {
font-weight: bold;
color: #1a1a1a;
}
.word-content em,
.word-content i {
font-style: italic;
}
.word-content a {
color: #82B2D6;
text-decoration: underline;
}
.word-content a:hover {
color: #6a9bc4;
}
.word-content blockquote {
border-left: 4px solid #82B2D6;
padding-left: 1em;
margin: 1em 0;
color: #666;
font-style: italic;
}
`}</style>
</div>
);
};
+74
View File
@@ -0,0 +1,74 @@
import { X } from "lucide-react";
import { WordViewer } from "./WordViewer";
import { useEffect } from "react";
interface WordViewerModalProps {
isOpen: boolean;
onClose: () => void;
docPath: string;
title?: string;
}
export const WordViewerModal = ({
isOpen,
onClose,
docPath,
title = "活动文稿",
}: WordViewerModalProps) => {
// 监听 ESC 键关闭弹窗
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
// 阻止背景滚动
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩 */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* 弹窗内容 */}
<div className="relative w-[90vw] h-[90vh] bg-white rounded-2xl shadow-2xl overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-[#82B2D6] to-[#6a9bc4] text-white">
<h2 className="text-2xl font-bold">{title}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
aria-label="关闭"
>
<X size={24} />
</button>
</div>
{/* 文档内容区域 */}
<div className="h-[calc(100%-64px)] overflow-hidden">
<WordViewer docPath={docPath} className="w-full h-full" />
</div>
</div>
</div>
);
};
+3
View File
@@ -1,2 +1,5 @@
export { default as Image } from "./Image";
export { default as BackgroundImage } from "./BackgroundImage";
export { WordViewer } from "./WordViewer";
export { WordViewerModal } from "./WordViewerModal";
export { Alert } from "./Alert";