Merge branch 'yongpxu-dev' into yongpxu-dev4

# Conflicts:
#	nkebao/vite.config.ts   resolved by yongpxu-dev version
This commit is contained in:
超级老白兔
2025-07-29 19:31:35 +08:00
18 changed files with 1946 additions and 645 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

@@ -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");
}

View File

@@ -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;
}
}

View File

@@ -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 }}
>
&lt;
</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 />
&nbsp;
</Button>
</div>
)}
</div>
</div>
</Layout>
);

View File

@@ -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

View File

@@ -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>
);

View File

@@ -29,7 +29,7 @@ const routes = [
auth: true,
},
{
path: "/traffic-pool/detail/:id",
path: "/traffic-pool/detail/:wxid/:userId",
element: <TrafficPoolDetail />,
auth: true,
},

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,
},
});