Merge branch 'yongpxu-dev' into yongpxu-dev4
# Conflicts: # nkebao/vite.config.ts resolved by yongpxu-dev version
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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 默认样式
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 文件类型不匹配时会显示错误提示
|
||||
- 文件大小超限时会显示错误提示
|
||||
- 上传失败时会显示错误提示
|
||||
- 网络错误时会显示错误提示
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
// 获取内容库详情
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]}>
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
import request from "@/api/request";
|
||||
import type {
|
||||
TrafficPoolUserDetail,
|
||||
UserJourneyResponse,
|
||||
UserTagsResponse,
|
||||
} from "./data";
|
||||
|
||||
export function getTrafficPoolDetail(id: string): Promise<any> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
export function getTrafficPoolDetail(
|
||||
wechatId: string
|
||||
): Promise<TrafficPoolUserDetail> {
|
||||
return request("/v1/wechats/getWechatInfo", { wechatId }, "GET");
|
||||
}
|
||||
|
||||
// 获取用户旅程记录
|
||||
export function getUserJourney(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
userId: string;
|
||||
}): Promise<UserJourneyResponse> {
|
||||
return request("/v1/traffic/pool/getUserJourney", params, "GET");
|
||||
}
|
||||
|
||||
// 获取用户标签
|
||||
export function getUserTags(userId: string): Promise<UserTagsResponse> {
|
||||
return request("/v1/traffic/pool/getUserTags", { userId }, "GET");
|
||||
}
|
||||
|
||||
// 添加用户标签
|
||||
export function addUserTag(userId: string, tagData: any): Promise<any> {
|
||||
return request("/v1/user/tags", { userId, ...tagData }, "POST");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
.container {
|
||||
padding: 0;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// 头部样式
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// 用户卡片
|
||||
.userCard {
|
||||
margin: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wechatId {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.userTag {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 标签导航
|
||||
.tabNav {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
margin: 0 16px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.tabItem {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
background: rgba(24, 142, 238, 0.05);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(24, 142, 238, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// 信息卡片
|
||||
.infoCard {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
|
||||
:global(.adm-card-header) {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:global(.adm-card-body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// RFM评分网格
|
||||
.rfmGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rfmItem {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rfmLabel {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rfmValue {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 流量池区域
|
||||
.poolSection {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.currentPool,
|
||||
.availablePools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.poolLabel {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 统计数据网格
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
// 用户旅程
|
||||
.journeyItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loadingText {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.loadingMore {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loadMoreBtn {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 标签区域
|
||||
.tagsSection {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.valueTagsSection {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tagItem {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.valueTagContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.valueTagRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rfmScoreText {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.valueLevelLabel {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.valueTagItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.valueInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
// 添加标签按钮
|
||||
.addTagBtn {
|
||||
margin-top: 16px;
|
||||
border-radius: 8px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.emptyDesc {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// 限制记录样式
|
||||
.restrictionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.restrictionLevel {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.restrictionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 375px) {
|
||||
.rfmGrid,
|
||||
.statsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.restrictionTitle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.restrictionContent {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,285 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Avatar,
|
||||
Tag,
|
||||
Tabs,
|
||||
List,
|
||||
Badge,
|
||||
SpinLoading,
|
||||
} from "antd-mobile";
|
||||
import {
|
||||
UserOutlined,
|
||||
CrownOutlined,
|
||||
PlusOutlined,
|
||||
CloseOutlined,
|
||||
EyeOutlined,
|
||||
DollarOutlined,
|
||||
MobileOutlined,
|
||||
TagOutlined,
|
||||
FileTextOutlined,
|
||||
UserAddOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { getTrafficPoolDetail } from "./api";
|
||||
import type { TrafficPoolUserDetail } from "./data";
|
||||
import { Card, Button, Avatar, Tag, Spin } from "antd";
|
||||
|
||||
const tabList = [
|
||||
{ key: "base", label: "基本信息" },
|
||||
{ key: "journey", label: "用户旅程" },
|
||||
{ key: "tags", label: "用户标签" },
|
||||
];
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { getTrafficPoolDetail, getUserJourney, getUserTags } from "./api";
|
||||
import type {
|
||||
TrafficPoolUserDetail,
|
||||
ExtendedUserDetail,
|
||||
InteractionRecord,
|
||||
UserJourneyRecord,
|
||||
UserTagsResponse,
|
||||
UserTagItem,
|
||||
} from "./data";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const TrafficPoolDetail: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const { wxid, userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<TrafficPoolUserDetail | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"base" | "journey" | "tags">(
|
||||
"base"
|
||||
);
|
||||
const [user, setUser] = useState<ExtendedUserDetail | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
|
||||
// 用户旅程相关状态
|
||||
const [journeyLoading, setJourneyLoading] = useState(false);
|
||||
const [journeyList, setJourneyList] = useState<UserJourneyRecord[]>([]);
|
||||
const [journeyPage, setJourneyPage] = useState(1);
|
||||
const [journeyTotal, setJourneyTotal] = useState(0);
|
||||
const pageSize = 10;
|
||||
|
||||
// 用户标签相关状态
|
||||
const [tagsLoading, setTagsLoading] = useState(false);
|
||||
const [userTagsList, setUserTagsList] = useState<UserTagItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (!wxid) return;
|
||||
setLoading(true);
|
||||
getTrafficPoolDetail(id as string)
|
||||
.then(res => setUser(res))
|
||||
getTrafficPoolDetail(wxid as string)
|
||||
.then(res => {
|
||||
// 将API数据转换为扩展的用户详情数据
|
||||
const extendedUser: ExtendedUserDetail = {
|
||||
...res,
|
||||
// 模拟RFM评分数据
|
||||
rfmScore: {
|
||||
recency: 5,
|
||||
frequency: 5,
|
||||
monetary: 5,
|
||||
totalScore: 15,
|
||||
},
|
||||
// 模拟流量池数据
|
||||
trafficPools: {
|
||||
currentPool: "新用户池",
|
||||
availablePools: ["高价值客户池", "活跃用户池"],
|
||||
},
|
||||
// 模拟用户标签数据
|
||||
userTags: [
|
||||
{ id: "1", name: "近期活跃", color: "success", type: "user" },
|
||||
{ id: "2", name: "高频互动", color: "primary", type: "user" },
|
||||
{ id: "3", name: "高消费", color: "warning", type: "user" },
|
||||
{ id: "4", name: "老客户", color: "danger", type: "user" },
|
||||
],
|
||||
// 模拟价值标签数据
|
||||
valueTags: [
|
||||
{
|
||||
id: "1",
|
||||
name: "重要保持客户",
|
||||
color: "primary",
|
||||
icon: "crown",
|
||||
rfmScore: 14,
|
||||
valueLevel: "高价值",
|
||||
},
|
||||
],
|
||||
};
|
||||
setUser(extendedUser);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
}, [wxid]);
|
||||
|
||||
// 获取用户旅程数据
|
||||
const fetchUserJourney = async (page: number = 1) => {
|
||||
if (!userId) return;
|
||||
|
||||
setJourneyLoading(true);
|
||||
try {
|
||||
const response = await getUserJourney({
|
||||
page,
|
||||
pageSize,
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
if (page === 1) {
|
||||
setJourneyList(response.list);
|
||||
} else {
|
||||
setJourneyList(prev => [...prev, ...response.list]);
|
||||
}
|
||||
setJourneyTotal(response.total);
|
||||
setJourneyPage(page);
|
||||
} catch (error) {
|
||||
console.error("获取用户旅程失败:", error);
|
||||
} finally {
|
||||
setJourneyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取用户标签数据
|
||||
const fetchUserTags = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setTagsLoading(true);
|
||||
try {
|
||||
const response: UserTagsResponse = await getUserTags(userId);
|
||||
setUserTagsList(response.siteLabels || []);
|
||||
} catch (error) {
|
||||
console.error("获取用户标签失败:", error);
|
||||
} finally {
|
||||
setTagsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 标签切换处理
|
||||
const handleTabChange = (tab: string) => {
|
||||
setActiveTab(tab);
|
||||
if (tab === "journey" && journeyList.length === 0) {
|
||||
fetchUserJourney(1);
|
||||
}
|
||||
if (tab === "tags" && userTagsList.length === 0) {
|
||||
fetchUserTags();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const getJourneyTypeIcon = (type: number) => {
|
||||
switch (type) {
|
||||
case 0: // 浏览
|
||||
return <EyeOutlined style={{ color: "#722ed1" }} />;
|
||||
case 2: // 提交订单
|
||||
return <FileTextOutlined style={{ color: "#52c41a" }} />;
|
||||
case 3: // 注册
|
||||
return <UserAddOutlined style={{ color: "#1677ff" }} />;
|
||||
default:
|
||||
return <MobileOutlined style={{ color: "#999" }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getJourneyTypeText = (type: number) => {
|
||||
switch (type) {
|
||||
case 0:
|
||||
return "浏览行为";
|
||||
case 2:
|
||||
return "提交订单";
|
||||
case 3:
|
||||
return "注册行为";
|
||||
default:
|
||||
return "其他行为";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateTime: string) => {
|
||||
try {
|
||||
const date = new Date(dateTime);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch (error) {
|
||||
return dateTime;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "click":
|
||||
return <MobileOutlined style={{ color: "#1677ff" }} />;
|
||||
case "view":
|
||||
return <EyeOutlined style={{ color: "#722ed1" }} />;
|
||||
case "purchase":
|
||||
return <DollarOutlined style={{ color: "#52c41a" }} />;
|
||||
default:
|
||||
return <MobileOutlined style={{ color: "#999" }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return `¥${amount.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const getGenderText = (gender: number) => {
|
||||
switch (gender) {
|
||||
case 1:
|
||||
return "男";
|
||||
case 2:
|
||||
return "女";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const getGenderColor = (gender: number) => {
|
||||
switch (gender) {
|
||||
case 1:
|
||||
return "#1677ff";
|
||||
case 2:
|
||||
return "#eb2f96";
|
||||
default:
|
||||
return "#999";
|
||||
}
|
||||
};
|
||||
|
||||
const getRestrictionLevelText = (level: number) => {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return "轻微";
|
||||
case 2:
|
||||
return "中等";
|
||||
case 3:
|
||||
return "严重";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const getRestrictionLevelColor = (level: number) => {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return "warning";
|
||||
case 2:
|
||||
return "danger";
|
||||
case 3:
|
||||
return "danger";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number | null) => {
|
||||
if (!timestamp) return "--";
|
||||
try {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString("zh-CN");
|
||||
} catch (error) {
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取标签颜色
|
||||
const getTagColor = (index: number): string => {
|
||||
const colors = ["primary", "success", "warning", "danger", "default"];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ textAlign: "center", padding: "64px 0" }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ textAlign: "center", color: "#aaa", padding: "64px 0" }}>
|
||||
未找到该用户
|
||||
<Layout header={<NavCommon title="用户详情" />} loading={loading}>
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyText}>未找到该用户</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
@@ -49,249 +287,418 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
header={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: 48,
|
||||
borderBottom: "1px solid #eee",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
< 返回
|
||||
</Button>
|
||||
<div style={{ fontWeight: 600, fontSize: 18 }}>用户详情</div>
|
||||
</div>
|
||||
<>
|
||||
<NavCommon title="用户详情" />
|
||||
{/* 用户基本信息 */}
|
||||
<Card className={styles.userCard}>
|
||||
<div className={styles.userInfo}>
|
||||
<Avatar
|
||||
src={user.userInfo.avatar}
|
||||
className={styles.avatar}
|
||||
fallback={<UserOutlined />}
|
||||
/>
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.nickname}>{user.userInfo.nickname}</div>
|
||||
<div className={styles.wechatId}>{user.userInfo.wechatId}</div>
|
||||
<div className={styles.tags}>
|
||||
<Tag
|
||||
color="warning"
|
||||
fill="outline"
|
||||
className={styles.userTag}
|
||||
>
|
||||
<CrownOutlined />
|
||||
重要价值客户
|
||||
</Tag>
|
||||
<Tag color="danger" fill="outline" className={styles.userTag}>
|
||||
优先添加
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* 导航标签 */}
|
||||
<div className={styles.tabNav}>
|
||||
<div
|
||||
className={`${styles.tabItem} ${
|
||||
activeTab === "basic" ? styles.active : ""
|
||||
}`}
|
||||
onClick={() => handleTabChange("basic")}
|
||||
>
|
||||
基本信息
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.tabItem} ${
|
||||
activeTab === "journey" ? styles.active : ""
|
||||
}`}
|
||||
onClick={() => handleTabChange("journey")}
|
||||
>
|
||||
用户旅程
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.tabItem} ${
|
||||
activeTab === "tags" ? styles.active : ""
|
||||
}`}
|
||||
onClick={() => handleTabChange("tags")}
|
||||
>
|
||||
用户标签
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: 16 }}>
|
||||
{/* 顶部信息 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Avatar src={user.avatar} size={64} />
|
||||
<div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{user.nickname}</div>
|
||||
<div style={{ color: "#1677ff", fontSize: 14, margin: "4px 0" }}>
|
||||
{user.wechatId}
|
||||
</div>
|
||||
{user.packages &&
|
||||
user.packages.length > 0 &&
|
||||
user.packages.map(pkg => (
|
||||
<Tag color="purple" key={pkg} style={{ marginRight: 4 }}>
|
||||
{pkg}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tab栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 24,
|
||||
borderBottom: "1px solid #eee",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{tabList.map(tab => (
|
||||
<div
|
||||
key={tab.key}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
fontWeight: 500,
|
||||
color: activeTab === tab.key ? "#1677ff" : "#888",
|
||||
borderBottom:
|
||||
activeTab === tab.key ? "2px solid #1677ff" : "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
}}
|
||||
onClick={() => setActiveTab(tab.key as any)}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Tab内容 */}
|
||||
{activeTab === "base" && (
|
||||
<>
|
||||
<Card style={{ marginBottom: 16 }} title="关键信息">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 24 }}>
|
||||
<div>设备:{user.deviceName || "--"}</div>
|
||||
<div>微信号:{user.wechatAccountName || "--"}</div>
|
||||
<div>客服:{user.customerServiceName || "--"}</div>
|
||||
<div>添加时间:{user.addTime || "--"}</div>
|
||||
<div>最近互动:{user.lastInteraction || "--"}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 16 }} title="RFM评分">
|
||||
<div style={{ display: "flex", gap: 32 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#1677ff" }}
|
||||
>
|
||||
{user.rfmScore?.recency ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>最近性(R)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#52c41a" }}
|
||||
>
|
||||
{user.rfmScore?.frequency ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>频率(F)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#eb2f96" }}
|
||||
>
|
||||
{user.rfmScore?.monetary ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>金额(M)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 16 }} title="统计数据">
|
||||
<div style={{ display: "flex", gap: 32 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}
|
||||
>
|
||||
¥{user.totalSpent ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>总消费</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}
|
||||
>
|
||||
{user.interactionCount ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>互动次数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}
|
||||
>
|
||||
{user.conversionRate ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>转化率</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}
|
||||
>
|
||||
{user.status === "failed"
|
||||
? "添加失败"
|
||||
: user.status === "added"
|
||||
? "添加成功"
|
||||
: "未添加"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>添加状态</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "journey" && (
|
||||
<Card title="互动记录">
|
||||
{user.interactions && user.interactions.length > 0 ? (
|
||||
user.interactions.slice(0, 4).map(it => (
|
||||
<div
|
||||
key={it.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
padding: "12px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 22 }}>
|
||||
{it.type === "click" && "📱"}
|
||||
{it.type === "message" && "💬"}
|
||||
{it.type === "purchase" && "💲"}
|
||||
{it.type === "view" && "👁️"}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{it.type === "click" && "点击行为"}
|
||||
{it.type === "message" && "消息互动"}
|
||||
{it.type === "purchase" && "购买行为"}
|
||||
{it.type === "view" && "页面浏览"}
|
||||
<div className={styles.container}>
|
||||
{/* 内容区域 */}
|
||||
<div className={styles.content}>
|
||||
{activeTab === "basic" && (
|
||||
<div className={styles.tabContent}>
|
||||
{/* 关联信息 */}
|
||||
<Card title="关联信息" className={styles.infoCard}>
|
||||
<List>
|
||||
<List.Item extra="设备4">设备</List.Item>
|
||||
<List.Item extra="微信4-1">微信号</List.Item>
|
||||
<List.Item extra="客服1">客服</List.Item>
|
||||
<List.Item extra="2025/07/21">添加时间</List.Item>
|
||||
<List.Item extra="2025/07/25">最近互动</List.Item>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
{/* RFM评分 */}
|
||||
{user.rfmScore && (
|
||||
<Card title="RFM评分" className={styles.infoCard}>
|
||||
<div className={styles.rfmGrid}>
|
||||
<div className={styles.rfmItem}>
|
||||
<div className={styles.rfmLabel}>最近性(R)</div>
|
||||
<div
|
||||
className={styles.rfmValue}
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
{user.rfmScore.recency}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 13 }}>
|
||||
{it.content}
|
||||
{it.type === "purchase" && it.value && (
|
||||
<span
|
||||
style={{
|
||||
color: "#52c41a",
|
||||
fontWeight: 600,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
¥{it.value}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.rfmItem}>
|
||||
<div className={styles.rfmLabel}>频率(F)</div>
|
||||
<div
|
||||
className={styles.rfmValue}
|
||||
style={{ color: "#52c41a" }}
|
||||
>
|
||||
{user.rfmScore.frequency}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.rfmItem}>
|
||||
<div className={styles.rfmLabel}>金额(M)</div>
|
||||
<div
|
||||
className={styles.rfmValue}
|
||||
style={{ color: "#722ed1" }}
|
||||
>
|
||||
{user.rfmScore.monetary}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.rfmItem}>
|
||||
<div className={styles.rfmLabel}>总分</div>
|
||||
<div
|
||||
className={styles.rfmValue}
|
||||
style={{ color: "#ff4d4f" }}
|
||||
>
|
||||
{user.rfmScore.totalScore}/15
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#aaa",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{it.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
color: "#aaa",
|
||||
textAlign: "center",
|
||||
padding: "24px 0",
|
||||
}}
|
||||
>
|
||||
暂无互动记录
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{activeTab === "tags" && (
|
||||
<Card title="用户标签">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{user.tags && user.tags.length > 0 ? (
|
||||
user.tags.map(tag => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color="blue"
|
||||
style={{ marginRight: 8, marginBottom: 8 }}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: "#aaa" }}>暂无标签</span>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 流量池 */}
|
||||
{user.trafficPools && (
|
||||
<Card title="流量池" className={styles.infoCard}>
|
||||
<div className={styles.poolSection}>
|
||||
<div className={styles.currentPool}>
|
||||
<span className={styles.poolLabel}>当前池:</span>
|
||||
<Tag color="primary" fill="outline">
|
||||
{user.trafficPools.currentPool}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.availablePools}>
|
||||
<span className={styles.poolLabel}>可选池:</span>
|
||||
{user.trafficPools.availablePools.map((pool, index) => (
|
||||
<Tag key={index} color="default" fill="outline">
|
||||
{pool}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 统计数据 */}
|
||||
<Card title="统计数据" className={styles.infoCard}>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.statItem}>
|
||||
<div
|
||||
className={styles.statValue}
|
||||
style={{ color: "#52c41a" }}
|
||||
>
|
||||
¥9561
|
||||
</div>
|
||||
<div className={styles.statLabel}>总消费</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div
|
||||
className={styles.statValue}
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
6
|
||||
</div>
|
||||
<div className={styles.statLabel}>互动次数</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div
|
||||
className={styles.statValue}
|
||||
style={{ color: "#722ed1" }}
|
||||
>
|
||||
3%
|
||||
</div>
|
||||
<div className={styles.statLabel}>转化率</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div className={styles.statValue} style={{ color: "#999" }}>
|
||||
未添加
|
||||
</div>
|
||||
<div className={styles.statLabel}>添加状态</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 好友统计 */}
|
||||
<Card title="好友统计" className={styles.infoCard}>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.statItem}>
|
||||
<div
|
||||
className={styles.statValue}
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
{user.userInfo.friendShip.totalFriend}
|
||||
</div>
|
||||
<div className={styles.statLabel}>总好友</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div
|
||||
className={styles.statValue}
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
{user.userInfo.friendShip.maleFriend}
|
||||
</div>
|
||||
<div className={styles.statLabel}>男性好友</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div
|
||||
className={styles.statValue}
|
||||
style={{ color: "#eb2f96" }}
|
||||
>
|
||||
{user.userInfo.friendShip.femaleFriend}
|
||||
</div>
|
||||
<div className={styles.statLabel}>女性好友</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div className={styles.statValue} style={{ color: "#999" }}>
|
||||
{user.userInfo.friendShip.unknowFriend}
|
||||
</div>
|
||||
<div className={styles.statLabel}>未知性别</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 限制记录 */}
|
||||
<Card title="限制记录" className={styles.infoCard}>
|
||||
{user.restrictions && user.restrictions.length > 0 ? (
|
||||
<List>
|
||||
{user.restrictions.map(restriction => (
|
||||
<List.Item
|
||||
key={restriction.id}
|
||||
title={
|
||||
<div className={styles.restrictionTitle}>
|
||||
<span>{restriction.reason || "未知原因"}</span>
|
||||
<Tag
|
||||
color={getRestrictionLevelColor(
|
||||
restriction.level
|
||||
)}
|
||||
fill="outline"
|
||||
className={styles.restrictionLevel}
|
||||
>
|
||||
{getRestrictionLevelText(restriction.level)}
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div className={styles.restrictionContent}>
|
||||
<span>限制ID: {restriction.id}</span>
|
||||
{restriction.date && (
|
||||
<span>
|
||||
限制时间: {formatDate(restriction.date)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<UserOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
||||
</div>
|
||||
<div className={styles.emptyText}>暂无限制记录</div>
|
||||
<div className={styles.emptyDesc}>
|
||||
该用户没有任何限制记录
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
<Button type="dashed" block>
|
||||
➕ 添加新标签
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
)}
|
||||
|
||||
{activeTab === "journey" && (
|
||||
<div className={styles.tabContent}>
|
||||
<Card title="互动记录" className={styles.infoCard}>
|
||||
{journeyLoading && journeyList.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 24 }} />
|
||||
<div className={styles.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : journeyList.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<EyeOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
||||
</div>
|
||||
<div className={styles.emptyText}>暂无互动记录</div>
|
||||
<div className={styles.emptyDesc}>
|
||||
该用户还没有任何互动行为
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<List>
|
||||
{journeyList.map(record => (
|
||||
<List.Item
|
||||
key={record.id}
|
||||
prefix={getJourneyTypeIcon(record.type)}
|
||||
title={getJourneyTypeText(record.type)}
|
||||
description={
|
||||
<div className={styles.journeyItem}>
|
||||
<span>{record.remark}</span>
|
||||
<span className={styles.timestamp}>
|
||||
{formatDateTime(record.createTime)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{journeyLoading && journeyList.length > 0 && (
|
||||
<div className={styles.loadingMore}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 16 }} />
|
||||
<span>加载更多...</span>
|
||||
</div>
|
||||
)}
|
||||
{!journeyLoading && journeyList.length < journeyTotal && (
|
||||
<div className={styles.loadMoreBtn}>
|
||||
<Button
|
||||
size="small"
|
||||
fill="outline"
|
||||
onClick={() => fetchUserJourney(journeyPage + 1)}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "tags" && (
|
||||
<div className={styles.tabContent}>
|
||||
{/* 用户标签 */}
|
||||
<Card title="用户标签" className={styles.infoCard}>
|
||||
{tagsLoading && userTagsList.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 24 }} />
|
||||
<div className={styles.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : userTagsList.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<TagOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
||||
</div>
|
||||
<div className={styles.emptyText}>暂无用户标签</div>
|
||||
<div className={styles.emptyDesc}>该用户还没有任何标签</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tagsSection}>
|
||||
{userTagsList.map((tag, index) => (
|
||||
<Tag
|
||||
key={tag.id}
|
||||
color={getTagColor(index)}
|
||||
fill="outline"
|
||||
className={styles.tagItem}
|
||||
>
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 价值标签 */}
|
||||
<Card title="价值标签" className={styles.infoCard}>
|
||||
{user.valueTags && user.valueTags.length > 0 ? (
|
||||
<div className={styles.valueTagsSection}>
|
||||
{user.valueTags.map(tag => (
|
||||
<div key={tag.id} className={styles.valueTagContainer}>
|
||||
<div className={styles.valueTagRow}>
|
||||
<Tag
|
||||
color={tag.color}
|
||||
fill="outline"
|
||||
className={styles.tagItem}
|
||||
>
|
||||
{tag.icon === "crown" && <CrownOutlined />}
|
||||
{tag.name}
|
||||
</Tag>
|
||||
<span className={styles.rfmScoreText}>
|
||||
RFM总分: {tag.rfmScore}/15
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.valueTagRow}>
|
||||
<span className={styles.valueLevelLabel}>
|
||||
价值等级:
|
||||
</span>
|
||||
<Tag color="danger" fill="outline">
|
||||
{tag.valueLevel}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<CrownOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
||||
</div>
|
||||
<div className={styles.emptyText}>暂无价值标签</div>
|
||||
<div className={styles.emptyDesc}>
|
||||
该用户还没有任何价值标签
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 添加新标签按钮 */}
|
||||
<Button block color="primary" className={styles.addTagBtn}>
|
||||
<TagOutlined />
|
||||
添加新标签
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -191,7 +191,9 @@ const TrafficPoolList: React.FC = () => {
|
||||
<div
|
||||
className={styles.card}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/traffic-pool/detail/${item.id}`)}
|
||||
onClick={() =>
|
||||
navigate(`/traffic-pool/detail/${item.sourceId}/${item.id}`)
|
||||
}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
<Checkbox
|
||||
|
||||
@@ -73,17 +73,6 @@ const Workspace: React.FC = () => {
|
||||
path: "/workspace/traffic-distribution",
|
||||
bgColor: "#e6f7ff",
|
||||
},
|
||||
{
|
||||
id: "ai-assistant",
|
||||
name: "AI对话助手",
|
||||
description: "智能回复,提高互动质量",
|
||||
icon: (
|
||||
<MessageOutlined className={styles.icon} style={{ color: "#1890ff" }} />
|
||||
),
|
||||
path: "/workspace/ai-assistant",
|
||||
bgColor: "#e6f7ff",
|
||||
isNew: true,
|
||||
},
|
||||
];
|
||||
|
||||
// AI智能助手
|
||||
@@ -176,7 +165,7 @@ const Workspace: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* AI智能助手 */}
|
||||
<div className={styles.section}>
|
||||
{/* <div className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>AI 智能助手</h2>
|
||||
<div className={styles.featuresGrid}>
|
||||
{aiFeatures.map(feature => (
|
||||
@@ -205,7 +194,7 @@ const Workspace: React.FC = () => {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ const routes = [
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/traffic-pool/detail/:id",
|
||||
path: "/traffic-pool/detail/:wxid/:userId",
|
||||
element: <TrafficPoolDetail />,
|
||||
auth: true,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user