Merge branch 'yongpxu-dev' into yongxu-dev3

# Conflicts:
#	nkebao/vite.config.ts   resolved by yongpxu-dev version
This commit is contained in:
超级老白兔
2025-07-29 19:29:20 +08:00
12 changed files with 820 additions and 363 deletions

View File

@@ -1,6 +1,4 @@
# 基础环境变量示例
VITE_API_BASE_URL=http://www.yishi.com
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
# VITE_API_BASE_URL=http://www.yishi.com
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=Nkebao Base

View File

@@ -10,7 +10,7 @@ module.exports = {
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"plugin:prettier/recommended", // 这个配置会自动处理大部分冲突
],
parser: "@typescript-eslint/parser",
parserOptions: {
@@ -32,8 +32,29 @@ module.exports = {
"eol-last": "off",
"no-empty": "warn",
"prefer-const": "warn",
// 移除与Prettier冲突的规则
// 确保与 Prettier 完全兼容
"comma-dangle": "off",
"comma-spacing": "off",
"comma-style": "off",
"object-curly-spacing": "off",
"array-bracket-spacing": "off",
indent: "off",
quotes: "off",
semi: "off",
"arrow-parens": "off",
"no-multiple-empty-lines": "off",
"max-len": "off",
"space-before-function-paren": "off",
"space-before-blocks": "off",
"keyword-spacing": "off",
"space-infix-ops": "off",
"space-in-parens": "off",
"space-in-brackets": "off",
"object-property-newline": "off",
"array-element-newline": "off",
"function-paren-newline": "off",
"object-curly-newline": "off",
"array-bracket-newline": "off",
},
settings: {
react: {

View File

@@ -1,6 +1,6 @@
{
"semi": true,
"trailingComma": "es5",
"trailingComma": "all",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
@@ -10,4 +10,4 @@
"arrowParens": "avoid",
"jsxSingleQuote": false,
"quoteProps": "as-needed"
}
}

View File

@@ -38,7 +38,8 @@
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "vite build",
"build:check": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\"",

View File

@@ -1,150 +1,112 @@
# Upload 组件
# Upload 上传组件
通用的文件上传组件,基于 Ant Design 的 Upload 组件封装。
基于 antd-mobile 的 ImageUploader 组件封装的上传组件,支持图片上传、预览、删除等功能
## 组件列表
## 功能特性
### UploadComponent
- ✅ 支持单张/多张图片上传
- ✅ 文件类型和大小验证
- ✅ 上传进度显示
- ✅ 图片预览功能
- ✅ 删除确认
- ✅ 数量限制
- ✅ 编辑和新增状态支持
- ✅ 响应式设计
通用的图片/文件上传组件,支持多文件上传。
## 使用方法
#### 使用方
### 基础用
```tsx
import UploadComponent from '@/components/Upload';
import React, { useState } from "react";
import UploadComponent from "@/components/Upload";
// 基础用法
<UploadComponent
value={imageUrls}
onChange={setImageUrls}
count={9}
accept="image/*"
/>
const MyComponent = () => {
const [images, setImages] = useState<string[]>([]);
// 单文件上传
<UploadComponent
value={[singleImage]}
onChange={(urls) => setSingleImage(urls[0])}
count={1}
listType="picture-card"
/>
return (
<UploadComponent
value={images}
onChange={setImages}
count={5}
accept="image/*"
/>
);
};
```
#### Props
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| value | 已上传的文件URL数组 | `string[]` | `[]` |
| onChange | 文件列表变化时的回调 | `(urls: string[]) => void` | - |
| count | 最大上传数量 | `number` | `9` |
| accept | 接受的文件类型 | `string` | `"image/*"` |
| listType | 上传列表的内建样式 | `"text" \| "picture" \| "picture-card"` | `"picture-card"` |
| disabled | 是否禁用 | `boolean` | `false` |
| className | 自定义样式类名 | `string` | - |
### VideoUpload
专门的视频上传组件,支持单文件上传。
#### 使用方法
### 编辑模式
```tsx
import VideoUpload from '@/components/Upload/VideoUpload';
const EditComponent = () => {
const [images, setImages] = useState<string[]>([
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
]);
<VideoUpload
value={videoUrl}
onChange={setVideoUrl}
disabled={false}
/>
return (
<UploadComponent
value={images}
onChange={setImages}
count={9}
disabled={false}
/>
);
};
```
#### Props
### 禁用状态
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| value | 已上传的视频URL | `string` | `""` |
| onChange | 视频URL变化时的回调 | `(url: string) => void` | - |
| disabled | 是否禁用 | `boolean` | `false` |
| className | 自定义样式类名 | `string` | - |
## 技术实现
### 上传接口
- **接口地址**: `/v1/attachment/upload`
- **请求方式**: `POST`
- **请求格式**: `multipart/form-data`
- **文件字段名**: `file`
### 响应格式
组件会自动处理以下响应格式:
```json
{
"code": 200,
"data": "https://example.com/file.jpg",
"message": "success"
}
```tsx
<UploadComponent value={images} onChange={setImages} disabled={true} />
```
或者:
## API
```json
{
"code": 200,
"data": {
"url": "https://example.com/file.jpg"
},
"message": "success"
}
```
### Props
### 文件限制
| 参数 | 说明 | 类型 | 默认值 |
| --------- | -------------- | -------------------------- | ----------- |
| value | 图片URL数组 | `string[]` | `[]` |
| onChange | 图片变化回调 | `(urls: string[]) => void` | - |
| count | 最大上传数量 | `number` | `9` |
| accept | 接受的文件类型 | `string` | `"image/*"` |
| disabled | 是否禁用 | `boolean` | `false` |
| className | 自定义类名 | `string` | - |
- **图片文件**: 最大 5MB
- **视频文件**: 最大 50MB
- **支持格式**: 根据 `accept` 属性设置
### 事件
### 状态管理
组件内部管理以下状态:
- `uploading`: 文件上传中
- `done`: 文件上传完成
- `error`: 文件上传失败
- `removed`: 文件已删除
| 事件名 | 说明 | 回调参数 |
| -------- | ------------------ | -------------------------- |
| onChange | 图片列表变化时触发 | `(urls: string[]) => void` |
## 注意事项
1. **API 集成**: 组件使用 Ant Design Upload 的 `action` 属性,直接调用 `/v1/attachment/upload` 接口
2. **文件验证**: `beforeUpload` 中进行文件类型和大小验证
3. **状态同步**: 组件会自动同步 `value` 和内部 `fileList` 状态
4. **错误处理**: 上传失败时会显示错误提示并自动清理失败的文件
5. **进度显示**: 上传过程中会显示加载状态和进度指示
1. **文件大小限制**: 默认限制为 5MB
2. **文件类型**: 默认只接受图片文件
3. **上传接口**: 使用 `/v1/attachment/upload` 接口
4. **认证**: 自动携带 token 进行认证
5. **预览**: 点击图片可预览
6. **删除**: 删除图片会有确认提示
## 样式定制
组件使用 SCSS Modules可以通过修改 `index.module.scss` 文件来自定义样式
组件支持通过 CSS 模块进行样式定制
```scss
.upload-container {
// 容器样式
}
.upload-button {
// 上传按钮样式
}
.upload-icon {
// 图标样式
}
.upload-text {
// 文字样式
}
.uploading {
// 上传中状态样式
.uploadContainer {
// 自定义样式
:global {
.adm-image-uploader {
// 覆盖 antd-mobile 默认样式
}
}
}
```
## 错误处理
- 文件类型不匹配时会显示错误提示
- 文件大小超限时会显示错误提示
- 上传失败时会显示错误提示
- 网络错误时会显示错误提示

View File

@@ -1,75 +1,108 @@
.upload-container {
.uploadContainer {
width: 100%;
}
.upload-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
background: #fafafa;
cursor: pointer;
transition: all 0.3s;
// 自定义上传组件样式
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button {
width: 100px;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
background: #f0f8ff;
}
}
&:hover {
border-color: #1677ff;
background: #f0f8ff;
}
.upload-icon {
font-size: 24px;
color: #999;
margin-bottom: 8px;
}
.adm-image-uploader-upload-button-icon {
font-size: 32px;
color: #999;
}
}
.upload-text {
font-size: 14px;
color: #666;
}
.adm-image-uploader-item {
width: 100px;
height: 100px;
border-radius: 8px;
overflow: hidden;
position: relative;
.uploading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
.adm-image-uploader-item-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-icon {
color: #1677ff;
animation: spin 1s linear infinite;
}
.adm-image-uploader-item-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
cursor: pointer;
}
.upload-text {
color: #1677ff;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 覆盖antd默认样式
:global {
.ant-upload-list-picture-card {
.ant-upload-list-item {
width: 100px;
height: 100px;
.adm-image-uploader-item-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
.ant-upload-select-picture-card {
width: 100px;
height: 100px;
// 禁用状态
.uploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.uploadContainer.error {
:global {
.adm-image-uploader-upload-button {
border-color: #ff4d4f;
background: #fff2f0;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.uploadContainer {
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button,
.adm-image-uploader-item {
width: 80px;
height: 80px;
}
.adm-image-uploader-upload-button-icon {
font-size: 28px;
}
}
}
}
}

View File

@@ -1,7 +1,6 @@
import React, { useState } from "react";
import { Upload, message } from "antd";
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import React, { useState, useEffect } from "react";
import { ImageUploader, Toast, Dialog } from "antd-mobile";
import type { ImageUploadItem } from "antd-mobile/es/components/image-uploader";
import style from "./index.module.scss";
interface UploadComponentProps {
@@ -9,7 +8,6 @@ interface UploadComponentProps {
onChange?: (urls: string[]) => void;
count?: number; // 最大上传数量
accept?: string; // 文件类型
listType?: "text" | "picture" | "picture-card";
disabled?: boolean;
className?: string;
}
@@ -19,21 +17,17 @@ const UploadComponent: React.FC<UploadComponentProps> = ({
onChange,
count = 9,
accept = "image/*",
listType = "picture-card",
disabled = false,
className,
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [fileList, setFileList] = useState<ImageUploadItem[]>([]);
// 将value转换为fileList格式
React.useEffect(() => {
useEffect(() => {
if (value && value.length > 0) {
const files = value.map((url, index) => ({
url: url || "",
uid: `file-${index}`,
name: `file-${index}`,
status: "done" as const,
url: url || "", // 确保 URL 不为 undefined
}));
setFileList(files);
} else {
@@ -43,89 +37,97 @@ const UploadComponent: React.FC<UploadComponentProps> = ({
// 文件验证
const beforeUpload = (file: File) => {
// 检查文件类型
const isValidType = file.type.startsWith(accept.replace("*", ""));
if (!isValidType) {
message.error(`只能上传${accept}格式的文件!`);
return false;
Toast.show(`只能上传${accept}格式的文件!`);
return null;
}
// 检查文件大小 (5MB)
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
message.error("文件大小不能超过5MB");
return false;
Toast.show("文件大小不能超过5MB");
return null;
}
return true; // 允许上传
return file;
};
// 上传函数
const upload = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/v1/attachment/upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
}
);
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
if (result.code === 200) {
Toast.show("上传成功");
return { url: result.data.url || result.data };
} else {
throw new Error(result.msg || "上传失败");
}
} catch (error) {
Toast.show("上传失败,请重试");
throw error;
}
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => ({
...file,
url:
file.url ||
file.response?.data ||
file.response?.url ||
file.response ||
"",
}));
const handleChange = (files: ImageUploadItem[]) => {
setFileList(files);
setFileList(updatedFileList);
// 提取URL数组并传递给父组件
const urls = files
.map(file => file.url)
.filter(url => Boolean(url)) as string[];
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
} else if (info.file.status === "done") {
setLoading(false);
message.success("上传成功");
console.log(info.file.response);
// 从响应中获取上传后的URL
const uploadedUrl = info.file.response?.data?.url || "";
if (uploadedUrl) {
onChange?.([uploadedUrl]);
}
} else if (info.file.status === "error") {
setLoading(false);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
// 文件被删除
const urls = updatedFileList
.map(f => f.url || "")
.filter(Boolean) as string[];
onChange?.(urls);
}
};
// 删除文件
const handleRemove = (file: UploadFile) => {
const newFileList = fileList.filter(f => f.uid !== file.uid);
setFileList(newFileList);
const urls = newFileList.map(f => f.url || "").filter(Boolean) as string[];
onChange?.(urls);
return true;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
// 删除确认
const handleDelete = () => {
return Dialog.confirm({
content: "确定要删除这张图片吗?",
});
};
// 数量超出限制
const handleCountExceed = (exceed: number) => {
Toast.show(`最多选择 ${count} 张图片,你多选了 ${exceed}`);
};
return (
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={count > 1}
fileList={fileList}
accept={accept}
listType={listType}
showUploadList={true}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={count}
></Upload>
<div className={`${style.uploadContainer} ${className || ""}`}>
<ImageUploader
value={fileList}
onChange={handleChange}
upload={upload}
beforeUpload={beforeUpload}
onDelete={handleDelete}
onCountExceed={handleCountExceed}
multiple={count > 1}
maxCount={count}
showUpload={fileList.length < count && !disabled}
accept={accept}
/>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Toast, SpinLoading, Card } from "antd-mobile";
import { Input, TimePicker, Select } from "antd";
import { Input, Select } from "antd";
import {
ArrowLeftOutlined,
SaveOutlined,
@@ -20,7 +20,6 @@ import {
createContentItem,
updateContentItem,
} from "./api";
import { ContentItem } from "./data";
import style from "./index.module.scss";
const { Option } = Select;
@@ -43,7 +42,6 @@ const MaterialForm: React.FC = () => {
}>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [material, setMaterial] = useState<ContentItem | null>(null);
// 表单状态
const [contentType, setContentType] = useState<number>(4);
@@ -52,9 +50,6 @@ const MaterialForm: React.FC = () => {
const [comment, setComment] = useState("");
const [sendTime, setSendTime] = useState("");
const [resUrls, setResUrls] = useState<string[]>([]);
const [urls, setUrls] = useState<
{ desc: string; image: string; url: string }[]
>([]);
// 链接相关状态
const [linkDesc, setLinkDesc] = useState("");
@@ -68,13 +63,7 @@ const MaterialForm: React.FC = () => {
const isEdit = !!materialId;
// 获取素材详情
useEffect(() => {
if (isEdit && materialId) {
fetchMaterialDetail();
}
}, [isEdit, materialId]);
const fetchMaterialDetail = async () => {
const fetchMaterialDetail = useCallback(async () => {
if (!materialId) return;
setLoading(true);
try {
@@ -95,7 +84,6 @@ const MaterialForm: React.FC = () => {
}
setResUrls(response.resUrls || []);
setUrls(response.urls || []);
// 设置链接相关数据
if (response.urls && response.urls.length > 0) {
@@ -111,7 +99,13 @@ const MaterialForm: React.FC = () => {
} finally {
setLoading(false);
}
};
}, [materialId]);
useEffect(() => {
if (isEdit && materialId) {
fetchMaterialDetail();
}
}, [isEdit, materialId, fetchMaterialDetail]);
const handleSubmit = async () => {
if (!libraryId) return;
@@ -147,16 +141,16 @@ const MaterialForm: React.FC = () => {
sendTime: sendTime || "",
resUrls,
urls: finalUrls,
type: contentType,
};
let response;
if (isEdit) {
response = await updateContentItem({
await updateContentItem({
id: materialId!,
...params,
});
} else {
response = await createContentItem(params);
await createContentItem(params);
}
// 直接使用返回数据无需判断code
@@ -191,7 +185,30 @@ const MaterialForm: React.FC = () => {
}
return (
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}>
<Layout
header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}
footer={
<div className={style["form-actions"]}>
<Button
fill="outline"
onClick={handleBack}
className={style["back-btn"]}
>
<ArrowLeftOutlined />
</Button>
<Button
color="primary"
onClick={handleSubmit}
loading={saving}
className={style["submit-btn"]}
>
<SaveOutlined />
{isEdit ? " 保存修改" : " 保存素材"}
</Button>
</div>
}
>
<div className={style["form-page"]}>
<div className={style["form"]}>
{/* 基础信息 */}
@@ -269,7 +286,6 @@ const MaterialForm: React.FC = () => {
value={linkImage ? [linkImage] : []}
onChange={urls => setLinkImage(urls[0] || "")}
count={1}
listType="picture-card"
/>
</div>
@@ -302,17 +318,29 @@ const MaterialForm: React.FC = () => {
{/* 素材上传(仅图片类型和小程序类型) */}
{[1, 5].includes(contentType) && (
<Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<div className={style["card-title"]}>
(: {contentType})
</div>
{contentType === 1 && (
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<UploadComponent
value={resUrls}
onChange={setResUrls}
count={9}
listType="picture-card"
/>
<div>
<UploadComponent
value={resUrls}
onChange={setResUrls}
count={9}
/>
</div>
<div
style={{
fontSize: "12px",
color: "#666",
marginTop: "4px",
}}
>
: {contentType}, : {resUrls.length}
</div>
</div>
)}
@@ -344,7 +372,6 @@ const MaterialForm: React.FC = () => {
value={resUrls}
onChange={setResUrls}
count={9}
listType="picture-card"
/>
</div>
</>
@@ -367,27 +394,6 @@ const MaterialForm: React.FC = () => {
/>
</div>
</Card>
{/* 操作按钮 */}
<div className={style["form-actions"]}>
<Button
fill="outline"
onClick={handleBack}
className={style["back-btn"]}
>
<ArrowLeftOutlined />
</Button>
<Button
color="primary"
onClick={handleSubmit}
loading={saving}
className={style["submit-btn"]}
>
<SaveOutlined />
{isEdit ? "保存修改" : "保存素材"}
</Button>
</div>
</div>
</div>
</Layout>

View File

@@ -28,7 +28,7 @@ export function updateContentItem(params: UpdateContentItemParams) {
// 删除素材
export function deleteContentItem(id: string) {
return request("/v1/content/item/delete", { id }, "DELETE");
return request("/v1/content/library/delete-item", { id }, "DELETE");
}
// 获取内容库详情

View File

@@ -1,7 +1,5 @@
.materials-page {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.search-bar {
@@ -338,3 +336,280 @@
background: white;
border-top: 1px solid #f0f0f0;
}
// 内容类型标签样式
.content-type-tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid currentColor;
}
// 图片类型预览样式
.material-image-preview {
margin: 12px 0;
.image-grid {
display: grid;
gap: 8px;
width: 100%;
// 1张图片宽度拉伸高度自适应
&.single {
grid-template-columns: 1fr;
img {
width: 100%;
height: auto;
object-fit: cover;
border-radius: 8px;
}
}
// 2张图片左右并列
&.double {
grid-template-columns: 1fr 1fr;
img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
}
// 3张图片三张并列
&.triple {
grid-template-columns: 1fr 1fr 1fr;
img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 8px;
}
}
// 4张图片2x2网格布局
&.quad {
grid-template-columns: repeat(2, 1fr);
img {
width: 100%;
height: 140px;
object-fit: cover;
border-radius: 8px;
}
}
// 5张及以上网格布局
&.grid {
grid-template-columns: repeat(3, 1fr);
img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 8px;
}
.image-more {
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
color: #666;
font-size: 12px;
font-weight: 500;
height: 100px;
}
}
}
.no-image {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
background: #f5f5f5;
border-radius: 8px;
color: #999;
font-size: 14px;
}
}
// 链接类型预览样式
.material-link-preview {
margin: 12px 0;
.link-card {
display: flex;
background: #e9f8ff;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #cde6ff;
&:hover {
background: #cde6ff;
}
.link-image {
width: 60px;
height: 60px;
margin-right: 12px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
}
.link-content {
flex: 1;
min-width: 0;
.link-title {
font-weight: 500;
margin-bottom: 4px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-url {
font-size: 12px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
// 视频类型预览样式
.material-video-preview {
margin: 12px 0;
.video-thumbnail {
video {
width: 100%;
max-height: 200px;
border-radius: 8px;
}
}
.no-video {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
background: #f5f5f5;
border-radius: 8px;
color: #999;
font-size: 14px;
}
}
// 文本类型预览样式
.material-text-preview {
margin: 12px 0;
.text-content {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
line-height: 1.6;
color: #333;
font-size: 14px;
}
}
// 小程序类型预览样式
.material-miniprogram-preview {
margin: 12px 0;
.miniprogram-card {
display: flex;
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
width: 100%;
img {
width: 60px;
height: 60px;
border-radius: 8px;
margin-right: 12px;
flex-shrink: 0;
object-fit: cover;
}
.miniprogram-info {
flex: 1;
min-width: 0;
.miniprogram-title {
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
// 图文类型预览样式
.material-article-preview {
margin: 12px 0;
.article-image {
margin-bottom: 12px;
img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
}
.article-content {
.article-title {
font-weight: 500;
color: #333;
margin-bottom: 8px;
font-size: 16px;
}
.article-text {
color: #666;
line-height: 1.6;
font-size: 14px;
}
}
}
// 默认预览样式
.material-default-preview {
margin: 12px 0;
.default-content {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
color: #333;
line-height: 1.6;
}
}

View File

@@ -10,6 +10,11 @@ import {
DeleteOutlined,
UserOutlined,
BarChartOutlined,
PictureOutlined,
LinkOutlined,
VideoCameraOutlined,
FileTextOutlined,
AppstoreOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
@@ -17,6 +22,16 @@ import { getContentItemList, deleteContentItem } from "./api";
import { ContentItem } from "./data";
import style from "./index.module.scss";
// 内容类型配置
const contentTypeConfig = {
1: { label: "图片", icon: PictureOutlined, color: "#52c41a" },
2: { label: "链接", icon: LinkOutlined, color: "#1890ff" },
3: { label: "视频", icon: VideoCameraOutlined, color: "#722ed1" },
4: { label: "文本", icon: FileTextOutlined, color: "#fa8c16" },
5: { label: "小程序", icon: AppstoreOutlined, color: "#eb2f96" },
6: { label: "图文", icon: PictureOutlined, color: "#13c2c2" },
};
const MaterialsList: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
@@ -73,19 +88,12 @@ const MaterialsList: React.FC = () => {
if (result) {
try {
const response = await deleteContentItem(materialId);
if (response.code === 200) {
Toast.show({
content: "删除成功",
position: "top",
});
fetchMaterials();
} else {
Toast.show({
content: response.msg || "删除失败",
position: "top",
});
}
await deleteContentItem(materialId.toString());
Toast.show({
content: "删除成功",
position: "top",
});
fetchMaterials();
} catch (error: unknown) {
console.error("删除素材失败:", error);
Toast.show({
@@ -114,6 +122,161 @@ const MaterialsList: React.FC = () => {
setCurrentPage(page);
};
// 渲染内容类型标签
const renderContentTypeTag = (contentType: number) => {
const config =
contentTypeConfig[contentType as keyof typeof contentTypeConfig];
if (!config) return null;
const IconComponent = config.icon;
return (
<div
className={style["content-type-tag"]}
style={{ backgroundColor: config.color + "20", color: config.color }}
>
<IconComponent style={{ fontSize: 12, marginRight: 4 }} />
{config.label}
</div>
);
};
// 渲染素材内容预览
const renderContentPreview = (material: ContentItem) => {
const { contentType, content, resUrls, urls, coverImage } = material;
switch (contentType) {
case 1: // 图片
return (
<div className={style["material-image-preview"]}>
{resUrls && resUrls.length > 0 ? (
<div
className={`${style["image-grid"]} ${
resUrls.length === 1
? style.single
: resUrls.length === 2
? style.double
: resUrls.length === 3
? style.triple
: resUrls.length === 4
? style.quad
: style.grid
}`}
>
{resUrls.slice(0, 9).map((url, index) => (
<img key={index} src={url} alt={`图片${index + 1}`} />
))}
{resUrls.length > 9 && (
<div className={style["image-more"]}>
+{resUrls.length - 9}
</div>
)}
</div>
) : coverImage ? (
<div className={`${style["image-grid"]} ${style.single}`}>
<img src={coverImage} alt="封面图" />
</div>
) : (
<div className={style["no-image"]}></div>
)}
</div>
);
case 2: // 链接
return (
<div className={style["material-link-preview"]}>
{urls && urls.length > 0 && (
<div
className={style["link-card"]}
onClick={() => {
window.open(urls[0].url, "_blank");
}}
>
{urls[0].image && (
<div className={style["link-image"]}>
<img src={urls[0].image} alt="链接预览" />
</div>
)}
<div className={style["link-content"]}>
<div className={style["link-title"]}>
{urls[0].desc || "链接"}
</div>
<div className={style["link-url"]}>{urls[0].url}</div>
</div>
</div>
)}
</div>
);
case 3: // 视频
return (
<div className={style["material-video-preview"]}>
{resUrls && resUrls.length > 0 ? (
<div className={style["video-thumbnail"]}>
<video src={resUrls[0]} controls />
</div>
) : (
<div className={style["no-video"]}></div>
)}
</div>
);
case 4: // 文本
return (
<div className={style["material-text-preview"]}>
<div className={style["text-content"]}>
{content.length > 100
? `${content.substring(0, 100)}...`
: content}
</div>
</div>
);
case 5: // 小程序
return (
<div className={style["material-miniprogram-preview"]}>
{resUrls && resUrls.length > 0 && (
<div className={style["miniprogram-card"]}>
<img src={resUrls[0]} alt="小程序封面" />
<div className={style["miniprogram-info"]}>
<div className={style["miniprogram-title"]}>
{material.title || "小程序"}
</div>
</div>
</div>
)}
</div>
);
case 6: // 图文
return (
<div className={style["material-article-preview"]}>
{coverImage && (
<div className={style["article-image"]}>
<img src={coverImage} alt="文章封面" />
</div>
)}
<div className={style["article-content"]}>
<div className={style["article-title"]}>
{material.title || "图文内容"}
</div>
<div className={style["article-text"]}>
{content.length > 80
? `${content.substring(0, 80)}...`
: content}
</div>
</div>
</div>
);
default:
return (
<div className={style["material-default-preview"]}>
<div className={style["default-content"]}>{content}</div>
</div>
);
}
};
return (
<Layout
header={
@@ -130,7 +293,7 @@ const MaterialsList: React.FC = () => {
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索计划名称"
placeholder="搜索素材内容"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
@@ -185,7 +348,7 @@ const MaterialsList: React.FC = () => {
<>
{materials.map(material => (
<Card key={material.id} className={style["material-card"]}>
{/* 顶部头像+系统创建+ID */}
{/* 顶部信息 */}
<div className={style["card-header"]}>
<div className={style["avatar-section"]}>
<div className={style["avatar"]}>
@@ -193,41 +356,23 @@ const MaterialsList: React.FC = () => {
</div>
<div className={style["header-info"]}>
<span className={style["creator-name"]}>
{material.senderNickname}
{material.senderNickname || "系统创建"}
</span>
<span className={style["material-id"]}>
ID: {material.id}
</span>
</div>
</div>
{renderContentTypeTag(material.contentType)}
</div>
{/* 主标题 */}
<div className={style["material-title"]}>
{material.content}
</div>
{/* 链接预览 */}
{material.urls && material.urls.length > 0 && (
<div
className={style["link-preview"]}
onClick={() => {
window.open(material.urls[0].url, "_blank");
}}
>
<div className={style["link-icon"]}>
<img src={material.urls[0].image} />
</div>
<div className={style["link-content"]}>
<div className={style["link-title"]}>
{material.urls[0].desc}
</div>
<div className={style["link-url"]}>
{material.urls[0].url}
</div>
</div>
{/* 标题 */}
{material.contentType != 4 && (
<div className={style["card-title"]}>
{material.content}
</div>
)}
{/* 内容预览 */}
{renderContentPreview(material)}
{/* 操作按钮区 */}
<div className={style["action-buttons"]}>

View File

@@ -13,15 +13,29 @@ export default defineConfig({
open: true,
},
build: {
chunkSizeWarningLimit: 1500, // 提高警告阈值,减少无关警告
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
// 减少文件数量,合并更多依赖
manualChunks: {
"react-vendor": ["react", "react-dom"],
"antd-vendor": ["antd", "@ant-design/icons", "antd-mobile"],
"echarts-vendor": ["echarts", "echarts-for-react"],
// 核心框架
vendor: ["react", "react-dom", "react-router-dom"],
// UI组件库
ui: ["antd", "@ant-design/icons", "antd-mobile"],
// 工具库
utils: ["axios", "dayjs", "zustand"],
// 图表库
charts: ["echarts", "echarts-for-react"],
},
// 文件名格式
chunkFileNames: "assets/[name]-[hash].js",
entryFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash].[ext]",
},
},
// 启用压缩
minify: "esbuild",
// 启用源码映射(可选,生产环境可以关闭)
sourcemap: false,
},
});