0514.1
This commit is contained in:
50
.cursor/rules/nextjs.mdc
Normal file
50
.cursor/rules/nextjs.mdc
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Always respond in 中文
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 相关规范文档(项目根目录下,需要时请查看)
|
||||
- 数据模型结构:模型定义-团队级.txt 和 模型定义-系统级.txt
|
||||
- 自定义钩子使用文档:README-hooks.md
|
||||
- 数据库连接模块使用指南:README-DB.md
|
||||
- API请求规范与最佳实践:README-API.md
|
||||
- UI设计规范:README-UI.md
|
||||
- 工具函数已在src\utils\index.ts中导出
|
||||
- 钩子在src\hooks\index.ts,请查看相关使用方式,禁止使用src\store
|
||||
|
||||
### 开发规范
|
||||
- 现在已启用TypeScript严格模式,不要出现类型错误,禁止出现未使用变量
|
||||
- UI使用Ant Design 5.X 注意兼容性问题
|
||||
- api路由不需要auth,禁止使用auth!!!
|
||||
- 前端UI、API请求都要符合我们现有的规范(可查看相关文件)
|
||||
- 开发时,单文件代码严格限制在700行以内(含注释) 不要画蛇添足!
|
||||
- 禁止使用any类型!
|
||||
- 必须包含三级注释体系:
|
||||
▸ 文件头注释(作者:阿瑞/功能/版本)
|
||||
▸ 模块级注释(逻辑分段说明)
|
||||
▸ 关键代码行注释(复杂逻辑解释)
|
||||
- 当前在Windows 11环境下使用PowerShell终端进行开发,禁用类Unix命令,使用`Get-ChildItem` 替代ls ;
|
||||
- 已使用pnpm run dev启动了项目,禁止重复启动
|
||||
- 当修改超过300行的文件时,确保每次修改代码行数 ≤ 100行,分多次修改。
|
||||
|
||||
## 补充说明
|
||||
|
||||
1. 适当使用useMemo、React.memo来优化性能和防止不必要的重新渲染;
|
||||
2. 尽量少使用第三方库,优先使用原生功能或已有依赖;
|
||||
3. 创建模块化样式文件(Modular Stylesheet),命名遵循[name].module.css约定;
|
||||
4. 创建新组件时,遵循项目的目录组织结构和命名规范;
|
||||
5. 最好是模块化能够复用的;
|
||||
6. 创建前端页面时,复杂样式时应分离样式文件,创建`page.module.css`使用`import styles from './page.module.css';`引入样式;
|
||||
7. 已有UI组件已在`src\components\ui`路径下,使用时可查看具体代码获取使用方法;
|
||||
8. 不使用`import { toast } from 'sonner';`这个第三方组件,使用`src\components\ui\Notification.tsx`这个组件;
|
||||
9. 数据模型定义在`src\models\system`(系统级)和`src\models\team(团队及)文件中;
|
||||
10. 避免重复定义类型!!!数据模型接口定义在`src\models\system\types`(系统级)和`src\models\team\types`(团队级)文件中;
|
||||
11. 当执行终端命令时,先解释这个命令的作用;如果要使用pnpm安装包时,先解释为什么需要这个包;
|
||||
|
||||
## 数据库说明
|
||||
|
||||
数据库是docker中搭建的,可以使用这个命令进入数据库: docker exec -it my-mysql mysql -uroot -p"aiwoQwo520.."
|
||||
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
||||
# 开发环境文件
|
||||
node_modules
|
||||
.git
|
||||
.github
|
||||
.next
|
||||
.vscode
|
||||
*.log
|
||||
*.md
|
||||
!README-*.md
|
||||
|
||||
# 测试文件
|
||||
__tests__
|
||||
coverage
|
||||
jest.config.js
|
||||
|
||||
# 本地开发文件
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Docker相关文件
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# 其他文件
|
||||
.DS_Store
|
||||
*.zip
|
||||
*.tar.gz
|
||||
.cursor
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# 基础镜像阶段 - 使用 Node.js 22 作为基础镜像
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# 安装依赖阶段
|
||||
FROM base AS deps
|
||||
# 使用 Alpine 的包管理器安装必要的系统依赖
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 package.json 和 package-lock.json
|
||||
COPY package.json package-lock.json* ./
|
||||
# 使用 npm 安装项目依赖
|
||||
RUN npm install
|
||||
|
||||
# 构建阶段
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
# 从依赖阶段复制 node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# 复制所有项目文件
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN npm run build
|
||||
|
||||
# 生产运行阶段
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# 设置为生产环境
|
||||
ENV NODE_ENV production
|
||||
|
||||
# 创建非 root 用户运行应用,提高安全性
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
# 从构建阶段复制必要文件
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# 设置环境变量
|
||||
ENV PORT 3000
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 启动命令
|
||||
CMD ["node", "server.js"]
|
||||
482
README-API.md
Normal file
482
README-API.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# API请求规范与最佳实践
|
||||
|
||||
## 目录
|
||||
|
||||
- [基本规范](#基本规范)
|
||||
- [响应格式](#响应格式)
|
||||
- [错误处理](#错误处理)
|
||||
- [参数验证](#参数验证)
|
||||
- [安全性考虑](#安全性考虑)
|
||||
- [API路由实现](#api路由实现)
|
||||
- [基础API路由](#基础api路由)
|
||||
- [动态路由实现](#动态路由实现)
|
||||
- [Next.js 15.3+路由处理器](#nextjs-153路由处理器)
|
||||
- [最佳实践](#最佳实践)
|
||||
- [常见问题](#常见问题)
|
||||
|
||||
## 基本规范
|
||||
|
||||
### 响应格式
|
||||
|
||||
所有API响应应遵循统一的格式:
|
||||
|
||||
```typescript
|
||||
// 成功响应
|
||||
{
|
||||
success: true,
|
||||
data?: any, // 通用数据字段
|
||||
[key: string]: any, // 或使用特定业务字段名(如users, products等)
|
||||
message?: string // 可选,成功消息
|
||||
}
|
||||
|
||||
// 错误响应
|
||||
{
|
||||
success: false,
|
||||
error: string, // 错误消息
|
||||
code?: string // 可选,错误代码
|
||||
}
|
||||
```
|
||||
|
||||
**示例:通用数据字段**
|
||||
```typescript
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: rows
|
||||
});
|
||||
```
|
||||
|
||||
**示例:特定业务字段**
|
||||
```typescript
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
users: rows // 使用更具描述性的字段名
|
||||
});
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
始终使用try-catch处理API操作,并返回适当的错误信息:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// API操作
|
||||
} catch (error) {
|
||||
console.error('操作描述失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 参数验证
|
||||
|
||||
在执行操作前验证输入参数:
|
||||
|
||||
```typescript
|
||||
// 获取参数
|
||||
const { id } = params;
|
||||
|
||||
// 验证参数
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的ID参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 继续操作
|
||||
```
|
||||
|
||||
### 安全性考虑
|
||||
|
||||
- 使用参数化查询防止SQL注入
|
||||
- 不要在响应中返回敏感信息(如密码、令牌)
|
||||
- 对数据库操作使用最小权限原则
|
||||
- 验证用户权限和身份认证信息
|
||||
|
||||
```typescript
|
||||
// 良好实践 - 使用参数化查询
|
||||
const [user] = await req.db.query(
|
||||
'SELECT id, username FROM users WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 不良实践 - 容易遭受SQL注入
|
||||
const [user] = await req.db.query(
|
||||
`SELECT * FROM users WHERE id = ${userId}`
|
||||
);
|
||||
```
|
||||
|
||||
## API路由实现
|
||||
|
||||
### 基础API路由
|
||||
|
||||
使用数据库连接中间件的API路由基本实现:
|
||||
|
||||
```typescript
|
||||
// API路由文件(app/api/your-route/route.ts)
|
||||
import { NextResponse } from 'next/server';
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
async function handler(req: RequestWithDB) {
|
||||
try {
|
||||
// 使用req.db访问数据库连接
|
||||
const [rows] = await req.db.query('SELECT * FROM your_table');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '数据库操作失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出使用中间件包装的处理函数
|
||||
export const GET = connectSystemDB(handler);
|
||||
```
|
||||
|
||||
### 必要的导入说明
|
||||
|
||||
在Next.js的API路由中,通常需要以下两个核心导入:
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
```
|
||||
|
||||
- **NextRequest**: 扩展了原生Request对象,提供了额外的便利方法和属性,如:
|
||||
- `nextUrl`: 获取解析后的URL对象,可访问`pathname`、`searchParams`等
|
||||
- `cookies`: 访问和操作请求的Cookie
|
||||
- `headers`: 获取请求头
|
||||
- `json()`: 解析JSON请求体
|
||||
|
||||
- **NextResponse**: 用于创建和返回响应,提供了多种便利方法:
|
||||
- `NextResponse.json()`: 创建JSON响应,自动设置Content-Type
|
||||
- `NextResponse.redirect()`: 创建重定向响应
|
||||
- `NextResponse.rewrite()`: 创建重写响应
|
||||
- `NextResponse.next()`: 继续请求处理链
|
||||
|
||||
这两个对象是构建API路由的基础,几乎在所有API路由实现中都需要使用。
|
||||
|
||||
### 动态路由实现
|
||||
|
||||
在Next.js的动态路由中,必须**正确处理动态参数**。在13.4以后的版本中,动态路由参数需要使用`await`来获取:
|
||||
|
||||
```typescript
|
||||
// 正确的动态路由实现
|
||||
// API路由文件(app/api/your-route/[param]/route.ts)
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
export const GET = async (
|
||||
req: NextRequest,
|
||||
context: { params: { param: string } }
|
||||
) => {
|
||||
try {
|
||||
// 正确获取动态参数 - 使用await
|
||||
const params = await context.params;
|
||||
const param = params.param;
|
||||
|
||||
// 验证参数
|
||||
if (!param) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理函数
|
||||
const handler = async (dbReq: RequestWithDB) => {
|
||||
// 数据库操作
|
||||
const [rows] = await dbReq.db.query(
|
||||
'SELECT * FROM table WHERE id = ?',
|
||||
[param]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: rows
|
||||
});
|
||||
};
|
||||
|
||||
// 执行处理函数
|
||||
return await connectSystemDB(handler)(req);
|
||||
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '操作失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
1. 使用箭头函数定义路由处理器:`export const GET = async (...) => {...}`
|
||||
2. 正确获取动态参数:`const params = await context.params;`
|
||||
3. 使用ES6解构后再使用参数:`const param = params.param;`
|
||||
4. 不要直接解构:`const { param } = context.params;` - 这会导致错误
|
||||
|
||||
#### 路由处理函数参数说明
|
||||
|
||||
在Next.js App Router中,路由处理函数(如GET、POST、PUT、DELETE等)需要接收两个参数:
|
||||
|
||||
1. **第一个参数**:`request: NextRequest` - 包含请求信息的对象,包括请求头、URL、搜索参数等
|
||||
2. **第二个参数**:`context: { params: { [key: string]: string } }` - 包含动态路由参数的上下文对象
|
||||
|
||||
对于动态路由如`/api/users/[id]`,路由处理函数会接收如下参数:
|
||||
|
||||
```typescript
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
const userId = context.params.id;
|
||||
// 使用userId处理请求...
|
||||
}
|
||||
```
|
||||
|
||||
这种双参数模式对于获取动态路由参数非常有用,但在Next.js 15.3+版本中已被弃用,转而使用单参数模式,从URL中提取参数(见下文)。
|
||||
|
||||
### Next.js 15.3+路由处理器
|
||||
|
||||
在Next.js 15.3+版本中,路由处理器的类型定义发生了变化。为避免类型错误,应使用以下方式实现API路由:
|
||||
|
||||
#### 单参数路由处理器模式
|
||||
|
||||
对于动态路由,应使用单参数函数并从URL中提取路径参数,避免使用带context的双参数函数:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 符合Next.js 15.3+的路由处理器签名
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取动态参数
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const parts = pathname.split('/');
|
||||
const paramValue = parts[3]; // 例如: /api/path/[param]
|
||||
|
||||
// 验证参数
|
||||
if (!paramValue) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '参数不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理逻辑
|
||||
// ...
|
||||
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 路由处理器函数声明方式
|
||||
|
||||
推荐使用函数声明而非箭头函数:
|
||||
|
||||
```typescript
|
||||
// 推荐 ✅
|
||||
export async function GET(request: NextRequest) {
|
||||
// 处理逻辑
|
||||
}
|
||||
|
||||
// 不推荐 ❌
|
||||
export const GET = async (request: NextRequest) => {
|
||||
// 处理逻辑
|
||||
};
|
||||
```
|
||||
|
||||
#### 修复类型错误示例
|
||||
|
||||
如果构建时出现以下类型错误:
|
||||
|
||||
```
|
||||
Type '{ __tag__: "GET"; __param_position__: "second"; __param_type__: { params: { paramName: string; }; }; }'
|
||||
does not satisfy the constraint 'ParamCheck<RouteContext>'.
|
||||
```
|
||||
|
||||
应将路由函数从:
|
||||
|
||||
```typescript
|
||||
// 可能导致类型错误的实现 ❌
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { paramName: string } }
|
||||
) {
|
||||
const value = params.paramName;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
修改为:
|
||||
|
||||
```typescript
|
||||
// 修复后的实现 ✅
|
||||
export async function GET(request: NextRequest) {
|
||||
// 从URL中提取参数
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const paramName = pathname.split('/')[3]; // 适当调整索引位置
|
||||
|
||||
// ...继续处理
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用标准格式**:始终使用统一的响应格式
|
||||
2. **错误处理**:所有API路由都应包含try-catch块
|
||||
3. **参数验证**:在处理前验证所有输入参数
|
||||
4. **日志记录**:记录关键操作和错误信息
|
||||
5. **权限验证**:确保用户有权限执行请求的操作
|
||||
6. **状态码使用**:使用正确的HTTP状态码
|
||||
- 200: 成功
|
||||
- 201: 创建成功
|
||||
- 400: 请求错误/参数无效
|
||||
- 401: 未授权
|
||||
- 403: 禁止访问
|
||||
- 404: 资源不存在
|
||||
- 500: 服务器内部错误
|
||||
7. **客户端组件中使用useSearchParams**:始终将使用useSearchParams的组件包裹在Suspense边界中,避免构建警告
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问:如何处理文件上传?
|
||||
|
||||
**答**:在Next.js的App Router中,使用`formData`来处理文件上传:
|
||||
|
||||
```typescript
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
// 处理文件上传
|
||||
// ...
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '文件上传失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 问:如何实现分页API?
|
||||
|
||||
**答**:使用查询参数实现分页:
|
||||
|
||||
```typescript
|
||||
export const GET = async (req: NextRequest) => {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// 查询数据
|
||||
// ...
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalCount
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 问:如何处理动态路由中的错误?
|
||||
|
||||
**答**:确保正确使用await获取参数,并使用try-catch处理可能的错误:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const params = await context.params;
|
||||
// 使用params...
|
||||
} catch (error) {
|
||||
console.error('参数获取失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '参数处理错误' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 问:如何修复Next.js 15.3+中的路由处理器类型错误?
|
||||
|
||||
**答**:使用单参数路由处理器,从请求URL中提取路径参数:
|
||||
|
||||
```typescript
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取动态参数
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const paramValue = pathname.split('/')[3]; // 根据路径结构调整索引
|
||||
|
||||
// 继续处理...
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 问:如何处理useSearchParams导致的构建警告?
|
||||
|
||||
**答**:将使用useSearchParams的组件包裹在Suspense边界中:
|
||||
|
||||
```tsx
|
||||
// 正确处理useSearchParams的方式
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
// 创建一个独立的组件处理搜索参数
|
||||
function SearchParamsHandler({ onParamsChange }) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// 使用searchParams的逻辑
|
||||
// ...
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
// 页面主要内容
|
||||
return (
|
||||
<div>
|
||||
{/* 包裹在Suspense中 */}
|
||||
<Suspense fallback={null}>
|
||||
<SearchParamsHandler onParamsChange={handleParamsChange} />
|
||||
</Suspense>
|
||||
|
||||
{/* 页面其他内容 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
文档由阿瑞创建和维护。如有问题,请联系系统管理员。
|
||||
236
README-DB.md
Normal file
236
README-DB.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# 数据库连接模块使用指南
|
||||
|
||||
## 目录
|
||||
|
||||
- [简介](#简介)
|
||||
- [文件结构](#文件结构)
|
||||
- [基本用法](#基本用法)
|
||||
- [系统数据库连接](#系统数据库连接)
|
||||
- [团队数据库连接](#团队数据库连接)
|
||||
- [数据库查询最佳实践](#数据库查询最佳实践)
|
||||
- [性能考虑](#性能考虑)
|
||||
- [常见问题](#常见问题)
|
||||
|
||||
## 简介
|
||||
|
||||
数据库连接模块提供了一套高效的数据库连接管理系统,基于连接池实现,用于管理系统级数据库和团队级数据库的连接。主要特点:
|
||||
|
||||
- 使用高阶函数中间件模式,易于集成到API路由
|
||||
- 基于`mysql2/promise`的连接池,提供高性能的连接复用
|
||||
- 自动管理连接的获取和释放,防止连接泄漏
|
||||
- 分离的系统和团队数据库连接逻辑,支持多租户架构
|
||||
- 完善的日志系统,方便调试和监控
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/lib/db/
|
||||
├── config.ts # 数据库配置
|
||||
├── connect-system.ts # 系统数据库连接
|
||||
├── connect-team.ts # 团队数据库连接
|
||||
└── index.ts # 统一导出入口
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 系统数据库连接
|
||||
|
||||
系统数据库用于存储全局数据,如用户账户、工作空间和团队信息。
|
||||
|
||||
```typescript
|
||||
// 导入系统数据库连接
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
// 定义处理器函数
|
||||
async function handler(req: RequestWithDB) {
|
||||
try {
|
||||
// 使用req.db访问数据库连接
|
||||
const [rows] = await req.db.query('SELECT * FROM your_table');
|
||||
|
||||
// 处理结果
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('数据库操作失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用中间件包装处理函数
|
||||
export const processData = connectSystemDB(handler);
|
||||
```
|
||||
|
||||
### 团队数据库连接
|
||||
|
||||
团队数据库用于存储特定团队的数据,每个团队可以使用独立的数据库。
|
||||
|
||||
```typescript
|
||||
// 导入团队数据库连接
|
||||
import { connectTeamDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
// 假设已获取团队数据库配置
|
||||
const teamDbConfig = {
|
||||
host: 'localhost',
|
||||
name: 'team_db',
|
||||
user: 'team_user',
|
||||
password: 'team_password'
|
||||
};
|
||||
|
||||
// 定义处理器函数
|
||||
async function handler(req: RequestWithDB) {
|
||||
try {
|
||||
// 使用req.db访问数据库连接
|
||||
const [rows] = await req.db.query('SELECT * FROM team_data');
|
||||
|
||||
// 处理结果
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('数据库操作失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用中间件包装处理函数并创建处理函数
|
||||
export const processTeamData = connectTeamDB(
|
||||
teamDbConfig.host,
|
||||
teamDbConfig.name,
|
||||
teamDbConfig.user,
|
||||
teamDbConfig.password
|
||||
)(handler);
|
||||
```
|
||||
|
||||
## 数据库查询最佳实践
|
||||
|
||||
1. **使用参数化查询**
|
||||
|
||||
参数化查询是防止SQL注入的最佳方式:
|
||||
|
||||
```typescript
|
||||
// 良好实践 - 使用参数化查询
|
||||
const [user] = await req.db.query(
|
||||
'SELECT id, username FROM users WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 不良实践 - 容易遭受SQL注入
|
||||
const [user] = await req.db.query(
|
||||
`SELECT * FROM users WHERE id = ${userId}`
|
||||
);
|
||||
```
|
||||
|
||||
2. **只查询需要的列**
|
||||
|
||||
为提高性能,只查询实际需要的列:
|
||||
|
||||
```typescript
|
||||
// 良好实践 - 只查询需要的列
|
||||
const [rows] = await req.db.query(
|
||||
'SELECT id, name, email FROM users'
|
||||
);
|
||||
|
||||
// 不良实践 - 查询所有列
|
||||
const [rows] = await req.db.query(
|
||||
'SELECT * FROM users'
|
||||
);
|
||||
```
|
||||
|
||||
3. **使用事务保证数据一致性**
|
||||
|
||||
当需要进行多个相关操作时,使用事务确保数据一致性:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await req.db.beginTransaction();
|
||||
|
||||
// 执行多个查询
|
||||
await req.db.query('INSERT INTO orders (user_id, total) VALUES (?, ?)', [userId, total]);
|
||||
const [result] = await req.db.query('SELECT LAST_INSERT_ID() as id');
|
||||
const orderId = result[0].id;
|
||||
|
||||
for (const item of items) {
|
||||
await req.db.query(
|
||||
'INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)',
|
||||
[orderId, item.productId, item.quantity]
|
||||
);
|
||||
}
|
||||
|
||||
await req.db.commit();
|
||||
return orderId;
|
||||
} catch (error) {
|
||||
await req.db.rollback();
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## 性能考虑
|
||||
|
||||
连接池已经配置为最佳性能,但仍需注意:
|
||||
|
||||
- 系统数据库连接池:最大10个连接
|
||||
- 团队数据库连接池:每个团队最大5个连接
|
||||
- 长时间运行的查询可能会耗尽连接池
|
||||
- 确保查询有合适的索引
|
||||
- 对大型结果集使用分页
|
||||
|
||||
### 连接池监控
|
||||
|
||||
连接池状态可以在日志中查看。成功的连接操作会显示为绿色圆点(🟢),失败的操作会显示为红色圆点(🔴)。
|
||||
|
||||
```
|
||||
🟢 MySQL 已连接: localhost:3306/saas_master
|
||||
🟢 MySQL 系统数据库连接已获取
|
||||
🟢 MySQL 系统数据库连接已释放
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问:如何执行事务?
|
||||
|
||||
**答**:使用`req.db.beginTransaction()`、`req.db.commit()`和`req.db.rollback()`:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await req.db.beginTransaction();
|
||||
|
||||
// 执行多个查询
|
||||
await req.db.query('INSERT INTO ...');
|
||||
await req.db.query('UPDATE ...');
|
||||
|
||||
await req.db.commit();
|
||||
} catch (error) {
|
||||
await req.db.rollback();
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
### 问:如何处理大量并发请求?
|
||||
|
||||
**答**:当前连接池配置应该足以处理中等负载。如果需要处理高并发,考虑:
|
||||
1. 增加连接池大小(修改`connectionLimit`)
|
||||
2. 添加缓存层减少数据库查询
|
||||
3. 实现请求节流或批处理
|
||||
|
||||
### 问:连接池中的连接是如何管理的?
|
||||
|
||||
**答**:连接池自动管理连接的创建、分配和回收:
|
||||
- 首次请求时,创建连接并添加到池中
|
||||
- 处理完请求后,连接被释放回池中(而不是关闭)
|
||||
- 连接有最大空闲时间,超时会被关闭
|
||||
- 连接池有最大连接数限制,超过限制的请求会等待
|
||||
|
||||
```typescript
|
||||
// 不需要手动关闭连接
|
||||
// 中间件会自动处理释放连接
|
||||
const connection = await systemPool.getConnection();
|
||||
try {
|
||||
// 使用连接...
|
||||
const [rows] = await connection.query('SELECT ...');
|
||||
return rows;
|
||||
} finally {
|
||||
// 中间件会自动调用这一行
|
||||
connection.release();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
文档由阿瑞创建和维护。如有问题,请联系系统管理员。
|
||||
63
README-DOCKER-IMPORT.md
Normal file
63
README-DOCKER-IMPORT.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Docker 镜像导入导出指南
|
||||
|
||||
## 镜像信息
|
||||
|
||||
- 名称: saas
|
||||
- 版本: 25.5.12
|
||||
- 文件: saas-25.5.12.tar
|
||||
- 大小: 67.4 MB
|
||||
- 导出日期: 2025/5/12
|
||||
|
||||
## 导入镜像
|
||||
|
||||
使用以下命令将镜像导入 Docker:
|
||||
|
||||
```powershell
|
||||
# 导入镜像
|
||||
docker load -i D:\DockerImages\saas-25.5.12.tar
|
||||
```
|
||||
|
||||
## 验证导入
|
||||
|
||||
导入后,使用以下命令确认镜像已成功加载:
|
||||
|
||||
```powershell
|
||||
# 查看镜像列表
|
||||
docker images | findstr saas
|
||||
```
|
||||
|
||||
## 运行容器
|
||||
|
||||
使用以下命令从镜像运行容器:
|
||||
|
||||
```powershell
|
||||
# 基本运行命令
|
||||
docker run -d -p 3000:3000 --name saas-app saas:25.5.12
|
||||
|
||||
# 或者使用 docker-compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 导出说明
|
||||
|
||||
此镜像是使用以下命令导出的:
|
||||
|
||||
```powershell
|
||||
# 导出镜像命令
|
||||
docker save -o D:\DockerImages\saas-25.5.12.tar saas:25.5.12
|
||||
```
|
||||
|
||||
## 镜像构建说明
|
||||
|
||||
此镜像通过以下命令构建:
|
||||
|
||||
```powershell
|
||||
# 构建命令
|
||||
docker build -t saas:25.5.12 -f Dockerfile .
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 导入/导出过程可能需要几分钟时间,取决于系统性能
|
||||
2. 确保有足够的磁盘空间用于导入操作
|
||||
3. 镜像基于 Node.js 22 Alpine,优化用于生产环境
|
||||
93
README-DOCKER.md
Normal file
93
README-DOCKER.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Docker 部署说明
|
||||
|
||||
## 项目 Docker 化说明
|
||||
|
||||
本项目使用 Docker 和 Docker Compose 进行容器化部署,包含两个主要服务:
|
||||
- Next.js 应用服务(基于 Node.js 22)
|
||||
- MySQL 数据库服务
|
||||
|
||||
## 前提条件
|
||||
|
||||
- 安装 [Docker](https://www.docker.com/get-started)
|
||||
- 安装 [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 构建并启动服务
|
||||
|
||||
```bash
|
||||
# 构建并在后台启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 只构建不启动
|
||||
docker-compose build
|
||||
|
||||
# 查看服务日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 2. 访问应用
|
||||
|
||||
应用将在以下地址可用:
|
||||
- 前端应用: http://localhost:3000
|
||||
|
||||
### 3. 数据库访问
|
||||
|
||||
MySQL 数据库信息:
|
||||
- 主机: localhost:3306
|
||||
- 用户名: root
|
||||
- 密码: aiwoQwo520..
|
||||
- 数据库名: saas_db
|
||||
|
||||
可以使用以下命令连接数据库:
|
||||
|
||||
```bash
|
||||
docker exec -it my-mysql mysql -uroot -p"aiwoQwo520.."
|
||||
```
|
||||
|
||||
### 4. 停止服务
|
||||
|
||||
```bash
|
||||
# 停止所有服务但不删除容器
|
||||
docker-compose stop
|
||||
|
||||
# 停止并删除容器和网络
|
||||
docker-compose down
|
||||
|
||||
# 停止并删除容器、网络和数据卷(谨慎使用,会删除数据库数据)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `Dockerfile`: 用于构建 Next.js 应用的镜像(使用 Node.js 22 和 npm)
|
||||
- `docker-compose.yml`: 定义和配置服务
|
||||
- `.dockerignore`: 指定不包含在 Docker 构建上下文中的文件
|
||||
|
||||
## 构建流程说明
|
||||
|
||||
Dockerfile 使用多阶段构建以优化最终镜像大小:
|
||||
|
||||
1. 基础阶段:使用 Node.js 22 Alpine 镜像作为基础
|
||||
2. 依赖阶段:安装项目依赖
|
||||
- 使用 `npm install` 安装依赖
|
||||
3. 构建阶段:构建 Next.js 应用
|
||||
- 使用 `npm run build` 构建应用
|
||||
4. 运行阶段:配置生产环境并运行应用
|
||||
- 使用 standalone 输出模式优化部署
|
||||
|
||||
## 环境变量
|
||||
|
||||
可在 `docker-compose.yml` 文件中修改环境变量:
|
||||
|
||||
- `DB_HOST`: 数据库主机名
|
||||
- `DB_USER`: 数据库用户名
|
||||
- `DB_PASSWORD`: 数据库密码
|
||||
- `DB_NAME`: 数据库名称
|
||||
- `DB_PORT`: 数据库端口
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 数据库数据存储在 Docker 卷 `mysql_data` 中,即使容器被删除也会保留
|
||||
2. 上传的文件保存在宿主机的 `./uploads` 目录,并映射到容器内的 `/app/uploads`
|
||||
3. 初始化脚本位于 `./scripts` 目录,会在 MySQL 容器首次启动时执行
|
||||
74
README-TEAM-DB.md
Normal file
74
README-TEAM-DB.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 团队数据库连接工具使用指南
|
||||
|
||||
## 简介
|
||||
|
||||
团队数据库连接工具提供了一种便捷的方式来连接和管理团队级别的数据库。该工具会从请求中自动获取团队信息,并连接到对应的数据库。
|
||||
|
||||
## 基本用法
|
||||
|
||||
使用团队数据库连接工具非常简单,只需直接将处理函数传递给`connectTeamDB`:
|
||||
|
||||
```typescript
|
||||
import { connectTeamDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
export const GET = connectTeamDB(async (req: RequestWithDB) => {
|
||||
// 数据库连接已经附加到req.db上
|
||||
const [rows] = await req.db.query('SELECT * FROM your_table');
|
||||
return NextResponse.json({ data: rows });
|
||||
});
|
||||
```
|
||||
|
||||
使用时,需要确保请求中包含团队标识,可以通过以下方式之一提供:
|
||||
- HTTP头:`x-team-id`
|
||||
- URL查询参数:`?teamId=xxx`
|
||||
|
||||
## 连接池管理
|
||||
|
||||
团队数据库连接工具会自动为每个不同的数据库维护一个连接池,并在适当的时候复用它们。你不需要手动管理连接池的创建和销毁。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **正确处理错误**:始终在查询中使用try-catch来捕获和处理可能的数据库错误。
|
||||
|
||||
2. **不要手动释放连接**:中间件会自动管理连接的获取和释放,你不需要手动调用`connection.release()`。
|
||||
|
||||
## 示例
|
||||
|
||||
### 基本查询示例
|
||||
```typescript
|
||||
export const GET = connectTeamDB(async (req: RequestWithDB) => {
|
||||
try {
|
||||
const [users] = await req.db.query('SELECT * FROM users WHERE status = ?', ['active']);
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
console.error('查询失败:', error);
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 事务示例
|
||||
```typescript
|
||||
export const POST = connectTeamDB(async (req: RequestWithDB) => {
|
||||
// 开始事务
|
||||
await req.db.beginTransaction();
|
||||
|
||||
try {
|
||||
const { userId, amount } = await req.json();
|
||||
|
||||
// 执行多个查询
|
||||
await req.db.query('UPDATE accounts SET balance = balance - ? WHERE user_id = ?', [amount, userId]);
|
||||
await req.db.query('INSERT INTO transactions (user_id, amount) VALUES (?, ?)', [userId, amount]);
|
||||
|
||||
// 提交事务
|
||||
await req.db.commit();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
// 回滚事务
|
||||
await req.db.rollback();
|
||||
console.error('事务失败:', error);
|
||||
return NextResponse.json({ error: '交易失败' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
```
|
||||
54
README-UI.md
Normal file
54
README-UI.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 现代毛玻璃UI效果
|
||||
|
||||
一个基于Next.js和React开发的现代化SaaS管理平台,使用最新的毛玻璃UI设计风格,提供明亮通透的用户界面体验。
|
||||
|
||||
## 项目特点
|
||||
|
||||
- **现代毛玻璃UI效果**:采用最新流行的毛玻璃(Glassmorphism)设计风格,提供通透、现代的用户界面
|
||||
- **主题切换功能**:支持明亮/深色两种主题模式,满足不同用户偏好和使用场景
|
||||
- **主题持久化存储**:使用 zustand 管理状态,localStorage 保存主题偏好,刷新页面后仍保持设置
|
||||
- **响应式设计**:完全适配移动端和桌面端的各种屏幕尺寸
|
||||
- **动态视觉元素**:使用多种动画效果增强用户体验,包括背景气泡、渐变动画等
|
||||
- **模块化组件**:基于组件化思想构建,便于维护和扩展
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端框架**:Next.js 15.x + React 19
|
||||
- **状态管理**:Zustand
|
||||
- **样式解决方案**:Tailwind CSS 4.x(准备弃用)改为antd5.X
|
||||
- **字体**:Geist字体家族(提供现代简约风格)
|
||||
- **动画**:CSS原生动画
|
||||
- **开发语言**:TypeScript
|
||||
|
||||
## 界面预览
|
||||
|
||||
项目提供了多种精美的UI组件和效果:
|
||||
|
||||
- **毛玻璃导航栏**:半透明模糊效果,随着主题变化而调整
|
||||
- **功能展示卡片**:带有微妙悬浮效果的信息卡片
|
||||
- **数据统计面板**:展示关键数据指标的可视化组件
|
||||
- **主题切换开关**:允许用户在明亮/深色主题间切换,并记住用户选择
|
||||
|
||||
## 设计说明
|
||||
|
||||
### 色彩系统
|
||||
|
||||
项目使用了一套鲜明而和谐的色彩系统:
|
||||
|
||||
- **主色调**:
|
||||
- 蓝色 (#2d7ff9)
|
||||
- 紫色 (#8e6bff)
|
||||
- 青色 (#06d7b2)
|
||||
- 粉色 (#ff66c2)
|
||||
- 橙色 (#ff9640)
|
||||
|
||||
- **明亮主题背景**:明亮的淡蓝色,配合多彩渐变气泡
|
||||
- **暗色主题背景**:深蓝色调,带有鲜艳的强调色点缀
|
||||
|
||||
### 毛玻璃效果参数
|
||||
|
||||
精心调整的毛玻璃效果参数,确保最佳视觉体验:
|
||||
|
||||
- **背景模糊**:`backdrop-blur-xl`确保适当的模糊程度
|
||||
- **透明度**:卡片背景透明度在0.25-0.6之间
|
||||
- **边框**:微妙的半透明边框提升层次感
|
||||
312
README-hooks.md
Normal file
312
README-hooks.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# SaaS项目自定义钩子使用文档
|
||||
|
||||
## 简介
|
||||
|
||||
本文档介绍了SaaS项目中的自定义React钩子(Custom Hooks),这些钩子封装了常用的状态管理逻辑,使组件代码更加简洁、可读和可维护。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── hooks/
|
||||
│ ├── index.ts # 统一导出所有钩子
|
||||
│ ├── useUser.ts # 用户相关钩子
|
||||
│ └── useSettings.ts # 设置相关钩子
|
||||
└── store/
|
||||
├── userStore.ts # 用户状态管理
|
||||
└── settingStore.ts # 设置状态管理
|
||||
```
|
||||
|
||||
## 安装和导入
|
||||
|
||||
钩子已集成到项目中,无需额外安装。使用时只需从hooks目录导入:
|
||||
|
||||
```tsx
|
||||
// 导入单个钩子
|
||||
import { useTheme } from '@/hooks';
|
||||
|
||||
// 或导入多个钩子
|
||||
import { useTheme, useAuth, useUserProfile } from '@/hooks';
|
||||
```
|
||||
|
||||
## 用户相关钩子
|
||||
|
||||
### useUserInfo
|
||||
|
||||
获取当前登录用户的基本信息。
|
||||
|
||||
```tsx
|
||||
import { useUserInfo } from '@/hooks';
|
||||
|
||||
function UserInfoComponent() {
|
||||
const userInfo = useUserInfo();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>用户名: {userInfo?.username}</p>
|
||||
<p>邮箱: {userInfo?.email}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useUserProfile
|
||||
|
||||
获取格式化后的用户信息,适用于UI显示。
|
||||
|
||||
```tsx
|
||||
import { useUserProfile } from '@/hooks';
|
||||
|
||||
function ProfileComponent() {
|
||||
const profile = useUserProfile();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>用户名: {profile.username}</p>
|
||||
<p>邮箱: {profile.email}</p>
|
||||
<p>电话: {profile.phone}</p>
|
||||
<p>角色: {profile.roleName}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useIsAdmin
|
||||
|
||||
检查当前用户是否为管理员。
|
||||
|
||||
```tsx
|
||||
import { useIsAdmin } from '@/hooks';
|
||||
|
||||
function AdminSection() {
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return <p>您没有访问此页面的权限</p>;
|
||||
}
|
||||
|
||||
return <div>管理员面板内容</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### useHasRole
|
||||
|
||||
检查当前用户是否拥有特定角色。
|
||||
|
||||
```tsx
|
||||
import { useHasRole } from '@/hooks';
|
||||
|
||||
function RoleBasedComponent() {
|
||||
const hasRole = useHasRole();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasRole('editor') && <button>编辑内容</button>}
|
||||
{hasRole('admin') && <button>管理设置</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useAuth
|
||||
|
||||
管理用户登录状态和认证操作。
|
||||
|
||||
```tsx
|
||||
import { useAuth } from '@/hooks';
|
||||
|
||||
function LoginComponent() {
|
||||
const { isAuthenticated, login, logout } = useAuth();
|
||||
|
||||
const handleLogin = async () => {
|
||||
const response = await fetch('/api/login', { /* 登录请求 */ });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
login(data.token, data.user);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isAuthenticated ? (
|
||||
<button onClick={logout}>退出登录</button>
|
||||
) : (
|
||||
<button onClick={handleLogin}>登录</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 设置相关钩子
|
||||
|
||||
### useTheme
|
||||
|
||||
管理主题模式(亮色/暗色)和相关操作。
|
||||
|
||||
```tsx
|
||||
import { useTheme } from '@/hooks';
|
||||
|
||||
function ThemeToggleComponent() {
|
||||
const {
|
||||
themeMode,
|
||||
isDarkMode,
|
||||
toggleThemeMode,
|
||||
enableLightMode,
|
||||
enableDarkMode
|
||||
} = useTheme();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>当前主题: {themeMode}</p>
|
||||
<button onClick={toggleThemeMode}>
|
||||
切换至{isDarkMode ? '亮色' : '暗色'}主题
|
||||
</button>
|
||||
<button onClick={enableLightMode} disabled={!isDarkMode}>
|
||||
切换至亮色主题
|
||||
</button>
|
||||
<button onClick={enableDarkMode} disabled={isDarkMode}>
|
||||
切换至暗色主题
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useApplyTheme
|
||||
|
||||
自动将当前主题应用到DOM。适用于自定义布局组件或页面。
|
||||
|
||||
```tsx
|
||||
import { useApplyTheme } from '@/hooks';
|
||||
|
||||
function Layout({ children }) {
|
||||
// 将当前主题应用到DOM
|
||||
useApplyTheme();
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useSettings
|
||||
|
||||
获取所有系统设置。
|
||||
|
||||
```tsx
|
||||
import { useSettings } from '@/hooks';
|
||||
|
||||
function SettingsComponent() {
|
||||
const settings = useSettings();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>主题模式: {settings.themeMode}</p>
|
||||
{/* 其他设置项 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 实际使用示例
|
||||
|
||||
下面是一个综合使用多个钩子的组件示例:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useTheme,
|
||||
useApplyTheme,
|
||||
useUserProfile,
|
||||
useIsAdmin,
|
||||
useAuth
|
||||
} from '@/hooks';
|
||||
|
||||
function DashboardPage() {
|
||||
// 应用主题到DOM
|
||||
useApplyTheme();
|
||||
|
||||
// 获取主题状态和操作
|
||||
const { isDarkMode, toggleThemeMode } = useTheme();
|
||||
|
||||
// 获取用户信息和权限
|
||||
const userProfile = useUserProfile();
|
||||
const isAdmin = useIsAdmin();
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginRedirect />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header>
|
||||
<div className="user-info">
|
||||
<p>欢迎, {userProfile.username}</p>
|
||||
<button onClick={logout}>退出登录</button>
|
||||
</div>
|
||||
<button onClick={toggleThemeMode}>
|
||||
切换至{isDarkMode ? '亮色' : '暗色'}主题
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{/* 普通用户内容 */}
|
||||
<UserContent />
|
||||
|
||||
{/* 仅管理员可见内容 */}
|
||||
{isAdmin && <AdminPanel />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 测试页面
|
||||
|
||||
项目中包含一个专门的测试页面,用于演示各种钩子的使用方法:
|
||||
|
||||
- 路径: `/test/usehook`
|
||||
- 源代码: `src/app/test/usehook/page.tsx`
|
||||
|
||||
在这个页面上可以测试以下功能:
|
||||
- 主题切换
|
||||
- 用户登录/登出
|
||||
- 角色权限检查
|
||||
- 用户信息显示
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **避免重复导入基础store**:使用自定义钩子而不是直接导入store,减少对底层实现的依赖。
|
||||
|
||||
2. **优先使用语义化钩子**:例如使用`useIsAdmin()`而不是`useUserInfo().roleType === 'admin'`。
|
||||
|
||||
3. **组合使用多个钩子**:根据需要组合使用不同的钩子,构建复杂功能。
|
||||
|
||||
4. **使用useMemo和useCallback**:在性能敏感的场景中,确保不会导致不必要的重新渲染。
|
||||
|
||||
## 扩展自定义钩子
|
||||
|
||||
如需扩展现有钩子或添加新钩子,请遵循以下步骤:
|
||||
|
||||
1. 在适当的文件中添加新钩子(`useUser.ts`或`useSettings.ts`)
|
||||
2. 在`hooks/index.ts`中导出新钩子
|
||||
3. 确保包含适当的注释和类型定义
|
||||
4. 遵循三级注释规范(文件头注释、模块级注释、关键代码行注释)
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 为什么推荐使用自定义钩子而不是直接使用store?**
|
||||
|
||||
A: 自定义钩子提供了更高级别的抽象,隐藏了状态管理的复杂性,使组件代码更加简洁和易于维护。同时,它们可以更轻松地进行单元测试。
|
||||
|
||||
**Q: 如何处理钩子之间的依赖关系?**
|
||||
|
||||
A: 当一个钩子需要依赖另一个钩子时,应在钩子内部导入并使用依赖的钩子,而不是在组件中手动管理这些依赖关系。
|
||||
|
||||
**Q: 如何确保钩子在正确的上下文中使用?**
|
||||
|
||||
A: React钩子只能在React函数组件或其他自定义钩子中使用。确保不在类组件、普通函数或条件语句中直接调用钩子。
|
||||
46
README.md
46
README.md
@@ -1,36 +1,18 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# SaaS 多租户管理系统
|
||||
|
||||
## Getting Started
|
||||
## 项目概述
|
||||
|
||||
First, run the development server:
|
||||
本项目是一个基于Next.js的多租户SaaS管理系统,支持工作空间、用户和团队的管理,采用MySQL作为数据存储。
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
## 系统架构
|
||||
|
||||
### 创建数据库
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
sudo docker run --name my-mysql \
|
||||
-p 3306:3306 \
|
||||
-v /vol1/1000/SSD/mysql:/var/lib/mysql \
|
||||
-e MYSQL_ROOT_PASSWORD=aiwoQwo520.. \
|
||||
-e MYSQL_DATABASE=myapp \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-d mysql:latest \
|
||||
--default-time-zone='+08:00'
|
||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Next.js 应用服务
|
||||
nextjs-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nextjs-saas-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DB_HOST=mysql
|
||||
- DB_USER=root
|
||||
- DB_PASSWORD=aiwoQwo520..
|
||||
- DB_NAME=saas_db
|
||||
- DB_PORT=3306
|
||||
depends_on:
|
||||
- mysql
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
# MySQL 数据库服务
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: my-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=aiwoQwo520..
|
||||
- MYSQL_DATABASE=saas_db
|
||||
- MYSQL_USER=saas_user
|
||||
- MYSQL_PASSWORD=saas_password
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./scripts:/docker-entrypoint-initdb.d
|
||||
|
||||
# 定义持久化卷
|
||||
volumes:
|
||||
mysql_data:
|
||||
@@ -2,6 +2,21 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
reactStrictMode: true,
|
||||
// 配置开发服务器选项,降低热重载频率
|
||||
onDemandEntries: {
|
||||
// 期间页面在内存中保持的时间(毫秒)
|
||||
maxInactiveAge: 25 * 1000,
|
||||
// 同时保持的页面数
|
||||
pagesBufferLength: 2,
|
||||
},
|
||||
// 添加 standalone 输出模式,优化 Docker 部署
|
||||
output: 'standalone',
|
||||
// 禁用 ESLint 检查
|
||||
eslint: {
|
||||
// 在构建时不进行 ESLint 检查
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
6712
package-lock.json
generated
6712
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@@ -6,22 +6,43 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"init-db": "ts-node scripts/init-db.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"antd": "^5.25.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.14.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "15.3.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"node-cron": "^4.0.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.3.2"
|
||||
"react-icons": "^5.5.0",
|
||||
"styled-components": "^6.0.9",
|
||||
"uuid": "^11.1.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.2",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
5660
pnpm-lock.yaml
generated
Normal file
5660
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
public/robots.txt
Normal file
20
public/robots.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
# SaaS平台robots.txt
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# 禁止访问的路径
|
||||
Disallow: /api/
|
||||
Disallow: /admin/
|
||||
Disallow: /dashboard/private/
|
||||
Disallow: /uploads/private/
|
||||
|
||||
# 允许访问的特定路径
|
||||
Allow: /public/
|
||||
Allow: /blog/
|
||||
Allow: /docs/
|
||||
|
||||
# 站点地图
|
||||
Sitemap: https://yourdomain.com/sitemap.xml
|
||||
|
||||
# 抓取频率设置
|
||||
Crawl-delay: 10
|
||||
193
src/app/(auth)/layout.tsx
Normal file
193
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 认证页面布局
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供登录和注册页面的统一布局,左侧显示应用简介,右侧显示认证界面
|
||||
* 版本: 1.4
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useIsDarkMode } from "@/components/ThemeProvider";
|
||||
|
||||
/**
|
||||
* 认证布局组件
|
||||
* 提供左右分栏布局,左侧展示应用介绍,右侧展示登录/注册组件
|
||||
*/
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthContent>{children}</AuthContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证页面内容组件
|
||||
* 使用ThemeContext获取主题状态
|
||||
*/
|
||||
function AuthContent({ children }: { children: React.ReactNode }) {
|
||||
// 使用Context API获取主题状态,无需水合逻辑
|
||||
const { isDarkMode, isMounted } = useIsDarkMode();
|
||||
|
||||
// 如果未水合完成,交由ThemeProvider处理加载状态
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col md:flex-row relative overflow-hidden">
|
||||
{/* 动态背景元素 */}
|
||||
<div className="fixed inset-0 z-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-0 -left-10 w-[800px] h-[800px] bg-purple-500/8 rounded-full filter blur-[120px] opacity-70 animate-blob"></div>
|
||||
<div className="absolute top-[20%] -right-10 w-[700px] h-[700px] bg-blue-500/10 rounded-full filter blur-[100px] opacity-70 animate-blob animation-delay-4000"></div>
|
||||
<div className="absolute -bottom-20 left-[15%] w-[850px] h-[850px] bg-teal-400/8 rounded-full filter blur-[120px] opacity-60 animate-blob animation-delay-2000"></div>
|
||||
</div>
|
||||
|
||||
{/* 装饰小球元素 */}
|
||||
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||
<div className="absolute top-[15%] left-[10%] w-4 h-4 rounded-full bg-accent-teal/60 animate-float"></div>
|
||||
<div className="absolute top-[30%] right-[15%] w-3 h-3 rounded-full bg-accent-purple/50 animate-float animation-delay-2000"></div>
|
||||
<div className="absolute bottom-[25%] left-[20%] w-4 h-4 rounded-full bg-accent-blue/50 animate-float animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
{/* 左侧应用简介 - 占据2/3空间 */}
|
||||
<div className="hidden md:flex md:w-2/3 relative z-10">
|
||||
<div className="flex flex-col items-center justify-center p-12 h-full w-full">
|
||||
<div className={`max-w-2xl ${isDarkMode ? 'glass-card-dark' : 'glass-card'} p-8 rounded-xl`}>
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className={`h-20 w-20 rounded-2xl ${isDarkMode ? 'bg-gradient-to-br from-blue-500/40 via-purple-500/30 to-teal-500/40' : 'bg-gradient-to-br from-blue-100 via-purple-100 to-teal-100'} flex items-center justify-center mb-4 rotate-3 shadow-lg`}>
|
||||
<svg className={`${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className={`text-4xl font-bold text-center ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
多租户SaaS管理系统
|
||||
</h1>
|
||||
<p className={`mt-3 text-lg text-center max-w-lg ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
基于Next.js的现代化私域管理平台<br/>助力企业高效管理团队与资源
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<div className={`p-5 rounded-xl ${isDarkMode ? 'bg-blue-900/20 border border-blue-800/40' : 'bg-blue-50 border border-blue-100'}`}>
|
||||
<div className="flex items-center mb-3">
|
||||
<div className={`h-8 w-8 rounded-lg ${isDarkMode ? 'bg-blue-700/40' : 'bg-blue-200'} flex items-center justify-center mr-3`}>
|
||||
<svg className={`h-4 w-4 ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
多租户架构
|
||||
</h3>
|
||||
</div>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
支持多工作空间独立管理,为每个企业提供专属环境,数据隔离安全可靠
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`p-5 rounded-xl ${isDarkMode ? 'bg-purple-900/20 border border-purple-800/40' : 'bg-purple-50 border border-purple-100'}`}>
|
||||
<div className="flex items-center mb-3">
|
||||
<div className={`h-8 w-8 rounded-lg ${isDarkMode ? 'bg-purple-700/40' : 'bg-purple-200'} flex items-center justify-center mr-3`}>
|
||||
<svg className={`h-4 w-4 ${isDarkMode ? 'text-purple-300' : 'text-purple-600'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
精细权限控制
|
||||
</h3>
|
||||
</div>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
基于角色的访问控制系统,确保用户只能访问其权限范围内的资源和功能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`p-5 rounded-xl ${isDarkMode ? 'bg-teal-900/20 border border-teal-800/40' : 'bg-teal-50 border border-teal-100'}`}>
|
||||
<div className="flex items-center mb-3">
|
||||
<div className={`h-8 w-8 rounded-lg ${isDarkMode ? 'bg-teal-700/40' : 'bg-teal-200'} flex items-center justify-center mr-3`}>
|
||||
<svg className={`h-4 w-4 ${isDarkMode ? 'text-teal-300' : 'text-teal-600'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
现代UI设计
|
||||
</h3>
|
||||
</div>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
采用流行的毛玻璃设计风格,提供明亮通透的用户界面和流畅的交互体验
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`p-5 rounded-xl ${isDarkMode ? 'bg-amber-900/20 border border-amber-800/40' : 'bg-amber-50 border border-amber-100'}`}>
|
||||
<div className="flex items-center mb-3">
|
||||
<div className={`h-8 w-8 rounded-lg ${isDarkMode ? 'bg-amber-700/40' : 'bg-amber-200'} flex items-center justify-center mr-3`}>
|
||||
<svg className={`h-4 w-4 ${isDarkMode ? 'text-amber-300' : 'text-amber-600'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 12v3c0 1.657 3.134 3 7 3s7-1.343 7-3v-3c0 1.657-3.134 3-7 3s-7-1.343-7-3z" />
|
||||
<path d="M3 7v3c0 1.657 3.134 3 7 3s7-1.343 7-3V7c0 1.657-3.134 3-7 3S3 8.657 3 7z" />
|
||||
<path d="M17 5c0 1.657-3.134 3-7 3S3 6.657 3 5s3.134-3 7-3 7 1.343 7 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
高效数据管理
|
||||
</h3>
|
||||
</div>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
智能数据库连接池,支持自动初始化和维护,提供完整的团队和工作空间数据隔离
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统特点 */}
|
||||
<div className="mb-8">
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isDarkMode ? 'text-white' : 'text-gray-900'} border-b ${isDarkMode ? 'border-gray-700' : 'border-gray-200'} pb-2`}>系统特点</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center">
|
||||
<svg className={`h-5 w-5 mr-2 ${isDarkMode ? 'text-green-400' : 'text-green-500'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>工作空间和团队管理</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg className={`h-5 w-5 mr-2 ${isDarkMode ? 'text-green-400' : 'text-green-500'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>基于JWT的认证机制</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg className={`h-5 w-5 mr-2 ${isDarkMode ? 'text-green-400' : 'text-green-500'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>响应式设计,全设备支持</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg className={`h-5 w-5 mr-2 ${isDarkMode ? 'text-green-400' : 'text-green-500'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>深色/明亮主题切换</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg className={`h-5 w-5 mr-2 ${isDarkMode ? 'text-green-400' : 'text-green-500'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>成员邀请与注册</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg className={`h-5 w-5 mr-2 ${isDarkMode ? 'text-green-400' : 'text-green-500'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>基于Next.js的现代技术栈</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧登录/注册界面 - 占据1/3空间 */}
|
||||
<div className="w-full md:w-1/3 relative z-10 flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
src/app/(auth)/login/page.tsx
Normal file
256
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 登录页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供用户登录界面和功能,使用毛玻璃UI效果
|
||||
* 版本: 1.6
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuth, useTheme, useApplyTheme } from '@/hooks';
|
||||
import { useNotification } from '@/components/ui/Notification';
|
||||
|
||||
/**
|
||||
* 登录页面组件
|
||||
* 处理用户登录表单提交和验证
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
// 状态管理
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
// 添加认证加载状态
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
|
||||
// 路由
|
||||
const router = useRouter();
|
||||
const notification = useNotification();
|
||||
|
||||
// 应用主题
|
||||
useApplyTheme();
|
||||
|
||||
// 使用自定义钩子
|
||||
const { isAuthenticated, login } = useAuth();
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
/**
|
||||
* 检查认证状态并设置标志
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 确保认证状态已经从持久化存储中加载完成
|
||||
const timer = setTimeout(() => {
|
||||
setAuthChecked(true);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 已登录用户重定向
|
||||
* 如果用户已经登录,直接跳转到工作空间页面
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 只有在认证状态检查完成后才执行重定向
|
||||
if (!authChecked) return;
|
||||
|
||||
console.log('登录状态检查 isAuthenticated:', isAuthenticated);
|
||||
if (isAuthenticated) {
|
||||
console.log('用户已登录,跳转到工作空间');
|
||||
router.replace('/workspace'); // 使用replace代替push进行强制导航
|
||||
}
|
||||
}, [isAuthenticated, router, authChecked]);
|
||||
|
||||
/**
|
||||
* 表单输入变更处理
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
// 清除错误提示
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单提交处理
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
console.log('提交登录表单,用户名:', formData.username);
|
||||
|
||||
try {
|
||||
// 验证表单
|
||||
if (!formData.username || !formData.password) {
|
||||
throw new Error('用户名和密码不能为空');
|
||||
}
|
||||
|
||||
// 发送登录请求
|
||||
console.log('发送登录请求...');
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
// 处理响应
|
||||
const data = await response.json();
|
||||
console.log('登录响应状态:', response.status, response.ok);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '登录失败,请检查用户名和密码');
|
||||
}
|
||||
|
||||
// 显示登录成功提示
|
||||
console.log('登录成功,获取到令牌和用户信息');
|
||||
notification.success('登录成功,正在跳转...');
|
||||
|
||||
// 使用自定义钩子进行登录
|
||||
await login(data.accessToken, data.user);
|
||||
|
||||
// 短暂延迟后强制导航到工作空间
|
||||
console.log('准备跳转到工作空间页面...');
|
||||
setTimeout(() => {
|
||||
console.log('执行强制跳转');
|
||||
window.location.href = '/workspace'; // 使用原生导航,绕过任何Route拦截
|
||||
}, 500);
|
||||
|
||||
} catch (error: Error | unknown) {
|
||||
console.error('登录失败详情:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '登录过程中发生错误';
|
||||
setError(errorMessage);
|
||||
notification.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 认证状态检查中显示加载界面
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-700 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-4 py-8">
|
||||
<div className="sm:mx-auto sm:w-full max-w-sm mb-6">
|
||||
<h2 className={`text-center text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
登录账户
|
||||
</h2>
|
||||
<p className={`mt-2 text-center text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
或{' '}
|
||||
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
注册新账户
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`w-full max-w-sm mx-auto ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="py-8 px-4 sm:px-8">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">{error}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户名输入 */}
|
||||
<div>
|
||||
<label htmlFor="username" className={`block text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
|
||||
用户名
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className={`appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm ${isDarkMode ? 'bg-gray-800/40 text-white border-gray-700' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
<div>
|
||||
<label htmlFor="password" className={`block text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
|
||||
密码
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className={`appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm ${isDarkMode ? 'bg-gray-800/40 text-white border-gray-700' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 记住我和忘记密码 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember_me"
|
||||
name="remember_me"
|
||||
type="checkbox"
|
||||
className={`h-4 w-4 focus:ring-blue-500 border-gray-300 rounded ${isDarkMode ? 'bg-gray-700 border-gray-600' : ''}`}
|
||||
/>
|
||||
<label htmlFor="remember_me" className={`ml-2 block text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-900'}`}>
|
||||
记住我
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<a href="#" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`btn-primary w-full flex justify-center py-2 px-4 ${
|
||||
loading ? 'opacity-70 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
650
src/app/(auth)/register/page.tsx
Normal file
650
src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,650 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 用户注册页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供用户注册和创建工作空间功能,支持邀请链接注册,使用毛玻璃UI效果
|
||||
* 版本: 1.4
|
||||
*/
|
||||
|
||||
import { useState, FormEvent, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTheme } from '@/hooks';
|
||||
import { useNotification } from '@/components/ui/Notification';
|
||||
|
||||
// 缓存已验证的令牌结果,避免重复请求
|
||||
const verifiedTokenCache = new Map();
|
||||
|
||||
/**
|
||||
* 注册表单状态接口
|
||||
*/
|
||||
interface RegisterFormState {
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
workspaceName: string;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单验证错误接口
|
||||
*/
|
||||
interface FormErrors {
|
||||
username?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
workspaceName?: string;
|
||||
general?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册页面组件
|
||||
*/
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const notification = useNotification();
|
||||
|
||||
// 获取邀请令牌
|
||||
const inviteToken = searchParams?.get('token');
|
||||
|
||||
// 用于确保请求只发送一次的标志
|
||||
const tokenFetchAttemptedRef = useRef(false);
|
||||
|
||||
// 使用主题钩子(移除了不必要的useApplyTheme调用)
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// 添加注册完成状态标志
|
||||
const [registrationCompleted, setRegistrationCompleted] = useState(false);
|
||||
|
||||
/**
|
||||
* 表单状态
|
||||
*/
|
||||
const [formState, setFormState] = useState<RegisterFormState>({
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
workspaceName: '',
|
||||
isSubmitting: false
|
||||
});
|
||||
|
||||
/**
|
||||
* 表单验证错误
|
||||
*/
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
/**
|
||||
* 邀请信息状态
|
||||
*/
|
||||
const [inviteInfo, setInviteInfo] = useState<{
|
||||
isValid: boolean;
|
||||
workspaceName?: string;
|
||||
inviterName?: string;
|
||||
isLoading: boolean;
|
||||
error?: string;
|
||||
used?: boolean;
|
||||
usedBy?: string;
|
||||
usedAt?: string;
|
||||
}>({
|
||||
isValid: false,
|
||||
isLoading: !!inviteToken
|
||||
});
|
||||
|
||||
/**
|
||||
* 验证邀请令牌的函数 - 使用useCallback避免重复创建
|
||||
*/
|
||||
const verifyInviteToken = useCallback(async (token: string) => {
|
||||
// 如果令牌已缓存,直接使用缓存结果
|
||||
if (verifiedTokenCache.has(token)) {
|
||||
const cachedData = verifiedTokenCache.get(token);
|
||||
setInviteInfo(cachedData);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auth/verify-invite?token=${token}`);
|
||||
|
||||
let resultData;
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
resultData = {
|
||||
isValid: true,
|
||||
workspaceName: data.workspaceName,
|
||||
inviterName: data.inviterName,
|
||||
isLoading: false
|
||||
};
|
||||
setInviteInfo(resultData);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
|
||||
// 增加对已使用令牌的处理
|
||||
if (errorData.used) {
|
||||
resultData = {
|
||||
isValid: false,
|
||||
isLoading: false,
|
||||
error: `该邀请链接已被${errorData.usedBy}使用`,
|
||||
used: true,
|
||||
usedBy: errorData.usedBy,
|
||||
usedAt: errorData.usedAt
|
||||
};
|
||||
setInviteInfo(resultData);
|
||||
notification.error(`该邀请链接已被使用,请联系管理员获取新的邀请链接`);
|
||||
} else {
|
||||
resultData = {
|
||||
isValid: false,
|
||||
isLoading: false,
|
||||
error: errorData.error || '无效的邀请链接'
|
||||
};
|
||||
setInviteInfo(resultData);
|
||||
notification.error('邀请链接无效或已过期');
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
verifiedTokenCache.set(token, resultData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('验证邀请令牌失败:', error);
|
||||
const resultData = {
|
||||
isValid: false,
|
||||
isLoading: false,
|
||||
error: '验证邀请链接失败'
|
||||
};
|
||||
setInviteInfo(resultData);
|
||||
notification.error('验证邀请链接失败');
|
||||
|
||||
// 即使是错误也缓存,避免重复请求
|
||||
verifiedTokenCache.set(token, resultData);
|
||||
}
|
||||
}, [notification]);
|
||||
|
||||
/**
|
||||
* 初始化验证邀请令牌
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 只有在有令牌且尚未完成注册的情况下处理
|
||||
if (inviteToken && !registrationCompleted && !tokenFetchAttemptedRef.current) {
|
||||
// 标记已尝试获取
|
||||
tokenFetchAttemptedRef.current = true;
|
||||
// 验证邀请令牌
|
||||
verifyInviteToken(inviteToken);
|
||||
} else if (!inviteToken && inviteInfo.isLoading) {
|
||||
// 如果没有令牌但状态仍为加载中,重置状态
|
||||
setInviteInfo(prev => ({...prev, isLoading: false}));
|
||||
}
|
||||
// 添加inviteInfo.isLoading到依赖数组
|
||||
}, [inviteToken, registrationCompleted, verifyInviteToken, inviteInfo.isLoading]);
|
||||
|
||||
/**
|
||||
* 处理表单输入变化
|
||||
* @param e 输入事件
|
||||
*/
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormState(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 清除相应字段的错误
|
||||
if (errors[name as keyof FormErrors]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证表单输入
|
||||
* @returns 表单是否有效
|
||||
*/
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
// 验证用户名
|
||||
if (!formState.username.trim()) {
|
||||
newErrors.username = '请输入用户名';
|
||||
} else if (formState.username.length < 3) {
|
||||
newErrors.username = '用户名长度至少为3个字符';
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
if (!formState.email.trim()) {
|
||||
newErrors.email = '请输入电子邮箱';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.email)) {
|
||||
newErrors.email = '请输入有效的电子邮箱';
|
||||
}
|
||||
|
||||
// 验证手机号
|
||||
if (!formState.phone.trim()) {
|
||||
newErrors.phone = '请输入手机号码';
|
||||
} else if (!/^1\d{10}$/.test(formState.phone)) {
|
||||
newErrors.phone = '请输入有效的手机号码';
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (!formState.password) {
|
||||
newErrors.password = '请输入密码';
|
||||
} else if (formState.password.length < 6) {
|
||||
newErrors.password = '密码长度至少为6个字符';
|
||||
}
|
||||
|
||||
// 验证确认密码
|
||||
if (!formState.confirmPassword) {
|
||||
newErrors.confirmPassword = '请确认密码';
|
||||
} else if (formState.confirmPassword !== formState.password) {
|
||||
newErrors.confirmPassword = '两次输入的密码不一致';
|
||||
}
|
||||
|
||||
// 验证工作空间名称(仅在没有邀请令牌时需要)
|
||||
if (!inviteToken && !formState.workspaceName.trim()) {
|
||||
newErrors.workspaceName = '请输入工作空间名称';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理表单提交
|
||||
* @param e 表单提交事件
|
||||
*/
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 表单验证
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFormState(prev => ({ ...prev, isSubmitting: true }));
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
username: formState.username,
|
||||
email: formState.email,
|
||||
phone: formState.phone,
|
||||
password: formState.password,
|
||||
...(inviteToken
|
||||
? { inviteToken }
|
||||
: { workspaceName: formState.workspaceName })
|
||||
};
|
||||
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '注册失败');
|
||||
}
|
||||
|
||||
// 设置注册完成状态
|
||||
setRegistrationCompleted(true);
|
||||
|
||||
// 如果注册成功并使用了邀请令牌,从缓存中删除该令牌
|
||||
if (inviteToken) {
|
||||
verifiedTokenCache.delete(inviteToken);
|
||||
}
|
||||
|
||||
// 注册成功提示
|
||||
notification.success('注册成功!正在跳转到登录页面...');
|
||||
|
||||
// 使用setTimeout确保通知显示后再跳转
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// 首先尝试使用Next.js路由
|
||||
router.replace('/login');
|
||||
|
||||
// 如果在500ms内没有跳转成功,使用window.location强制跳转
|
||||
setTimeout(() => {
|
||||
if (document.location.pathname.includes('register')) {
|
||||
console.log('使用window.location强制跳转到登录页');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('路由跳转失败:', error);
|
||||
// 如果路由跳转失败,使用window.location作为备选方案
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}, 100); // 给予通知足够的时间显示
|
||||
|
||||
} catch (error: Error | unknown) {
|
||||
console.error('注册失败:', error);
|
||||
|
||||
setErrors({
|
||||
general: error instanceof Error ? error.message : '注册失败,请稍后再试'
|
||||
});
|
||||
notification.error('注册失败:' + (error instanceof Error ? error.message : '未知错误'));
|
||||
} finally {
|
||||
// 无论成功还是失败,都重置提交状态
|
||||
setFormState(prev => ({ ...prev, isSubmitting: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 如果正在加载邀请信息,显示加载状态
|
||||
if (inviteToken && inviteInfo.isLoading) {
|
||||
return (
|
||||
<div className="w-full flex items-center justify-center px-4 py-8">
|
||||
<div className="text-center">
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>验证邀请链接中...</h2>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-700 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果有邀请令牌但无效,显示错误信息
|
||||
if (inviteToken && !inviteInfo.isValid && !inviteInfo.isLoading) {
|
||||
// 显示自定义的错误消息,包括令牌已使用的情况
|
||||
const errorMessage = inviteInfo.used
|
||||
? `该邀请链接已于 ${new Date(inviteInfo.usedAt || Date.now()).toLocaleString()} 被${inviteInfo.usedBy || '其他用户'}使用`
|
||||
: (inviteInfo.error || '该邀请链接可能已过期或无效。请联系邀请人获取新的邀请链接。');
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center justify-center px-4 py-8">
|
||||
<div className="text-center max-w-md mx-auto p-6 bg-red-50 dark:bg-red-900/30 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isDarkMode ? 'text-red-300' : 'text-red-700'}`}>无效的邀请链接</h2>
|
||||
<p className={`mb-4 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
{errorMessage}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-4 inline-block px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-4 py-4">
|
||||
<div className="max-w-sm w-full mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{inviteToken ? '接受邀请并注册' : '注册新账号'}
|
||||
</h2>
|
||||
<p className={`mt-2 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
{inviteToken && inviteInfo.workspaceName
|
||||
? `您被邀请加入"${inviteInfo.workspaceName}"工作空间`
|
||||
: '创建您的账号和工作空间'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${isDarkMode ? 'glass-card-dark' : 'glass-card'} p-6 rounded-xl`}>
|
||||
{/* 通用错误提示 */}
|
||||
{errors.general && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-md p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
{errors.general}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 用户名 */}
|
||||
<div>
|
||||
<label htmlFor="username" className={`block text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
|
||||
用户名
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={formState.username}
|
||||
onChange={handleInputChange}
|
||||
className={`block w-full px-3 py-2 border ${
|
||||
errors.username
|
||||
? 'border-red-300 dark:border-red-700 text-red-900 dark:text-red-300 placeholder-red-300 dark:placeholder-red-600'
|
||||
: isDarkMode
|
||||
? 'border-gray-700 bg-gray-800/40 text-white placeholder-gray-400'
|
||||
: 'border-gray-300 text-gray-900 placeholder-gray-400'
|
||||
} rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500`}
|
||||
placeholder="输入您的用户名"
|
||||
/>
|
||||
{errors.username && (
|
||||
<div className={`mt-1 text-xs text-red-600 dark:text-red-400`}>
|
||||
{errors.username}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 电子邮箱 */}
|
||||
<div>
|
||||
<label htmlFor="email" className={`block text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
|
||||
电子邮箱
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formState.email}
|
||||
onChange={handleInputChange}
|
||||
className={`block w-full px-3 py-2 border ${
|
||||
errors.email
|
||||
? 'border-red-300 dark:border-red-700 text-red-900 dark:text-red-300 placeholder-red-300 dark:placeholder-red-600'
|
||||
: isDarkMode
|
||||
? 'border-gray-700 bg-gray-800/40 text-white placeholder-gray-400'
|
||||
: 'border-gray-300 text-gray-900 placeholder-gray-400'
|
||||
} rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500`}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<div className={`mt-1 text-xs text-red-600 dark:text-red-400`}>
|
||||
{errors.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手机号码 */}
|
||||
<div>
|
||||
<label htmlFor="phone" className={`block text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
|
||||
手机号码
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
required
|
||||
value={formState.phone}
|
||||
onChange={handleInputChange}
|
||||
className={`block w-full px-3 py-2 border ${
|
||||
errors.phone
|
||||
? 'border-red-300 dark:border-red-700 text-red-900 dark:text-red-300 placeholder-red-300 dark:placeholder-red-600'
|
||||
: isDarkMode
|
||||
? 'border-gray-700 bg-gray-800/40 text-white placeholder-gray-400'
|
||||
: 'border-gray-300 text-gray-900 placeholder-gray-400'
|
||||
} rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500`}
|
||||
placeholder="13800138000"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<div className={`mt-1 text-xs text-red-600 dark:text-red-400`}>
|
||||
{errors.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div>
|
||||
<label htmlFor="password" className={`block text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
|
||||
密码
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formState.password}
|
||||
onChange={handleInputChange}
|
||||
className={`block w-full px-3 py-2 border ${
|
||||
errors.password
|
||||
? 'border-red-300 dark:border-red-700 text-red-900 dark:text-red-300 placeholder-red-300 dark:placeholder-red-600'
|
||||
: isDarkMode
|
||||
? 'border-gray-700 bg-gray-800/40 text-white placeholder-gray-400'
|
||||
: 'border-gray-300 text-gray-900 placeholder-gray-400'
|
||||
} rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500`}
|
||||
placeholder="********"
|
||||
/>
|
||||
{errors.password && (
|
||||
<div className={`mt-1 text-xs text-red-600 dark:text-red-400`}>
|
||||
{errors.password}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 确认密码 */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className={`block text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
|
||||
确认密码
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formState.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={`block w-full px-3 py-2 border ${
|
||||
errors.confirmPassword
|
||||
? 'border-red-300 dark:border-red-700 text-red-900 dark:text-red-300 placeholder-red-300 dark:placeholder-red-600'
|
||||
: isDarkMode
|
||||
? 'border-gray-700 bg-gray-800/40 text-white placeholder-gray-400'
|
||||
: 'border-gray-300 text-gray-900 placeholder-gray-400'
|
||||
} rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500`}
|
||||
placeholder="********"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<div className={`mt-1 text-xs text-red-600 dark:text-red-400`}>
|
||||
{errors.confirmPassword}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工作空间名称(仅当没有邀请令牌时显示) */}
|
||||
{!inviteToken && (
|
||||
<div>
|
||||
<label htmlFor="workspaceName" className={`block text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
|
||||
工作空间名称
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="workspaceName"
|
||||
name="workspaceName"
|
||||
type="text"
|
||||
required
|
||||
value={formState.workspaceName}
|
||||
onChange={handleInputChange}
|
||||
className={`block w-full px-3 py-2 border ${
|
||||
errors.workspaceName
|
||||
? 'border-red-300 dark:border-red-700 text-red-900 dark:text-red-300 placeholder-red-300 dark:placeholder-red-600'
|
||||
: isDarkMode
|
||||
? 'border-gray-700 bg-gray-800/40 text-white placeholder-gray-400'
|
||||
: 'border-gray-300 text-gray-900 placeholder-gray-400'
|
||||
} rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500`}
|
||||
placeholder="您的团队或公司名称"
|
||||
/>
|
||||
{errors.workspaceName && (
|
||||
<div className={`mt-1 text-xs text-red-600 dark:text-red-400`}>
|
||||
{errors.workspaceName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示邀请的工作空间信息(如果有) */}
|
||||
{inviteToken && inviteInfo.isValid && (
|
||||
<div className={`p-4 rounded-md ${isDarkMode ? 'bg-blue-900/30 border border-blue-800' : 'bg-blue-50 border border-blue-200'}`}>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||
您将加入工作空间: <span className="font-semibold">{inviteInfo.workspaceName}</span>
|
||||
</p>
|
||||
{inviteInfo.inviterName && (
|
||||
<p className={`text-sm mt-1 ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
邀请人: {inviteInfo.inviterName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting}
|
||||
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none ${
|
||||
formState.isSubmitting ? 'opacity-70 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{formState.isSubmitting ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
inviteToken ? '接受邀请并注册' : '注册'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 登录链接 */}
|
||||
<div className="text-center mt-4">
|
||||
<p className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
已有账号?{' '}
|
||||
<Link href="/login" className={`font-medium ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-500'}`}>
|
||||
去登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/app/api/auth/login/route.ts
Normal file
138
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 登录API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 处理用户登录验证并返回JWT令牌
|
||||
* 版本: 1.1
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
import { SystemUserModel, WorkspaceModel } from '@/models/system';
|
||||
import { AuthUtils } from '@/lib/auth';
|
||||
import { UserStatus } from '@/models/system/types';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* 处理登录请求
|
||||
* @param req 带数据库连接的请求对象
|
||||
*/
|
||||
async function handler(req: RequestWithDB) {
|
||||
try {
|
||||
// 解析请求体
|
||||
const body = await req.json();
|
||||
const { username, password } = body;
|
||||
|
||||
// 验证请求数据
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '用户名和密码不能为空'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
const user = await SystemUserModel.getByUsername(username);
|
||||
|
||||
// 用户不存在
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '用户名或密码错误'
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (!user.password) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '用户密码数据异常'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '用户名或密码错误'
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查用户状态是否正常
|
||||
if (user.status !== UserStatus.ENABLED) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '账户已被禁用,请联系管理员'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取工作空间信息
|
||||
const workspace = await WorkspaceModel.getById(user.workspace_id);
|
||||
if (!workspace) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '用户工作空间不存在或已被删除'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 更新最后登录时间
|
||||
await SystemUserModel.updateLastLogin(user.id);
|
||||
|
||||
// 创建用户信息对象(排除敏感字段)
|
||||
const userInfo = AuthUtils.sanitizeUser(user);
|
||||
|
||||
// 补充工作空间信息
|
||||
userInfo.workspace.name = workspace.name;
|
||||
userInfo.workspace.status = workspace.status;
|
||||
|
||||
// 生成JWT访问令牌
|
||||
const accessToken = await AuthUtils.createToken(user);
|
||||
|
||||
// 返回成功响应
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '登录成功',
|
||||
user: userInfo,
|
||||
accessToken
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('登录处理失败:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '登录处理过程中发生错误';
|
||||
|
||||
// 返回错误响应
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理POST请求
|
||||
* 符合Next.js 15.3+的路由处理器签名
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const wrappedHandler = connectSystemDB(handler);
|
||||
return wrappedHandler(request);
|
||||
}
|
||||
351
src/app/api/auth/register/route.ts
Normal file
351
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 用户注册API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 处理用户注册请求,支持普通注册和邀请注册
|
||||
* 版本: 3.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
import { SystemUserModel } from '@/models/system';
|
||||
import { WorkspaceStatus, UserStatus } from '@/models/system/types';
|
||||
|
||||
/**
|
||||
* MySQL查询结果接口
|
||||
*/
|
||||
interface MySQLRow {
|
||||
[key: string]: string | number | boolean | Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL插入结果接口
|
||||
*/
|
||||
interface MySQLInsertResult {
|
||||
insertId: number;
|
||||
affectedRows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 邀请信息接口
|
||||
*/
|
||||
interface InvitationInfo {
|
||||
id: number;
|
||||
token: string;
|
||||
workspace_id: number;
|
||||
role?: string;
|
||||
role_type?: string;
|
||||
role_name?: string;
|
||||
is_custom_role?: boolean;
|
||||
workspace_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库错误接口,扩展了Error接口添加了code属性
|
||||
* MySQL错误会包含错误码
|
||||
*/
|
||||
interface DbError extends Error {
|
||||
code?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户注册请求
|
||||
* @param req 带数据库连接的请求对象
|
||||
*/
|
||||
async function handler(req: RequestWithDB) {
|
||||
try {
|
||||
// 解析请求体
|
||||
const body = await req.json();
|
||||
const { username, email, phone, password, workspaceName, inviteToken } = body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!username || !email || !phone || !password) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '用户名、邮箱、手机号和密码为必填字段'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有提供邀请令牌,则需要工作空间名称
|
||||
if (!inviteToken && !workspaceName) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '工作空间名称为必填字段'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUserByUsername = await SystemUserModel.getByUsername(username);
|
||||
if (existingUserByUsername) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '用户名已存在'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
const existingUserByEmail = await SystemUserModel.getByEmail(email);
|
||||
if (existingUserByEmail) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '邮箱已被注册'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查手机号是否已存在
|
||||
const [existingPhones] = await req.db.query(
|
||||
'SELECT id FROM system_users WHERE phone = ?',
|
||||
[phone]
|
||||
);
|
||||
|
||||
if (existingPhones && (existingPhones as MySQLRow[]).length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '手机号已被注册'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 对密码进行加密
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(password, salt);
|
||||
|
||||
// 开始注册流程,使用事务确保数据一致性
|
||||
try {
|
||||
// 开始事务
|
||||
await req.db.beginTransaction();
|
||||
|
||||
// 初始化变量
|
||||
let workspaceId: number | null = null;
|
||||
let roleType = "user"; // 默认角色类型
|
||||
let roleName = "普通用户"; // 默认角色名称
|
||||
let isCustomRole = false; // 默认不是自定义角色
|
||||
let invitationId: number | null = null;
|
||||
let returnWorkspaceName = workspaceName || '';
|
||||
const isInviteFlow = Boolean(inviteToken && inviteToken.trim() !== '');
|
||||
|
||||
if (isInviteFlow) {
|
||||
// 查询邀请信息
|
||||
const [rows] = await req.db.query(
|
||||
`SELECT
|
||||
i.id,
|
||||
i.token,
|
||||
i.workspace_id,
|
||||
i.role,
|
||||
i.role_type,
|
||||
i.role_name,
|
||||
i.is_custom_role,
|
||||
w.name as workspace_name
|
||||
FROM workspace_invitations i
|
||||
JOIN workspaces w ON i.workspace_id = w.id
|
||||
WHERE i.token = ? AND i.used_by IS NULL AND i.expires_at > NOW()`,
|
||||
[inviteToken]
|
||||
);
|
||||
|
||||
// 检查是否有结果
|
||||
if (!rows || !(rows as MySQLRow[]).length) {
|
||||
await req.db.rollback();
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '无效的邀请令牌或已过期'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取原始邀请记录
|
||||
const invitation = (rows as InvitationInfo[])[0];
|
||||
|
||||
// 提取邀请信息
|
||||
invitationId = Number(invitation.id);
|
||||
workspaceId = Number(invitation.workspace_id);
|
||||
returnWorkspaceName = String(invitation.workspace_name);
|
||||
|
||||
// 获取角色信息
|
||||
if (invitation.role_type !== undefined) {
|
||||
// 使用新角色系统
|
||||
roleType = String(invitation.role_type || 'user');
|
||||
roleName = String(invitation.role_name || '普通用户');
|
||||
isCustomRole = Boolean(invitation.is_custom_role || false);
|
||||
} else {
|
||||
// 兼容旧角色系统
|
||||
roleType = String(invitation.role || 'user');
|
||||
roleName = roleType === 'admin' ? '管理员' : '普通用户';
|
||||
isCustomRole = false;
|
||||
}
|
||||
|
||||
// 验证工作空间ID
|
||||
if (!workspaceId || isNaN(workspaceId)) {
|
||||
await req.db.rollback();
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '邀请中的工作空间ID无效'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 普通注册流程,创建新工作空间
|
||||
const [result] = await req.db.query(
|
||||
`INSERT INTO workspaces (name, creator_id, status, created_at) VALUES (?, ?, ?, ?)`,
|
||||
[workspaceName, 0, WorkspaceStatus.ACTIVE, new Date()]
|
||||
);
|
||||
|
||||
// 从插入结果中获取ID
|
||||
workspaceId = (result as MySQLInsertResult).insertId;
|
||||
|
||||
// 管理员角色
|
||||
roleType = "admin";
|
||||
roleName = "管理员";
|
||||
isCustomRole = false;
|
||||
|
||||
if (!workspaceId) {
|
||||
await req.db.rollback();
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '创建工作空间失败'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 最终检查工作空间ID
|
||||
if (workspaceId === null || isNaN(workspaceId) || workspaceId === 0) {
|
||||
await req.db.rollback();
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '无法确定工作空间ID'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 创建用户并关联到工作空间
|
||||
const [userResult] = await req.db.query(
|
||||
`INSERT INTO system_users
|
||||
(username, password, email, phone, workspace_id, role_type, role_name, is_custom_role, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
username,
|
||||
hashedPassword,
|
||||
email,
|
||||
phone,
|
||||
workspaceId,
|
||||
roleType,
|
||||
roleName,
|
||||
isCustomRole,
|
||||
UserStatus.ENABLED,
|
||||
new Date()
|
||||
]
|
||||
);
|
||||
|
||||
const userId = (userResult as MySQLInsertResult).insertId;
|
||||
|
||||
if (!userId) {
|
||||
await req.db.rollback();
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '创建用户失败'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是普通注册(创建了新工作空间),更新工作空间的创建者ID
|
||||
if (!isInviteFlow) {
|
||||
await req.db.query(
|
||||
`UPDATE workspaces SET creator_id = ?, updated_at = ? WHERE id = ?`,
|
||||
[userId, new Date(), workspaceId]
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是邀请注册,更新邀请记录
|
||||
if (isInviteFlow && invitationId !== null) {
|
||||
await req.db.query(
|
||||
`UPDATE workspace_invitations SET used_by = ?, used_at = NOW() WHERE id = ?`,
|
||||
[userId, invitationId]
|
||||
);
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
await req.db.commit();
|
||||
|
||||
// 返回注册成功响应
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: '注册成功',
|
||||
user: { id: userId, username, email },
|
||||
workspace: { id: workspaceId, name: returnWorkspaceName }
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
// 回滚事务
|
||||
await req.db.rollback();
|
||||
throw error; // 重新抛出错误以便外层catch处理
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('注册失败:', error);
|
||||
|
||||
let statusCode = 500;
|
||||
let errorMessage = '注册失败,请稍后再试';
|
||||
|
||||
// 处理特定类型的错误
|
||||
const dbError = error as DbError;
|
||||
if (dbError.code === 'ER_DUP_ENTRY') {
|
||||
statusCode = 409;
|
||||
errorMessage = '用户名、邮箱或手机号已存在';
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage
|
||||
},
|
||||
{ status: statusCode }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户注册请求
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
return await connectSystemDB(handler)(request);
|
||||
} catch (error) {
|
||||
console.error('注册请求处理失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '处理注册请求失败'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
142
src/app/api/auth/verify-invite/route.ts
Normal file
142
src/app/api/auth/verify-invite/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 邀请令牌验证API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 验证邀请令牌的有效性并返回相关信息
|
||||
* 版本: 1.3
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 验证邀请令牌
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取令牌参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const token = searchParams.get('token');
|
||||
|
||||
// 验证参数
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '缺少令牌参数'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('验证邀请令牌:', token);
|
||||
|
||||
// 使用连接数据库的模式
|
||||
const handler = async (req: RequestWithDB) => {
|
||||
// 先查询是否是已使用的令牌
|
||||
const [usedRows] = await req.db.query(
|
||||
`SELECT i.id, u.username, i.used_at
|
||||
FROM workspace_invitations i
|
||||
LEFT JOIN system_users u ON i.used_by = u.id
|
||||
WHERE i.token = ? AND i.used_by IS NOT NULL`,
|
||||
[token]
|
||||
);
|
||||
|
||||
// 转换为纯JavaScript对象
|
||||
const usedInvitations = JSON.parse(JSON.stringify(usedRows));
|
||||
|
||||
// 如果令牌已被使用,返回更明确的信息
|
||||
if (usedInvitations && usedInvitations.length > 0) {
|
||||
const usedInvitation = usedInvitations[0];
|
||||
console.log('邀请令牌已被使用:', usedInvitation);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '该邀请链接已被使用',
|
||||
used: true,
|
||||
usedBy: usedInvitation.username || '其他用户',
|
||||
usedAt: usedInvitation.used_at
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 查询令牌
|
||||
const [rows] = await req.db.query(
|
||||
`SELECT i.*, w.name as workspace_name, u.username as inviter_name
|
||||
FROM workspace_invitations i
|
||||
JOIN workspaces w ON i.workspace_id = w.id
|
||||
JOIN system_users u ON i.created_by = u.id
|
||||
WHERE i.token = ? AND i.used_by IS NULL AND i.expires_at > NOW()`,
|
||||
[token]
|
||||
);
|
||||
|
||||
// 转换为纯JavaScript对象
|
||||
const invitations = JSON.parse(JSON.stringify(rows));
|
||||
|
||||
// 打印查询结果,用于调试
|
||||
console.log('邀请记录查询结果:', JSON.stringify(invitations && invitations.length > 0 ? invitations[0] : {}, null, 2));
|
||||
|
||||
// 如果未找到有效令牌
|
||||
if (!invitations || invitations.length === 0) {
|
||||
console.log('未找到有效的邀请令牌');
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '无效的邀请令牌或已过期'
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const invitation = invitations[0];
|
||||
console.log('找到有效的邀请令牌:', invitation.id);
|
||||
|
||||
// 检查是否有新的角色字段
|
||||
const hasNewRoleFields = invitation.hasOwnProperty('role_type') &&
|
||||
invitation.hasOwnProperty('role_name') &&
|
||||
invitation.hasOwnProperty('is_custom_role');
|
||||
|
||||
// 确保字段名称统一
|
||||
const responseData = {
|
||||
success: true,
|
||||
valid: true,
|
||||
workspaceId: invitation.workspace_id,
|
||||
workspaceName: invitation.workspace_name,
|
||||
inviterId: invitation.created_by,
|
||||
inviterName: invitation.inviter_name,
|
||||
role: invitation.role,
|
||||
// 新角色系统字段
|
||||
role_type: hasNewRoleFields ? invitation.role_type : invitation.role,
|
||||
role_name: hasNewRoleFields ? invitation.role_name : (invitation.role === 'admin' ? '管理员' : '普通用户'),
|
||||
is_custom_role: hasNewRoleFields ? invitation.is_custom_role : false,
|
||||
expiresAt: invitation.expires_at,
|
||||
// 增加额外的字段格式,保证前端能够正确读取
|
||||
workspace_id: invitation.workspace_id,
|
||||
workspace_name: invitation.workspace_name,
|
||||
created_by: invitation.created_by,
|
||||
inviter_name: invitation.inviter_name
|
||||
};
|
||||
|
||||
console.log('响应数据:', responseData);
|
||||
|
||||
// 返回邀请相关信息
|
||||
return NextResponse.json(responseData);
|
||||
};
|
||||
|
||||
// 执行处理函数
|
||||
const wrappedHandler = connectSystemDB(handler);
|
||||
return await wrappedHandler(request);
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error('验证邀请令牌失败:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '验证邀请令牌失败: ' + (error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
391
src/app/api/cron/init/route.ts
Normal file
391
src/app/api/cron/init/route.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 物流定时任务初始化API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 初始化物流查询定时任务
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import * as nodeCron from 'node-cron';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { querySFExpress } from '@/utils/querySFExpress';
|
||||
import { RowDataPacket, Connection } from 'mysql2/promise';
|
||||
|
||||
// 存储正在运行的cron任务
|
||||
const runningCronTasks: Record<string, nodeCron.ScheduledTask> = {};
|
||||
|
||||
// 存储任务状态
|
||||
const cronTaskStatus: Record<string, {
|
||||
lastRunTime: string | null;
|
||||
nextScheduledTime: string | null;
|
||||
currentStatus: 'idle' | 'running' | 'completed' | 'failed';
|
||||
runCount: number;
|
||||
}> = {};
|
||||
|
||||
/**
|
||||
* 查询需要更新的物流记录
|
||||
*/
|
||||
async function fetchPendingLogistics(db: Connection, teamCode: string) {
|
||||
try {
|
||||
// 查询满足条件的物流记录:顺丰快递、可查询的、状态不是已签收
|
||||
const [records] = await db.query(`
|
||||
SELECT
|
||||
id,
|
||||
tracking_number,
|
||||
customer_tail_number,
|
||||
company,
|
||||
status
|
||||
FROM
|
||||
logistics_records
|
||||
WHERE
|
||||
company = '顺丰速运'
|
||||
AND is_queryable = 1
|
||||
AND (status IS NULL OR status != '已签收')
|
||||
LIMIT 100
|
||||
`) as [RowDataPacket[], unknown];
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error(`[${teamCode}] 查询待更新物流记录失败:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新物流记录状态
|
||||
*/
|
||||
async function updateLogisticsStatus(db: Connection, record: Record<string, unknown>, sfResponse: Record<string, unknown>, teamCode: string) {
|
||||
try {
|
||||
// 解析API返回的物流信息
|
||||
const resultData = typeof sfResponse.apiResultData === 'string'
|
||||
? JSON.parse(sfResponse.apiResultData)
|
||||
: sfResponse.apiResultData;
|
||||
|
||||
// 获取路由信息(兼容新旧格式)
|
||||
const routeInfo = resultData?.msgData?.routeResps?.[0] || {};
|
||||
|
||||
// 提取状态信息
|
||||
let status = null;
|
||||
let details = null;
|
||||
|
||||
// 处理有路由数据的情况
|
||||
if (routeInfo.routes && Array.isArray(routeInfo.routes) && routeInfo.routes.length > 0) {
|
||||
// 保存完整的物流详情
|
||||
details = JSON.stringify(routeInfo);
|
||||
|
||||
// 分析最新的物流状态
|
||||
const latestRoute = routeInfo.routes[0];
|
||||
const content = latestRoute.remark || latestRoute.content || '';
|
||||
|
||||
// 根据描述内容识别状态
|
||||
if (content.includes('已签收') || content.includes('签收')) {
|
||||
status = '已签收';
|
||||
} else if (content.includes('派送') || content.includes('派件')) {
|
||||
status = '派送中';
|
||||
} else if (content.includes('运输') || content.includes('发往')) {
|
||||
status = '运输中';
|
||||
} else if (content.includes('已揽收') || content.includes('揽件') || content.includes('已收取')) {
|
||||
status = '已揽件';
|
||||
} else {
|
||||
status = '已填单';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新数据库记录
|
||||
if (status || details) {
|
||||
console.log(`[${teamCode}] 更新物流状态: ID=${record.id}, 状态=${status}`);
|
||||
|
||||
const updateFields = [];
|
||||
const updateValues = [];
|
||||
|
||||
if (status) {
|
||||
updateFields.push('status = ?');
|
||||
updateValues.push(status);
|
||||
}
|
||||
|
||||
if (details) {
|
||||
updateFields.push('details = ?');
|
||||
updateValues.push(details);
|
||||
}
|
||||
|
||||
if (updateFields.length > 0) {
|
||||
updateFields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE logistics_records
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await db.query(updateQuery, [...updateValues, record.id]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`[${teamCode}] 更新物流记录状态失败:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行物流记录更新
|
||||
*/
|
||||
async function executeUpdate(teamCode: string) {
|
||||
console.log(`[${teamCode}] 开始自动查询物流记录...`);
|
||||
|
||||
// 更新状态
|
||||
cronTaskStatus[teamCode] = {
|
||||
...cronTaskStatus[teamCode] || {},
|
||||
currentStatus: 'running',
|
||||
lastRunTime: new Date().toISOString()
|
||||
};
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
try {
|
||||
// 使用connectTeamDB创建带连接的请求
|
||||
// 创建正确的请求对象,确保包含团队标识
|
||||
const url = new URL(`http://localhost/api/cron/init`);
|
||||
url.searchParams.set('teamId', teamCode);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('x-team-id', teamCode);
|
||||
|
||||
const request = new NextRequest(url, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
return await connectTeamDB(async (req: RequestWithDB) => {
|
||||
const db = req.db;
|
||||
|
||||
// 查询需要更新的物流记录
|
||||
const records = await fetchPendingLogistics(db, teamCode);
|
||||
console.log(`[${teamCode}] 找到 ${records.length} 条需更新的物流记录`);
|
||||
|
||||
// 逐个更新物流记录
|
||||
for (const record of records) {
|
||||
try {
|
||||
const trackingNumber = record.tracking_number;
|
||||
const phoneLast4Digits = record.customer_tail_number;
|
||||
|
||||
// 跳过无效记录
|
||||
if (!trackingNumber || !phoneLast4Digits) {
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 调用顺丰API查询物流
|
||||
console.log(`[${teamCode}] 查询物流: ${trackingNumber}, 手机尾号: ${phoneLast4Digits}`);
|
||||
const sfResponse = await querySFExpress(trackingNumber, phoneLast4Digits);
|
||||
|
||||
// 处理API错误
|
||||
if (sfResponse.apiResultCode !== '0000' && sfResponse.apiResultCode !== 'S0000') {
|
||||
console.error(`[${teamCode}] 物流API错误: ${sfResponse.apiErrorMsg}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新物流状态
|
||||
const updated = await updateLogisticsStatus(db, record, sfResponse, teamCode);
|
||||
if (updated) {
|
||||
successCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${teamCode}] 处理物流记录失败:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${teamCode}] 物流记录更新完成: 成功=${successCount}, 失败=${errorCount}`);
|
||||
|
||||
// 更新任务状态
|
||||
cronTaskStatus[teamCode] = {
|
||||
...cronTaskStatus[teamCode],
|
||||
currentStatus: 'completed',
|
||||
runCount: (cronTaskStatus[teamCode]?.runCount || 0) + 1
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '物流记录查询完成',
|
||||
results: {
|
||||
successCount,
|
||||
errorCount,
|
||||
totalProcessed: records.length
|
||||
}
|
||||
});
|
||||
})(request);
|
||||
} catch (error) {
|
||||
console.error(`[${teamCode}] 执行物流更新失败:`, error);
|
||||
|
||||
// 更新任务状态为失败
|
||||
cronTaskStatus[teamCode] = {
|
||||
...cronTaskStatus[teamCode],
|
||||
currentStatus: 'failed'
|
||||
};
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '执行物流更新失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定时更新任务
|
||||
*/
|
||||
function startCronTask(teamCode: string) {
|
||||
// 检查是否已存在任务
|
||||
if (runningCronTasks[teamCode]) {
|
||||
console.log(`[${teamCode}] 物流定时任务已在运行中`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[${teamCode}] 启动物流定时任务`);
|
||||
|
||||
try {
|
||||
// 创建定时任务 - 每2小时执行一次
|
||||
runningCronTasks[teamCode] = nodeCron.schedule('0 */2 * * *', async () => {
|
||||
await executeUpdate(teamCode);
|
||||
});
|
||||
|
||||
// 计算下次执行时间
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const nextHour = currentHour + (currentHour % 2 === 0 ? 2 : 1);
|
||||
const nextRunDate = new Date(now);
|
||||
nextRunDate.setHours(nextHour, 0, 0, 0);
|
||||
|
||||
// 初始化任务状态
|
||||
cronTaskStatus[teamCode] = {
|
||||
lastRunTime: null,
|
||||
nextScheduledTime: nextRunDate.toISOString(),
|
||||
currentStatus: 'idle',
|
||||
runCount: 0
|
||||
};
|
||||
|
||||
console.log(`[${teamCode}] 物流定时任务已启动,下次执行时间: ${nextRunDate.toISOString()}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[${teamCode}] 启动物流定时任务失败:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 路由处理函数 - 获取任务状态或初始化任务
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从查询参数中获取团队代码
|
||||
const { searchParams } = new URL(request.url);
|
||||
const teamCode = searchParams.get('teamCode');
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少团队代码参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 将teamCode添加到请求头和查询参数,用于数据库连接
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('x-team-id', teamCode);
|
||||
|
||||
// 克隆请求并添加团队标识
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.set('teamId', teamCode);
|
||||
|
||||
// 检查是否需要初始化
|
||||
const shouldInit = searchParams.get('init') === 'true';
|
||||
const runNow = searchParams.get('runNow') === 'true';
|
||||
|
||||
// 根据参数决定启动还是获取状态
|
||||
if (shouldInit) {
|
||||
const initialized = startCronTask(teamCode);
|
||||
|
||||
if (initialized) {
|
||||
// 如果需要立即执行一次
|
||||
if (runNow) {
|
||||
// 直接使用executeUpdate函数
|
||||
await executeUpdate(teamCode);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '物流定时任务已初始化',
|
||||
status: cronTaskStatus[teamCode]
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '物流定时任务已在运行中',
|
||||
status: cronTaskStatus[teamCode]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 返回任务状态
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskExists: !!runningCronTasks[teamCode],
|
||||
status: cronTaskStatus[teamCode] || {
|
||||
lastRunTime: null,
|
||||
nextScheduledTime: null,
|
||||
currentStatus: 'idle',
|
||||
runCount: 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('物流定时任务初始化/查询失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '物流定时任务初始化/查询失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 路由处理函数 - 手动触发更新
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从查询参数中获取团队代码
|
||||
const { searchParams } = new URL(request.url);
|
||||
const teamCode = searchParams.get('teamCode');
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少团队代码参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 将teamCode添加到请求头和查询参数,用于数据库连接
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('x-team-id', teamCode);
|
||||
|
||||
// 克隆请求并添加团队标识
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.set('teamId', teamCode);
|
||||
|
||||
// 确保任务已初始化
|
||||
if (!runningCronTasks[teamCode]) {
|
||||
startCronTask(teamCode);
|
||||
}
|
||||
|
||||
// 立即执行更新
|
||||
return await executeUpdate(teamCode);
|
||||
|
||||
} catch (error) {
|
||||
console.error('手动触发物流更新失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '手动触发物流更新失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
287
src/app/api/team/[teamCode]/brands/[id]/route.ts
Normal file
287
src/app/api/team/[teamCode]/brands/[id]/route.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 品牌详情API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供单个品牌数据的获取、更新和删除API
|
||||
* 版本: 2.1.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { connectTeamDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
// 不复制body,因为它可能已被读取
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从URL路径中提取参数
|
||||
* @param req 请求对象
|
||||
* @returns 提取的参数对象
|
||||
*/
|
||||
const extractParamsFromPath = (req: NextRequest): { teamCode: string; id: string } => {
|
||||
// 路径格式: /api/team/[teamCode]/brands/[id]
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3,品牌ID位于索引5
|
||||
return {
|
||||
teamCode: pathParts[3] || '',
|
||||
id: pathParts[5] || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个品牌详情
|
||||
* GET /api/team/[teamCode]/brands/[id]
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
// 验证参数
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的品牌ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理函数
|
||||
const handler = async (dbReq: RequestWithDB) => {
|
||||
// 执行查询
|
||||
const [brands] = await dbReq.db.query(
|
||||
'SELECT id, `order`, name, description, created_at, updated_at FROM brands WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
// 检查是否找到品牌
|
||||
if (!Array.isArray(brands) || brands.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '品牌不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
brand: brands[0] || null
|
||||
});
|
||||
};
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return await connectTeamDB(handler)(teamReq);
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取品牌详情失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新品牌信息
|
||||
* PUT /api/team/[teamCode]/brands/[id]
|
||||
*/
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
// 验证参数
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的品牌ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取请求体数据
|
||||
let body;
|
||||
try {
|
||||
// 克隆请求,确保body只被读取一次
|
||||
const clonedReq = req.clone();
|
||||
body = await clonedReq.json();
|
||||
|
||||
// 验证必填字段
|
||||
if (!body.name || body.name.trim() === '') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '品牌名称不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析请求数据失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的请求数据' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理函数
|
||||
const handler = async (dbReq: RequestWithDB) => {
|
||||
// 检查品牌是否存在
|
||||
const [existingBrands] = await dbReq.db.query(
|
||||
'SELECT id FROM brands WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingBrands) || existingBrands.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '品牌不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 准备更新数据
|
||||
const brandData = {
|
||||
name: body.name.trim(),
|
||||
order: typeof body.order === 'number' ? body.order : 0,
|
||||
description: body.description ? body.description.trim() : null
|
||||
};
|
||||
|
||||
// 更新数据
|
||||
await dbReq.db.query(
|
||||
'UPDATE brands SET `order` = ?, name = ?, description = ? WHERE id = ?',
|
||||
[brandData.order, brandData.name, brandData.description, id]
|
||||
);
|
||||
|
||||
// 获取更新后的品牌数据
|
||||
const [updatedBrands] = await dbReq.db.query(
|
||||
'SELECT id, `order`, name, description, created_at, updated_at FROM brands WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
brand: Array.isArray(updatedBrands) && updatedBrands.length > 0 ? updatedBrands[0] : null,
|
||||
message: '品牌更新成功'
|
||||
});
|
||||
};
|
||||
|
||||
// 添加团队信息到请求(不包含body)
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return await connectTeamDB(handler)(teamReq);
|
||||
|
||||
} catch (error) {
|
||||
console.error('更新品牌失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除品牌
|
||||
* DELETE /api/team/[teamCode]/brands/[id]
|
||||
*/
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
// 验证参数
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的品牌ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理函数
|
||||
const handler = async (dbReq: RequestWithDB) => {
|
||||
// 检查品牌是否存在
|
||||
const [existingBrands] = await dbReq.db.query(
|
||||
'SELECT id FROM brands WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingBrands) || existingBrands.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '品牌不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 删除品牌
|
||||
await dbReq.db.query('DELETE FROM brands WHERE id = ?', [id]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '品牌删除成功'
|
||||
});
|
||||
};
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return await connectTeamDB(handler)(teamReq);
|
||||
|
||||
} catch (error) {
|
||||
console.error('删除品牌失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
251
src/app/api/team/[teamCode]/brands/route.ts
Normal file
251
src/app/api/team/[teamCode]/brands/route.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 品牌API接口
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供品牌数据的查询和创建接口
|
||||
* 版本: 2.3.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* GET 获取品牌列表
|
||||
*/
|
||||
const getBrands = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
|
||||
const keyword = url.searchParams.get('keyword') || '';
|
||||
|
||||
// 计算偏移量
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]品牌列表, 页码:${page}, 每页:${pageSize}`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams = [];
|
||||
|
||||
if (keyword) {
|
||||
conditions.push('(name LIKE ? OR description LIKE ?)');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM brands ${whereClause}`;
|
||||
const [totalRows] = await connection.query(countSql, queryParams);
|
||||
|
||||
const total = (totalRows as Array<{ total: number }>)[0].total;
|
||||
|
||||
// 查询分页数据
|
||||
const querySql = `
|
||||
SELECT
|
||||
id, \`order\`, name, description,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM brands ${whereClause}
|
||||
ORDER BY \`order\` ASC, id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
// 添加分页参数
|
||||
const paginatedParams = [...queryParams, pageSize, offset];
|
||||
|
||||
const [rows] = await connection.query(querySql, paginatedParams);
|
||||
|
||||
console.log(`查询团队[${teamCode}]品牌列表成功, 总数:${total}`);
|
||||
|
||||
return NextResponse.json({
|
||||
total,
|
||||
brands: Array.isArray(rows) ? rows : []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]品牌列表失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取品牌列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST 创建品牌
|
||||
*/
|
||||
const createBrand = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取请求数据
|
||||
const data = await req.json();
|
||||
|
||||
// 验证必填字段
|
||||
if (!data.name) {
|
||||
return NextResponse.json({ error: '品牌名称为必填字段' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始创建团队[${teamCode}]品牌, 名称:${data.name}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查品牌名称是否已存在
|
||||
const [existingBrands] = await connection.query(
|
||||
'SELECT id FROM brands WHERE name = ?',
|
||||
[data.name]
|
||||
);
|
||||
|
||||
if (Array.isArray(existingBrands) && existingBrands.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json({ error: '该品牌名称已存在' }, { status: 409 });
|
||||
}
|
||||
|
||||
// 插入品牌记录
|
||||
const insertSql = `
|
||||
INSERT INTO brands (
|
||||
\`order\`, name, description, created_at, updated_at
|
||||
) VALUES (?, ?, ?, NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await connection.query(insertSql, [
|
||||
data.order || 0,
|
||||
data.name,
|
||||
data.description || null
|
||||
]);
|
||||
|
||||
const insertId = (result as { insertId: number }).insertId;
|
||||
|
||||
// 查询新插入的品牌信息
|
||||
const [newBrandRows] = await connection.query(
|
||||
`SELECT
|
||||
id, \`order\`, name, description,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM brands WHERE id = ?`,
|
||||
[insertId]
|
||||
);
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`创建团队[${teamCode}]品牌成功, ID:${insertId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
message: '品牌创建成功',
|
||||
brand: Array.isArray(newBrandRows) && newBrandRows.length > 0 ? newBrandRows[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`创建团队[${teamCode}]品牌失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ error: '创建品牌失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/brands
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引2的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
// 导出处理函数,使用单参数路由处理器
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getBrands)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理品牌列表请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(createBrand)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理品牌创建请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
371
src/app/api/team/[teamCode]/categories/[id]/route.ts
Normal file
371
src/app/api/team/[teamCode]/categories/[id]/route.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 单个品类API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供单个品类的查询、更新和删除接口
|
||||
* 版本: 2.3.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从URL路径中提取参数
|
||||
* @param req 请求对象
|
||||
* @returns 提取的参数对象
|
||||
*/
|
||||
const extractParamsFromPath = (req: NextRequest): { teamCode: string; id: string } => {
|
||||
// 路径格式: /api/team/[teamCode]/categories/[id]
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3,品类ID位于索引5
|
||||
return {
|
||||
teamCode: pathParts[3] || '',
|
||||
id: pathParts[5] || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个品类
|
||||
*/
|
||||
const getCategory = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的品类ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]品类, ID:${id}`);
|
||||
|
||||
const [rows] = await req.db.query(
|
||||
`SELECT
|
||||
id, name, description, icon,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM categories WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应品类' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`查询团队[${teamCode}]品类成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
category: rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]品类详情失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取品类详情失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新品类
|
||||
*/
|
||||
const updateCategory = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的品类ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取请求数据或使用预解析的数据
|
||||
const data = req.parsedBody || await req.json();
|
||||
|
||||
// 验证是否有需要更新的字段
|
||||
if (!data.name && !data.description && !data.icon) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少需要更新的字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始更新团队[${teamCode}]品类, ID:${id}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查品类是否存在
|
||||
const [existingCategory] = await connection.query(
|
||||
'SELECT id FROM categories WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingCategory) || existingCategory.length === 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应品类' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果更新名称,检查名称是否已存在
|
||||
if (data.name) {
|
||||
const [nameCheck] = await connection.query(
|
||||
'SELECT id FROM categories WHERE name = ? AND id != ?',
|
||||
[data.name, id]
|
||||
);
|
||||
|
||||
if (Array.isArray(nameCheck) && nameCheck.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该品类名称已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建更新SQL
|
||||
const updateFields = [];
|
||||
const updateParams = [];
|
||||
|
||||
if (data.name) {
|
||||
updateFields.push('name = ?');
|
||||
updateParams.push(data.name);
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
updateFields.push('description = ?');
|
||||
updateParams.push(data.description || null);
|
||||
}
|
||||
|
||||
if (data.icon !== undefined) {
|
||||
updateFields.push('icon = ?');
|
||||
updateParams.push(data.icon || null);
|
||||
}
|
||||
|
||||
updateFields.push('updated_at = NOW()');
|
||||
|
||||
// 添加ID作为WHERE条件参数
|
||||
updateParams.push(id);
|
||||
|
||||
const updateSql = `
|
||||
UPDATE categories
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await connection.query(updateSql, updateParams);
|
||||
|
||||
// 查询更新后的品类信息
|
||||
const [updatedCategoryRows] = await connection.query(
|
||||
`SELECT
|
||||
id, name, description, icon,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM categories WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`更新团队[${teamCode}]品类成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '品类更新成功',
|
||||
category: Array.isArray(updatedCategoryRows) && updatedCategoryRows.length > 0 ? updatedCategoryRows[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`更新团队[${teamCode}]品类失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '更新品类失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除品类
|
||||
*/
|
||||
const deleteCategory = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的品类ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始删除团队[${teamCode}]品类, ID:${id}`);
|
||||
|
||||
// 检查品类是否存在
|
||||
const [existingCategory] = await req.db.query(
|
||||
'SELECT id FROM categories WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingCategory) || existingCategory.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应品类' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 直接删除品类记录
|
||||
await req.db.query('DELETE FROM categories WHERE id = ?', [id]);
|
||||
|
||||
console.log(`删除团队[${teamCode}]品类成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '品类删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`删除团队[${teamCode}]品类失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '删除品类失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getCategory)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理品类详情请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 克隆请求,确保body只被读取一次
|
||||
const clonedReq = req.clone();
|
||||
|
||||
// 预先读取请求体
|
||||
let body;
|
||||
try {
|
||||
body = await clonedReq.json();
|
||||
} catch {
|
||||
// 如果无法解析JSON,继续处理
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 传递body数据到teamReq的headers中,避免重复读取body
|
||||
if (body) {
|
||||
teamReq.headers.set('x-request-body', JSON.stringify(body));
|
||||
}
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(updateCategory)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理品类更新请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(deleteCategory)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理品类删除请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
259
src/app/api/team/[teamCode]/categories/route.ts
Normal file
259
src/app/api/team/[teamCode]/categories/route.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 品类API接口
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供品类数据的查询和创建接口
|
||||
* 版本: 2.3.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/categories
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* GET 获取品类列表
|
||||
*/
|
||||
const getCategories = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
|
||||
const keyword = url.searchParams.get('keyword') || '';
|
||||
|
||||
// 计算偏移量
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]品类列表, 页码:${page}, 每页:${pageSize}`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams = [];
|
||||
|
||||
if (keyword) {
|
||||
conditions.push('(name LIKE ? OR description LIKE ?)');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM categories ${whereClause}`;
|
||||
const [totalRows] = await connection.query(countSql, queryParams);
|
||||
|
||||
const total = (totalRows as Array<{ total: number }>)[0].total;
|
||||
|
||||
// 查询分页数据
|
||||
const querySql = `
|
||||
SELECT
|
||||
id, name, description, icon,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM categories ${whereClause}
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
// 添加分页参数
|
||||
const paginatedParams = [...queryParams, pageSize, offset];
|
||||
|
||||
const [rows] = await connection.query(querySql, paginatedParams);
|
||||
|
||||
console.log(`查询团队[${teamCode}]品类列表成功, 总数:${total}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total,
|
||||
categories: Array.isArray(rows) ? rows : []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]品类列表失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取品类列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST 创建品类
|
||||
*/
|
||||
const createCategory = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取请求数据
|
||||
const data = await req.json();
|
||||
|
||||
// 验证必填字段
|
||||
if (!data.name) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '品类名称为必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始创建团队[${teamCode}]品类, 名称:${data.name}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查品类名称是否已存在
|
||||
const [existingCategories] = await connection.query(
|
||||
'SELECT id FROM categories WHERE name = ?',
|
||||
[data.name]
|
||||
);
|
||||
|
||||
if (Array.isArray(existingCategories) && existingCategories.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该品类名称已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 插入品类记录
|
||||
const insertSql = `
|
||||
INSERT INTO categories (
|
||||
name, description, icon, created_at, updated_at
|
||||
) VALUES (?, ?, ?, NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await connection.query(insertSql, [
|
||||
data.name,
|
||||
data.description || null,
|
||||
data.icon || null
|
||||
]);
|
||||
|
||||
const insertId = (result as { insertId: number }).insertId;
|
||||
|
||||
// 查询新插入的品类信息
|
||||
const [newCategoryRows] = await connection.query(
|
||||
`SELECT
|
||||
id, name, description, icon,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM categories WHERE id = ?`,
|
||||
[insertId]
|
||||
);
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`创建团队[${teamCode}]品类成功, ID:${insertId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '品类创建成功',
|
||||
category: Array.isArray(newCategoryRows) && newCategoryRows.length > 0 ? newCategoryRows[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`创建团队[${teamCode}]品类失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '创建品类失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getCategories)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理品类列表请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(createCategory)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理品类创建请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
444
src/app/api/team/[teamCode]/customers/[id]/route.ts
Normal file
444
src/app/api/team/[teamCode]/customers/[id]/route.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* 客户详情API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供单个客户数据的获取、更新和删除API
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
// 不复制body,因为它可能已被读取
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从URL路径中提取参数
|
||||
* @param req 请求对象
|
||||
* @returns 提取的参数对象
|
||||
*/
|
||||
const extractParamsFromPath = (req: NextRequest): { teamCode: string; id: string } => {
|
||||
// 路径格式: /api/team/[teamCode]/customers/[id]
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3,客户ID位于索引5
|
||||
return {
|
||||
teamCode: pathParts[3] || '',
|
||||
id: pathParts[5] || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个客户详情
|
||||
*/
|
||||
const getCustomerDetail = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的客户ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 查询客户信息
|
||||
const [rows] = await req.db.query(
|
||||
`SELECT
|
||||
id, name, phone, gender, wechat,
|
||||
address, birthday, follow_date as followDate, balance,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM customers
|
||||
WHERE id = ? AND deleted_at IS NULL`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '客户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
customer: rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取客户详情失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取客户详情失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新客户信息
|
||||
*/
|
||||
interface CustomerRequestBody {
|
||||
id?: number;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
gender?: number;
|
||||
wechat?: string;
|
||||
birthday?: string;
|
||||
followDate?: string;
|
||||
balance?: number;
|
||||
address?: string | null | {
|
||||
province?: string | null;
|
||||
city?: string | null;
|
||||
district?: string | null;
|
||||
county?: string | null;
|
||||
detail?: string | null;
|
||||
};
|
||||
[key: string]: unknown; // 索引签名允许动态访问属性
|
||||
}
|
||||
|
||||
const updateCustomer = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const data = params?.requestBody as CustomerRequestBody;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的客户ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求数据为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证ID是否匹配
|
||||
if (data.id !== undefined && data.id !== Number(id)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '客户ID不匹配' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查客户是否存在
|
||||
const [existingCustomers] = await req.db.query(
|
||||
'SELECT id FROM customers WHERE id = ? AND deleted_at IS NULL',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingCustomers) || existingCustomers.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '客户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果更新手机号,检查是否与其他客户重复
|
||||
if (data.phone) {
|
||||
const [phoneCheck] = await req.db.query(
|
||||
'SELECT id FROM customers WHERE phone = ? AND id != ? AND deleted_at IS NULL',
|
||||
[data.phone, id]
|
||||
);
|
||||
|
||||
if (Array.isArray(phoneCheck) && phoneCheck.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该手机号已被其他客户使用' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理地址字段,确保正确存储结构化地址
|
||||
let addressJson: string | null = null;
|
||||
if (data.address !== undefined) {
|
||||
if (data.address === null) {
|
||||
addressJson = null;
|
||||
} else {
|
||||
// 标准化地址对象结构
|
||||
const addressObj: {
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
district: string | null;
|
||||
detail: string | null;
|
||||
} = {
|
||||
province: null,
|
||||
city: null,
|
||||
district: null,
|
||||
detail: null
|
||||
};
|
||||
|
||||
if (typeof data.address === 'string') {
|
||||
// 如果是字符串,作为详细地址存储
|
||||
addressObj.detail = data.address;
|
||||
} else if (typeof data.address === 'object' && data.address !== null) {
|
||||
// 将提供的地址对象结构化
|
||||
const addrObj = data.address as {
|
||||
province?: string | null;
|
||||
city?: string | null;
|
||||
district?: string | null;
|
||||
county?: string | null;
|
||||
detail?: string | null;
|
||||
};
|
||||
if (addrObj.province) addressObj.province = addrObj.province;
|
||||
if (addrObj.city) addressObj.city = addrObj.city;
|
||||
if (addrObj.district || addrObj.county) {
|
||||
addressObj.district = addrObj.district || addrObj.county || null;
|
||||
}
|
||||
if (addrObj.detail) addressObj.detail = addrObj.detail;
|
||||
}
|
||||
|
||||
// 只有当至少有一个地址字段有值时才存储地址
|
||||
if (addressObj.province || addressObj.city || addressObj.district || addressObj.detail) {
|
||||
addressJson = JSON.stringify(addressObj);
|
||||
} else {
|
||||
addressJson = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建更新字段
|
||||
const updateFields: string[] = [];
|
||||
const updateValues: (string | number | null)[] = [];
|
||||
|
||||
// 可更新的字段列表
|
||||
const fieldMapping: Record<string, string> = {
|
||||
'name': 'name',
|
||||
'phone': 'phone',
|
||||
'gender': 'gender',
|
||||
'wechat': 'wechat',
|
||||
'birthday': 'birthday',
|
||||
'followDate': 'follow_date',
|
||||
'balance': 'balance'
|
||||
};
|
||||
|
||||
// 特殊处理地址字段
|
||||
if (addressJson !== undefined) {
|
||||
updateFields.push('address = ?');
|
||||
updateValues.push(addressJson);
|
||||
}
|
||||
|
||||
// 遍历请求数据,构建更新语句
|
||||
Object.entries(fieldMapping).forEach(([clientField, dbField]) => {
|
||||
if (data[clientField] !== undefined) {
|
||||
updateFields.push(`${dbField} = ?`);
|
||||
|
||||
// 特殊处理日期字段,空字符串转为NULL
|
||||
if ((clientField === 'birthday' || clientField === 'followDate') && data[clientField] === '') {
|
||||
updateValues.push(null);
|
||||
} else {
|
||||
const value = data[clientField];
|
||||
// 类型安全处理:确保值为string、number或null
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
updateValues.push(null);
|
||||
} else {
|
||||
updateValues.push(value as string | number | null);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 添加更新时间
|
||||
updateFields.push('updated_at = NOW()');
|
||||
|
||||
// 如果没有要更新的字段,返回成功
|
||||
if (updateFields.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '无需更新'
|
||||
});
|
||||
}
|
||||
|
||||
// 构建并执行更新SQL
|
||||
const updateSql = `UPDATE customers SET ${updateFields.join(', ')} WHERE id = ?`;
|
||||
updateValues.push(id);
|
||||
|
||||
await req.db.query(updateSql, updateValues);
|
||||
|
||||
// 获取更新后的客户信息
|
||||
const [updatedRows] = await req.db.query(
|
||||
`SELECT
|
||||
id, name, phone, gender, wechat,
|
||||
address, birthday, follow_date as followDate, balance,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM customers
|
||||
WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '客户更新成功',
|
||||
customer: Array.isArray(updatedRows) && updatedRows.length > 0 ? updatedRows[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新客户失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '更新客户失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除客户
|
||||
*/
|
||||
const deleteCustomer = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的客户ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查客户是否存在
|
||||
const [existingCustomers] = await req.db.query(
|
||||
'SELECT id FROM customers WHERE id = ? AND deleted_at IS NULL',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingCustomers) || existingCustomers.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '客户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 软删除:设置deleted_at字段
|
||||
await req.db.query(
|
||||
'UPDATE customers SET deleted_at = NOW(), updated_at = NOW() WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '客户删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除客户失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '删除客户失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getCustomerDetail)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理客户详情请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 先读取请求体
|
||||
let requestBody;
|
||||
try {
|
||||
// 克隆请求,确保body只被读取一次
|
||||
const clonedReq = req.clone();
|
||||
requestBody = await clonedReq.json();
|
||||
} catch (error) {
|
||||
console.error('解析请求数据失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的请求数据格式' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求(不传递body)
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(updateCustomer)(teamReq, { teamCode, id, requestBody });
|
||||
} catch (error) {
|
||||
console.error('处理客户更新请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(deleteCustomer)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理客户删除请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
300
src/app/api/team/[teamCode]/customers/route.ts
Normal file
300
src/app/api/team/[teamCode]/customers/route.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 客户API接口
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供客户数据的查询和创建接口
|
||||
* 版本: 2.3.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/customers
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* GET 获取客户列表
|
||||
*/
|
||||
const getCustomers = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
|
||||
const keyword = url.searchParams.get('keyword') || '';
|
||||
|
||||
// 计算偏移量
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]客户列表, 页码:${page}, 每页:${pageSize}`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams = [];
|
||||
|
||||
if (keyword) {
|
||||
conditions.push('(name LIKE ? OR phone LIKE ? OR wechat LIKE ?)');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
// 只查询未删除的客户
|
||||
conditions.push('deleted_at IS NULL');
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM customers ${whereClause}`;
|
||||
const [totalRows] = await connection.query(countSql, queryParams);
|
||||
|
||||
const total = (totalRows as Array<{ total: number }>)[0].total;
|
||||
|
||||
// 查询分页数据
|
||||
const querySql = `
|
||||
SELECT
|
||||
id, name, phone, gender, wechat,
|
||||
address, birthday, follow_date as followDate, balance,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM customers ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
// 添加分页参数
|
||||
const paginatedParams = [...queryParams, pageSize, offset];
|
||||
|
||||
const [rows] = await connection.query(querySql, paginatedParams);
|
||||
|
||||
console.log(`查询团队[${teamCode}]客户列表成功, 总数:${total}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total,
|
||||
customers: Array.isArray(rows) ? rows : []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]客户列表失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取客户列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST 创建客户
|
||||
*/
|
||||
const createCustomer = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取请求数据
|
||||
const data = await req.json();
|
||||
|
||||
// 验证必填字段
|
||||
if (!data.name || !data.phone) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '姓名和手机号为必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始创建团队[${teamCode}]客户, 姓名:${data.name}, 手机:${data.phone}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查手机号是否已存在
|
||||
const [existingCustomers] = await connection.query(
|
||||
'SELECT id FROM customers WHERE phone = ? AND deleted_at IS NULL',
|
||||
[data.phone]
|
||||
);
|
||||
|
||||
if (Array.isArray(existingCustomers) && existingCustomers.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该手机号已被注册' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理地址字段,确保正确存储结构化地址
|
||||
let addressJson = null;
|
||||
if (data.address) {
|
||||
// 标准化地址对象结构
|
||||
const addressObj = {
|
||||
province: null,
|
||||
city: null,
|
||||
district: null,
|
||||
detail: null
|
||||
};
|
||||
|
||||
if (typeof data.address === 'string') {
|
||||
// 如果是字符串,作为详细地址存储
|
||||
addressObj.detail = data.address;
|
||||
} else if (typeof data.address === 'object') {
|
||||
// 将提供的地址对象结构化
|
||||
if (data.address.province) addressObj.province = data.address.province;
|
||||
if (data.address.city) addressObj.city = data.address.city;
|
||||
if (data.address.district || data.address.county) {
|
||||
addressObj.district = data.address.district || data.address.county;
|
||||
}
|
||||
if (data.address.detail) addressObj.detail = data.address.detail;
|
||||
}
|
||||
|
||||
// 只有当至少有一个地址字段有值时才存储地址
|
||||
if (addressObj.province || addressObj.city || addressObj.district || addressObj.detail) {
|
||||
addressJson = JSON.stringify(addressObj);
|
||||
}
|
||||
}
|
||||
|
||||
// 插入客户记录
|
||||
const insertSql = `
|
||||
INSERT INTO customers (
|
||||
name, phone, gender, wechat, address, birthday,
|
||||
follow_date, balance, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await connection.query(insertSql, [
|
||||
data.name,
|
||||
data.phone,
|
||||
data.gender || null,
|
||||
data.wechat || null,
|
||||
addressJson,
|
||||
data.birthday || null,
|
||||
data.followDate || null,
|
||||
data.balance || 0
|
||||
]);
|
||||
|
||||
const insertId = (result as { insertId: number }).insertId;
|
||||
|
||||
// 查询新插入的客户信息
|
||||
const [newCustomerRows] = await connection.query(
|
||||
`SELECT
|
||||
id, name, phone, gender, wechat,
|
||||
address, birthday, follow_date as followDate, balance,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM customers WHERE id = ?`,
|
||||
[insertId]
|
||||
);
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`创建团队[${teamCode}]客户成功, ID:${insertId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '客户创建成功',
|
||||
customer: Array.isArray(newCustomerRows) && newCustomerRows.length > 0 ? newCustomerRows[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`创建团队[${teamCode}]客户失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '创建客户失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getCustomers)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理客户列表请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(createCustomer)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理客户创建请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
212
src/app/api/team/[teamCode]/logistics/query/route.ts
Normal file
212
src/app/api/team/[teamCode]/logistics/query/route.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 物流查询API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 查询物流单号状态
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { querySFExpress } from '@/utils/querySFExpress';
|
||||
import { RowDataPacket } from 'mysql2/promise';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 物流记录数据接口
|
||||
*/
|
||||
interface LogisticsRecord extends RowDataPacket {
|
||||
id: number;
|
||||
tracking_number: string;
|
||||
is_queryable: boolean;
|
||||
customer_tail_number: string;
|
||||
company: string;
|
||||
details?: string;
|
||||
status?: string;
|
||||
record_id: number;
|
||||
record_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询物流信息处理函数
|
||||
*/
|
||||
const queryLogistics = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
try {
|
||||
// 获取团队代码
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少团队代码参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取并验证请求参数
|
||||
const body = await req.json();
|
||||
const { id, trackingNumber, phoneLast4Digits } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少记录ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!trackingNumber) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少物流单号' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!phoneLast4Digits) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少手机尾号' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 调用顺丰物流查询API
|
||||
const sfResult = await querySFExpress(trackingNumber, phoneLast4Digits);
|
||||
|
||||
// 记录API调用结果,用于调试
|
||||
console.log(`物流查询结果 - 单号: ${trackingNumber}, 状态码: ${sfResult.apiResultCode}`);
|
||||
|
||||
if (sfResult.apiResultCode !== '0000' && sfResult.apiResultCode !== 'S0000') {
|
||||
// 详细记录错误信息
|
||||
console.error(`顺丰API查询失败: ${JSON.stringify({
|
||||
id,
|
||||
trackingNumber,
|
||||
phoneLast4Digits,
|
||||
errorCode: sfResult.apiResultCode,
|
||||
errorMsg: sfResult.apiErrorMsg,
|
||||
originalError: sfResult.error
|
||||
}, null, 2)}`);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `查询物流失败: ${sfResult.apiErrorMsg}`,
|
||||
details: sfResult,
|
||||
params: {
|
||||
id,
|
||||
trackingNumber,
|
||||
phoneLast4DigitsMasked: phoneLast4Digits ? `****${phoneLast4Digits.substring(Math.max(0, phoneLast4Digits.length - 4))}` : null
|
||||
}
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 解析API返回的物流信息
|
||||
// 根据修改后的querySFExpress,结果可能直接是解析后的对象
|
||||
const resultData = typeof sfResult.apiResultData === 'string'
|
||||
? JSON.parse(sfResult.apiResultData)
|
||||
: sfResult.apiResultData;
|
||||
|
||||
// 获取路由信息(兼容新旧格式)
|
||||
const routeInfo = resultData?.msgData?.routeResps?.[0] || {};
|
||||
|
||||
// 确保有有效的物流路由数据
|
||||
if (!routeInfo.routes || !Array.isArray(routeInfo.routes)) {
|
||||
console.log('顺丰API没有返回有效的物流路由数据');
|
||||
} else {
|
||||
console.log(`顺丰API返回了${routeInfo.routes.length}条物流路由记录`);
|
||||
}
|
||||
|
||||
// 提取状态信息
|
||||
let status = null;
|
||||
if (routeInfo.routes && routeInfo.routes.length > 0) {
|
||||
const latestRoute = routeInfo.routes[0];
|
||||
|
||||
// 根据内容识别状态
|
||||
const content = latestRoute.remark || '';
|
||||
if (content.includes('已签收')) {
|
||||
status = '已签收';
|
||||
} else if (content.includes('派送中') || content.includes('派件中')) {
|
||||
status = '运输中';
|
||||
} else if (content.includes('已收取') || content.includes('已揽件')) {
|
||||
status = '已揽件';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 更新数据库中的物流记录
|
||||
const details = JSON.stringify({
|
||||
routes: routeInfo.routes || [],
|
||||
routeType: routeInfo.routeType,
|
||||
mailNo: routeInfo.mailNo,
|
||||
queryTime: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log('即将更新物流记录:', {
|
||||
id,
|
||||
status,
|
||||
hasDetails: !!details
|
||||
});
|
||||
|
||||
await connection.query(
|
||||
`UPDATE logistics_records
|
||||
SET status = ?,
|
||||
details = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[status, details, id]
|
||||
);
|
||||
|
||||
// 获取完整的物流记录信息
|
||||
const [logisticsRecords] = await connection.query<LogisticsRecord[]>(
|
||||
`SELECT * FROM logistics_records WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (logisticsRecords.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '物流记录不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const logisticsRecord = logisticsRecords[0];
|
||||
|
||||
// 不需要查询关联产品,直接返回物流信息即可
|
||||
|
||||
// 返回成功响应
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '查询物流信息成功',
|
||||
data: logisticsRecord,
|
||||
sfResponse: sfResult
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('查询物流API错误:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '查询物流失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理POST请求
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const teamCode = pathname.split('/')[3]; // /api/team/[teamCode]/...
|
||||
|
||||
return connectTeamDB((dbReq: RequestWithDB) => {
|
||||
return queryLogistics(dbReq, { teamCode });
|
||||
})(request);
|
||||
} catch (error) {
|
||||
console.error('处理POST请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '处理请求失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
483
src/app/api/team/[teamCode]/logistics/route.ts
Normal file
483
src/app/api/team/[teamCode]/logistics/route.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* 物流记录API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供物流记录的查询和创建接口
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RowDataPacket, ResultSetHeader } from 'mysql2/promise';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { RecordType } from '@/models/team/types/ILogisticsRecord';
|
||||
|
||||
/**
|
||||
* 物流记录请求参数接口
|
||||
*/
|
||||
interface LogisticsRequestParams {
|
||||
salesRecordId: number;
|
||||
trackingNumber: string;
|
||||
company: string;
|
||||
customerTailNumber: string;
|
||||
isQueryable: boolean;
|
||||
products: {
|
||||
productId: number;
|
||||
quantity: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 物流记录数据接口
|
||||
*/
|
||||
interface LogisticsRecord extends RowDataPacket {
|
||||
id: number;
|
||||
tracking_number: string;
|
||||
is_queryable: boolean;
|
||||
customer_tail_number: string;
|
||||
company: string;
|
||||
status?: string;
|
||||
record_id: number;
|
||||
record_type: RecordType;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 物流产品数据接口
|
||||
*/
|
||||
interface LogisticsProduct extends RowDataPacket {
|
||||
id: number;
|
||||
logistics_id: number;
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建物流记录
|
||||
*/
|
||||
const createLogisticsRecord = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
try {
|
||||
// 正确处理动态路由参数
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少团队代码参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 解析请求数据
|
||||
const requestData: LogisticsRequestParams = await req.json();
|
||||
const {
|
||||
salesRecordId,
|
||||
trackingNumber,
|
||||
company,
|
||||
customerTailNumber,
|
||||
isQueryable,
|
||||
products
|
||||
} = requestData;
|
||||
|
||||
// 数据验证
|
||||
if (!salesRecordId) {
|
||||
return NextResponse.json(
|
||||
{ error: '销售记录ID不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!trackingNumber) {
|
||||
return NextResponse.json(
|
||||
{ error: '物流单号不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json(
|
||||
{ error: '物流公司不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!customerTailNumber) {
|
||||
return NextResponse.json(
|
||||
{ error: '客户手机尾号不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!products || !Array.isArray(products) || products.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '发货产品不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 检查销售记录是否存在
|
||||
const [salesRecords] = await connection.query<RowDataPacket[]>(
|
||||
'SELECT id, order_status FROM sales_records WHERE id = ?',
|
||||
[salesRecordId]
|
||||
);
|
||||
|
||||
if (salesRecords.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '销售记录不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查销售记录是否已有物流记录
|
||||
const [existingLogistics] = await connection.query<LogisticsRecord[]>(
|
||||
`SELECT id FROM logistics_records
|
||||
WHERE record_id = ? AND record_type = ?`,
|
||||
[salesRecordId, RecordType.SALES_RECORD]
|
||||
);
|
||||
|
||||
let logisticsId: number;
|
||||
let isUpdate = false;
|
||||
|
||||
if (existingLogistics.length > 0) {
|
||||
// 更新现有物流记录
|
||||
isUpdate = true;
|
||||
logisticsId = existingLogistics[0].id;
|
||||
|
||||
await connection.query(
|
||||
`UPDATE logistics_records
|
||||
SET tracking_number = ?,
|
||||
is_queryable = ?,
|
||||
customer_tail_number = ?,
|
||||
company = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[
|
||||
trackingNumber,
|
||||
isQueryable,
|
||||
customerTailNumber,
|
||||
company,
|
||||
logisticsId
|
||||
]
|
||||
);
|
||||
|
||||
// 删除旧的产品关联
|
||||
await connection.query(
|
||||
`DELETE FROM logistics_products WHERE logistics_id = ?`,
|
||||
[logisticsId]
|
||||
);
|
||||
|
||||
// 更新记录时不改变订单状态
|
||||
} else {
|
||||
// 创建新物流记录
|
||||
const [logisticsResult] = await connection.query<ResultSetHeader>(
|
||||
`INSERT INTO logistics_records
|
||||
(tracking_number, is_queryable, customer_tail_number, company, status, record_id, record_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
trackingNumber,
|
||||
isQueryable,
|
||||
customerTailNumber,
|
||||
company,
|
||||
'已填单', // 初始状态改为已填单
|
||||
salesRecordId,
|
||||
RecordType.SALES_RECORD
|
||||
]
|
||||
);
|
||||
|
||||
logisticsId = logisticsResult.insertId;
|
||||
|
||||
// 更新销售记录的订单状态(仅对新记录)
|
||||
// 确保初始状态为"已填单"
|
||||
await connection.query(
|
||||
`UPDATE sales_records
|
||||
SET order_status =
|
||||
CASE
|
||||
WHEN order_status IS NULL THEN JSON_ARRAY('已填单')
|
||||
ELSE order_status
|
||||
END
|
||||
WHERE id = ?`,
|
||||
[salesRecordId]
|
||||
);
|
||||
}
|
||||
|
||||
// 批量插入物流产品关联记录
|
||||
if (products.length > 0) {
|
||||
const logisticsProductValues = products.map(product => [
|
||||
logisticsId,
|
||||
product.productId,
|
||||
product.quantity
|
||||
]);
|
||||
|
||||
await connection.query(
|
||||
`INSERT INTO logistics_products (logistics_id, product_id, quantity) VALUES ?`,
|
||||
[logisticsProductValues]
|
||||
);
|
||||
}
|
||||
|
||||
// 查询更新后的物流记录详情
|
||||
const [logisticsRecords] = await connection.query<LogisticsRecord[]>(
|
||||
`SELECT * FROM logistics_records WHERE id = ?`,
|
||||
[logisticsId]
|
||||
);
|
||||
|
||||
const createdLogistics = logisticsRecords[0];
|
||||
|
||||
// 查询关联的产品信息
|
||||
const [logisticsProducts] = await connection.query<LogisticsProduct[]>(
|
||||
`SELECT
|
||||
lp.id, lp.logistics_id, lp.product_id, lp.quantity, lp.created_at,
|
||||
p.name as product_name, p.code as product_code, p.image
|
||||
FROM logistics_products lp
|
||||
JOIN products p ON lp.product_id = p.id
|
||||
WHERE lp.logistics_id = ?`,
|
||||
[logisticsId]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: isUpdate ? '物流记录更新成功' : '物流记录创建成功',
|
||||
data: {
|
||||
...createdLogistics,
|
||||
products: logisticsProducts
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建物流记录失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '创建物流记录失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取物流记录列表
|
||||
*/
|
||||
const getLogisticsRecords = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
try {
|
||||
// 正确处理动态路由参数
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少团队代码参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
|
||||
const recordId = url.searchParams.get('recordId') || '';
|
||||
const recordType = url.searchParams.get('recordType') || '';
|
||||
const trackingNumber = url.searchParams.get('trackingNumber') || '';
|
||||
|
||||
// 计算偏移量
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams = [];
|
||||
|
||||
if (recordId) {
|
||||
conditions.push('lr.record_id = ?');
|
||||
queryParams.push(recordId);
|
||||
}
|
||||
|
||||
if (recordType) {
|
||||
conditions.push('lr.record_type = ?');
|
||||
queryParams.push(recordType);
|
||||
}
|
||||
|
||||
if (trackingNumber) {
|
||||
conditions.push('lr.tracking_number LIKE ?');
|
||||
queryParams.push(`%${trackingNumber}%`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总数
|
||||
const countSql = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM logistics_records lr
|
||||
${whereClause}
|
||||
`;
|
||||
const [totalRows] = await connection.query<RowDataPacket[]>(countSql, queryParams);
|
||||
|
||||
const total = totalRows[0].total as number;
|
||||
|
||||
// 查询分页数据
|
||||
const querySql = `
|
||||
SELECT
|
||||
lr.id, lr.tracking_number, lr.is_queryable, lr.customer_tail_number,
|
||||
lr.company, lr.status, lr.record_id, lr.record_type,
|
||||
lr.created_at, lr.updated_at
|
||||
FROM logistics_records lr
|
||||
${whereClause}
|
||||
ORDER BY lr.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
// 添加分页参数
|
||||
const paginatedParams = [...queryParams, pageSize, offset];
|
||||
|
||||
const [rows] = await connection.query<LogisticsRecord[]>(querySql, paginatedParams);
|
||||
|
||||
// 查询每条物流记录关联的产品
|
||||
const logisticsRecords = rows;
|
||||
|
||||
if (logisticsRecords.length > 0) {
|
||||
for (let i = 0; i < logisticsRecords.length; i++) {
|
||||
const record = logisticsRecords[i];
|
||||
|
||||
// 查询关联产品
|
||||
const [productRows] = await connection.query<LogisticsProduct[]>(`
|
||||
SELECT
|
||||
lp.id, lp.logistics_id, lp.product_id, lp.quantity, lp.created_at,
|
||||
p.name as product_name, p.code as product_code, p.image
|
||||
FROM logistics_products lp
|
||||
JOIN products p ON lp.product_id = p.id
|
||||
WHERE lp.logistics_id = ?
|
||||
`, [record.id]);
|
||||
|
||||
record.products = productRows;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
total,
|
||||
records: logisticsRecords
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取物流记录列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取物流记录列表失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个物流记录详情
|
||||
*/
|
||||
const getLogisticsRecord = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
try {
|
||||
// 正确处理动态路由参数
|
||||
const teamCode = params?.teamCode as string;
|
||||
const logisticsId = params?.logisticsId as string;
|
||||
|
||||
if (!teamCode || !logisticsId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必要参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询物流记录
|
||||
const [logisticsRecords] = await connection.query<LogisticsRecord[]>(
|
||||
`SELECT * FROM logistics_records WHERE id = ?`,
|
||||
[logisticsId]
|
||||
);
|
||||
|
||||
if (logisticsRecords.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '物流记录不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const logisticsRecord = logisticsRecords[0];
|
||||
|
||||
// 查询关联产品
|
||||
const [productRows] = await connection.query<LogisticsProduct[]>(`
|
||||
SELECT
|
||||
lp.id, lp.logistics_id, lp.product_id, lp.quantity, lp.created_at,
|
||||
p.name as product_name, p.code as product_code, p.image
|
||||
FROM logistics_products lp
|
||||
JOIN products p ON lp.product_id = p.id
|
||||
WHERE lp.logistics_id = ?
|
||||
`, [logisticsId]);
|
||||
|
||||
logisticsRecord.products = productRows;
|
||||
|
||||
return NextResponse.json({
|
||||
record: logisticsRecord
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取物流记录详情失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取物流记录详情失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理GET请求 - 获取物流记录列表
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const teamCode = pathname.split('/')[3]; // /api/team/[teamCode]/...
|
||||
|
||||
// 检查是否请求单个物流记录
|
||||
if (pathname.includes('/logistics/')) {
|
||||
const pathParts = pathname.split('/');
|
||||
const logisticsId = pathParts[pathParts.length - 1];
|
||||
|
||||
if (logisticsId && /^\d+$/.test(logisticsId)) {
|
||||
return connectTeamDB((dbReq: RequestWithDB) => {
|
||||
return getLogisticsRecord(dbReq, {
|
||||
teamCode,
|
||||
logisticsId
|
||||
});
|
||||
})(request);
|
||||
}
|
||||
}
|
||||
|
||||
// 否则返回物流记录列表
|
||||
return connectTeamDB((dbReq: RequestWithDB) => {
|
||||
return getLogisticsRecords(dbReq, { teamCode });
|
||||
})(request);
|
||||
} catch (error) {
|
||||
console.error('处理GET请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '处理请求失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理POST请求 - 创建物流记录
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const teamCode = pathname.split('/')[3]; // /api/team/[teamCode]/...
|
||||
|
||||
return connectTeamDB((dbReq: RequestWithDB) => {
|
||||
return createLogisticsRecord(dbReq, { teamCode });
|
||||
})(request);
|
||||
} catch (error) {
|
||||
console.error('处理POST请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '处理请求失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
192
src/app/api/team/[teamCode]/logistics/update-status/route.ts
Normal file
192
src/app/api/team/[teamCode]/logistics/update-status/route.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 物流状态批量更新API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 批量更新物流记录状态
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RowDataPacket } from 'mysql2/promise';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { querySFExpress } from '@/utils/querySFExpress';
|
||||
|
||||
/**
|
||||
* 物流记录数据接口
|
||||
*/
|
||||
interface LogisticsRecord extends RowDataPacket {
|
||||
id: number;
|
||||
tracking_number: string;
|
||||
is_queryable: boolean;
|
||||
customer_tail_number: string;
|
||||
company: string;
|
||||
status?: string;
|
||||
details?: string;
|
||||
record_id: number;
|
||||
record_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新物流状态处理函数
|
||||
*/
|
||||
const updateLogisticsStatus = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
try {
|
||||
// 获取团队代码
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少团队代码参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询需要更新的物流记录(只查询未签收且可查询的记录)
|
||||
const [logisticsRecords] = await connection.query<LogisticsRecord[]>(`
|
||||
SELECT id, tracking_number, customer_tail_number, status, is_queryable
|
||||
FROM logistics_records
|
||||
WHERE
|
||||
is_queryable = 1
|
||||
AND customer_tail_number IS NOT NULL
|
||||
AND tracking_number IS NOT NULL
|
||||
AND (status IS NULL OR status != '已签收')
|
||||
LIMIT 50
|
||||
`);
|
||||
|
||||
if (logisticsRecords.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '没有需要更新的物流记录',
|
||||
updatedCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 记录更新结果
|
||||
const results = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [] as string[]
|
||||
};
|
||||
|
||||
// 逐条更新物流记录
|
||||
for (const record of logisticsRecords) {
|
||||
try {
|
||||
// 跳过无法查询的记录
|
||||
if (!record.is_queryable || !record.tracking_number || !record.customer_tail_number) {
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 调用顺丰查询API
|
||||
const sfResult = await querySFExpress(record.tracking_number, record.customer_tail_number);
|
||||
|
||||
// 记录API调用结果,用于调试
|
||||
console.log(`批量更新 - 物流查询结果 - ID: ${record.id}, 单号: ${record.tracking_number}, 状态码: ${sfResult.apiResultCode}`);
|
||||
|
||||
if (sfResult.apiResultCode !== '0000' && sfResult.apiResultCode !== 'S0000') {
|
||||
// 详细记录错误信息
|
||||
console.error(`批量更新 - 顺丰API查询失败: ${JSON.stringify({
|
||||
id: record.id,
|
||||
trackingNumber: record.tracking_number,
|
||||
phoneLast4Digits: record.customer_tail_number,
|
||||
errorCode: sfResult.apiResultCode,
|
||||
errorMsg: sfResult.apiErrorMsg,
|
||||
originalError: sfResult.error
|
||||
}, null, 2)}`);
|
||||
|
||||
results.failed++;
|
||||
results.errors.push(`记录ID ${record.id}: ${sfResult.apiErrorMsg} (错误码: ${sfResult.apiResultCode})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析API返回的物流信息
|
||||
const resultData = typeof sfResult.apiResultData === 'string'
|
||||
? JSON.parse(sfResult.apiResultData)
|
||||
: sfResult.apiResultData;
|
||||
|
||||
// 获取路由信息(兼容新旧格式)
|
||||
const routeInfo = resultData?.msgData?.routeResps?.[0] || {};
|
||||
|
||||
// 提取状态信息
|
||||
let status = null;
|
||||
if (routeInfo.routes && routeInfo.routes.length > 0) {
|
||||
const latestRoute = routeInfo.routes[0];
|
||||
|
||||
// 根据内容识别状态
|
||||
const content = latestRoute.content || '';
|
||||
if (content.includes('已签收')) {
|
||||
status = '已签收';
|
||||
} else if (content.includes('派送中') || content.includes('派件中')) {
|
||||
status = '运输中';
|
||||
} else if (content.includes('已收取') || content.includes('已揽件')) {
|
||||
status = '已揽件';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新数据库中的物流记录
|
||||
const details = resultData ? JSON.stringify({
|
||||
routes: routeInfo.routes || [],
|
||||
routeType: routeInfo.routeType,
|
||||
mailNo: routeInfo.mailNo,
|
||||
queryTime: new Date().toISOString()
|
||||
}) : null;
|
||||
|
||||
await connection.query(
|
||||
`UPDATE logistics_records
|
||||
SET status = ?,
|
||||
details = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[status, details, record.id]
|
||||
);
|
||||
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
console.error(`更新物流记录 ${record.id} 失败:`, error);
|
||||
results.failed++;
|
||||
results.errors.push(`记录ID ${record.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回更新结果
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '物流状态更新完成',
|
||||
total: logisticsRecords.length,
|
||||
results
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('批量更新物流状态失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '批量更新物流状态失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理POST请求
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const teamCode = pathname.split('/')[3]; // /api/team/[teamCode]/...
|
||||
|
||||
return connectTeamDB((dbReq: RequestWithDB) => {
|
||||
return updateLogisticsStatus(dbReq, { teamCode });
|
||||
})(request);
|
||||
} catch (error) {
|
||||
console.error('处理POST请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '处理请求失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
387
src/app/api/team/[teamCode]/payment-platforms/[id]/route.ts
Normal file
387
src/app/api/team/[teamCode]/payment-platforms/[id]/route.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 单个支付平台API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供单个支付平台的查询、更新和删除接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
// 不复制body,因为它可能已被读取
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从URL路径中提取参数
|
||||
* @param req 请求对象
|
||||
* @returns 提取的参数对象
|
||||
*/
|
||||
const extractParamsFromPath = (req: NextRequest): { teamCode: string; id: string } => {
|
||||
// 路径格式: /api/team/[teamCode]/payment-platforms/[id]
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3,支付平台ID位于索引5
|
||||
return {
|
||||
teamCode: pathParts[3] || '',
|
||||
id: pathParts[5] || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个支付平台
|
||||
*/
|
||||
const getPaymentPlatform = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的支付平台ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]支付平台, ID:${id}`);
|
||||
|
||||
const [rows] = await req.db.query(
|
||||
`SELECT
|
||||
id, \`order\`, name, description, status,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM payment_platforms WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应支付平台' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`查询团队[${teamCode}]支付平台成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
paymentPlatform: rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]支付平台详情失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取支付平台详情失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新支付平台
|
||||
*/
|
||||
interface PaymentPlatformRequestBody {
|
||||
id?: number;
|
||||
name?: string;
|
||||
order?: number;
|
||||
description?: string | null;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
const updatePaymentPlatform = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
const data = params?.requestBody as PaymentPlatformRequestBody;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的支付平台ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求数据为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证是否有需要更新的字段
|
||||
if (!data.name && data.order === undefined && !data.description && data.status === undefined) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少需要更新的字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始更新团队[${teamCode}]支付平台, ID:${id}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查支付平台是否存在
|
||||
const [existingPlatform] = await connection.query(
|
||||
'SELECT id FROM payment_platforms WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingPlatform) || existingPlatform.length === 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应支付平台' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果更新名称,检查名称是否已存在
|
||||
if (data.name) {
|
||||
const [nameCheck] = await connection.query(
|
||||
'SELECT id FROM payment_platforms WHERE name = ? AND id != ?',
|
||||
[data.name, id]
|
||||
);
|
||||
|
||||
if (Array.isArray(nameCheck) && nameCheck.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该支付平台名称已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建更新SQL
|
||||
const updateFields = [];
|
||||
const updateParams = [];
|
||||
|
||||
if (data.name) {
|
||||
updateFields.push('name = ?');
|
||||
updateParams.push(data.name);
|
||||
}
|
||||
|
||||
if (data.order !== undefined) {
|
||||
updateFields.push('`order` = ?');
|
||||
updateParams.push(data.order);
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
updateFields.push('description = ?');
|
||||
updateParams.push(data.description || null);
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updateFields.push('status = ?');
|
||||
updateParams.push(data.status);
|
||||
}
|
||||
|
||||
updateFields.push('updated_at = NOW()');
|
||||
|
||||
// 添加ID作为WHERE条件参数
|
||||
updateParams.push(id);
|
||||
|
||||
const updateSql = `
|
||||
UPDATE payment_platforms
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await connection.query(updateSql, updateParams);
|
||||
|
||||
// 查询更新后的支付平台信息
|
||||
const [updatedPlatformRows] = await connection.query(
|
||||
`SELECT
|
||||
id, \`order\`, name, description, status,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM payment_platforms WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`更新团队[${teamCode}]支付平台成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '支付平台更新成功',
|
||||
paymentPlatform: Array.isArray(updatedPlatformRows) && updatedPlatformRows.length > 0 ? updatedPlatformRows[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`更新团队[${teamCode}]支付平台失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '更新支付平台失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除支付平台
|
||||
*/
|
||||
const deletePaymentPlatform = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的支付平台ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始删除团队[${teamCode}]支付平台, ID:${id}`);
|
||||
|
||||
// 检查支付平台是否存在
|
||||
const [existingPlatform] = await req.db.query(
|
||||
'SELECT id FROM payment_platforms WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingPlatform) || existingPlatform.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应支付平台' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 直接删除支付平台记录
|
||||
await req.db.query('DELETE FROM payment_platforms WHERE id = ?', [id]);
|
||||
|
||||
console.log(`删除团队[${teamCode}]支付平台成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '支付平台删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`删除团队[${teamCode}]支付平台失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '删除支付平台失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getPaymentPlatform)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理支付平台详情请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 先读取请求体
|
||||
let requestBody;
|
||||
try {
|
||||
// 克隆请求,确保body只被读取一次
|
||||
const clonedReq = req.clone();
|
||||
requestBody = await clonedReq.json();
|
||||
} catch (error) {
|
||||
console.error('解析请求数据失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的请求数据格式' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求(不传递body)
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(updatePaymentPlatform)(teamReq, { teamCode, id, requestBody });
|
||||
} catch (error) {
|
||||
console.error('处理支付平台更新请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(deletePaymentPlatform)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理支付平台删除请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
152
src/app/api/team/[teamCode]/payment-platforms/route.ts
Normal file
152
src/app/api/team/[teamCode]/payment-platforms/route.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 支付平台API接口
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供支付平台数据的查询接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/payment-platforms
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* GET 获取支付平台列表
|
||||
*/
|
||||
const getPaymentPlatforms = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
|
||||
const keyword = url.searchParams.get('keyword') || '';
|
||||
const status = url.searchParams.get('status') || '';
|
||||
|
||||
// 计算偏移量
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]支付平台列表, 页码:${page}, 每页:${pageSize}`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams = [];
|
||||
|
||||
if (keyword) {
|
||||
conditions.push('(name LIKE ? OR description LIKE ?)');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push('status = ?');
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM payment_platforms ${whereClause}`;
|
||||
const [totalRows] = await connection.query(countSql, queryParams);
|
||||
|
||||
const total = (totalRows as Array<{ total: number }>)[0].total;
|
||||
|
||||
// 查询分页数据
|
||||
const querySql = `
|
||||
SELECT
|
||||
id, \`order\`, name, description, status,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM payment_platforms
|
||||
${whereClause}
|
||||
ORDER BY \`order\` ASC, id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
// 添加分页参数
|
||||
const paginatedParams = [...queryParams, pageSize, offset];
|
||||
|
||||
const [rows] = await connection.query(querySql, paginatedParams);
|
||||
|
||||
console.log(`查询团队[${teamCode}]支付平台列表成功, 总数:${total}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total,
|
||||
paymentPlatforms: Array.isArray(rows) ? rows : []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]支付平台列表失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取支付平台列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getPaymentPlatforms)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理支付平台列表请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
474
src/app/api/team/[teamCode]/products/[id]/route.ts
Normal file
474
src/app/api/team/[teamCode]/products/[id]/route.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 单个产品API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供单个产品的查询、更新和删除接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从URL路径中提取参数
|
||||
* @param req 请求对象
|
||||
* @returns 提取的参数对象
|
||||
*/
|
||||
const extractParamsFromPath = (req: NextRequest): { teamCode: string; id: string } => {
|
||||
// 路径格式: /api/team/[teamCode]/products/[id]
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3,产品ID位于索引5
|
||||
return {
|
||||
teamCode: pathParts[3] || '',
|
||||
id: pathParts[5] || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个产品
|
||||
*/
|
||||
const getProduct = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的产品ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]产品, ID:${id}`);
|
||||
|
||||
// 查询产品信息,包括关联的供应商、品牌、品类名称
|
||||
const [rows] = await req.db.query(
|
||||
`SELECT
|
||||
p.id, p.supplier_id as supplierId, p.brand_id as brandId, p.category_id as categoryId,
|
||||
p.name, p.description, p.code, p.image, p.sku, p.aliases, p.level, p.cost,
|
||||
p.price, p.stock, p.logistics_status as logisticsStatus,
|
||||
p.logistics_details as logisticsDetails, p.tracking_number as trackingNumber,
|
||||
p.created_at as createdAt, p.updated_at as updatedAt,
|
||||
s.name as supplierName, b.name as brandName, c.name as categoryName
|
||||
FROM products p
|
||||
LEFT JOIN suppliers s ON p.supplier_id = s.id
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应产品' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理产品数据
|
||||
const product: {
|
||||
id: number;
|
||||
aliases?: string | string[];
|
||||
cost?: string | Record<string, number>;
|
||||
[key: string]: string | number | boolean | null | string[] | Record<string, number> | undefined;
|
||||
} = rows[0] as RowDataPacket & {
|
||||
id: number;
|
||||
aliases?: string | string[];
|
||||
cost?: string | Record<string, number>;
|
||||
[key: string]: string | number | boolean | null | string[] | Record<string, number> | undefined;
|
||||
};
|
||||
|
||||
// 处理JSON字段
|
||||
if (product.aliases && typeof product.aliases === 'string') {
|
||||
try {
|
||||
product.aliases = JSON.parse(product.aliases);
|
||||
} catch (error) {
|
||||
console.error('解析别名JSON失败:', error);
|
||||
product.aliases = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (product.cost && typeof product.cost === 'string') {
|
||||
try {
|
||||
product.cost = JSON.parse(product.cost);
|
||||
} catch (error) {
|
||||
console.error('解析成本JSON失败:', error);
|
||||
product.cost = {};
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`查询团队[${teamCode}]产品成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
product
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]产品详情失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取产品详情失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新产品
|
||||
*/
|
||||
const updateProduct = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的产品ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取请求数据或使用预解析的数据
|
||||
const data = req.parsedBody || await req.json();
|
||||
|
||||
// 验证是否有需要更新的字段
|
||||
if (Object.keys(data).length === 1 && data.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少需要更新的字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始更新团队[${teamCode}]产品, ID:${id}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查产品是否存在
|
||||
const [existingProduct] = await connection.query(
|
||||
'SELECT id FROM products WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingProduct) || existingProduct.length === 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应产品' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理JSON字段
|
||||
let aliasesJson = undefined;
|
||||
if (data.aliases !== undefined) {
|
||||
aliasesJson = data.aliases ? JSON.stringify(data.aliases) : null;
|
||||
}
|
||||
|
||||
let costJson = undefined;
|
||||
if (data.cost !== undefined) {
|
||||
costJson = data.cost ? JSON.stringify(data.cost) : null;
|
||||
}
|
||||
|
||||
// 构建更新SQL
|
||||
const updateFields = [];
|
||||
const updateParams = [];
|
||||
|
||||
// 映射字段名
|
||||
const fieldMappings: Record<string, string> = {
|
||||
supplierId: 'supplier_id',
|
||||
brandId: 'brand_id',
|
||||
categoryId: 'category_id',
|
||||
logisticsStatus: 'logistics_status',
|
||||
logisticsDetails: 'logistics_details',
|
||||
trackingNumber: 'tracking_number',
|
||||
};
|
||||
|
||||
// 特殊处理的字段
|
||||
const specialFields = ['aliases', 'cost', 'id'];
|
||||
|
||||
// 添加常规字段
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key === 'id' || specialFields.includes(key)) continue;
|
||||
|
||||
const dbField = fieldMappings[key] || key;
|
||||
updateFields.push(`${dbField} = ?`);
|
||||
updateParams.push(value === null ? null : value);
|
||||
}
|
||||
|
||||
// 添加JSON字段
|
||||
if (aliasesJson !== undefined) {
|
||||
updateFields.push('aliases = ?');
|
||||
updateParams.push(aliasesJson);
|
||||
}
|
||||
|
||||
if (costJson !== undefined) {
|
||||
updateFields.push('cost = ?');
|
||||
updateParams.push(costJson);
|
||||
}
|
||||
|
||||
// 添加更新时间
|
||||
updateFields.push('updated_at = NOW()');
|
||||
|
||||
// 如果没有需要更新的字段,返回错误
|
||||
if (updateFields.length === 1) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少需要更新的字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加ID作为WHERE条件参数
|
||||
updateParams.push(id);
|
||||
|
||||
const updateSql = `
|
||||
UPDATE products
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await connection.query(updateSql, updateParams);
|
||||
|
||||
// 查询更新后的产品信息,包括关联的供应商、品牌、品类名称
|
||||
const [updatedProductRows] = await connection.query(
|
||||
`SELECT
|
||||
p.id, p.supplier_id as supplierId, p.brand_id as brandId, p.category_id as categoryId,
|
||||
p.name, p.description, p.code, p.image, p.sku, p.aliases, p.level, p.cost,
|
||||
p.price, p.stock, p.logistics_status as logisticsStatus,
|
||||
p.logistics_details as logisticsDetails, p.tracking_number as trackingNumber,
|
||||
p.created_at as createdAt, p.updated_at as updatedAt,
|
||||
s.name as supplierName, b.name as brandName, c.name as categoryName
|
||||
FROM products p
|
||||
LEFT JOIN suppliers s ON p.supplier_id = s.id
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// 处理产品数据
|
||||
let updatedProduct = null;
|
||||
if (Array.isArray(updatedProductRows) && updatedProductRows.length > 0) {
|
||||
updatedProduct = updatedProductRows[0] as RowDataPacket & {
|
||||
id: number;
|
||||
aliases?: string | string[];
|
||||
cost?: string | Record<string, number>;
|
||||
[key: string]: string | number | boolean | null | string[] | Record<string, number> | undefined;
|
||||
};
|
||||
|
||||
// 处理JSON字段
|
||||
if (updatedProduct.aliases && typeof updatedProduct.aliases === 'string') {
|
||||
try {
|
||||
updatedProduct.aliases = JSON.parse(updatedProduct.aliases);
|
||||
} catch (error) {
|
||||
console.error('解析别名JSON失败:', error);
|
||||
updatedProduct.aliases = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedProduct.cost && typeof updatedProduct.cost === 'string') {
|
||||
try {
|
||||
updatedProduct.cost = JSON.parse(updatedProduct.cost);
|
||||
} catch (error) {
|
||||
console.error('解析成本JSON失败:', error);
|
||||
updatedProduct.cost = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`更新团队[${teamCode}]产品成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '产品更新成功',
|
||||
product: updatedProduct
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`更新团队[${teamCode}]产品失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '更新产品失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除产品
|
||||
*/
|
||||
const deleteProduct = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的产品ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始删除团队[${teamCode}]产品, ID:${id}`);
|
||||
|
||||
// 检查产品是否存在
|
||||
const [existingProduct] = await req.db.query(
|
||||
'SELECT id FROM products WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingProduct) || existingProduct.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应产品' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 直接删除产品记录
|
||||
await req.db.query('DELETE FROM products WHERE id = ?', [id]);
|
||||
|
||||
console.log(`删除团队[${teamCode}]产品成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '产品删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`删除团队[${teamCode}]产品失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '删除产品失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getProduct)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理产品详情请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 克隆请求,确保body只被读取一次
|
||||
const clonedReq = req.clone();
|
||||
|
||||
// 预先读取请求体
|
||||
let body;
|
||||
try {
|
||||
body = await clonedReq.json();
|
||||
} catch {
|
||||
// 如果无法解析JSON,继续处理
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 传递body数据到teamReq的headers中,避免重复读取body
|
||||
if (body) {
|
||||
teamReq.headers.set('x-request-body', JSON.stringify(body));
|
||||
}
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(updateProduct)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理产品更新请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(deleteProduct)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理产品删除请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
424
src/app/api/team/[teamCode]/products/route.ts
Normal file
424
src/app/api/team/[teamCode]/products/route.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* 产品API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供产品数据的查询和创建接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
// 产品数据类型接口
|
||||
interface ProductRow extends RowDataPacket {
|
||||
id: number;
|
||||
supplierId?: number;
|
||||
supplierName?: string;
|
||||
brandId?: number;
|
||||
brandName?: string;
|
||||
categoryId?: number;
|
||||
categoryName?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
code?: string;
|
||||
image?: string;
|
||||
sku?: string;
|
||||
aliases?: string | string[];
|
||||
level?: string;
|
||||
cost?: string | Record<string, number>;
|
||||
price: number;
|
||||
stock: number;
|
||||
logisticsStatus?: string;
|
||||
logisticsDetails?: string;
|
||||
trackingNumber?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
supplier?: { id: number; name: string };
|
||||
brand?: { id: number; name: string };
|
||||
category?: { id: number; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/products
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* GET 获取产品列表
|
||||
*/
|
||||
const getProducts = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
|
||||
const keyword = url.searchParams.get('keyword') || '';
|
||||
const supplierId = url.searchParams.get('supplierId') || '';
|
||||
const brandId = url.searchParams.get('brandId') || '';
|
||||
const categoryId = url.searchParams.get('categoryId') || '';
|
||||
|
||||
// 计算偏移量
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]产品列表, 页码:${page}, 每页:${pageSize}`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams = [];
|
||||
|
||||
if (keyword) {
|
||||
conditions.push('(p.name LIKE ? OR p.code LIKE ? OR p.sku LIKE ? OR p.description LIKE ?)');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
if (supplierId) {
|
||||
conditions.push('p.supplier_id = ?');
|
||||
queryParams.push(supplierId);
|
||||
}
|
||||
|
||||
if (brandId) {
|
||||
conditions.push('p.brand_id = ?');
|
||||
queryParams.push(brandId);
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
conditions.push('p.category_id = ?');
|
||||
queryParams.push(categoryId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总数
|
||||
const countSql = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM products p
|
||||
${whereClause}
|
||||
`;
|
||||
const [totalRows] = await connection.query<RowDataPacket[]>(countSql, queryParams);
|
||||
|
||||
const total = totalRows[0].total as number;
|
||||
|
||||
// 查询分页数据
|
||||
const querySql = `
|
||||
SELECT
|
||||
p.id, p.supplier_id as supplierId, s.name as supplierName,
|
||||
p.brand_id as brandId, b.name as brandName,
|
||||
p.category_id as categoryId, c.name as categoryName,
|
||||
p.name, p.description, p.code, p.image, p.sku,
|
||||
p.aliases, p.level, p.cost, p.price, p.stock,
|
||||
p.logistics_status as logisticsStatus,
|
||||
p.logistics_details as logisticsDetails,
|
||||
p.tracking_number as trackingNumber,
|
||||
p.created_at as createdAt, p.updated_at as updatedAt
|
||||
FROM products p
|
||||
LEFT JOIN suppliers s ON p.supplier_id = s.id
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
${whereClause}
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
// 添加分页参数
|
||||
const paginatedParams = [...queryParams, pageSize, offset];
|
||||
|
||||
const [rows] = await connection.query<ProductRow[]>(querySql, paginatedParams);
|
||||
|
||||
// 处理查询结果
|
||||
let products = rows;
|
||||
|
||||
// 处理别名和成本字段的JSON解析
|
||||
products = products.map(product => {
|
||||
// 处理别名字段
|
||||
if (product.aliases && typeof product.aliases === 'string') {
|
||||
try {
|
||||
product.aliases = JSON.parse(product.aliases) as string[];
|
||||
} catch (error) {
|
||||
console.error('解析别名JSON失败:', error);
|
||||
product.aliases = [] as string[];
|
||||
}
|
||||
}
|
||||
|
||||
// 处理成本字段
|
||||
if (product.cost && typeof product.cost === 'string') {
|
||||
try {
|
||||
product.cost = JSON.parse(product.cost) as Record<string, number>;
|
||||
} catch (error) {
|
||||
console.error('解析成本JSON失败:', error);
|
||||
product.cost = {} as Record<string, number>;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理关联对象
|
||||
if (product.supplierId && product.supplierName) {
|
||||
product.supplier = {
|
||||
id: product.supplierId,
|
||||
name: product.supplierName
|
||||
};
|
||||
}
|
||||
|
||||
if (product.brandId && product.brandName) {
|
||||
product.brand = {
|
||||
id: product.brandId,
|
||||
name: product.brandName
|
||||
};
|
||||
}
|
||||
|
||||
if (product.categoryId && product.categoryName) {
|
||||
product.category = {
|
||||
id: product.categoryId,
|
||||
name: product.categoryName
|
||||
};
|
||||
}
|
||||
|
||||
return product;
|
||||
});
|
||||
|
||||
console.log(`查询团队[${teamCode}]产品列表成功, 总数:${total}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total,
|
||||
products
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]产品列表失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取产品列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST 创建产品
|
||||
*/
|
||||
const createProduct = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取请求数据
|
||||
const data = await req.json();
|
||||
|
||||
// 验证必填字段
|
||||
if (!data.name) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '产品名称为必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (data.price === undefined) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '产品价格为必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始创建团队[${teamCode}]产品, 名称:${data.name}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 处理JSON字段
|
||||
let aliasesJson = null;
|
||||
if (data.aliases && Array.isArray(data.aliases)) {
|
||||
aliasesJson = JSON.stringify(data.aliases);
|
||||
}
|
||||
|
||||
let costJson = null;
|
||||
if (data.cost) {
|
||||
costJson = JSON.stringify(data.cost);
|
||||
}
|
||||
|
||||
// 插入产品记录
|
||||
const insertSql = `
|
||||
INSERT INTO products (
|
||||
supplier_id, brand_id, category_id, name, description, code, image, sku,
|
||||
aliases, level, cost, price, stock, logistics_status, logistics_details,
|
||||
tracking_number, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await connection.execute(insertSql, [
|
||||
data.supplierId || null,
|
||||
data.brandId || null,
|
||||
data.categoryId || null,
|
||||
data.name,
|
||||
data.description || null,
|
||||
data.code || null,
|
||||
data.image || null,
|
||||
data.sku || null,
|
||||
aliasesJson,
|
||||
data.level || null,
|
||||
costJson,
|
||||
data.price,
|
||||
data.stock || 0,
|
||||
data.logisticsStatus || null,
|
||||
data.logisticsDetails || null,
|
||||
data.trackingNumber || null
|
||||
]);
|
||||
|
||||
const insertId = (result as { insertId: number }).insertId;
|
||||
|
||||
// 查询新插入的产品信息,包括关联的供应商、品牌、品类名称
|
||||
const [newProductRows] = await connection.query<ProductRow[]>(
|
||||
`SELECT
|
||||
p.id, p.supplier_id as supplierId, p.brand_id as brandId, p.category_id as categoryId,
|
||||
p.name, p.description, p.code, p.image, p.sku, p.aliases, p.level, p.cost,
|
||||
p.price, p.stock, p.logistics_status as logisticsStatus,
|
||||
p.logistics_details as logisticsDetails, p.tracking_number as trackingNumber,
|
||||
p.created_at as createdAt, p.updated_at as updatedAt,
|
||||
s.name as supplierName, b.name as brandName, c.name as categoryName
|
||||
FROM products p
|
||||
LEFT JOIN suppliers s ON p.supplier_id = s.id
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = ?`,
|
||||
[insertId]
|
||||
);
|
||||
|
||||
// 处理查询结果
|
||||
let newProduct: ProductRow | null = null;
|
||||
if (newProductRows.length > 0) {
|
||||
newProduct = newProductRows[0];
|
||||
|
||||
// 处理JSON字段
|
||||
if (newProduct.aliases && typeof newProduct.aliases === 'string') {
|
||||
try {
|
||||
newProduct.aliases = JSON.parse(newProduct.aliases) as string[];
|
||||
} catch (error) {
|
||||
console.error('解析别名JSON失败:', error);
|
||||
newProduct.aliases = [] as string[];
|
||||
}
|
||||
}
|
||||
|
||||
if (newProduct.cost && typeof newProduct.cost === 'string') {
|
||||
try {
|
||||
newProduct.cost = JSON.parse(newProduct.cost) as Record<string, number>;
|
||||
} catch (error) {
|
||||
console.error('解析成本JSON失败:', error);
|
||||
newProduct.cost = {} as Record<string, number>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`创建团队[${teamCode}]产品成功, ID:${insertId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '产品创建成功',
|
||||
product: newProduct
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`创建团队[${teamCode}]产品失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '创建产品失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getProducts)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理产品列表请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(createProduct)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理产品创建请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
598
src/app/api/team/[teamCode]/sales-records/route.ts
Normal file
598
src/app/api/team/[teamCode]/sales-records/route.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* 销售记录API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供销售记录的查询和创建接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RowDataPacket } from 'mysql2/promise';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { RecordType } from '@/models/team/types/ILogisticsRecord';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/sales-records
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 销售记录数据接口
|
||||
*/
|
||||
interface SalesRecordRow extends RowDataPacket {
|
||||
id: number;
|
||||
customerId: number;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
//sourceId?: number;
|
||||
sourceName?: string;
|
||||
sourceWechat?: string;
|
||||
guideId?: number;
|
||||
guideUsername?: string;
|
||||
guideName?: string;
|
||||
guidePhone?: string;
|
||||
guideWechat?: string;
|
||||
paymentType: number;
|
||||
dealDate: string;
|
||||
receivable: number;
|
||||
received: number;
|
||||
pending: number;
|
||||
platformId?: number;
|
||||
platformName?: string;
|
||||
dealShopId?: string;
|
||||
dealShopName?: string;
|
||||
dealShopWechat?: string;
|
||||
orderStatus: string | string[];
|
||||
followupDate?: string;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
products?: ProductRow[];
|
||||
logisticsStatus?: string; // 物流状态
|
||||
logisticsTrackingNumber?: string; // 物流单号
|
||||
logisticsCompany?: string; // 物流公司
|
||||
logisticsCustomerTailNumber?: string; // 客户尾号
|
||||
logisticsIsQueryable?: boolean; // 是否可查询
|
||||
logisticsDetails?: string; // 物流详情
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品数据接口
|
||||
*/
|
||||
interface ProductRow extends RowDataPacket {
|
||||
id: number;
|
||||
salesRecordId: number;
|
||||
productId: number;
|
||||
productName: string;
|
||||
productCode?: string;
|
||||
productSku?: string;
|
||||
image?: string;
|
||||
description?: string;
|
||||
brandId?: number;
|
||||
brandName?: string;
|
||||
categoryId?: number;
|
||||
categoryName?: string;
|
||||
supplierId?: number;
|
||||
supplierName?: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 物流记录数据接口
|
||||
*/
|
||||
interface LogisticsRecord extends RowDataPacket {
|
||||
id: number;
|
||||
tracking_number: string;
|
||||
is_queryable: boolean;
|
||||
customer_tail_number: string;
|
||||
company: string;
|
||||
details?: string;
|
||||
status?: string;
|
||||
record_id: number;
|
||||
record_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库查询结果类型
|
||||
*/
|
||||
interface DbResult {
|
||||
insertId: number;
|
||||
affectedRows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 获取销售记录列表
|
||||
*/
|
||||
const getSalesRecords = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
|
||||
const keyword = url.searchParams.get('keyword') || '';
|
||||
const customerId = url.searchParams.get('customerId') || '';
|
||||
//const sourceId = url.searchParams.get('sourceId') || '';
|
||||
const platformId = url.searchParams.get('platformId') || '';
|
||||
const startDate = url.searchParams.get('startDate') || '';
|
||||
const endDate = url.searchParams.get('endDate') || '';
|
||||
|
||||
// 计算偏移量
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]销售记录列表, 页码:${page}, 每页:${pageSize}`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams = [];
|
||||
|
||||
if (keyword) {
|
||||
conditions.push('(c.name LIKE ? OR sr.remark LIKE ?)');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
if (customerId) {
|
||||
conditions.push('sr.customer_id = ?');
|
||||
queryParams.push(customerId);
|
||||
}
|
||||
|
||||
//if (sourceId) {
|
||||
// conditions.push('sr.source_id = ?');
|
||||
// queryParams.push(sourceId);
|
||||
//}
|
||||
|
||||
if (platformId) {
|
||||
conditions.push('sr.platform_id = ?');
|
||||
queryParams.push(platformId);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push('sr.deal_date >= ?');
|
||||
queryParams.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push('sr.deal_date <= ?');
|
||||
queryParams.push(endDate);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总数
|
||||
const countSql = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM sales_records sr
|
||||
LEFT JOIN customers c ON sr.customer_id = c.id
|
||||
${whereClause}
|
||||
`;
|
||||
const [totalRows] = await connection.query<RowDataPacket[]>(countSql, queryParams);
|
||||
|
||||
const total = totalRows[0].total as number;
|
||||
|
||||
// 查询分页数据
|
||||
const querySql = `
|
||||
SELECT
|
||||
sr.id, sr.customer_id as customerId, c.name as customerName, c.phone as customerPhone,
|
||||
sr.source_id as sourceId, s1.nickname as sourceName, s1.wechat as sourceWechat,
|
||||
sr.guide_id as guideId,
|
||||
sr.payment_type as paymentType, sr.deal_date as dealDate,
|
||||
sr.receivable, sr.received, sr.pending,
|
||||
sr.platform_id as platformId, pp.name as platformName,
|
||||
sr.deal_shop as dealShopId, s2.nickname as dealShopName, s2.wechat as dealShopWechat,
|
||||
sr.order_status as orderStatus,
|
||||
sr.followup_date as followupDate, sr.remark,
|
||||
sr.created_at as createdAt, sr.updated_at as updatedAt
|
||||
FROM sales_records sr
|
||||
LEFT JOIN customers c ON sr.customer_id = c.id
|
||||
LEFT JOIN shops s1 ON sr.source_id = s1.id
|
||||
LEFT JOIN payment_platforms pp ON sr.platform_id = pp.id
|
||||
LEFT JOIN shops s2 ON sr.deal_shop = s2.id
|
||||
${whereClause}
|
||||
ORDER BY sr.deal_date DESC, sr.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
// 添加分页参数
|
||||
const paginatedParams = [...queryParams, pageSize, offset];
|
||||
|
||||
const [rows] = await connection.query<SalesRecordRow[]>(querySql, paginatedParams);
|
||||
|
||||
// 查询每条销售记录关联的产品
|
||||
const salesRecords = rows;
|
||||
if (salesRecords.length > 0) {
|
||||
for (let i = 0; i < salesRecords.length; i++) {
|
||||
const record = salesRecords[i];
|
||||
|
||||
// 处理order_status字段,从JSON字符串转为数组
|
||||
if (record.orderStatus && typeof record.orderStatus === 'string') {
|
||||
try {
|
||||
record.orderStatus = JSON.parse(record.orderStatus as string);
|
||||
} catch {
|
||||
record.orderStatus = ['正常']; // 默认值
|
||||
}
|
||||
}
|
||||
|
||||
// 查询关联产品
|
||||
const [productRows] = await connection.query<ProductRow[]>(`
|
||||
SELECT
|
||||
srp.id, srp.sales_record_id as salesRecordId,
|
||||
srp.product_id as productId, p.name as productName,
|
||||
p.code as productCode, p.sku as productSku,
|
||||
p.image, p.description,
|
||||
p.brand_id as brandId, b.name as brandName,
|
||||
p.category_id as categoryId, c.name as categoryName,
|
||||
p.supplier_id as supplierId, s.name as supplierName,
|
||||
srp.quantity, srp.price,
|
||||
srp.created_at as createdAt
|
||||
FROM sales_record_products srp
|
||||
LEFT JOIN products p ON srp.product_id = p.id
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
LEFT JOIN suppliers s ON p.supplier_id = s.id
|
||||
WHERE srp.sales_record_id = ?
|
||||
`, [record.id]);
|
||||
|
||||
record.products = productRows;
|
||||
|
||||
// 查询关联的物流记录,并添加物流状态信息
|
||||
const [logisticsRows] = await connection.query<LogisticsRecord[]>(`
|
||||
SELECT
|
||||
id, tracking_number, company, status, details,
|
||||
is_queryable, customer_tail_number
|
||||
FROM logistics_records
|
||||
WHERE record_id = ? AND record_type = ?
|
||||
`, [record.id, RecordType.SALES_RECORD]);
|
||||
|
||||
// 将物流信息添加到销售记录中
|
||||
if (logisticsRows.length > 0) {
|
||||
const logistics = logisticsRows[0];
|
||||
record.logisticsStatus = logistics.status;
|
||||
record.logisticsTrackingNumber = logistics.tracking_number;
|
||||
record.logisticsCompany = logistics.company;
|
||||
record.logisticsCustomerTailNumber = logistics.customer_tail_number;
|
||||
record.logisticsIsQueryable = Boolean(logistics.is_queryable);
|
||||
record.logisticsDetails = logistics.details;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`查询团队[${teamCode}]销售记录列表成功, 总数:${total}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total,
|
||||
salesRecords
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]销售记录列表失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取销售记录列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST 创建销售记录
|
||||
*/
|
||||
const createSalesRecord = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取请求数据
|
||||
const data = await req.json();
|
||||
|
||||
// 验证必填字段
|
||||
if (!data.customerId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '客户ID为必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.dealDate) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '成交日期为必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data.paymentType !== 'number') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '收款方式为必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data.receivable !== 'number' || data.receivable < 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '应收金额必须是大于等于0的数字' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data.received !== 'number' || data.received < 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '实收金额必须是大于等于0的数字' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.products) || data.products.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '至少需要添加一个产品' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证每个产品的信息
|
||||
for (const product of data.products) {
|
||||
if (!product.productId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '产品ID为必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof product.quantity !== 'number' || product.quantity <= 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '产品数量必须大于0' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof product.price !== 'number' || product.price < 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '产品价格必须是大于等于0的数字' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始创建团队[${teamCode}]销售记录, 客户ID:${data.customerId}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查客户是否存在
|
||||
const [existingCustomer] = await connection.query<RowDataPacket[]>(
|
||||
'SELECT id FROM customers WHERE id = ? AND deleted_at IS NULL',
|
||||
[data.customerId]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingCustomer) || existingCustomer.length === 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '选择的客户不存在或已被删除' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理订单状态,默认为["正常"]
|
||||
const orderStatus = data.orderStatus && Array.isArray(data.orderStatus) && data.orderStatus.length > 0
|
||||
? JSON.stringify(data.orderStatus)
|
||||
: JSON.stringify(['正常']);
|
||||
|
||||
// 插入销售记录
|
||||
const insertSql = `
|
||||
INSERT INTO sales_records (
|
||||
customer_id, source_id, guide_id, payment_type, deal_date,
|
||||
receivable, received, pending, platform_id, deal_shop,
|
||||
order_status, followup_date, remark, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await connection.query(insertSql, [
|
||||
data.customerId,
|
||||
data.sourceId || null,
|
||||
data.guideId || null, // 使用请求中的导购ID
|
||||
data.paymentType,
|
||||
data.dealDate,
|
||||
data.receivable,
|
||||
data.received,
|
||||
data.pending || 0,
|
||||
data.platformId || null,
|
||||
data.dealShop || null, // dealShop现在是店铺ID
|
||||
orderStatus,
|
||||
data.followupDate || null,
|
||||
data.remark || null
|
||||
]);
|
||||
|
||||
const salesRecordId = (result as DbResult).insertId;
|
||||
|
||||
// 插入销售记录-产品关联
|
||||
if (data.products && data.products.length > 0) {
|
||||
const productInsertSql = `
|
||||
INSERT INTO sales_record_products (
|
||||
sales_record_id, product_id, quantity, price, created_at
|
||||
) VALUES (?, ?, ?, ?, NOW())
|
||||
`;
|
||||
|
||||
for (const product of data.products) {
|
||||
await connection.query(productInsertSql, [
|
||||
salesRecordId,
|
||||
product.productId,
|
||||
product.quantity,
|
||||
product.price
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 查询新插入的销售记录信息及关联产品
|
||||
const [salesRecordRows] = await connection.query<SalesRecordRow[]>(`
|
||||
SELECT
|
||||
sr.id, sr.customer_id as customerId, c.name as customerName, c.phone as customerPhone,
|
||||
sr.source_id as sourceId, s1.nickname as sourceName, s1.wechat as sourceWechat,
|
||||
sr.guide_id as guideId,
|
||||
sr.payment_type as paymentType, sr.deal_date as dealDate,
|
||||
sr.receivable, sr.received, sr.pending,
|
||||
sr.platform_id as platformId, pp.name as platformName,
|
||||
sr.deal_shop as dealShopId, s2.nickname as dealShopName, s2.wechat as dealShopWechat,
|
||||
sr.order_status as orderStatus,
|
||||
sr.followup_date as followupDate, sr.remark,
|
||||
sr.created_at as createdAt, sr.updated_at as updatedAt
|
||||
FROM sales_records sr
|
||||
LEFT JOIN customers c ON sr.customer_id = c.id
|
||||
LEFT JOIN shops s1 ON sr.source_id = s1.id
|
||||
LEFT JOIN payment_platforms pp ON sr.platform_id = pp.id
|
||||
LEFT JOIN shops s2 ON sr.deal_shop = s2.id
|
||||
WHERE sr.id = ?
|
||||
`, [salesRecordId]);
|
||||
|
||||
const salesRecord = salesRecordRows.length > 0 ? salesRecordRows[0] : null;
|
||||
if (salesRecord) {
|
||||
// 处理order_status字段,从JSON字符串转为数组
|
||||
if (salesRecord.orderStatus && typeof salesRecord.orderStatus === 'string') {
|
||||
try {
|
||||
salesRecord.orderStatus = JSON.parse(salesRecord.orderStatus as string);
|
||||
} catch {
|
||||
salesRecord.orderStatus = ['正常']; // 默认值
|
||||
}
|
||||
}
|
||||
|
||||
// 查询关联产品
|
||||
const [productRows] = await connection.query<ProductRow[]>(`
|
||||
SELECT
|
||||
srp.id, srp.sales_record_id as salesRecordId,
|
||||
srp.product_id as productId, p.name as productName,
|
||||
p.code as productCode, p.sku as productSku,
|
||||
p.image, p.description,
|
||||
p.brand_id as brandId, b.name as brandName,
|
||||
p.category_id as categoryId, c.name as categoryName,
|
||||
p.supplier_id as supplierId, s.name as supplierName,
|
||||
srp.quantity, srp.price,
|
||||
srp.created_at as createdAt
|
||||
FROM sales_record_products srp
|
||||
LEFT JOIN products p ON srp.product_id = p.id
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
LEFT JOIN suppliers s ON p.supplier_id = s.id
|
||||
WHERE srp.sales_record_id = ?
|
||||
`, [salesRecordId]);
|
||||
|
||||
salesRecord.products = productRows;
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`创建团队[${teamCode}]销售记录成功, ID:${salesRecordId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '销售记录创建成功',
|
||||
salesRecord
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`创建团队[${teamCode}]销售记录失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '创建销售记录失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getSalesRecords)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理销售记录列表请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(createSalesRecord)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理销售记录创建请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
404
src/app/api/team/[teamCode]/shop-follower-growth/[id]/route.ts
Normal file
404
src/app/api/team/[teamCode]/shop-follower-growth/[id]/route.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 单个店铺粉丝增长记录API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供单个店铺粉丝增长记录的获取、更新和删除接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { RowDataPacket } from 'mysql2/promise';
|
||||
|
||||
// 定义数据类型接口
|
||||
interface ShopRecord extends RowDataPacket {
|
||||
id: number;
|
||||
shop_id: number;
|
||||
date: Date;
|
||||
total: number;
|
||||
deducted: number;
|
||||
daily_increase: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
wechat?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从URL路径中提取参数
|
||||
* @param req 请求对象
|
||||
* @returns 提取的参数对象
|
||||
*/
|
||||
const extractParamsFromPath = (req: NextRequest): { teamCode: string; id: string } => {
|
||||
// 路径格式: /api/team/[teamCode]/shop-follower-growth/[id]
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3,记录ID位于索引5
|
||||
return {
|
||||
teamCode: pathParts[3] || '',
|
||||
id: pathParts[5] || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化MySQL日期为YYYY-MM-DD格式
|
||||
* 修复时区问题,保留原始日期
|
||||
*/
|
||||
function formatMySQLDate(date: Date): string {
|
||||
// MySQL中存储的是本地日期,直接获取日期部分即可
|
||||
// 注意:不使用toISOString(),因为它会转换为UTC时间并导致日期偏移
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个店铺粉丝增长记录
|
||||
*/
|
||||
const getGrowthRecord = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
const id = params?.id as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的记录ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]店铺粉丝增长记录, ID:${id}`);
|
||||
|
||||
// 查询记录详情
|
||||
const [rows] = await req.db.query<ShopRecord[]>(
|
||||
`SELECT
|
||||
g.id, g.shop_id, g.date, g.total, g.deducted, g.daily_increase,
|
||||
g.created_at, g.updated_at,
|
||||
s.wechat, s.nickname
|
||||
FROM shop_follower_growth g
|
||||
LEFT JOIN shops s ON g.shop_id = s.id
|
||||
WHERE g.id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应记录' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 格式化返回数据
|
||||
const record = {
|
||||
id: rows[0].id,
|
||||
shop_id: rows[0].shop_id,
|
||||
date: formatMySQLDate(rows[0].date), // 使用修复的日期格式化函数
|
||||
total: rows[0].total,
|
||||
deducted: rows[0].deducted,
|
||||
daily_increase: rows[0].daily_increase,
|
||||
created_at: rows[0].created_at,
|
||||
updated_at: rows[0].updated_at,
|
||||
shop_name: rows[0].nickname || rows[0].wechat || `店铺ID: ${rows[0].shop_id}`
|
||||
};
|
||||
|
||||
console.log(`查询团队[${teamCode}]店铺粉丝增长记录成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
record
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]店铺粉丝增长记录详情失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取粉丝增长记录详情失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新店铺粉丝增长记录
|
||||
*/
|
||||
const updateGrowthRecord = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
const id = params?.id as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的记录ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析请求数据或使用预解析的数据
|
||||
const data = req.parsedBody || await req.json();
|
||||
const { shop_id, date, total, deducted } = data;
|
||||
|
||||
// 验证必填字段
|
||||
if (!shop_id || !date || total === undefined) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '店铺ID、日期和粉丝总数为必填项' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证数据类型
|
||||
if (typeof shop_id !== 'number' || typeof total !== 'number') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '数据类型错误' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 检查记录是否存在
|
||||
const [existingRecord] = await connection.query<RowDataPacket[]>(
|
||||
'SELECT id FROM shop_follower_growth WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingRecord) || existingRecord.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应记录' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查店铺是否存在
|
||||
const [shopExists] = await connection.query<RowDataPacket[]>(
|
||||
'SELECT id FROM shops WHERE id = ?',
|
||||
[shop_id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(shopExists) || shopExists.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '店铺不存在' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查日期是否与其他记录冲突
|
||||
const [dateConflict] = await connection.query<RowDataPacket[]>(
|
||||
'SELECT id FROM shop_follower_growth WHERE shop_id = ? AND date = ? AND id != ?',
|
||||
[shop_id, date, id]
|
||||
);
|
||||
|
||||
if (Array.isArray(dateConflict) && dateConflict.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该店铺在选定日期已有其他记录,一个店铺每天只能有一条记录' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 计算日增长
|
||||
const daily_increase = total - (deducted || 0);
|
||||
|
||||
// 更新记录
|
||||
await connection.query(
|
||||
`UPDATE shop_follower_growth
|
||||
SET shop_id = ?, date = ?, total = ?, deducted = ?, daily_increase = ?
|
||||
WHERE id = ?`,
|
||||
[shop_id, date, total, deducted || 0, daily_increase, id]
|
||||
);
|
||||
|
||||
console.log(`更新团队[${teamCode}]店铺粉丝增长记录成功, ID:${id}`);
|
||||
|
||||
// 返回更新后的数据
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
record: {
|
||||
id: parseInt(id),
|
||||
shop_id,
|
||||
date,
|
||||
total,
|
||||
deducted: deducted || 0,
|
||||
daily_increase
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`更新团队[${teamCode}]店铺粉丝增长记录失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '更新粉丝增长记录失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除店铺粉丝增长记录
|
||||
*/
|
||||
const deleteGrowthRecord = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
const id = params?.id as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的记录ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始删除团队[${teamCode}]店铺粉丝增长记录, ID:${id}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 检查记录是否存在
|
||||
const [existingRecord] = await connection.query<RowDataPacket[]>(
|
||||
'SELECT id FROM shop_follower_growth WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingRecord) || existingRecord.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应记录' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
await connection.query(
|
||||
'DELETE FROM shop_follower_growth WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
console.log(`删除团队[${teamCode}]店铺粉丝增长记录成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '记录删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`删除团队[${teamCode}]店铺粉丝增长记录失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '删除粉丝增长记录失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getGrowthRecord)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理粉丝增长记录详情请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 克隆请求,确保body只被读取一次
|
||||
const clonedReq = req.clone();
|
||||
|
||||
// 预先读取请求体
|
||||
let body;
|
||||
try {
|
||||
body = await clonedReq.json();
|
||||
} catch {
|
||||
// 如果无法解析JSON,继续处理
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 传递body数据到teamReq的headers中,避免重复读取body
|
||||
if (body) {
|
||||
teamReq.headers.set('x-request-body', JSON.stringify(body));
|
||||
}
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(updateGrowthRecord)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理粉丝增长记录更新请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(deleteGrowthRecord)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理粉丝增长记录删除请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
324
src/app/api/team/[teamCode]/shop-follower-growth/route.ts
Normal file
324
src/app/api/team/[teamCode]/shop-follower-growth/route.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 店铺粉丝增长API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供店铺粉丝增长记录的查询和新增接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { RowDataPacket } from 'mysql2/promise';
|
||||
|
||||
// 定义数据类型接口
|
||||
interface ShopRecord extends RowDataPacket {
|
||||
id: number;
|
||||
shop_id: number;
|
||||
date: Date;
|
||||
total: number;
|
||||
deducted: number;
|
||||
daily_increase: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
wechat?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/shop-follower-growth
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化MySQL日期为YYYY-MM-DD格式
|
||||
* 修复时区问题,保留原始日期
|
||||
*/
|
||||
function formatMySQLDate(date: Date): string {
|
||||
// MySQL中存储的是本地日期,直接获取日期部分即可
|
||||
// 注意:不使用toISOString(),因为它会转换为UTC时间并导致日期偏移
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 查询店铺粉丝增长记录列表
|
||||
*/
|
||||
const getGrowthRecords = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
// 获取分页参数
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '10');
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 获取筛选参数
|
||||
const keyword = searchParams.get('keyword');
|
||||
const shopId = searchParams.get('shopId');
|
||||
const startDate = searchParams.get('startDate');
|
||||
const endDate = searchParams.get('endDate');
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]店铺粉丝增长记录`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams: (string | number)[] = [];
|
||||
|
||||
if (shopId && parseInt(shopId) > 0) {
|
||||
conditions.push('g.shop_id = ?');
|
||||
queryParams.push(parseInt(shopId));
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push('g.date >= ?');
|
||||
queryParams.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push('g.date <= ?');
|
||||
queryParams.push(endDate);
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push('(s.wechat LIKE ? OR s.nickname LIKE ?)');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总记录数
|
||||
const [countResult] = await connection.query(
|
||||
`SELECT COUNT(*) AS total FROM shop_follower_growth g
|
||||
LEFT JOIN shops s ON g.shop_id = s.id
|
||||
${whereClause}`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
const total = (countResult as Array<{ total: number }>)[0].total;
|
||||
|
||||
// 查询分页数据
|
||||
const paginationParams = [...queryParams, offset, pageSize];
|
||||
const [rows] = await connection.query<ShopRecord[]>(
|
||||
`SELECT
|
||||
g.id, g.shop_id, g.date, g.total, g.deducted, g.daily_increase,
|
||||
g.created_at, g.updated_at,
|
||||
s.wechat, s.nickname
|
||||
FROM shop_follower_growth g
|
||||
LEFT JOIN shops s ON g.shop_id = s.id
|
||||
${whereClause}
|
||||
ORDER BY g.date DESC, g.id DESC
|
||||
LIMIT ?, ?`,
|
||||
paginationParams
|
||||
);
|
||||
|
||||
// 格式化返回数据
|
||||
const records = Array.isArray(rows) ? rows.map(row => ({
|
||||
id: row.id,
|
||||
shop_id: row.shop_id,
|
||||
// 使用日期格式化函数
|
||||
date: formatMySQLDate(row.date),
|
||||
total: row.total,
|
||||
deducted: row.deducted,
|
||||
daily_increase: row.daily_increase,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
shop_name: row.nickname || row.wechat || `店铺ID: ${row.shop_id}`
|
||||
})) : [];
|
||||
|
||||
console.log(`查询团队[${teamCode}]店铺粉丝增长记录成功,共 ${total} 条记录`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total,
|
||||
records
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]店铺粉丝增长记录失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取店铺粉丝增长记录失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST 添加店铺粉丝增长记录
|
||||
*/
|
||||
const addGrowthRecord = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
try {
|
||||
// 解析请求数据
|
||||
const data = await req.json();
|
||||
const { shop_id, date, total, deducted } = data;
|
||||
|
||||
// 验证必填字段
|
||||
if (!shop_id || !date || total === undefined) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '店铺ID、日期和粉丝总数为必填项' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证数据类型
|
||||
if (typeof shop_id !== 'number' || typeof total !== 'number') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '数据类型错误' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 检查店铺是否存在
|
||||
const [shopExists] = await connection.query<RowDataPacket[]>(
|
||||
'SELECT id FROM shops WHERE id = ?',
|
||||
[shop_id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(shopExists) || shopExists.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '店铺不存在' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查记录是否已存在(同一店铺同一天只能有一条记录)
|
||||
const [existingRecord] = await connection.query<RowDataPacket[]>(
|
||||
'SELECT id FROM shop_follower_growth WHERE shop_id = ? AND date = ?',
|
||||
[shop_id, date]
|
||||
);
|
||||
|
||||
if (Array.isArray(existingRecord) && existingRecord.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该店铺在选定日期已有记录,请编辑现有记录或选择其他日期' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 计算日增长
|
||||
const daily_increase = total - (deducted || 0);
|
||||
|
||||
// 插入记录
|
||||
const [result] = await connection.query(
|
||||
`INSERT INTO shop_follower_growth
|
||||
(shop_id, date, total, deducted, daily_increase)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[shop_id, date, total, deducted || 0, daily_increase]
|
||||
);
|
||||
|
||||
const insertId = (result as { insertId: number }).insertId;
|
||||
|
||||
console.log(`添加团队[${teamCode}]店铺粉丝增长记录成功, ID: ${insertId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id: insertId,
|
||||
shop_id,
|
||||
date,
|
||||
total,
|
||||
deducted: deducted || 0,
|
||||
daily_increase
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error(`添加团队[${teamCode}]店铺粉丝增长记录失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '添加店铺粉丝增长记录失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getGrowthRecords)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理店铺粉丝增长记录请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(addGrowthRecord)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理添加店铺粉丝增长记录请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 店铺粉丝增长统计API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供店铺粉丝增长的统计分析数据
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { RowDataPacket } from 'mysql2/promise';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/shop-follower-growth/statistics
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取店铺粉丝增长统计数据
|
||||
*/
|
||||
const getGrowthStatistics = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
// 获取查询参数
|
||||
const shopIds = searchParams.getAll('shopIds');
|
||||
const startDate = searchParams.get('startDate');
|
||||
const endDate = searchParams.get('endDate');
|
||||
|
||||
if (!shopIds || shopIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请提供至少一个店铺ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]店铺粉丝增长统计数据`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = ['1=1']; // 始终为真的条件,方便后续拼接
|
||||
const queryParams: (string | number)[] = [];
|
||||
|
||||
// 添加店铺ID过滤
|
||||
if (shopIds.length > 0) {
|
||||
const shopIdsInt = shopIds.map(id => parseInt(id));
|
||||
conditions.push(`g.shop_id IN (${shopIdsInt.map(() => '?').join(',')})`);
|
||||
queryParams.push(...shopIdsInt);
|
||||
}
|
||||
|
||||
// 添加时间范围过滤
|
||||
if (startDate) {
|
||||
conditions.push('g.date >= ?');
|
||||
queryParams.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push('g.date <= ?');
|
||||
queryParams.push(endDate);
|
||||
}
|
||||
|
||||
// 拼接条件
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询数据
|
||||
const [rows] = await connection.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
g.shop_id, g.date, g.total, g.deducted, g.daily_increase,
|
||||
s.wechat, s.nickname
|
||||
FROM shop_follower_growth g
|
||||
LEFT JOIN shops s ON g.shop_id = s.id
|
||||
${whereClause}
|
||||
ORDER BY g.shop_id, g.date`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// 按店铺分组处理数据
|
||||
const shopGroups: Record<number, {
|
||||
shop_id: number;
|
||||
shop_name: string;
|
||||
dates: string[];
|
||||
total_counts: number[];
|
||||
daily_increases: number[];
|
||||
}> = {};
|
||||
|
||||
if (Array.isArray(rows)) {
|
||||
rows.forEach(row => {
|
||||
const shopId = row.shop_id;
|
||||
|
||||
if (!shopGroups[shopId]) {
|
||||
shopGroups[shopId] = {
|
||||
shop_id: shopId,
|
||||
shop_name: row.nickname || row.wechat || `店铺ID: ${shopId}`,
|
||||
dates: [],
|
||||
total_counts: [],
|
||||
daily_increases: []
|
||||
};
|
||||
}
|
||||
|
||||
shopGroups[shopId].dates.push(row.date.toISOString().split('T')[0]);
|
||||
shopGroups[shopId].total_counts.push(row.total);
|
||||
shopGroups[shopId].daily_increases.push(row.daily_increase);
|
||||
});
|
||||
}
|
||||
|
||||
// 转换为数组
|
||||
const statistics = Object.values(shopGroups);
|
||||
|
||||
console.log(`查询团队[${teamCode}]店铺粉丝增长统计数据成功,共 ${statistics.length} 个店铺的数据`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
statistics
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]店铺粉丝增长统计数据失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取店铺粉丝增长统计数据失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getGrowthStatistics)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理店铺粉丝增长统计请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
139
src/app/api/team/[teamCode]/shop-sales-analysis/route.ts
Normal file
139
src/app/api/team/[teamCode]/shop-sales-analysis/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 作者: 阿瑞
|
||||
* 功能: 店铺成交分析数据API
|
||||
* 版本: 1.0.0
|
||||
* 文件有问题!!!!!api处理方式不对!!!!待修复
|
||||
*/
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { teamDbManager } from '@/lib/db';
|
||||
import { Shop } from "@/lib/db/types";
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
/**
|
||||
* 类型定义
|
||||
*/
|
||||
interface ShopRow extends Shop, RowDataPacket {}
|
||||
interface FollowerResult extends RowDataPacket { total_followers: number }
|
||||
interface ExpenseResult extends RowDataPacket { total_expenses: number }
|
||||
interface SalesResult extends RowDataPacket {
|
||||
order_count: number;
|
||||
total_sales: number;
|
||||
}
|
||||
interface CostResult extends RowDataPacket { total_cost: number }
|
||||
|
||||
/**
|
||||
* 处理获取店铺成交分析数据的请求
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const teamCode = pathname.split('/')[3]; // /api/team/[teamCode]/shop-sales-analysis
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ error: "团队代码不能为空" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取查询参数
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const startDate = searchParams.get("startDate");
|
||||
const endDate = searchParams.get("endDate");
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return NextResponse.json(
|
||||
{ error: "开始日期和结束日期是必填参数" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取团队数据库连接
|
||||
const teamDBInfo = await teamDbManager.getTeamDBInfoByCode(teamCode);
|
||||
if (!teamDBInfo) {
|
||||
return NextResponse.json(
|
||||
{ error: "无法获取团队信息,请确保团队代码有效" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用团队数据库管理器获取连接池
|
||||
const teamPool = teamDbManager.getTeamDbPool(teamDBInfo);
|
||||
|
||||
// 从连接池获取连接
|
||||
const connection = await teamPool.getConnection();
|
||||
console.log(`🟢 MySQL 团队数据库连接已获取: ${teamDBInfo.dbHost}/${teamDBInfo.dbName}`);
|
||||
|
||||
try {
|
||||
// 获取所有店铺数据
|
||||
const [shops] = await connection.query<ShopRow[]>(`
|
||||
SELECT id, nickname, wechat, account_no FROM shops WHERE status = 1
|
||||
`);
|
||||
|
||||
// 获取分析数据
|
||||
const results = await Promise.all(
|
||||
shops.map(async (shop: ShopRow) => {
|
||||
// 获取进粉数量 (来自shop_follower_growth表,计算日增长人数总和)
|
||||
const [followersResult] = await connection.query<FollowerResult[]>(`
|
||||
SELECT COALESCE(SUM(daily_increase), 0) as total_followers
|
||||
FROM shop_follower_growth
|
||||
WHERE shop_id = ? AND date BETWEEN ? AND ?
|
||||
`, [shop.id, startDate, endDate]);
|
||||
|
||||
// 获取引流消费 (来自shop_traffic_expenses表)
|
||||
const [expensesResult] = await connection.query<ExpenseResult[]>(`
|
||||
SELECT COALESCE(SUM(expense_amount), 0) as total_expenses
|
||||
FROM shop_traffic_expenses
|
||||
WHERE shop_id = ? AND DATE(record_time) BETWEEN ? AND ?
|
||||
`, [shop.id, startDate, endDate]);
|
||||
|
||||
// 获取出单数和销售额 (来自sales_records表)
|
||||
const [salesResult] = await connection.query<SalesResult[]>(`
|
||||
SELECT
|
||||
COUNT(*) as order_count,
|
||||
COALESCE(SUM(receivable), 0) as total_sales
|
||||
FROM sales_records
|
||||
WHERE source_id = ? AND deal_date BETWEEN ? AND ?
|
||||
`, [shop.id, startDate, endDate]);
|
||||
|
||||
// 获取成本 (从sales_records关联到sales_record_products再到products表)
|
||||
const [costResult] = await connection.query<CostResult[]>(`
|
||||
SELECT COALESCE(SUM(p.cost->>'$.cost_price' * srp.quantity), 0) as total_cost
|
||||
FROM sales_records sr
|
||||
JOIN sales_record_products srp ON sr.id = srp.sales_record_id
|
||||
JOIN products p ON srp.product_id = p.id
|
||||
WHERE sr.source_id = ? AND sr.deal_date BETWEEN ? AND ?
|
||||
`, [shop.id, startDate, endDate]);
|
||||
|
||||
return {
|
||||
shopId: shop.id,
|
||||
shopName: shop.nickname || shop.wechat || shop.account_no || `店铺${shop.id}`,
|
||||
followers: followersResult[0]?.total_followers || 0,
|
||||
expenses: expensesResult[0]?.total_expenses || 0,
|
||||
orderCount: salesResult[0]?.order_count || 0,
|
||||
sales: salesResult[0]?.total_sales || 0,
|
||||
cost: costResult[0]?.total_cost || 0
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// 释放连接
|
||||
connection.release();
|
||||
console.log(`🟢 MySQL 团队数据库连接已释放: ${teamDBInfo.dbHost}/${teamDBInfo.dbName}`);
|
||||
|
||||
return NextResponse.json({ success: true, data: results });
|
||||
} catch (error) {
|
||||
// 确保释放连接
|
||||
connection.release();
|
||||
console.log(`🟢 MySQL 团队数据库连接已释放(异常): ${teamDBInfo.dbHost}/${teamDBInfo.dbName}`);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取店铺成交分析数据失败:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "获取店铺成交分析数据失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
436
src/app/api/team/[teamCode]/shops/[id]/route.ts
Normal file
436
src/app/api/team/[teamCode]/shops/[id]/route.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* 单个店铺API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供单个店铺的查询、更新和删除接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
// 不复制body,因为它可能已被读取
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从URL路径中提取参数
|
||||
* @param req 请求对象
|
||||
* @returns 提取的参数对象
|
||||
*/
|
||||
const extractParamsFromPath = (req: NextRequest): { teamCode: string; id: string } => {
|
||||
// 路径格式: /api/team/[teamCode]/shops/[id]
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3,店铺ID位于索引5
|
||||
return {
|
||||
teamCode: pathParts[3] || '',
|
||||
id: pathParts[5] || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个店铺
|
||||
*/
|
||||
const getShop = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的店铺ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]店铺, ID:${id}`);
|
||||
|
||||
const [rows] = await req.db.query(
|
||||
`SELECT
|
||||
id, unionid, openid, account_no as accountNo, wechat,
|
||||
avatar, nickname, phone, status, remark,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM shops WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应店铺' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`查询团队[${teamCode}]店铺成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
shop: rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]店铺详情失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取店铺详情失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新店铺
|
||||
*/
|
||||
interface ShopRequestBody {
|
||||
id?: number;
|
||||
unionid?: string | null;
|
||||
openid?: string | null;
|
||||
accountNo?: string | null;
|
||||
wechat?: string | null;
|
||||
avatar?: string | null;
|
||||
nickname?: string | null;
|
||||
phone?: string | null;
|
||||
status?: number;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
const updateShop = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
const data = params?.requestBody as ShopRequestBody;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的店铺ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求数据为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始更新团队[${teamCode}]店铺, ID:${id}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查店铺是否存在
|
||||
const [existingShop] = await connection.query(
|
||||
'SELECT id FROM shops WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingShop) || existingShop.length === 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应店铺' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果更新unionid,检查是否已存在
|
||||
if (data.unionid) {
|
||||
const [unionidCheck] = await connection.query(
|
||||
'SELECT id FROM shops WHERE unionid = ? AND id != ?',
|
||||
[data.unionid, id]
|
||||
);
|
||||
|
||||
if (Array.isArray(unionidCheck) && unionidCheck.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该unionid已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果更新wechat,检查是否已存在
|
||||
if (data.wechat) {
|
||||
const [wechatCheck] = await connection.query(
|
||||
'SELECT id FROM shops WHERE wechat = ? AND id != ?',
|
||||
[data.wechat, id]
|
||||
);
|
||||
|
||||
if (Array.isArray(wechatCheck) && wechatCheck.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该微信号已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建更新SQL
|
||||
const updateFields = [];
|
||||
const updateParams = [];
|
||||
|
||||
// 按字段顺序检查并添加到更新列表
|
||||
if (data.unionid !== undefined) {
|
||||
updateFields.push('unionid = ?');
|
||||
updateParams.push(data.unionid || null);
|
||||
}
|
||||
|
||||
if (data.openid !== undefined) {
|
||||
updateFields.push('openid = ?');
|
||||
updateParams.push(data.openid || null);
|
||||
}
|
||||
|
||||
if (data.accountNo !== undefined) {
|
||||
updateFields.push('account_no = ?');
|
||||
updateParams.push(data.accountNo || null);
|
||||
}
|
||||
|
||||
if (data.wechat !== undefined) {
|
||||
updateFields.push('wechat = ?');
|
||||
updateParams.push(data.wechat || null);
|
||||
}
|
||||
|
||||
if (data.avatar !== undefined) {
|
||||
updateFields.push('avatar = ?');
|
||||
updateParams.push(data.avatar || null);
|
||||
}
|
||||
|
||||
if (data.nickname !== undefined) {
|
||||
updateFields.push('nickname = ?');
|
||||
updateParams.push(data.nickname || null);
|
||||
}
|
||||
|
||||
if (data.phone !== undefined) {
|
||||
updateFields.push('phone = ?');
|
||||
updateParams.push(data.phone || null);
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updateFields.push('status = ?');
|
||||
updateParams.push(data.status);
|
||||
}
|
||||
|
||||
if (data.remark !== undefined) {
|
||||
updateFields.push('remark = ?');
|
||||
updateParams.push(data.remark || null);
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '没有提供需要更新的字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
updateFields.push('updated_at = NOW()');
|
||||
|
||||
// 添加ID作为WHERE条件参数
|
||||
updateParams.push(id);
|
||||
|
||||
const updateSql = `
|
||||
UPDATE shops
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await connection.query(updateSql, updateParams);
|
||||
|
||||
// 查询更新后的店铺信息
|
||||
const [updatedShopRows] = await connection.query(
|
||||
`SELECT
|
||||
id, unionid, openid, account_no as accountNo, wechat,
|
||||
avatar, nickname, phone, status, remark,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM shops WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`更新团队[${teamCode}]店铺成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '店铺更新成功',
|
||||
shop: Array.isArray(updatedShopRows) && updatedShopRows.length > 0 ? updatedShopRows[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`更新团队[${teamCode}]店铺失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '更新店铺失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除店铺
|
||||
*/
|
||||
const deleteShop = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的店铺ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始删除团队[${teamCode}]店铺, ID:${id}`);
|
||||
|
||||
// 检查店铺是否存在
|
||||
const [existingShop] = await req.db.query(
|
||||
'SELECT id FROM shops WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingShop) || existingShop.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应店铺' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 直接删除店铺记录
|
||||
await req.db.query('DELETE FROM shops WHERE id = ?', [id]);
|
||||
|
||||
console.log(`删除团队[${teamCode}]店铺成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '店铺删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`删除团队[${teamCode}]店铺失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '删除店铺失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getShop)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理店铺详情请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 先读取请求体
|
||||
let requestBody;
|
||||
try {
|
||||
// 克隆请求,确保body只被读取一次
|
||||
const clonedReq = req.clone();
|
||||
requestBody = await clonedReq.json();
|
||||
} catch (error) {
|
||||
console.error('解析请求数据失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的请求数据格式' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求(不传递body)
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(updateShop)(teamReq, { teamCode, id, requestBody });
|
||||
} catch (error) {
|
||||
console.error('处理店铺更新请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(deleteShop)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理店铺删除请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
385
src/app/api/team/[teamCode]/shops/route.ts
Normal file
385
src/app/api/team/[teamCode]/shops/route.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 店铺API接口
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供店铺数据的查询和创建接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { RowDataPacket } from 'mysql2/promise';
|
||||
|
||||
/**
|
||||
* 自定义类型定义
|
||||
*/
|
||||
interface CountResult extends RowDataPacket {
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ShopType extends RowDataPacket {
|
||||
categoryId: number;
|
||||
}
|
||||
|
||||
interface Shop extends RowDataPacket {
|
||||
id: number;
|
||||
unionid?: string;
|
||||
openid?: string;
|
||||
accountNo?: string;
|
||||
wechat?: string;
|
||||
avatar?: string;
|
||||
nickname?: string;
|
||||
phone?: string;
|
||||
status: number;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
accountTypes?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/shops
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* GET 获取店铺列表
|
||||
*/
|
||||
const getShops = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
|
||||
const keyword = url.searchParams.get('keyword') || '';
|
||||
const status = url.searchParams.get('status') || '';
|
||||
const accountType = url.searchParams.get('accountType') || '';
|
||||
|
||||
// 计算偏移量
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]店铺列表, 页码:${page}, 每页:${pageSize}`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams = [];
|
||||
|
||||
if (keyword) {
|
||||
conditions.push('(s.nickname LIKE ? OR s.wechat LIKE ? OR s.account_no LIKE ? OR s.remark LIKE ?)');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push('s.status = ?');
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
// 需要连接账号类型表进行查询
|
||||
let joinAccountTypeTable = '';
|
||||
if (accountType) {
|
||||
joinAccountTypeTable = 'LEFT JOIN shop_account_types sat ON s.id = sat.shop_id';
|
||||
conditions.push('sat.category_id = ?');
|
||||
queryParams.push(accountType);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总数
|
||||
const countSql = `
|
||||
SELECT COUNT(DISTINCT s.id) as total
|
||||
FROM shops s
|
||||
${joinAccountTypeTable}
|
||||
${whereClause}
|
||||
`;
|
||||
const [totalRows] = await connection.query<CountResult[]>(countSql, queryParams);
|
||||
|
||||
const total = totalRows[0].total;
|
||||
|
||||
// 查询分页数据
|
||||
const querySql = `
|
||||
SELECT DISTINCT
|
||||
s.id, s.unionid, s.openid, s.account_no as accountNo,
|
||||
s.wechat, s.avatar, s.nickname, s.phone, s.status,
|
||||
s.remark, s.created_at as createdAt, s.updated_at as updatedAt
|
||||
FROM shops s
|
||||
${joinAccountTypeTable}
|
||||
${whereClause}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
// 添加分页参数
|
||||
const paginatedParams = [...queryParams, pageSize, offset];
|
||||
|
||||
const [rows] = await connection.query<Shop[]>(querySql, paginatedParams);
|
||||
|
||||
// 获取每个店铺的账号类型
|
||||
const shops = Array.isArray(rows) ? rows : [];
|
||||
if (shops.length > 0) {
|
||||
for (const shop of shops) {
|
||||
// 查询店铺关联的账号类型
|
||||
const [accountTypes] = await connection.query<ShopType[]>(
|
||||
`SELECT category_id as categoryId FROM shop_account_types WHERE shop_id = ?`,
|
||||
[shop.id]
|
||||
);
|
||||
|
||||
shop.accountTypes = Array.isArray(accountTypes)
|
||||
? accountTypes.map((type) => type.categoryId)
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`查询团队[${teamCode}]店铺列表成功, 总数:${total}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total,
|
||||
shops
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]店铺列表失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取店铺列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST 创建店铺
|
||||
*/
|
||||
const createShop = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
try {
|
||||
console.log(`开始创建团队[${teamCode}]店铺`);
|
||||
|
||||
// 解析请求数据
|
||||
const data = await req.json();
|
||||
|
||||
// 校验请求数据:至少需要nickname或wechat之一
|
||||
if (!data.nickname && !data.wechat) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '店铺名称或微信号至少需要填写一项' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查wechat是否已存在
|
||||
if (data.wechat) {
|
||||
const [existingWechat] = await connection.query(
|
||||
'SELECT id FROM shops WHERE wechat = ?',
|
||||
[data.wechat]
|
||||
);
|
||||
|
||||
if (Array.isArray(existingWechat) && existingWechat.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该微信号已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查unionid是否已存在
|
||||
if (data.unionid) {
|
||||
const [existingUnionid] = await connection.query(
|
||||
'SELECT id FROM shops WHERE unionid = ?',
|
||||
[data.unionid]
|
||||
);
|
||||
|
||||
if (Array.isArray(existingUnionid) && existingUnionid.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该unionid已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 准备插入数据
|
||||
const insertSql = `
|
||||
INSERT INTO shops (
|
||||
unionid, openid, account_no, wechat,
|
||||
avatar, nickname, phone, status, remark
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const insertParams = [
|
||||
data.unionid || null,
|
||||
data.openid || null,
|
||||
data.accountNo || null,
|
||||
data.wechat || null,
|
||||
data.avatar || null,
|
||||
data.nickname || null,
|
||||
data.phone || null,
|
||||
data.status || 1, // 默认状态为正常
|
||||
data.remark || null
|
||||
];
|
||||
|
||||
// 执行插入操作
|
||||
const [result] = await connection.query(insertSql, insertParams);
|
||||
const shopId = (result as { insertId: number }).insertId;
|
||||
|
||||
// 处理账号类型关联
|
||||
if (data.accountTypes && Array.isArray(data.accountTypes) && data.accountTypes.length > 0) {
|
||||
// 创建账号类型关联
|
||||
for (const categoryId of data.accountTypes) {
|
||||
await connection.query(
|
||||
'INSERT INTO shop_account_types (shop_id, category_id) VALUES (?, ?)',
|
||||
[shopId, categoryId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
// 查询创建好的店铺完整信息
|
||||
const [shops] = await connection.query(
|
||||
`SELECT
|
||||
id, unionid, openid, account_no as accountNo, wechat,
|
||||
avatar, nickname, phone, status, remark,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM shops WHERE id = ?`,
|
||||
[shopId]
|
||||
);
|
||||
|
||||
if (!Array.isArray(shops) || shops.length === 0) {
|
||||
// 这种情况理论上不应该发生
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '店铺创建失败,无法获取新建店铺信息' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const shop = shops[0];
|
||||
|
||||
// 获取关联的账号类型
|
||||
const [accountTypes] = await connection.query(
|
||||
`SELECT category_id as categoryId FROM shop_account_types WHERE shop_id = ?`,
|
||||
[shopId]
|
||||
);
|
||||
|
||||
(shop as Shop).accountTypes = Array.isArray(accountTypes)
|
||||
? accountTypes.map((type) => (type as unknown as { categoryId: number }).categoryId)
|
||||
: [];
|
||||
|
||||
console.log(`创建团队[${teamCode}]店铺成功, ID:${shopId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '店铺创建成功',
|
||||
shop
|
||||
});
|
||||
} catch (error) {
|
||||
// 捕获到错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`创建团队[${teamCode}]店铺失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '创建店铺失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getShops)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理店铺列表请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(createShop)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理店铺创建请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
396
src/app/api/team/[teamCode]/suppliers/[id]/route.ts
Normal file
396
src/app/api/team/[teamCode]/suppliers/[id]/route.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 单个供应商API接口
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供单个供应商的查询、更新和删除接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从URL路径中提取参数
|
||||
* @param req 请求对象
|
||||
* @returns 提取的参数对象
|
||||
*/
|
||||
const extractParamsFromPath = (req: NextRequest): { teamCode: string; id: string } => {
|
||||
// 路径格式: /api/team/[teamCode]/suppliers/[id]
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3,供应商ID位于索引5
|
||||
return {
|
||||
teamCode: pathParts[3] || '',
|
||||
id: pathParts[5] || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET 获取单个供应商
|
||||
*/
|
||||
const getSupplier = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的供应商ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]供应商, ID:${id}`);
|
||||
|
||||
const [rows] = await req.db.query(
|
||||
`SELECT
|
||||
id, \`order\`, name, contact, status, level, type, remark,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM suppliers WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应供应商' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`查询团队[${teamCode}]供应商成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
supplier: rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]供应商详情失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取供应商详情失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT 更新供应商
|
||||
*/
|
||||
const updateSupplier = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的供应商ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取请求数据
|
||||
const data = await req.json();
|
||||
|
||||
// 验证是否有需要更新的字段
|
||||
if (!data.name && data.order === undefined && !data.contact && data.status === undefined &&
|
||||
!data.level && !data.type && data.remark === undefined) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少需要更新的字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始更新团队[${teamCode}]供应商, ID:${id}`);
|
||||
|
||||
// 使用数据库连接并开始事务
|
||||
try {
|
||||
await req.db.beginTransaction();
|
||||
|
||||
// 检查供应商是否存在
|
||||
const [existingSupplier] = await req.db.query(
|
||||
'SELECT id FROM suppliers WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingSupplier) || existingSupplier.length === 0) {
|
||||
await req.db.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应供应商' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果更新名称,检查名称是否已存在
|
||||
if (data.name) {
|
||||
const [nameCheck] = await req.db.query(
|
||||
'SELECT id FROM suppliers WHERE name = ? AND id != ?',
|
||||
[data.name, id]
|
||||
);
|
||||
|
||||
if (Array.isArray(nameCheck) && nameCheck.length > 0) {
|
||||
await req.db.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该供应商名称已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理联系方式字段
|
||||
let contactJson = undefined;
|
||||
if (data.contact !== undefined) {
|
||||
contactJson = data.contact ? JSON.stringify(data.contact) : null;
|
||||
}
|
||||
|
||||
// 构建更新SQL
|
||||
const updateFields = [];
|
||||
const updateParams = [];
|
||||
|
||||
if (data.name) {
|
||||
updateFields.push('name = ?');
|
||||
updateParams.push(data.name);
|
||||
}
|
||||
|
||||
if (data.order !== undefined) {
|
||||
updateFields.push('`order` = ?');
|
||||
updateParams.push(data.order);
|
||||
}
|
||||
|
||||
if (contactJson !== undefined) {
|
||||
updateFields.push('contact = ?');
|
||||
updateParams.push(contactJson);
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updateFields.push('status = ?');
|
||||
updateParams.push(data.status);
|
||||
}
|
||||
|
||||
if (data.level !== undefined) {
|
||||
updateFields.push('level = ?');
|
||||
updateParams.push(data.level || null);
|
||||
}
|
||||
|
||||
if (data.type !== undefined) {
|
||||
updateFields.push('type = ?');
|
||||
updateParams.push(data.type || null);
|
||||
}
|
||||
|
||||
if (data.remark !== undefined) {
|
||||
updateFields.push('remark = ?');
|
||||
updateParams.push(data.remark || null);
|
||||
}
|
||||
|
||||
updateFields.push('updated_at = NOW()');
|
||||
|
||||
// 添加ID作为WHERE条件参数
|
||||
updateParams.push(id);
|
||||
|
||||
const updateSql = `
|
||||
UPDATE suppliers
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await req.db.query(updateSql, updateParams);
|
||||
|
||||
// 查询更新后的供应商信息
|
||||
const [updatedSupplierRows] = await req.db.query(
|
||||
`SELECT
|
||||
id, \`order\`, name, contact, status, level, type, remark,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM suppliers WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// 提交事务
|
||||
await req.db.commit();
|
||||
|
||||
console.log(`更新团队[${teamCode}]供应商成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '供应商更新成功',
|
||||
supplier: Array.isArray(updatedSupplierRows) && updatedSupplierRows.length > 0 ? updatedSupplierRows[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await req.db.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`更新团队[${teamCode}]供应商失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '更新供应商失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE 删除供应商
|
||||
*/
|
||||
const deleteSupplier = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const id = params?.id as string;
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的供应商ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始删除团队[${teamCode}]供应商, ID:${id}`);
|
||||
|
||||
// 使用数据库连接执行删除操作
|
||||
try {
|
||||
await req.db.beginTransaction();
|
||||
|
||||
// 检查供应商是否存在
|
||||
const [existingSupplier] = await req.db.query(
|
||||
'SELECT id FROM suppliers WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!Array.isArray(existingSupplier) || existingSupplier.length === 0) {
|
||||
await req.db.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '未找到相应供应商' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 删除供应商与品类的关联记录
|
||||
await req.db.query('DELETE FROM supplier_categories WHERE supplier_id = ?', [id]);
|
||||
|
||||
// 删除供应商记录
|
||||
await req.db.query('DELETE FROM suppliers WHERE id = ?', [id]);
|
||||
|
||||
// 提交事务
|
||||
await req.db.commit();
|
||||
|
||||
console.log(`删除团队[${teamCode}]供应商成功, ID:${id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '供应商删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await req.db.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`删除团队[${teamCode}]供应商失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '删除供应商失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getSupplier)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理供应商详情请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(updateSupplier)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理供应商更新请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取参数
|
||||
const { teamCode, id } = extractParamsFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(deleteSupplier)(teamReq, { teamCode, id });
|
||||
} catch (error) {
|
||||
console.error('处理供应商删除请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
300
src/app/api/team/[teamCode]/suppliers/route.ts
Normal file
300
src/app/api/team/[teamCode]/suppliers/route.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 供应商API接口
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供供应商数据的查询和创建接口
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { RequestWithDB, connectTeamDB } from '@/lib/db';
|
||||
import { SupplierStatus } from '@/models/team/types/ISupplier';
|
||||
|
||||
/**
|
||||
* 为请求添加团队信息
|
||||
* @param req 原始请求对象
|
||||
* @param teamCode 团队代码
|
||||
* @returns 包含团队信息的新请求对象
|
||||
*/
|
||||
const addTeamInfoToRequest = (req: NextRequest, teamCode: string): NextRequest => {
|
||||
// 创建新的请求对象
|
||||
const teamReq = new NextRequest(req.url, {
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
body: req.body,
|
||||
cache: req.cache,
|
||||
credentials: req.credentials,
|
||||
integrity: req.integrity,
|
||||
keepalive: req.keepalive,
|
||||
mode: req.mode,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
referrerPolicy: req.referrerPolicy
|
||||
});
|
||||
|
||||
// 添加团队ID信息
|
||||
teamReq.headers.set('x-team-id', teamCode);
|
||||
|
||||
return teamReq;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从请求路径中提取团队代码
|
||||
* @param req 请求对象
|
||||
* @returns 团队代码
|
||||
*/
|
||||
const extractTeamCodeFromPath = (req: NextRequest): string => {
|
||||
// 路径格式: /api/team/[teamCode]/suppliers
|
||||
const pathParts = req.nextUrl.pathname.split('/');
|
||||
// 团队代码位于索引3的位置
|
||||
return pathParts[3] || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* GET 获取供应商列表
|
||||
*/
|
||||
const getSuppliers = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
|
||||
const keyword = url.searchParams.get('keyword') || '';
|
||||
const status = url.searchParams.get('status') !== null ? parseInt(url.searchParams.get('status') as string) : null;
|
||||
const level = url.searchParams.get('level') || null;
|
||||
const type = url.searchParams.get('type') || null;
|
||||
|
||||
// 计算偏移量
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
console.log(`开始查询团队[${teamCode}]供应商列表, 页码:${page}, 每页:${pageSize}`);
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
const queryParams = [];
|
||||
|
||||
if (keyword) {
|
||||
conditions.push('(name LIKE ? OR JSON_EXTRACT(contact, "$.contactPerson") LIKE ? OR JSON_EXTRACT(contact, "$.phone") LIKE ?)');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
if (status !== null) {
|
||||
conditions.push('status = ?');
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
if (level) {
|
||||
conditions.push('level = ?');
|
||||
queryParams.push(level);
|
||||
}
|
||||
|
||||
if (type) {
|
||||
conditions.push('type = ?');
|
||||
queryParams.push(type);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
// 查询总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM suppliers ${whereClause}`;
|
||||
const [totalRows] = await connection.query(countSql, queryParams);
|
||||
|
||||
const total = (totalRows as Array<{ total: number }>)[0].total;
|
||||
|
||||
// 查询分页数据
|
||||
const querySql = `
|
||||
SELECT
|
||||
id, \`order\`, name, contact, status, level, type, remark,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM suppliers ${whereClause}
|
||||
ORDER BY \`order\` ASC, id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
// 添加分页参数
|
||||
const paginatedParams = [...queryParams, pageSize, offset];
|
||||
|
||||
const [rows] = await connection.query(querySql, paginatedParams);
|
||||
|
||||
console.log(`查询团队[${teamCode}]供应商列表成功, 总数:${total}`);
|
||||
|
||||
// 查询所有不同的供应商级别和类型,用于前端筛选
|
||||
const [levelRows] = await connection.query('SELECT DISTINCT level FROM suppliers WHERE level IS NOT NULL AND level != ""');
|
||||
const [typeRows] = await connection.query('SELECT DISTINCT type FROM suppliers WHERE type IS NOT NULL AND type != ""');
|
||||
|
||||
// 转换结果
|
||||
const levels = Array.isArray(levelRows) ? levelRows.map((row) => (row as unknown as { level: string }).level).filter(Boolean) : [];
|
||||
const types = Array.isArray(typeRows) ? typeRows.map((row) => (row as unknown as { type: string }).type).filter(Boolean) : [];
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total,
|
||||
suppliers: Array.isArray(rows) ? rows : [],
|
||||
filters: {
|
||||
levels,
|
||||
types
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取团队[${teamCode}]供应商列表失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取供应商列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST 创建供应商
|
||||
*/
|
||||
const createSupplier = async (req: RequestWithDB, params?: Record<string, unknown>) => {
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
// 获取请求数据
|
||||
const data = await req.json();
|
||||
|
||||
// 验证必填字段
|
||||
if (!data.name) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '供应商名称为必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`开始创建团队[${teamCode}]供应商, 名称:${data.name}`);
|
||||
|
||||
// 使用数据库连接
|
||||
const connection = req.db;
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 检查供应商名称是否已存在
|
||||
const [existingSuppliers] = await connection.query(
|
||||
'SELECT id FROM suppliers WHERE name = ?',
|
||||
[data.name]
|
||||
);
|
||||
|
||||
if (Array.isArray(existingSuppliers) && existingSuppliers.length > 0) {
|
||||
await connection.rollback();
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该供应商名称已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理联系方式字段
|
||||
let contactJson = null;
|
||||
if (data.contact) {
|
||||
contactJson = JSON.stringify(data.contact);
|
||||
}
|
||||
|
||||
// 插入供应商记录
|
||||
const insertSql = `
|
||||
INSERT INTO suppliers (
|
||||
\`order\`, name, contact, status, level, type, remark, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await connection.query(insertSql, [
|
||||
data.order || 0,
|
||||
data.name,
|
||||
contactJson,
|
||||
data.status !== undefined ? data.status : SupplierStatus.ENABLED,
|
||||
data.level || null,
|
||||
data.type || null,
|
||||
data.remark || null
|
||||
]);
|
||||
|
||||
const insertId = (result as { insertId: number }).insertId;
|
||||
|
||||
// 查询新插入的供应商信息
|
||||
const [newSupplierRows] = await connection.query(
|
||||
`SELECT
|
||||
id, \`order\`, name, contact, status, level, type, remark,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM suppliers WHERE id = ?`,
|
||||
[insertId]
|
||||
);
|
||||
|
||||
// 提交事务
|
||||
await connection.commit();
|
||||
|
||||
console.log(`创建团队[${teamCode}]供应商成功, ID:${insertId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '供应商创建成功',
|
||||
supplier: Array.isArray(newSupplierRows) && newSupplierRows.length > 0 ? newSupplierRows[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
// 发生错误时回滚事务
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`创建团队[${teamCode}]供应商失败:`, error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '创建供应商失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出处理函数
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(getSuppliers)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理供应商列表请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const teamCode = extractTeamCodeFromPath(req);
|
||||
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的团队代码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加团队信息到请求
|
||||
const teamReq = addTeamInfoToRequest(req, teamCode);
|
||||
|
||||
// 使用新版的connectTeamDB中间件处理请求
|
||||
return connectTeamDB(createSupplier)(teamReq, { teamCode });
|
||||
} catch (error) {
|
||||
console.error('处理供应商创建请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
src/app/api/team/users/route.ts
Normal file
39
src/app/api/team/users/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 团队用户API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供团队用户相关API,演示如何使用团队数据库连接
|
||||
* 版本: 2.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { connectTeamDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 获取团队用户列表
|
||||
* 自动从请求中提取团队信息并连接对应的数据库
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 使用connectTeamDB中间件处理请求
|
||||
return await connectTeamDB(async (dbReq: RequestWithDB) => {
|
||||
try {
|
||||
// 执行SQL查询
|
||||
const [users] = await dbReq.db.query('SELECT * FROM users LIMIT 100');
|
||||
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
console.error('查询用户列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '查询用户列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
})(req);
|
||||
} catch (error) {
|
||||
console.error('处理请求失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '处理请求失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
252
src/app/api/teams/[teamCode]/init-database/route.ts
Normal file
252
src/app/api/teams/[teamCode]/init-database/route.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 团队数据库初始化API
|
||||
* 作者: 阿瑞
|
||||
* 功能: 初始化团队专用数据库
|
||||
* 版本: 1.2.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { TeamModel } from "@/models/team";
|
||||
import { AuthUtils } from "@/lib/auth";
|
||||
import mysql from 'mysql2/promise';
|
||||
import DbConfig from "@/lib/db/config";
|
||||
import { TeamDBInitializer } from "@/lib/db/init-team-db";
|
||||
import { ITeam } from "@/models/system/types";
|
||||
|
||||
/**
|
||||
* 团队数据库处理类
|
||||
*/
|
||||
class TeamDatabase {
|
||||
private config: {
|
||||
db_host: string;
|
||||
db_name: string;
|
||||
db_username: string;
|
||||
db_password: string;
|
||||
team_code: string;
|
||||
};
|
||||
|
||||
constructor(config: {
|
||||
db_host: string;
|
||||
db_name: string;
|
||||
db_username: string;
|
||||
db_password: string;
|
||||
team_code: string;
|
||||
}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// 连接到数据库
|
||||
console.log(`连接到团队数据库: ${this.config.db_name}`);
|
||||
const connection = await mysql.createConnection({
|
||||
host: this.config.db_host,
|
||||
user: this.config.db_username,
|
||||
password: this.config.db_password,
|
||||
database: this.config.db_name
|
||||
});
|
||||
|
||||
try {
|
||||
// 使用TeamDBInitializer初始化表结构
|
||||
await TeamDBInitializer.initialize(connection);
|
||||
|
||||
// 初始化默认数据
|
||||
await TeamDBInitializer.initDefaultData(connection);
|
||||
} catch (error) {
|
||||
console.error(`团队 ${this.config.team_code} 数据库初始化失败:`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
// 确保关闭连接
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 处理初始化团队数据库请求
|
||||
* 符合Next.js 15.3+的路由处理器签名
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const teamCode = pathname.split('/')[3]; // 路径格式: /api/teams/[teamCode]/init-database
|
||||
|
||||
console.log(`开始初始化团队数据库, 团队代码: ${teamCode}`);
|
||||
|
||||
// 从请求头获取访问令牌
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
let token = '';
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
} else {
|
||||
// 尝试从Cookie中获取
|
||||
const tokenCookie = request.cookies.get('accessToken');
|
||||
if (tokenCookie) {
|
||||
token = tokenCookie.value;
|
||||
} else {
|
||||
// 尝试读取localStorage中的令牌(从请求中)
|
||||
const saasStorage = request.cookies.get('saas-user-storage');
|
||||
if (saasStorage) {
|
||||
try {
|
||||
const storageData = JSON.parse(decodeURIComponent(saasStorage.value));
|
||||
if (storageData && storageData.state && storageData.state.accessToken) {
|
||||
token = storageData.state.accessToken;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析存储的用户数据失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证令牌并获取用户信息
|
||||
if (!token) {
|
||||
console.error('初始化数据库失败: 用户未登录');
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "用户未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证用户身份
|
||||
const user = await AuthUtils.verifyToken(token);
|
||||
if (!user || !user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "用户验证失败" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取团队信息
|
||||
const team = await TeamModel.getByTeamCode(teamCode) as ITeam;
|
||||
if (!team) {
|
||||
console.error(`团队不存在, 团队代码: ${teamCode}`);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "团队不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`获取到团队信息: ${team.name}, ID: ${team.id}`);
|
||||
|
||||
// 检查用户权限(必须是团队所有者)
|
||||
const userId = Number(user.id);
|
||||
if (team.owner_id !== userId) {
|
||||
console.error(`权限错误: 用户(${userId})不是团队(${team.id})的所有者(${team.owner_id})`);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "只有团队所有者可以初始化数据库" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保团队对象包含所需的数据库信息
|
||||
if (!team.db_name || !team.db_username || !team.db_password || !team.db_host) {
|
||||
console.error('团队数据库信息不完整');
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "团队数据库信息不完整" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 首先使用管理员连接
|
||||
console.log(`使用管理员账号连接到数据库: ${DbConfig.host}`);
|
||||
const rootConnection = await mysql.createConnection({
|
||||
host: DbConfig.host,
|
||||
user: DbConfig.adminUser,
|
||||
password: DbConfig.adminPassword
|
||||
});
|
||||
|
||||
// 查询数据库是否存在
|
||||
const [existingDbs] = await rootConnection.query(
|
||||
`SHOW DATABASES LIKE '${team.db_name}'`
|
||||
);
|
||||
|
||||
// 如果数据库不存在,创建数据库
|
||||
if (Array.isArray(existingDbs) && existingDbs.length === 0) {
|
||||
console.log(`创建团队数据库: ${team.db_name}`);
|
||||
await rootConnection.query(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${team.db_name}\`
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||
);
|
||||
} else {
|
||||
console.log(`团队数据库 ${team.db_name} 已存在`);
|
||||
}
|
||||
|
||||
// 创建或更新数据库用户
|
||||
console.log(`创建/更新团队数据库用户: ${team.db_username}`);
|
||||
|
||||
// 删除可能存在的同名用户(确保用户不存在)
|
||||
try {
|
||||
await rootConnection.query(`DROP USER IF EXISTS '${team.db_username}'@'%'`);
|
||||
await rootConnection.query(`DROP USER IF EXISTS '${team.db_username}'@'localhost'`);
|
||||
} catch (e) {
|
||||
console.log(`删除已存在用户失败,可能是用户不存在: ${e}`);
|
||||
}
|
||||
|
||||
// 创建新用户(同时为本地和远程连接创建)
|
||||
await rootConnection.query(
|
||||
`CREATE USER '${team.db_username}'@'localhost' IDENTIFIED BY '${team.db_password}'`
|
||||
);
|
||||
await rootConnection.query(
|
||||
`CREATE USER '${team.db_username}'@'%' IDENTIFIED BY '${team.db_password}'`
|
||||
);
|
||||
|
||||
// 授予用户对该数据库的所有权限
|
||||
await rootConnection.query(
|
||||
`GRANT ALL PRIVILEGES ON \`${team.db_name}\`.* TO '${team.db_username}'@'localhost'`
|
||||
);
|
||||
await rootConnection.query(
|
||||
`GRANT ALL PRIVILEGES ON \`${team.db_name}\`.* TO '${team.db_username}'@'%'`
|
||||
);
|
||||
|
||||
// 刷新权限表
|
||||
await rootConnection.query('FLUSH PRIVILEGES');
|
||||
|
||||
console.log(`用户 ${team.db_username} 创建完成并授权成功`);
|
||||
|
||||
// 关闭管理员连接
|
||||
await rootConnection.end();
|
||||
|
||||
// 初始化团队数据库表结构
|
||||
console.log(`使用团队专用账号连接并初始化数据库表结构: ${team.db_name}`);
|
||||
const teamDatabase = new TeamDatabase({
|
||||
db_host: team.db_host,
|
||||
db_name: team.db_name,
|
||||
db_username: team.db_username,
|
||||
db_password: team.db_password,
|
||||
team_code: team.team_code
|
||||
});
|
||||
|
||||
// 执行初始化
|
||||
await teamDatabase.initialize();
|
||||
console.log(`团队数据库 ${team.db_name} 初始化成功`);
|
||||
|
||||
} catch (dbError: unknown) {
|
||||
const err = dbError as Error;
|
||||
console.error("数据库操作失败:", err);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `数据库操作失败: ${err.message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `团队 ${team.name} 数据库初始化成功`
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error("初始化团队数据库出错:", err);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: err.message || "初始化团队数据库失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
114
src/app/api/teams/[teamCode]/route.ts
Normal file
114
src/app/api/teams/[teamCode]/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 单个团队查询API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 根据团队代码查询团队信息
|
||||
* 版本: 1.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectSystemDB, RequestWithDB } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* 错误类型定义
|
||||
*/
|
||||
interface ApiError extends Error {
|
||||
message: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 处理获取单个团队信息请求
|
||||
* 符合Next.js 15.3+的路由处理器签名
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取团队代码
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const teamCode = pathname.split('/')[3]; // 路径格式: /api/teams/[teamCode]
|
||||
|
||||
// 验证必要参数
|
||||
if (!teamCode) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "团队代码为必填项"
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用数据库连接中间件处理请求
|
||||
const handler = async (dbReq: RequestWithDB) => {
|
||||
// 执行查询
|
||||
const [rows] = await dbReq.db.query(
|
||||
`SELECT * FROM teams WHERE team_code = ? AND status = 1`,
|
||||
[teamCode]
|
||||
);
|
||||
|
||||
// 转换结果为JS对象
|
||||
const teams = JSON.parse(JSON.stringify(rows));
|
||||
|
||||
// 检查是否找到团队
|
||||
if (teams.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "未找到指定的团队"
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取第一个结果并排除敏感信息
|
||||
const team = teams[0];
|
||||
const { ...safeTeamData } = team;
|
||||
|
||||
// 查询团队拥有者信息
|
||||
const [ownerRows] = await dbReq.db.query(
|
||||
`SELECT id, username, email FROM system_users WHERE id = ?`,
|
||||
[team.owner_id]
|
||||
);
|
||||
|
||||
const owners = JSON.parse(JSON.stringify(ownerRows));
|
||||
const ownerInfo = owners.length > 0 ? owners[0] : null;
|
||||
|
||||
// 查询团队成员数量
|
||||
const [memberCountRows] = await dbReq.db.query(
|
||||
`SELECT COUNT(*) as member_count FROM user_team_relations WHERE team_id = ?`,
|
||||
[team.id]
|
||||
);
|
||||
|
||||
const memberCounts = JSON.parse(JSON.stringify(memberCountRows));
|
||||
const memberCount = memberCounts.length > 0 ? memberCounts[0].member_count : 0;
|
||||
|
||||
// 组装完整的团队信息
|
||||
const teamWithDetails = {
|
||||
...safeTeamData,
|
||||
owner: ownerInfo,
|
||||
member_count: memberCount
|
||||
};
|
||||
|
||||
// 返回结果
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: teamWithDetails
|
||||
});
|
||||
};
|
||||
|
||||
// 执行处理函数
|
||||
const wrappedHandler = connectSystemDB(handler);
|
||||
return await wrappedHandler(req);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const err = error as ApiError;
|
||||
console.error("获取团队信息出错:", err);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: err.message || "获取团队信息失败"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
389
src/app/api/teams/route.ts
Normal file
389
src/app/api/teams/route.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 团队管理API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 处理团队的创建和查询
|
||||
* 版本: 1.1.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectSystemDB, RequestWithDB } from "@/lib/db";
|
||||
import { AuthUtils } from "@/lib/auth";
|
||||
import DbConfig from "@/lib/db/config";
|
||||
import { TeamMemberRole } from "@/models/system/types";
|
||||
|
||||
/**
|
||||
* 扩展解码令牌接口添加workspaceId
|
||||
*/
|
||||
interface DecodedTokenWithWorkspace {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role_type: string;
|
||||
workspace_id: number;
|
||||
sub: number;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成安全的随机密码
|
||||
* @param length 密码长度,默认为12
|
||||
* @returns 生成的随机密码
|
||||
*/
|
||||
function generateSecurePassword(length = 12): string {
|
||||
// 定义字符集
|
||||
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const numbers = '0123456789';
|
||||
const special = '!@#$%^&*()_-+=<>?';
|
||||
|
||||
const allChars = lowercase + uppercase + numbers + special;
|
||||
|
||||
// 确保密码至少包含每种字符各一个
|
||||
let password = '';
|
||||
password += lowercase.charAt(Math.floor(Math.random() * lowercase.length));
|
||||
password += uppercase.charAt(Math.floor(Math.random() * uppercase.length));
|
||||
password += numbers.charAt(Math.floor(Math.random() * numbers.length));
|
||||
password += special.charAt(Math.floor(Math.random() * special.length));
|
||||
|
||||
// 添加剩余的随机字符
|
||||
for (let i = 4; i < length; i++) {
|
||||
password += allChars.charAt(Math.floor(Math.random() * allChars.length));
|
||||
}
|
||||
|
||||
// 打乱字符顺序
|
||||
return password.split('').sort(() => 0.5 - Math.random()).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误类型定义
|
||||
*/
|
||||
interface ApiError extends Error {
|
||||
message: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 处理获取团队列表请求
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 获取查询参数
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const workspaceId = searchParams.get('workspaceId');
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
// 验证必要参数
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "工作空间ID为必填项"
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用数据库连接中间件处理请求
|
||||
const handler = async (dbReq: RequestWithDB) => {
|
||||
let query = '';
|
||||
let params: (string | number)[] = [];
|
||||
|
||||
// 根据不同的查询参数构建SQL
|
||||
if (userId) {
|
||||
// 查询用户所属的团队
|
||||
query = `
|
||||
SELECT t.*
|
||||
FROM teams t
|
||||
JOIN user_team_relations utr ON t.id = utr.team_id
|
||||
WHERE t.workspace_id = ?
|
||||
AND utr.user_id = ?
|
||||
AND t.status = 1
|
||||
ORDER BY t.created_at DESC
|
||||
`;
|
||||
params = [workspaceId, userId];
|
||||
} else {
|
||||
// 查询工作空间内的所有团队
|
||||
query = `
|
||||
SELECT *
|
||||
FROM teams
|
||||
WHERE workspace_id = ?
|
||||
AND status = 1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [workspaceId];
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
const [rows] = await dbReq.db.query(query, params);
|
||||
|
||||
// 转换结果并排除敏感信息
|
||||
const teams = JSON.parse(JSON.stringify(rows)).map((team: Record<string, unknown>) => {
|
||||
// 排除敏感信息,使用解构赋值忽略db_password
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { db_password, ...safeTeamData } = team;
|
||||
return safeTeamData;
|
||||
});
|
||||
|
||||
// 返回结果
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: teams
|
||||
});
|
||||
};
|
||||
|
||||
// 执行处理函数
|
||||
const wrappedHandler = connectSystemDB(handler);
|
||||
return await wrappedHandler(req);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const err = error as ApiError;
|
||||
console.error("获取团队列表出错:", err);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: err.message || "获取团队列表失败"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 处理创建团队请求
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 解析请求体,提取备份的用户ID和工作空间ID
|
||||
const requestData = await req.json();
|
||||
const { teamName, teamCode, userId: backupUserId, workspaceId: backupWorkspaceId } = requestData;
|
||||
|
||||
// 验证必要参数
|
||||
if (!teamName || !teamCode) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "团队名称和团队代码为必填项"
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从请求头获取访问令牌
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
let token = '';
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
} else {
|
||||
// 尝试从Cookie中获取
|
||||
const tokenCookie = req.cookies.get('accessToken');
|
||||
if (tokenCookie) {
|
||||
token = tokenCookie.value;
|
||||
} else {
|
||||
// 尝试读取localStorage中的令牌(从请求中)
|
||||
const saasStorage = req.cookies.get('saas-user-storage');
|
||||
if (saasStorage) {
|
||||
try {
|
||||
const storageData = JSON.parse(decodeURIComponent(saasStorage.value));
|
||||
if (storageData && storageData.state && storageData.state.accessToken) {
|
||||
token = storageData.state.accessToken;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析存储的用户数据失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 准备存储用户ID和工作空间ID
|
||||
let userId: number | null = null;
|
||||
let workspaceId: number | null = null;
|
||||
|
||||
// 验证令牌并获取用户信息
|
||||
if (!token && !backupUserId && !backupWorkspaceId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "用户未登录"
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 尝试通过令牌获取用户信息
|
||||
if (token) {
|
||||
try {
|
||||
const user = await AuthUtils.verifyToken(token) as DecodedTokenWithWorkspace;
|
||||
// 验证用户信息的完整性
|
||||
if (user && user.id && user.workspace_id) {
|
||||
userId = Number(user.id);
|
||||
workspaceId = Number(user.workspace_id);
|
||||
|
||||
// 验证数字有效性
|
||||
if (isNaN(userId) || isNaN(workspaceId)) {
|
||||
console.error('令牌中用户ID或工作空间ID无效:', { userId, workspaceId });
|
||||
userId = null;
|
||||
workspaceId = null;
|
||||
}
|
||||
} else {
|
||||
console.error('令牌中用户信息不完整:', user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('令牌验证失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果令牌验证失败,尝试使用备份的用户信息
|
||||
if ((!userId || !workspaceId) && backupUserId && backupWorkspaceId) {
|
||||
console.log('使用备份的用户信息:', { backupUserId, backupWorkspaceId });
|
||||
userId = Number(backupUserId);
|
||||
workspaceId = Number(backupWorkspaceId);
|
||||
|
||||
// 验证备份数据有效性
|
||||
if (isNaN(userId) || isNaN(workspaceId)) {
|
||||
console.error('备份用户ID或工作空间ID无效:', { userId, workspaceId });
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "用户信息无效,请重新登录"
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 最终验证用户信息
|
||||
if (!userId || !workspaceId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "无法验证用户身份,请重新登录"
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证团队代码格式
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(teamCode)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "团队代码只能包含字母、数字和下划线,长度在3-20之间"
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用连接数据库的模式
|
||||
const handler = async (dbReq: RequestWithDB) => {
|
||||
// 检查团队代码是否已存在
|
||||
const [teamRows] = await dbReq.db.query(
|
||||
`SELECT * FROM teams WHERE team_code = ?`,
|
||||
[teamCode]
|
||||
);
|
||||
|
||||
// 转换为纯JavaScript对象
|
||||
const existingTeams = JSON.parse(JSON.stringify(teamRows));
|
||||
|
||||
if (existingTeams.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "该团队代码已存在,请使用其他代码"
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 为团队生成数据库名称和用户名
|
||||
const teamDbName = `team_${teamCode}`;
|
||||
const teamUsername = `user_${teamCode}`;
|
||||
|
||||
// 生成安全的随机密码
|
||||
const teamDbPassword = generateSecurePassword(12);
|
||||
|
||||
console.log('准备创建团队,用户ID:', userId, '工作空间ID:', workspaceId);
|
||||
console.log('团队数据库信息:', {
|
||||
db_name: teamDbName,
|
||||
db_username: teamUsername,
|
||||
// 不输出密码
|
||||
});
|
||||
|
||||
// 准备团队创建参数
|
||||
const teamParams = {
|
||||
team_code: teamCode,
|
||||
name: teamName,
|
||||
db_host: DbConfig.host,
|
||||
db_name: teamDbName,
|
||||
db_username: teamUsername,
|
||||
db_password: teamDbPassword,
|
||||
workspace_id: workspaceId,
|
||||
owner_id: userId
|
||||
};
|
||||
|
||||
console.log('创建团队参数:', {
|
||||
...teamParams,
|
||||
db_password: '******' // 不输出密码
|
||||
});
|
||||
|
||||
// 创建团队
|
||||
const [result] = await dbReq.db.query(
|
||||
`INSERT INTO teams (
|
||||
team_code, name, db_host, db_name, db_username, db_password,
|
||||
workspace_id, owner_id, status, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, NOW())`,
|
||||
[
|
||||
teamParams.team_code,
|
||||
teamParams.name,
|
||||
teamParams.db_host,
|
||||
teamParams.db_name,
|
||||
teamParams.db_username,
|
||||
teamParams.db_password,
|
||||
teamParams.workspace_id,
|
||||
teamParams.owner_id
|
||||
]
|
||||
);
|
||||
|
||||
// 正确处理插入结果
|
||||
const insertResult = JSON.parse(JSON.stringify(result));
|
||||
const teamId = insertResult.insertId;
|
||||
console.log('团队创建成功,ID:', teamId);
|
||||
|
||||
// 建立用户与团队的关系
|
||||
await dbReq.db.query(
|
||||
`INSERT INTO user_team_relations (
|
||||
user_id, team_id, role, created_at
|
||||
) VALUES (?, ?, ?, NOW())`,
|
||||
[userId, teamId, TeamMemberRole.OWNER]
|
||||
);
|
||||
console.log('用户团队关系创建成功');
|
||||
|
||||
// 返回成功响应
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
team: {
|
||||
id: teamId,
|
||||
team_code: teamCode,
|
||||
name: teamName
|
||||
}
|
||||
}, { status: 201 });
|
||||
};
|
||||
|
||||
// 执行处理函数
|
||||
const wrappedHandler = connectSystemDB(handler);
|
||||
return await wrappedHandler(req);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const err = error as ApiError;
|
||||
console.error("创建团队出错:", err);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: err.message || "创建团队失败"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/test/connection/route.ts
Normal file
42
src/app/api/test/connection/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 数据库连接测试API
|
||||
* 作者: 阿瑞
|
||||
* 功能: 测试系统数据库连接是否正常
|
||||
* 版本: 1.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 测试数据库连接的处理器
|
||||
*/
|
||||
async function handler(req: RequestWithDB) {
|
||||
try {
|
||||
// 尝试执行一个简单的查询
|
||||
const [result] = await req.db.query('SELECT 1 as connected');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '数据库连接成功',
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('数据库连接测试失败:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 使用数据库连接中间件包装处理器
|
||||
/**
|
||||
* 处理GET请求
|
||||
* 符合Next.js 15.3+的路由处理器签名
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const wrappedHandler = connectSystemDB(handler);
|
||||
return wrappedHandler(request);
|
||||
}
|
||||
61
src/app/api/test/users/route.ts
Normal file
61
src/app/api/test/users/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 用户列表查询API
|
||||
* 作者: 阿瑞
|
||||
* 功能: 查询系统用户列表数据
|
||||
* 版本: 1.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 查询用户列表处理器
|
||||
*/
|
||||
async function handler(req: RequestWithDB) {
|
||||
try {
|
||||
// 查询用户列表,排除密码和邀请令牌等敏感字段
|
||||
const [rows] = await req.db.query(`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
phone,
|
||||
role_name,
|
||||
role_type,
|
||||
workspace_id,
|
||||
status,
|
||||
last_login_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
system_users
|
||||
WHERE
|
||||
status = 1
|
||||
ORDER BY
|
||||
id DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
users: rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('查询用户列表失败:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 使用数据库连接中间件包装处理器
|
||||
/**
|
||||
* 处理GET请求
|
||||
* 符合Next.js 15.3+的路由处理器签名
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const wrappedHandler = connectSystemDB(handler);
|
||||
return wrappedHandler(request);
|
||||
}
|
||||
180
src/app/api/tools/images/[...path]/route.ts
Normal file
180
src/app/api/tools/images/[...path]/route.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @作者: 阿瑞
|
||||
* @功能: 图片管理API,支持获取和删除图片
|
||||
* @版本: 1.1.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { access, constants, unlink, readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// 设置基础上传目录
|
||||
const baseUploadsDir = path.join(process.cwd(), 'uploads');
|
||||
|
||||
// 获取图片的MIME类型
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
async function fileExists(filePath: string) {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取路径参数
|
||||
function extractPathParams(request: NextRequest): string[] {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
// 移除前面的 'api/tools/images' 部分
|
||||
const pathSegments = pathname.split('/').slice(4);
|
||||
return pathSegments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片文件(GET请求)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取动态参数
|
||||
const pathSegments = extractPathParams(request);
|
||||
|
||||
// 合并path参数得到完整的相对路径
|
||||
const relativePath = pathSegments.join('/');
|
||||
|
||||
// 构建绝对路径,确保不会超出uploads目录范围
|
||||
const filePath = path.join(baseUploadsDir, relativePath);
|
||||
|
||||
// 安全检查:确保文件路径在uploads目录下
|
||||
if (!filePath.startsWith(baseUploadsDir)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '无效的图片路径',
|
||||
code: 'INVALID_PATH'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!(await fileExists(filePath))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '图片不存在',
|
||||
code: 'FILE_NOT_FOUND'
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用fs.readFile读取文件,而不是fetch
|
||||
const fileBuffer = await readFile(filePath);
|
||||
|
||||
// 获取正确的content-type
|
||||
const contentType = getMimeType(filePath);
|
||||
|
||||
// 返回图片文件
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000' // 缓存1年
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取图片时出错:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '获取图片时出错',
|
||||
details: errorMessage
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除图片文件(DELETE请求)
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// 从URL路径中提取动态参数
|
||||
const pathSegments = extractPathParams(request);
|
||||
|
||||
// 合并path参数得到完整的相对路径
|
||||
const relativePath = pathSegments.join('/');
|
||||
|
||||
// 构建绝对路径
|
||||
const filePath = path.join(baseUploadsDir, relativePath);
|
||||
|
||||
// 安全检查:确保文件路径在uploads目录下
|
||||
if (!filePath.startsWith(baseUploadsDir)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '无效的图片路径',
|
||||
code: 'INVALID_PATH',
|
||||
details: '请求的文件路径超出了允许的范围',
|
||||
requestedPath: relativePath
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!(await fileExists(filePath))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '图片不存在',
|
||||
code: 'FILE_NOT_FOUND',
|
||||
details: `文件不存在或已被删除`,
|
||||
requestedPath: relativePath
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
await unlink(filePath);
|
||||
|
||||
return NextResponse.json({
|
||||
message: '图片删除成功',
|
||||
path: relativePath,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('删除图片时出错:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '删除图片时出错',
|
||||
code: 'DELETE_FAILED',
|
||||
details: errorMessage,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
118
src/app/api/tools/parseAddress/route.ts
Normal file
118
src/app/api/tools/parseAddress/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 地址解析API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 解析文本中的姓名、电话和地址信息
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
interface ExtractedInfoItem {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
probability: number;
|
||||
}
|
||||
|
||||
interface ExtractedInfo {
|
||||
姓名?: ExtractedInfoItem[];
|
||||
电话?: ExtractedInfoItem[];
|
||||
}
|
||||
|
||||
interface ExtractInfoResponse {
|
||||
extracted_info: ExtractedInfo[];
|
||||
}
|
||||
|
||||
interface ParseLocationResponse {
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
county: string | null;
|
||||
detail: string | null;
|
||||
full_location: string | null;
|
||||
orig_location: string | null;
|
||||
town: string | null;
|
||||
village: string | null;
|
||||
}
|
||||
|
||||
interface CombinedResponse {
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
address: ParseLocationResponse | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST处理函数
|
||||
* 接收文本内容,解析出姓名、电话和地址信息
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { text } = body;
|
||||
|
||||
if (!text || typeof text !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: '请求参数错误,缺少文本内容' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从环境变量获取 API 的 URL
|
||||
const extractInfoUrl = process.env.EXTRACT_INFO_API_URL || 'http://192.168.1.22:8006/extract_info/';
|
||||
const parseLocationUrl = process.env.PARSE_LOCATION_API_URL || 'http://192.168.1.22:8100/parse_location/';
|
||||
|
||||
// 第一步:提取姓名和电话
|
||||
const extractInfoResponse = await fetch(extractInfoUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
|
||||
const extractedInfoData = await extractInfoResponse.json() as ExtractInfoResponse;
|
||||
const extractedInfo = extractedInfoData.extracted_info[0];
|
||||
|
||||
let name: string | null = null;
|
||||
let phone: string | null = null;
|
||||
|
||||
if (extractedInfo['姓名'] && extractedInfo['姓名'].length > 0) {
|
||||
name = extractedInfo['姓名'][0].text;
|
||||
}
|
||||
|
||||
if (extractedInfo['电话'] && extractedInfo['电话'].length > 0) {
|
||||
phone = extractedInfo['电话'][0].text;
|
||||
}
|
||||
|
||||
// 从原始文本中移除姓名和电话,得到地址部分
|
||||
let addressText = text;
|
||||
|
||||
if (name) {
|
||||
addressText = addressText.replace(name, '').trim();
|
||||
}
|
||||
|
||||
if (phone) {
|
||||
addressText = addressText.replace(phone, '').trim();
|
||||
}
|
||||
|
||||
// 第二步:解析地址
|
||||
const parseLocationResponse = await fetch(parseLocationUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: addressText })
|
||||
});
|
||||
|
||||
const locationData = await parseLocationResponse.json() as ParseLocationResponse;
|
||||
|
||||
const combinedResponse: CombinedResponse = {
|
||||
name,
|
||||
phone,
|
||||
address: locationData,
|
||||
};
|
||||
|
||||
return NextResponse.json(combinedResponse);
|
||||
} catch (error: Error | unknown) {
|
||||
console.error('解析出错:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
157
src/app/api/tools/upload/route.ts
Normal file
157
src/app/api/tools/upload/route.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @作者: 阿瑞
|
||||
* @功能: 测试用上传图片API,支持指定文件夹
|
||||
* @版本: 1.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile, mkdir, access } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
// 设置基础上传目录
|
||||
const baseUploadsDir = path.join(process.cwd(), 'uploads');
|
||||
|
||||
// 文件大小限制 (10MB)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
// 确保目录存在
|
||||
async function ensureDir(dirPath: string) {
|
||||
if (!existsSync(dirPath)) {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
async function fileExists(filePath: string) {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理可能的文件重名
|
||||
async function generateUniqueFileName(dirPath: string, originalName: string): Promise<string> {
|
||||
// 分离文件名和扩展名
|
||||
const ext = path.extname(originalName);
|
||||
const nameWithoutExt = path.basename(originalName, ext);
|
||||
|
||||
// 检查文件是否已存在
|
||||
const filePath = path.join(dirPath, originalName);
|
||||
if (!(await fileExists(filePath))) {
|
||||
return originalName; // 文件不存在,可以直接使用原名
|
||||
}
|
||||
|
||||
// 文件存在,生成唯一的文件名
|
||||
const uniqueName = `${nameWithoutExt}_${nanoid(6)}${ext}`;
|
||||
return generateUniqueFileName(dirPath, uniqueName); // 递归检查新名称是否可用
|
||||
}
|
||||
|
||||
// 处理图片上传请求
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await ensureDir(baseUploadsDir);
|
||||
|
||||
// 使用FormData API处理上传文件
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const folder = formData.get('folder') as string | null;
|
||||
|
||||
// 确定上传目录
|
||||
let uploadsDir = baseUploadsDir;
|
||||
if (folder) {
|
||||
// 过滤文件夹名中的非法字符,防止目录遍历攻击
|
||||
const safeFolderName = folder.replace(/[^\w-]/g, '_');
|
||||
uploadsDir = path.join(baseUploadsDir, safeFolderName);
|
||||
await ensureDir(uploadsDir);
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '没有提供文件',
|
||||
code: 'NO_FILE_PROVIDED',
|
||||
details: '请确保在请求中包含一个有效的文件字段'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持的文件类型',
|
||||
code: 'INVALID_FILE_TYPE',
|
||||
details: '请上传JPG、PNG、GIF或WEBP格式的图片',
|
||||
supportedTypes: validTypes
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '文件大小超出限制',
|
||||
code: 'FILE_TOO_LARGE',
|
||||
details: `文件大小超出限制,最大允许${MAX_FILE_SIZE / (1024 * 1024)}MB`,
|
||||
maxSize: MAX_FILE_SIZE,
|
||||
actualSize: file.size
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理文件名(避免重名)
|
||||
const originalFileName = file.name;
|
||||
const fileName = await generateUniqueFileName(uploadsDir, originalFileName);
|
||||
const filePath = path.join(uploadsDir, fileName);
|
||||
|
||||
// 将文件内容转换为Buffer
|
||||
const fileBuffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// 写入文件到服务器
|
||||
await writeFile(filePath, fileBuffer);
|
||||
|
||||
// 构建相对于基础上传目录的URL路径
|
||||
let urlPath = `/api/tools/images/${fileName}`;
|
||||
if (folder) {
|
||||
const safeFolderName = folder.replace(/[^\w-]/g, '_');
|
||||
urlPath = `/api/tools/images/${safeFolderName}/${fileName}`;
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
return NextResponse.json({
|
||||
message: '图片上传成功',
|
||||
fileName,
|
||||
folder: folder || null,
|
||||
originalFileName: originalFileName !== fileName ? originalFileName : undefined,
|
||||
url: urlPath,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('上传图片时出错:', error);
|
||||
|
||||
// 提供更详细的错误信息
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '上传图片时出错',
|
||||
code: 'UPLOAD_FAILED',
|
||||
details: errorMessage,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
241
src/app/api/workspace/invite/route.ts
Normal file
241
src/app/api/workspace/invite/route.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 邀请注册API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 生成邀请链接和令牌
|
||||
* 版本: 2.2
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AuthUtils } from '@/lib/auth';
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { SystemRoleType } from '@/models/system/types';
|
||||
|
||||
/**
|
||||
* 生成邀请链接和令牌
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
console.log('API: 接收到生成邀请链接请求');
|
||||
|
||||
// 解析请求体
|
||||
const body = await req.json();
|
||||
const { role_type, role_name, is_custom_role, workspaceId } = body;
|
||||
|
||||
console.log('API: 请求参数:', { role_type, role_name, is_custom_role, workspaceId });
|
||||
|
||||
// 验证参数
|
||||
if (!workspaceId) {
|
||||
console.log('API: 缺少工作空间ID参数');
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '缺少必要参数,需要工作空间ID'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证授权
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
console.log('API: 授权头:', authHeader ? '存在' : '不存在');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未授权访问'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
// 解析令牌
|
||||
const token = authHeader.split(' ')[1];
|
||||
let decoded;
|
||||
|
||||
try {
|
||||
decoded = await AuthUtils.verifyToken(token);
|
||||
if (!decoded || !decoded.id) {
|
||||
console.log('API: 令牌无效或缺少用户ID');
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '无效的访问令牌'
|
||||
}, { status: 401 });
|
||||
}
|
||||
console.log('API: 令牌解析成功, 用户ID:', decoded.id);
|
||||
} catch (error) {
|
||||
console.log('API: 令牌验证失败:', error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '无效的访问令牌'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
// 使用连接数据库的模式
|
||||
const handler = async (req: RequestWithDB) => {
|
||||
// 查询当前用户
|
||||
const [userRows] = await req.db.query(
|
||||
'SELECT id, username, email, role_type, role_name, workspace_id FROM system_users WHERE id = ?',
|
||||
[decoded.id]
|
||||
);
|
||||
|
||||
// 转换为纯JavaScript对象
|
||||
const currentUserRows = JSON.parse(JSON.stringify(userRows));
|
||||
|
||||
if (currentUserRows.length === 0) {
|
||||
console.log('API: 当前用户不存在');
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '当前用户不存在'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
const currentUser = currentUserRows[0];
|
||||
console.log('API: 当前用户:', { id: currentUser.id, role_type: currentUser.role_type });
|
||||
|
||||
// 验证是否是管理员
|
||||
if (currentUser.role_type !== SystemRoleType.ADMIN) {
|
||||
console.log('API: 用户不是管理员');
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '只有管理员可以生成邀请链接'
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// 验证当前用户是否属于该工作空间
|
||||
if (currentUser.workspace_id.toString() !== workspaceId.toString()) {
|
||||
console.log('API: 用户不属于该工作空间');
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '无权访问该工作空间'
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// 查询工作空间
|
||||
const [wsRows] = await req.db.query(
|
||||
'SELECT id, name FROM workspaces WHERE id = ?',
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
// 转换为纯JavaScript对象
|
||||
const workspaceRows = JSON.parse(JSON.stringify(wsRows));
|
||||
|
||||
if (workspaceRows.length === 0) {
|
||||
console.log('API: 工作空间不存在');
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '工作空间不存在'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
const workspace = workspaceRows[0];
|
||||
console.log('API: 工作空间:', { id: workspace.id, name: workspace.name });
|
||||
|
||||
// 生成唯一邀请令牌
|
||||
const invitationToken = uuidv4();
|
||||
console.log('API: 生成邀请令牌:', invitationToken.substring(0, 8) + '...');
|
||||
|
||||
// 确定角色信息
|
||||
const finalRoleType = role_type || SystemRoleType.USER;
|
||||
const finalRoleName = role_name || (finalRoleType === SystemRoleType.ADMIN ? '管理员' : '普通用户');
|
||||
const finalIsCustomRole = is_custom_role || false;
|
||||
|
||||
// 检查表是否需要更新结构
|
||||
try {
|
||||
await req.db.query('SELECT role_type FROM workspace_invitations LIMIT 1');
|
||||
} catch (err: unknown) {
|
||||
// 如果字段不存在,添加新的字段
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('API: 更新邀请表结构以支持新的角色系统');
|
||||
await req.db.query(`
|
||||
ALTER TABLE workspace_invitations
|
||||
ADD COLUMN role_type VARCHAR(30) NOT NULL DEFAULT 'user' AFTER role,
|
||||
ADD COLUMN role_name VARCHAR(50) NOT NULL DEFAULT '普通用户' AFTER role_type,
|
||||
ADD COLUMN is_custom_role TINYINT(1) NOT NULL DEFAULT 0 AFTER role_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 将邀请数据存入数据库
|
||||
await req.db.query(
|
||||
`INSERT INTO workspace_invitations
|
||||
(token, workspace_id, role, role_type, role_name, is_custom_role, created_by, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY), NOW())`,
|
||||
[
|
||||
invitationToken,
|
||||
workspaceId,
|
||||
finalRoleType, // 保持兼容
|
||||
finalRoleType,
|
||||
finalRoleName,
|
||||
finalIsCustomRole,
|
||||
currentUser.id
|
||||
]
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
// 如果表不存在,创建表并重试
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'ER_NO_SUCH_TABLE') {
|
||||
console.log('API: 邀请表不存在,创建表');
|
||||
await req.db.query(`
|
||||
CREATE TABLE IF NOT EXISTS workspace_invitations (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
token VARCHAR(100) NOT NULL UNIQUE,
|
||||
workspace_id INT NOT NULL,
|
||||
role VARCHAR(30) NOT NULL DEFAULT 'user',
|
||||
role_type VARCHAR(30) NOT NULL DEFAULT 'user',
|
||||
role_name VARCHAR(50) NOT NULL DEFAULT '普通用户',
|
||||
is_custom_role TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_by INT NOT NULL,
|
||||
used_by INT NULL DEFAULT NULL,
|
||||
used_at DATETIME NULL DEFAULT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_token (token),
|
||||
INDEX idx_workspace (workspace_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(id),
|
||||
FOREIGN KEY (created_by) REFERENCES system_users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
await req.db.query(
|
||||
`INSERT INTO workspace_invitations
|
||||
(token, workspace_id, role, role_type, role_name, is_custom_role, created_by, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY), NOW())`,
|
||||
[
|
||||
invitationToken,
|
||||
workspaceId,
|
||||
finalRoleType, // 保持兼容
|
||||
finalRoleType,
|
||||
finalRoleName,
|
||||
finalIsCustomRole,
|
||||
currentUser.id
|
||||
]
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('API: 邀请记录已保存');
|
||||
|
||||
// 返回成功响应
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '邀请链接已生成',
|
||||
token: invitationToken,
|
||||
expiresIn: '7天'
|
||||
});
|
||||
};
|
||||
|
||||
// 执行处理函数
|
||||
const wrappedHandler = connectSystemDB(handler);
|
||||
return await wrappedHandler(req);
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error('API: 生成邀请链接失败:', error);
|
||||
|
||||
// 其他错误
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '生成邀请链接失败: ' + (error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
src/app/api/workspace/members/route.ts
Normal file
88
src/app/api/workspace/members/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 工作空间成员列表API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 获取当前工作空间下的所有成员
|
||||
* 版本: 1.8
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AuthUtils } from '@/lib/auth';
|
||||
import { SystemUserModel } from '@/models/system/UserModel';
|
||||
|
||||
/**
|
||||
* 错误接口
|
||||
*/
|
||||
interface ApiError extends Error {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作空间的成员列表
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
console.log('API: 接收到获取成员列表请求');
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const workspaceId = url.searchParams.get('workspaceId');
|
||||
|
||||
console.log('API: 获取工作空间ID:', workspaceId);
|
||||
|
||||
// 验证参数
|
||||
if (!workspaceId) {
|
||||
console.log('API: 缺少工作空间ID参数');
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '缺少工作空间ID参数'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 从请求中获取授权信息
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
console.log('API: 授权头信息:', authHeader ? '存在' : '不存在');
|
||||
|
||||
let userId = null;
|
||||
|
||||
// 尝试验证授权
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = await AuthUtils.verifyToken(token);
|
||||
if (decoded) {
|
||||
userId = decoded.id;
|
||||
console.log('API: 令牌验证成功, 用户ID:', userId);
|
||||
}
|
||||
} catch {
|
||||
// 空catch块,有意忽略错误
|
||||
console.log('API: 令牌验证失败, 跳过用户验证');
|
||||
// 令牌验证失败时,只是记录错误,但仍然允许请求继续
|
||||
}
|
||||
}
|
||||
|
||||
// 调用模型方法获取工作空间成员
|
||||
console.log('API: 查询工作空间ID:', workspaceId, '的成员');
|
||||
const members = await SystemUserModel.getWorkspaceMembers(workspaceId);
|
||||
|
||||
console.log('API: 查询到成员数量:', members.length);
|
||||
|
||||
// 返回成员列表
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
members
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
const err = error as ApiError;
|
||||
console.error('API: 获取工作空间成员列表失败:', err);
|
||||
|
||||
// 其他错误
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取工作空间成员列表失败: ' + err.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/app/api/workspace/teams/route.ts
Normal file
73
src/app/api/workspace/teams/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 工作空间团队列表API路由
|
||||
* 作者: 阿瑞
|
||||
* 功能: 获取当前工作空间下的所有团队
|
||||
* 版本: 1.7
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { connectSystemDB, RequestWithDB } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* 获取工作空间的团队列表
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
console.log('API: 接收到获取团队列表请求');
|
||||
|
||||
// 获取查询参数
|
||||
const url = new URL(req.url);
|
||||
const workspaceId = url.searchParams.get('workspaceId');
|
||||
|
||||
console.log('API: 获取工作空间ID:', workspaceId);
|
||||
|
||||
// 验证参数
|
||||
if (!workspaceId) {
|
||||
console.log('API: 缺少工作空间ID参数');
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '缺少工作空间ID参数'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 使用数据库连接模式
|
||||
const handler = async (dbReq: RequestWithDB) => {
|
||||
// 查询工作空间下的所有团队
|
||||
const [rows] = await dbReq.db.query(
|
||||
`SELECT id, team_code as teamCode, name, status
|
||||
FROM teams
|
||||
WHERE workspace_id = ?
|
||||
ORDER BY name ASC`,
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
// 转换为纯JavaScript对象
|
||||
const teams = JSON.parse(JSON.stringify(rows));
|
||||
|
||||
console.log('API: 查询到团队数量:', teams.length);
|
||||
|
||||
// 返回团队列表
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
teams: teams
|
||||
});
|
||||
};
|
||||
|
||||
// 执行处理函数
|
||||
const wrappedHandler = connectSystemDB(handler);
|
||||
return await wrappedHandler(req);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('API: 获取团队列表失败:', err);
|
||||
|
||||
// 其他错误
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取团队列表失败: ' + err.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,581 @@
|
||||
@import "tailwindcss";
|
||||
/**
|
||||
* @作者 阿瑞
|
||||
* @功能 全局样式定义,提供现代毛玻璃UI效果所需的基础样式
|
||||
* @版本 2.3.0
|
||||
*/
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@import "tailwindcss";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* 全局变量定义 */
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
/* 明亮现代主题配色 */
|
||||
--bg-primary: #f6f9fc;
|
||||
--bg-secondary: #eef4ff;
|
||||
--bg-dark: #0a1128;
|
||||
--text-primary: #0a1128;
|
||||
--text-secondary: rgba(10, 17, 40, 0.85);
|
||||
--text-tertiary: rgba(10, 17, 40, 0.65);
|
||||
--text-light: #ffffff;
|
||||
|
||||
/* 鲜艳现代强调色 */
|
||||
--accent-blue: #2d7ff9;
|
||||
--accent-purple: #8e6bff;
|
||||
--accent-teal: #06d7b2;
|
||||
--accent-pink: #ff66c2;
|
||||
--accent-orange: #ff9640;
|
||||
|
||||
/* 优化的毛玻璃效果参数 */
|
||||
--glass-bg-light: rgba(255, 255, 255, 0.25);
|
||||
--glass-bg-dark: rgba(16, 22, 58, 0.25);
|
||||
--glass-border-light: rgba(255, 255, 255, 0.25);
|
||||
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.3);
|
||||
--glass-shadow-light: rgba(31, 38, 135, 0.15);
|
||||
--glass-shadow-dark: rgba(0, 0, 10, 0.2);
|
||||
|
||||
/* 字体 */
|
||||
--font-primary: 'Geist', 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* 基础样式 */
|
||||
body {
|
||||
font-family: var(--font-primary);
|
||||
transition: background-color 0.5s ease, color 0.3s ease;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 明亮主题 */
|
||||
body.light-theme {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-image:
|
||||
radial-gradient(circle at 80% 10%, rgba(142, 107, 255, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(circle at 20% 30%, rgba(6, 215, 178, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(circle at 90% 80%, rgba(45, 127, 249, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(circle at 10% 90%, rgba(255, 102, 194, 0.12) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
/* 深色主题 */
|
||||
body.dark-theme {
|
||||
color: var(--text-light);
|
||||
background-color: var(--bg-dark);
|
||||
background-image:
|
||||
radial-gradient(circle at 80% 10%, rgba(142, 107, 255, 0.18) 0%, transparent 45%),
|
||||
radial-gradient(circle at 20% 30%, rgba(6, 215, 178, 0.15) 0%, transparent 45%),
|
||||
radial-gradient(circle at 90% 80%, rgba(45, 127, 249, 0.18) 0%, transparent 45%),
|
||||
radial-gradient(circle at 10% 90%, rgba(255, 102, 194, 0.15) 0%, transparent 45%);
|
||||
}
|
||||
|
||||
/* ThemeProvider 组件包装的初始主题类 */
|
||||
.dark-theme {
|
||||
color: var(--text-light);
|
||||
background-color: var(--bg-dark);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.85);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.light-theme {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
--text-primary: #0a1128;
|
||||
--text-secondary: rgba(10, 17, 40, 0.85);
|
||||
--text-tertiary: rgba(10, 17, 40, 0.65);
|
||||
}
|
||||
|
||||
/* 动画效果定义 */
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(8px, -8px) rotate(2deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 0.2;
|
||||
}
|
||||
33% {
|
||||
transform: translate(15px, -15px) scale(1.1);
|
||||
opacity: 0.25;
|
||||
}
|
||||
66% {
|
||||
transform: translate(-10px, 10px) scale(0.95);
|
||||
opacity: 0.18;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.95;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradientFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 进度条动画效果 */
|
||||
@keyframes progress {
|
||||
0% {
|
||||
background-position: 1rem 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画应用类 */
|
||||
.animate-float {
|
||||
animation: float 8s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 18s infinite ease-in-out alternate;
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse 5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
animation: gradientFlow 8s ease infinite;
|
||||
background-size: 200% auto;
|
||||
}
|
||||
|
||||
/* 进度条动画 */
|
||||
.animate-progress {
|
||||
animation: progress 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 动画延迟类 */
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
.animation-delay-6000 {
|
||||
animation-delay: 6s;
|
||||
}
|
||||
|
||||
/* 毛玻璃效果组件 - 明亮主题 */
|
||||
.glass-card {
|
||||
@apply backdrop-blur-xl;
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px solid var(--glass-border-light);
|
||||
box-shadow: 0 8px 32px 0 var(--glass-shadow-light);
|
||||
border-radius: 24px;
|
||||
transition: all 0.3s ease, background 0.5s ease, border-color 0.5s ease, box-shadow 0.5s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
box-shadow: 0 12px 48px 0 var(--glass-shadow-light);
|
||||
border-color: var(--glass-highlight);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 深色毛玻璃卡片 */
|
||||
.glass-card-dark {
|
||||
@apply backdrop-blur-xl;
|
||||
background: var(--glass-bg-dark);
|
||||
border: 1px solid var(--glass-border-dark);
|
||||
box-shadow: 0 8px 32px 0 var(--glass-shadow-dark);
|
||||
border-radius: 24px;
|
||||
transition: all 0.3s ease, background 0.5s ease, border-color 0.5s ease, box-shadow 0.5s ease;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.glass-card-dark:hover {
|
||||
box-shadow: 0 12px 48px 0 var(--glass-shadow-dark);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 进度条条纹效果 */
|
||||
.bg-stripes {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.15) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
rgba(255, 255, 255, 0.15) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 1rem 1rem;
|
||||
}
|
||||
|
||||
/* 深色主题下的条纹效果调整 */
|
||||
.dark-theme .bg-stripes {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.2) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
rgba(255, 255, 255, 0.2) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* 导航栏毛玻璃效果 */
|
||||
.glass-nav {
|
||||
@apply backdrop-blur-xl z-50 fixed top-0 left-0 right-0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 20px 0 rgba(31, 38, 135, 0.1);
|
||||
transition: all 0.3s ease, background 0.5s ease, border-color 0.5s ease;
|
||||
}
|
||||
|
||||
.dark-theme .glass-nav {
|
||||
background: rgba(10, 17, 40, 0.25);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.light-theme .glass-nav {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn-primary {
|
||||
@apply rounded-full font-medium text-white py-3 px-8 transition-all duration-300;
|
||||
background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
|
||||
box-shadow: 0 4px 12px rgba(45, 127, 249, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 8px 20px rgba(45, 127, 249, 0.35);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 导航栏紧凑按钮 */
|
||||
.nav-btn-primary {
|
||||
@apply rounded-full font-medium text-white py-2 px-6 transition-all duration-300;
|
||||
background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
|
||||
box-shadow: 0 3px 10px rgba(45, 127, 249, 0.2);
|
||||
}
|
||||
|
||||
.nav-btn-primary:hover {
|
||||
box-shadow: 0 6px 15px rgba(45, 127, 249, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.nav-btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply rounded-full font-medium py-3 px-8 transition-all duration-300;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 导航栏紧凑次要按钮 */
|
||||
.nav-btn-secondary {
|
||||
@apply rounded-full font-medium py-2 px-6 transition-all duration-300;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark-theme .btn-secondary,
|
||||
.dark-theme .nav-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.light-theme .btn-secondary,
|
||||
.light-theme .nav-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.nav-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.dark-theme .btn-secondary:hover,
|
||||
.dark-theme .nav-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* 导航链接 */
|
||||
.nav-link {
|
||||
@apply transition-all duration-300;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.dark-theme .nav-link {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.light-theme .nav-link {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark-theme .nav-link:hover {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.light-theme .nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, var(--accent-teal), var(--accent-blue));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 功能卡片样式 */
|
||||
.feature-card {
|
||||
@apply transition-all duration-300 rounded-3xl p-8;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.1);
|
||||
transition: all 0.3s ease, background 0.5s ease, border-color 0.5s ease;
|
||||
}
|
||||
|
||||
.dark-theme .feature-card {
|
||||
background: rgba(22, 34, 73, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.light-theme .feature-card {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 48px rgba(31, 38, 135, 0.15);
|
||||
}
|
||||
|
||||
/* 图标容器 */
|
||||
.icon-container {
|
||||
@apply flex items-center justify-center rounded-full mb-6 w-16 h-16;
|
||||
background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover .icon-container {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-card {
|
||||
@apply transition-all duration-300 rounded-3xl p-8 flex flex-col;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.1);
|
||||
transition: all 0.3s ease, background 0.5s ease, border-color 0.5s ease;
|
||||
}
|
||||
|
||||
.dark-theme .stats-card {
|
||||
background: rgba(22, 34, 73, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.light-theme .stats-card {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 48px rgba(31, 38, 135, 0.15);
|
||||
}
|
||||
|
||||
.stats-number {
|
||||
@apply text-5xl font-bold mb-2;
|
||||
background: linear-gradient(135deg, var(--accent-teal), var(--accent-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* 步骤样式 */
|
||||
.step-number {
|
||||
@apply flex items-center justify-center text-white font-medium rounded-full w-10 h-10;
|
||||
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple));
|
||||
}
|
||||
|
||||
/* 标签样式 */
|
||||
.tab-button {
|
||||
@apply transition-all duration-300 px-6 py-3 rounded-xl text-base font-medium;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.dark-theme .tab-button {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.light-theme .tab-button {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.1);
|
||||
}
|
||||
|
||||
.dark-theme .tab-button.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-light);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 10, 0.15);
|
||||
}
|
||||
|
||||
.light-theme .tab-button.active {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.1);
|
||||
}
|
||||
|
||||
.tab-button:not(.active):hover {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.dark-theme .tab-button:not(.active):hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.light-theme .tab-button:not(.active):hover {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 主题切换器 */
|
||||
.theme-switch {
|
||||
@apply relative inline-flex items-center cursor-pointer;
|
||||
}
|
||||
|
||||
.theme-switch input {
|
||||
@apply sr-only;
|
||||
}
|
||||
|
||||
.theme-slider {
|
||||
@apply relative w-14 h-7 bg-gray-200 rounded-full transition-colors duration-300 ease-in-out;
|
||||
}
|
||||
|
||||
.theme-slider:before {
|
||||
@apply absolute content-[''] h-5 w-5 left-1 bottom-1 bg-white rounded-full transition-transform duration-300 ease-in-out;
|
||||
}
|
||||
|
||||
input:checked + .theme-slider {
|
||||
@apply bg-blue-600;
|
||||
}
|
||||
|
||||
input:checked + .theme-slider:before {
|
||||
@apply transform translate-x-7;
|
||||
}
|
||||
|
||||
/* 修改滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(45, 127, 249, 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(45, 127, 249, 0.7);
|
||||
}
|
||||
|
||||
.dark-theme ::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dark-theme ::-webkit-scrollbar-thumb {
|
||||
background: rgba(142, 107, 255, 0.5);
|
||||
}
|
||||
|
||||
.dark-theme ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(142, 107, 255, 0.7);
|
||||
}
|
||||
|
||||
/* 动画弹入效果 */
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.3s cubic-bezier(0.38, 1.6, 0.55, 0.9) forwards;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
/**
|
||||
* @author: 阿瑞
|
||||
* @功能: 应用布局文件
|
||||
* @版本: 1.0.0
|
||||
*/
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import ThemeProvider from "@/components/ThemeProvider";
|
||||
import { NotificationProvider, NotificationContainer } from '@/components/ui/Notification';
|
||||
|
||||
// 设置 Ant Design 兼容 React 19
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
|
||||
// 引入 Ant Design 样式
|
||||
//import 'antd/dist/reset.css';
|
||||
// 引入自定义毛玻璃风格样式
|
||||
import '@/styles/antd-glass.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -12,9 +28,16 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
/* 站点元数据 */
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "私域管理系统 - 现代团队协作平台",
|
||||
description: "基于最新毛玻璃UI设计的SaaS管理平台,为多团队环境打造的高效协作工具",
|
||||
keywords: ["SaaS", "团队协作", "项目管理", "毛玻璃UI", "Next.js"],
|
||||
authors: [{ name: "阿瑞", url: "https://github.com/arei" }],
|
||||
creator: "阿瑞",
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
}
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +46,22 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
suppressHydrationWarning={true}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<NotificationProvider>
|
||||
{children}
|
||||
<NotificationContainer position="top-right" />
|
||||
<NotificationContainer position="top-left" />
|
||||
<NotificationContainer position="bottom-right" />
|
||||
<NotificationContainer position="bottom-left" />
|
||||
<NotificationContainer position="top-center" />
|
||||
<NotificationContainer position="bottom-center" />
|
||||
</NotificationProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
441
src/app/page.tsx
441
src/app/page.tsx
@@ -1,103 +1,350 @@
|
||||
import Image from "next/image";
|
||||
/**
|
||||
* 私域管理系统首页
|
||||
* 作者: 阿瑞
|
||||
* 功能: 展示现代简约毛玻璃质感UI效果
|
||||
* 版本: 2.3.0
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useIsAuthenticated } from "@/store/userStore";
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle, useIsDarkMode } from "@/components/ThemeProvider";
|
||||
|
||||
/* 首页模块 - 展示毛玻璃UI效果和系统概览 */
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
const [activeTab, setActiveTab] = useState<"features" | "stats" | "start">("features");
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
// 获取用户登录状态
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
return (
|
||||
<HomeContent
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 分离内容组件,使用Context获取主题状态
|
||||
function HomeContent({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
isAuthenticated
|
||||
}: {
|
||||
activeTab: "features" | "stats" | "start";
|
||||
setActiveTab: (tab: "features" | "stats" | "start") => void;
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
// 使用Context API获取主题状态,无需水合逻辑
|
||||
const { isDarkMode, isMounted } = useIsDarkMode();
|
||||
|
||||
// 使用CSS变量的类名
|
||||
const glassCard = "glass-card";
|
||||
const glassCardDark = "glass-card-dark";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen relative overflow-hidden">
|
||||
{/* 动态背景元素 - 使用大型柔和渐变气泡 */}
|
||||
<div className="fixed inset-0 z-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-0 -left-10 w-[800px] h-[800px] bg-purple-500/8 rounded-full filter blur-[120px] opacity-70 animate-blob"></div>
|
||||
<div className="absolute top-[20%] -right-10 w-[700px] h-[700px] bg-blue-500/10 rounded-full filter blur-[100px] opacity-70 animate-blob animation-delay-4000"></div>
|
||||
<div className="absolute -bottom-20 left-[15%] w-[850px] h-[850px] bg-teal-400/8 rounded-full filter blur-[120px] opacity-60 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute bottom-[10%] right-[5%] w-[750px] h-[750px] bg-pink-400/8 rounded-full filter blur-[100px] opacity-60 animate-blob animation-delay-6000"></div>
|
||||
</div>
|
||||
|
||||
{/* 装饰小球元素 */}
|
||||
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||
<div className="absolute top-[15%] left-[10%] w-6 h-6 rounded-full bg-accent-teal/60 animate-float"></div>
|
||||
<div className="absolute top-[30%] right-[15%] w-4 h-4 rounded-full bg-accent-purple/50 animate-float animation-delay-2000"></div>
|
||||
<div className="absolute bottom-[25%] left-[20%] w-5 h-5 rounded-full bg-accent-blue/50 animate-float animation-delay-4000"></div>
|
||||
<div className="absolute bottom-[15%] right-[25%] w-3 h-3 rounded-full bg-accent-pink/60 animate-float animation-delay-6000"></div>
|
||||
</div>
|
||||
|
||||
{/* 页面内容 */}
|
||||
<div className="container max-w-7xl mx-auto px-4 py-20 relative z-10">
|
||||
{/* 顶部导航栏 - 毛玻璃效果 */}
|
||||
<header>
|
||||
<nav className="glass-nav mx-4 mt-4 px-8 py-4 flex items-center justify-between rounded-full">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[var(--accent-blue)] to-[var(--accent-purple)] flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[var(--text-primary)]">
|
||||
私域管理系统
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
<a href="#" className="nav-link">功能</a>
|
||||
<a href="#" className="nav-link">价格</a>
|
||||
<a href="#" className="nav-link">文档</a>
|
||||
<Link href="/test/ui" className="nav-link">UI组件</Link>
|
||||
<a href="#" className="nav-link">关于我们</a>
|
||||
</div>
|
||||
<div className="flex items-center space-x-5">
|
||||
{/* 使用主题切换组件 */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* 根据登录状态显示不同的按钮 */}
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href="/workspace"
|
||||
className="nav-btn-primary"
|
||||
>
|
||||
进入工作空间
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="nav-btn-secondary"
|
||||
>
|
||||
登录
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="nav-btn-primary"
|
||||
>
|
||||
注册
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<section className="pt-40 pb-16">
|
||||
<div className="text-center mb-28">
|
||||
<h1 className="text-5xl md:text-6xl font-bold mb-6 leading-tight">
|
||||
<span className="inline-block bg-gradient-to-r from-[var(--accent-teal)] via-[var(--accent-blue)] to-[var(--accent-purple)] animate-gradient bg-clip-text text-transparent">
|
||||
简化团队协作的
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-[var(--text-primary)]">SaaS管理平台</span>
|
||||
</h1>
|
||||
<p className="text-xl max-w-3xl mx-auto mb-12 text-[var(--text-secondary)]">
|
||||
为多团队环境打造的高效管理系统,独立数据环境,统一管理界面
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-5">
|
||||
<button className="btn-primary px-10 py-4 animate-pulse-slow">
|
||||
开始免费试用
|
||||
</button>
|
||||
<Link href="/test/ui" className="btn-secondary px-10 py-4">
|
||||
查看UI组件
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 毛玻璃卡片区域 */}
|
||||
<div className={isDarkMode ? glassCardDark : glassCard} style={{ opacity: isMounted ? 1 : 0, transition: 'opacity 0.3s' }}>
|
||||
{/* 选项卡导航 */}
|
||||
<div className="flex space-x-4 mb-10 border-b border-white/5 pb-4 p-8">
|
||||
<button
|
||||
onClick={() => setActiveTab("features")}
|
||||
className={`tab-button ${activeTab === "features" ? "active" : ""}`}
|
||||
>
|
||||
核心功能
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("stats")}
|
||||
className={`tab-button ${activeTab === "stats" ? "active" : ""}`}
|
||||
>
|
||||
统计数据
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("start")}
|
||||
className={`tab-button ${activeTab === "start" ? "active" : ""}`}
|
||||
>
|
||||
快速开始
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 选项卡内容区域 */}
|
||||
<div className="p-6">
|
||||
{activeTab === "features" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* 功能卡片1 */}
|
||||
<div className="feature-card">
|
||||
<div className="icon-container">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-[var(--text-primary)]">多团队隔离</h3>
|
||||
<p className="text-[var(--text-secondary)]">数据库级别隔离架构,为每个团队提供独立安全的数据环境</p>
|
||||
</div>
|
||||
|
||||
{/* 功能卡片2 */}
|
||||
<div className="feature-card">
|
||||
<div className="icon-container">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-[var(--text-primary)]">实时数据分析</h3>
|
||||
<p className="text-[var(--text-secondary)]">直观的数据可视化,帮助团队进行数据驱动决策</p>
|
||||
</div>
|
||||
|
||||
{/* 功能卡片3 */}
|
||||
<div className="feature-card">
|
||||
<div className="icon-container">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-[var(--text-primary)]">安全认证</h3>
|
||||
<p className="text-[var(--text-secondary)]">JWT与bcryptjs加密确保系统安全,多层次访问控制</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "stats" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
|
||||
{/* 数据统计卡片1 */}
|
||||
<div className="stats-card">
|
||||
<h4 className="uppercase tracking-wider text-sm mb-2 text-[var(--text-tertiary)]">活跃用户</h4>
|
||||
<div className="stats-number">12,543</div>
|
||||
<div className="text-green-500 flex items-center text-lg font-medium">
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span>↑ 23.6%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据统计卡片2 */}
|
||||
<div className="stats-card">
|
||||
<h4 className="uppercase tracking-wider text-sm mb-2 text-[var(--text-tertiary)]">团队数量</h4>
|
||||
<div className="stats-number">542</div>
|
||||
<div className="text-green-500 flex items-center text-lg font-medium">
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span>↑ 18.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "start" && (
|
||||
<div className="glass-card p-8">
|
||||
<div className="mb-8">
|
||||
<h3 className="text-2xl font-semibold mb-4 text-[var(--text-primary)]">快速开始使用</h3>
|
||||
<p className="mb-6 text-[var(--text-secondary)]">按照以下步骤开始使用私域管理系统</p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start">
|
||||
<div className="step-number mr-4 flex-shrink-0">1</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-1 text-[var(--text-primary)]">注册账户</h4>
|
||||
<p className="text-[var(--text-tertiary)]">创建您的管理员账户,并设置团队信息</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="step-number mr-4 flex-shrink-0">2</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-1 text-[var(--text-primary)]">邀请团队成员</h4>
|
||||
<p className="text-[var(--text-tertiary)]">通过邮件链接邀请团队成员加入</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="step-number mr-4 flex-shrink-0">3</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-1 text-[var(--text-primary)]">配置工作空间</h4>
|
||||
<p className="text-[var(--text-tertiary)]">根据需求设置工作流程和权限</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary w-full py-3">
|
||||
立即开始
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 特色展示卡片 */}
|
||||
<div className="mb-24">
|
||||
<h2 className="text-3xl font-bold text-center mb-14 text-[var(--text-primary)]">探索更多功能</h2>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||
{/* 特色卡片1 - 左侧 */}
|
||||
<div className="rounded-3xl overflow-hidden glass-card">
|
||||
<div className="p-8">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-accent-orange/20 flex items-center justify-center mr-4">
|
||||
<svg className="w-6 h-6 text-[var(--accent-orange)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"></path>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-[var(--text-primary)]">数据可视化</h3>
|
||||
</div>
|
||||
<p className="mb-6 text-[var(--text-secondary)]">
|
||||
直观展现核心业务指标,支持自定义仪表盘和报表,帮助团队快速识别趋势和异常
|
||||
</p>
|
||||
<div className="bg-gradient-to-br from-orange-500/20 to-yellow-500/20 p-6 rounded-2xl">
|
||||
<div className="flex justify-between mb-4">
|
||||
<div className="text-[var(--text-primary)]">月度增长</div>
|
||||
<div className="text-[var(--accent-orange)]">+24.8%</div>
|
||||
</div>
|
||||
<div className="w-full bg-black/10 h-2 rounded-full mb-8">
|
||||
<div className="bg-[var(--accent-orange)] h-2 rounded-full" style={{ width: '68%' }}></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-16 bg-white/20 rounded-xl"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 特色卡片2 - 右侧 */}
|
||||
<div className="rounded-3xl overflow-hidden glass-card">
|
||||
<div className="p-8">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-accent-teal/20 flex items-center justify-center mr-4">
|
||||
<svg className="w-6 h-6 text-[var(--accent-teal)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-[var(--text-primary)]">团队协作</h3>
|
||||
</div>
|
||||
<p className="mb-6 text-[var(--text-secondary)]">
|
||||
集成消息、任务和日历功能,让团队成员保持同步,提高协作效率
|
||||
</p>
|
||||
<div className="bg-gradient-to-br from-teal-500/20 to-cyan-500/20 p-6 rounded-2xl">
|
||||
<div className="flex flex-col space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-white/30 mr-3 flex-shrink-0"></div>
|
||||
<div className="flex-1">
|
||||
<div className="h-2 bg-white/30 rounded-full w-3/4 mb-1"></div>
|
||||
<div className="h-2 bg-white/20 rounded-full w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="glass-card p-6 text-center">
|
||||
<div className="max-w-2xl mx-auto text-sm text-[var(--text-tertiary)]">
|
||||
© 2024 私域管理系统 | 使用现代前端技术栈构建 | TypeScript + Next.js + React + Tailwind CSS
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
239
src/app/team/[teamCode]/brands/brand-modal.tsx
Normal file
239
src/app/team/[teamCode]/brands/brand-modal.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 品牌模态框组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供品牌信息的添加和编辑界面
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { IBrand } from '@/models/team/types/IBrand';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
|
||||
interface BrandModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
brand?: IBrand | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌模态框组件
|
||||
*/
|
||||
export default function BrandModal({ isOpen, onClose, brand, onSuccess }: BrandModalProps) {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
const isEditing = !!brand;
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<Partial<IBrand>>({
|
||||
name: '',
|
||||
order: 0,
|
||||
description: ''
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 初始化编辑表单数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (brand) {
|
||||
setFormData({
|
||||
id: brand.id,
|
||||
name: brand.name,
|
||||
order: brand.order,
|
||||
description: brand.description || ''
|
||||
});
|
||||
} else {
|
||||
// 重置表单
|
||||
setFormData({
|
||||
name: '',
|
||||
order: 0,
|
||||
description: ''
|
||||
});
|
||||
}
|
||||
|
||||
setError(null);
|
||||
}, [brand, isOpen]);
|
||||
|
||||
/**
|
||||
* 处理表单输入变化
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// 处理数字类型字段
|
||||
if (name === 'order') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value === '' ? 0 : parseInt(value, 10)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 提交表单
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 基本验证
|
||||
if (!formData.name) {
|
||||
setError('品牌名称为必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (isEditing && brand) {
|
||||
// 更新品牌
|
||||
response = await fetch(`/api/team/${teamCode}/brands/${brand.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
} else {
|
||||
// 创建品牌
|
||||
response = await fetch(`/api/team/${teamCode}/brands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
}
|
||||
|
||||
// 先检查响应状态
|
||||
if (!response.ok) {
|
||||
// 克隆响应以避免"body已被读取"的错误
|
||||
const clonedResponse = response.clone();
|
||||
const errorData = await clonedResponse.json();
|
||||
throw new Error(errorData.error || '操作失败');
|
||||
}
|
||||
|
||||
// 响应成功,读取结果
|
||||
await response.json();
|
||||
|
||||
// 成功处理
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('提交品牌数据失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? '编辑品牌' : '添加品牌'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-4">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 品牌名称 */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
品牌名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 显示顺序 */}
|
||||
<div>
|
||||
<label htmlFor="order" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
显示顺序
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="order"
|
||||
name="order"
|
||||
value={formData.order}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">数字越小排序越靠前</p>
|
||||
</div>
|
||||
|
||||
{/* 品牌描述 */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
品牌描述
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description || ''}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表单按钮 */}
|
||||
<div className="flex justify-end space-x-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white dark:bg-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none ${
|
||||
isSubmitting ? 'opacity-70 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
处理中...
|
||||
</span>
|
||||
) : (
|
||||
isEditing ? '保存' : '创建'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
417
src/app/team/[teamCode]/brands/page.tsx
Normal file
417
src/app/team/[teamCode]/brands/page.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* 品牌管理页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供品牌数据的展示和管理功能
|
||||
* 版本: 1.1.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTeam, useTheme, useAccessToken } from '@/hooks';
|
||||
import { MdAdd, MdSearch, MdEdit, MdDelete, MdRefresh, MdLabel, MdSortByAlpha } from 'react-icons/md';
|
||||
import { IBrand } from '@/models/team/types/IBrand';
|
||||
import BrandModal from './brand-modal';
|
||||
|
||||
/**
|
||||
* 品牌管理页面组件
|
||||
*/
|
||||
export default function BrandsPage() {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { currentTeam } = useTeam();
|
||||
const accessToken = useAccessToken();
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// 品牌数据状态
|
||||
const [brands, setBrands] = useState<IBrand[]>([]);
|
||||
const [totalBrands, setTotalBrands] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 搜索状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
// 模态框状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedBrand, setSelectedBrand] = useState<IBrand | null>(null);
|
||||
|
||||
// 删除确认状态
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [brandToDelete, setBrandToDelete] = useState<IBrand | null>(null);
|
||||
|
||||
/**
|
||||
* 获取品牌列表数据
|
||||
*/
|
||||
const fetchBrands = useCallback(async () => {
|
||||
if (!currentTeam || !teamCode) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 构建查询参数
|
||||
const queryParams = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
|
||||
if (searchKeyword) queryParams.append('keyword', searchKeyword);
|
||||
|
||||
const response = await fetch(`/api/team/${teamCode}/brands?${queryParams.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取品牌列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setBrands(data.brands || []);
|
||||
setTotalBrands(data.total || 0);
|
||||
} catch (err) {
|
||||
console.error('获取品牌列表失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTeam, teamCode, accessToken, currentPage, pageSize, searchKeyword]);
|
||||
|
||||
/**
|
||||
* 初始加载和依赖变化时获取数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (accessToken && teamCode) {
|
||||
fetchBrands();
|
||||
}
|
||||
}, [accessToken, teamCode, fetchBrands]);
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
fetchBrands();
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空筛选条件
|
||||
*/
|
||||
const handleClearFilters = () => {
|
||||
setSearchKeyword('');
|
||||
setCurrentPage(1);
|
||||
fetchBrands();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理添加品牌按钮点击
|
||||
*/
|
||||
const handleAddBrand = () => {
|
||||
setSelectedBrand(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理编辑品牌
|
||||
*/
|
||||
const handleEditBrand = (brand: IBrand) => {
|
||||
setSelectedBrand(brand);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理删除品牌确认
|
||||
*/
|
||||
const handleDeleteClick = (brand: IBrand) => {
|
||||
setBrandToDelete(brand);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行删除品牌操作
|
||||
*/
|
||||
const confirmDelete = async () => {
|
||||
if (!brandToDelete || !teamCode) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/brands/${brandToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除品牌失败');
|
||||
}
|
||||
|
||||
// 移除已删除的品牌
|
||||
setBrands(prev => prev.filter(b => b.id !== brandToDelete.id));
|
||||
setTotalBrands(prev => prev - 1);
|
||||
setShowDeleteConfirm(false);
|
||||
setBrandToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('删除品牌失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算总页数
|
||||
*/
|
||||
const totalPages = Math.ceil(totalBrands / pageSize);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 页面标题区域 */}
|
||||
<div className="mb-6">
|
||||
<h1 className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
品牌管理
|
||||
</h1>
|
||||
<p className={`mt-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
管理和查看产品品牌资料
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索区域 */}
|
||||
<div className={`mb-6 p-4 rounded-xl shadow-sm ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
{/* 搜索表单 */}
|
||||
<form onSubmit={handleSearch} className="flex flex-1">
|
||||
<div className="relative flex-grow mr-2">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MdSearch className={isDarkMode ? 'text-gray-400' : 'text-gray-500'} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索品牌名称或描述..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className={`pl-10 pr-4 py-2 w-full rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center"
|
||||
>
|
||||
<MdSearch className="mr-1" />
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className={`px-3 py-2 rounded-lg flex items-center ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MdRefresh className="mr-1" />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddBrand}
|
||||
className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center"
|
||||
>
|
||||
<MdAdd className="mr-1" />
|
||||
添加品牌
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中提示 */}
|
||||
{isLoading && (
|
||||
<div className={`flex justify-center items-center py-12 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||
正在加载品牌数据...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无数据提示 */}
|
||||
{!isLoading && brands.length === 0 && (
|
||||
<div className={`text-center py-16 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<MdLabel className="mx-auto text-5xl mb-3 opacity-50" />
|
||||
<p className="text-lg">暂无品牌数据</p>
|
||||
<p className="mt-1 text-sm">点击“添加品牌”按钮创建新品牌</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 品牌列表 */}
|
||||
{!isLoading && brands.length > 0 && (
|
||||
<div className={`rounded-xl overflow-hidden shadow-sm ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className={`border-b ${isDarkMode ? 'bg-gray-800/50 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
ID/排序
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
品牌名称
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
描述
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${isDarkMode ? 'divide-gray-700' : 'divide-gray-200'}`}>
|
||||
{brands.map((brand) => (
|
||||
<tr key={brand.id} className={`${isDarkMode ? 'hover:bg-gray-800/30' : 'hover:bg-gray-50'} transition-colors`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
#{brand.id}
|
||||
</div>
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
排序: {brand.order}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className={`flex-shrink-0 h-10 w-10 rounded-full flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-purple-900/30' : 'bg-purple-100'
|
||||
}`}>
|
||||
<MdSortByAlpha className={`h-6 w-6 ${isDarkMode ? 'text-purple-300' : 'text-purple-600'}`} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{brand.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'} max-w-xs truncate`}>
|
||||
{brand.description || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditBrand(brand)}
|
||||
className={`p-2 rounded-lg ${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'} text-blue-600 hover:text-blue-800 transition-colors`}
|
||||
title="编辑"
|
||||
>
|
||||
<MdEdit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(brand)}
|
||||
className={`p-2 rounded-lg ${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'} text-red-600 hover:text-red-800 transition-colors`}
|
||||
title="删除"
|
||||
>
|
||||
<MdDelete size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 1 && (
|
||||
<div className={`flex items-center justify-between border-t px-4 py-3 ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||
<div className="hidden sm:block">
|
||||
<p className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 至{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(currentPage * pageSize, totalBrands)}
|
||||
</span>{' '}
|
||||
条,共 <span className="font-medium">{totalBrands}</span> 条
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-between sm:justify-end">
|
||||
<button
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md ${
|
||||
currentPage === 1
|
||||
? `cursor-not-allowed ${isDarkMode ? 'bg-gray-800 text-gray-500 border-gray-700' : 'bg-gray-100 text-gray-400 border-gray-200'}`
|
||||
: `${isDarkMode ? 'bg-gray-700 text-gray-200 border-gray-600 hover:bg-gray-600' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'}`
|
||||
} mr-3`}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md ${
|
||||
currentPage === totalPages
|
||||
? `cursor-not-allowed ${isDarkMode ? 'bg-gray-800 text-gray-500 border-gray-700' : 'bg-gray-100 text-gray-400 border-gray-200'}`
|
||||
: `${isDarkMode ? 'bg-gray-700 text-gray-200 border-gray-600 hover:bg-gray-600' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'}`
|
||||
}`}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 品牌模态框 */}
|
||||
<BrandModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
brand={selectedBrand}
|
||||
onSuccess={fetchBrands}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className={`p-6 rounded-lg shadow-xl max-w-md w-full ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}>
|
||||
<h3 className={`text-lg font-medium mb-3 ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>确认删除</h3>
|
||||
<p className={`mb-4 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
您确定要删除品牌 “{brandToDelete?.name}” 吗?此操作不可撤销。
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
|
||||
} transition`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
src/app/team/[teamCode]/categories/category-modal.tsx
Normal file
261
src/app/team/[teamCode]/categories/category-modal.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 品类模态框组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供品类信息的添加和编辑界面
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useAccessToken } from '@/hooks';
|
||||
import { ICategory } from '@/models/team/types/ICategory';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
|
||||
interface CategoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
category?: ICategory | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 品类模态框组件
|
||||
*/
|
||||
export default function CategoryModal({ isOpen, onClose, category, onSuccess }: CategoryModalProps) {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const accessToken = useAccessToken();
|
||||
|
||||
const isEditing = !!category;
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<Partial<ICategory>>({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: ''
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 初始化编辑表单数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setFormData({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: category.description || '',
|
||||
icon: category.icon || ''
|
||||
});
|
||||
} else {
|
||||
// 重置表单
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: ''
|
||||
});
|
||||
}
|
||||
|
||||
setError(null);
|
||||
}, [category, isOpen]);
|
||||
|
||||
/**
|
||||
* 处理表单输入变化
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 提交表单
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 基本验证
|
||||
if (!formData.name) {
|
||||
setError('品类名称为必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (isEditing && category) {
|
||||
// 更新品类
|
||||
response = await fetch(`/api/team/${teamCode}/categories/${category.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
} else {
|
||||
// 创建品类
|
||||
response = await fetch(`/api/team/${teamCode}/categories`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
}
|
||||
|
||||
// 先检查响应状态
|
||||
if (!response.ok) {
|
||||
// 克隆响应以避免"body已被读取"的错误
|
||||
const clonedResponse = response.clone();
|
||||
const errorData = await clonedResponse.json();
|
||||
throw new Error(errorData.error || '操作失败');
|
||||
}
|
||||
|
||||
// 响应成功,读取结果
|
||||
await response.json();
|
||||
|
||||
// 成功处理
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('提交品类数据失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? '编辑品类' : '添加品类'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-4">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 品类名称 */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
品类名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 图标URL */}
|
||||
<div>
|
||||
<label htmlFor="icon" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
图标URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="icon"
|
||||
name="icon"
|
||||
value={formData.icon || ''}
|
||||
onChange={handleChange}
|
||||
placeholder="输入图标的URL地址"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">图标的URL地址,可不填</p>
|
||||
</div>
|
||||
|
||||
{/* 图标预览 */}
|
||||
{formData.icon && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
图标预览
|
||||
</label>
|
||||
<div className="mt-1 flex items-center">
|
||||
<Image
|
||||
src={formData.icon}
|
||||
alt="图标预览"
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 object-contain bg-gray-100 dark:bg-gray-700 p-1 rounded"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%23ccc' d='M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm0 2v12h14V6H5zm1 1h12v7H6V7zm0 9h12v1H6v-1z'/%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
如果URL有效,图标将显示在这里
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 品类描述 */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
品类描述
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description || ''}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表单按钮 */}
|
||||
<div className="flex justify-end space-x-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white dark:bg-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none ${
|
||||
isSubmitting ? 'opacity-70 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
处理中...
|
||||
</span>
|
||||
) : (
|
||||
isEditing ? '保存' : '创建'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
474
src/app/team/[teamCode]/categories/page.tsx
Normal file
474
src/app/team/[teamCode]/categories/page.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 品类管理页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供品类数据的展示和管理功能
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useTeam, useTheme, useAccessToken } from '@/hooks';
|
||||
import { MdAdd, MdSearch, MdEdit, MdDelete, MdRefresh, MdCategory, MdImage } from 'react-icons/md';
|
||||
import { ICategory } from '@/models/team/types/ICategory';
|
||||
import CategoryModal from './category-modal';
|
||||
|
||||
/**
|
||||
* 品类管理页面组件
|
||||
*/
|
||||
export default function CategoriesPage() {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { currentTeam } = useTeam();
|
||||
const accessToken = useAccessToken();
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// 品类数据状态
|
||||
const [categories, setCategories] = useState<ICategory[]>([]);
|
||||
const [totalCategories, setTotalCategories] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 搜索状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
// 模态框状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<ICategory | null>(null);
|
||||
|
||||
// 删除确认状态
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [categoryToDelete, setCategoryToDelete] = useState<ICategory | null>(null);
|
||||
|
||||
/**
|
||||
* 获取品类列表数据
|
||||
*/
|
||||
const fetchCategories = useCallback(async () => {
|
||||
if (!currentTeam || !teamCode) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 构建查询参数
|
||||
const queryParams = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
|
||||
if (searchKeyword) queryParams.append('keyword', searchKeyword);
|
||||
|
||||
const response = await fetch(`/api/team/${teamCode}/categories?${queryParams.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取品类列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setCategories(data.categories || []);
|
||||
setTotalCategories(data.total || 0);
|
||||
} catch (err) {
|
||||
console.error('获取品类列表失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTeam, teamCode, accessToken, currentPage, pageSize, searchKeyword]);
|
||||
|
||||
/**
|
||||
* 初始加载和依赖变化时获取数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (accessToken && teamCode) {
|
||||
fetchCategories();
|
||||
}
|
||||
}, [accessToken, teamCode, fetchCategories]);
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
fetchCategories();
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空筛选条件
|
||||
*/
|
||||
const handleClearFilters = () => {
|
||||
setSearchKeyword('');
|
||||
setCurrentPage(1);
|
||||
fetchCategories();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理添加品类按钮点击
|
||||
*/
|
||||
const handleAddCategory = () => {
|
||||
setSelectedCategory(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理编辑品类
|
||||
*/
|
||||
const handleEditCategory = (category: ICategory) => {
|
||||
setSelectedCategory(category);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理删除品类确认
|
||||
*/
|
||||
const handleDeleteClick = (category: ICategory) => {
|
||||
setCategoryToDelete(category);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行删除品类操作
|
||||
*/
|
||||
const confirmDelete = async () => {
|
||||
if (!categoryToDelete || !teamCode) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/categories/${categoryToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除品类失败');
|
||||
}
|
||||
|
||||
// 移除已删除的品类
|
||||
setCategories(prev => prev.filter(c => c.id !== categoryToDelete.id));
|
||||
setTotalCategories(prev => prev - 1);
|
||||
setShowDeleteConfirm(false);
|
||||
setCategoryToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('删除品类失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算总页数
|
||||
*/
|
||||
const totalPages = Math.ceil(totalCategories / pageSize);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 页面标题区域 */}
|
||||
<div className="mb-6">
|
||||
<h1 className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
品类管理
|
||||
</h1>
|
||||
<p className={`mt-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
管理和查看产品品类资料
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索区域 */}
|
||||
<div className={`mb-6 p-4 rounded-xl shadow-sm ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
{/* 搜索表单 */}
|
||||
<form onSubmit={handleSearch} className="flex flex-1">
|
||||
<div className="relative flex-grow mr-2">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MdSearch className={isDarkMode ? 'text-gray-400' : 'text-gray-500'} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索品类名称或描述..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className={`pl-10 pr-4 py-2 w-full rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center"
|
||||
>
|
||||
<MdSearch className="mr-1" />
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className={`px-3 py-2 rounded-lg flex items-center ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MdRefresh className="mr-1" />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCategory}
|
||||
className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center"
|
||||
>
|
||||
<MdAdd className="mr-1" />
|
||||
添加品类
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中提示 */}
|
||||
{isLoading && (
|
||||
<div className={`flex justify-center items-center py-12 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||
正在加载品类数据...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空数据提示 */}
|
||||
{!isLoading && categories.length === 0 && (
|
||||
<div className={`text-center py-12 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<MdCategory className="mx-auto text-5xl mb-2" />
|
||||
<p>暂无品类数据</p>
|
||||
<button
|
||||
onClick={handleAddCategory}
|
||||
className="mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-flex items-center"
|
||||
>
|
||||
<MdAdd className="mr-1" />
|
||||
添加品类
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据列表 */}
|
||||
{!isLoading && categories.length > 0 && (
|
||||
<div className={`rounded-xl shadow-sm overflow-hidden ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className={isDarkMode ? 'bg-gray-800' : 'bg-gray-50'}>
|
||||
<tr>
|
||||
<th className={`px-4 py-3 text-left text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
ID
|
||||
</th>
|
||||
<th className={`px-4 py-3 text-left text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
图标
|
||||
</th>
|
||||
<th className={`px-4 py-3 text-left text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
品类名称
|
||||
</th>
|
||||
<th className={`px-4 py-3 text-left text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
描述
|
||||
</th>
|
||||
<th className={`px-4 py-3 text-right text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{categories.map((category) => (
|
||||
<tr key={category.id} className={isDarkMode ? 'hover:bg-gray-800/50' : 'hover:bg-gray-50'}>
|
||||
<td className={`px-4 py-4 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{category.id}
|
||||
</td>
|
||||
<td className={`px-4 py-4 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{category.icon ? (
|
||||
<Image
|
||||
src={category.icon}
|
||||
alt={`${category.name}图标`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<MdImage className="w-6 h-6 text-gray-400" />
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-4 py-4 text-sm font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{category.name}
|
||||
</td>
|
||||
<td className={`px-4 py-4 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
{category.description || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-right space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditCategory(category)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<MdEdit className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(category)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<MdDelete className="inline" /> 删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 1 && (
|
||||
<div className={`px-4 py-3 flex items-center justify-between border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium ${
|
||||
currentPage === 1
|
||||
? 'bg-gray-100 text-gray-500 cursor-not-allowed dark:bg-gray-700 dark:text-gray-400'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`ml-3 px-4 py-2 rounded-md text-sm font-medium ${
|
||||
currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-500 cursor-not-allowed dark:bg-gray-700 dark:text-gray-400'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 至{' '}
|
||||
<span className="font-medium">{Math.min(currentPage * pageSize, totalCategories)}</span> 条,共{' '}
|
||||
<span className="font-medium">{totalCategories}</span> 条结果
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`relative inline-flex items-center px-2 py-2 rounded-l-md border text-sm font-medium ${
|
||||
currentPage === 1
|
||||
? `border-gray-300 ${isDarkMode ? 'bg-gray-700 text-gray-400' : 'bg-gray-100 text-gray-500'} cursor-not-allowed`
|
||||
: `border-gray-300 ${isDarkMode ? 'bg-gray-800 text-gray-300 hover:bg-gray-700' : 'bg-white text-gray-500 hover:bg-gray-50'}`
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">上一页</span>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{/* 页码按钮 */}
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
// 计算显示的页码范围
|
||||
const startPage = Math.max(1, currentPage - 2);
|
||||
const endPage = Math.min(totalPages, startPage + 4);
|
||||
const adjustedStartPage = Math.max(1, endPage - 4);
|
||||
|
||||
const pageNum = adjustedStartPage + i;
|
||||
if (pageNum <= totalPages) {
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
currentPage === pageNum
|
||||
? `z-10 ${isDarkMode ? 'bg-blue-600 text-white' : 'bg-blue-50 border-blue-500 text-blue-600'}`
|
||||
: `border-gray-300 ${isDarkMode ? 'bg-gray-800 text-gray-300 hover:bg-gray-700' : 'bg-white text-gray-500 hover:bg-gray-50'}`
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`relative inline-flex items-center px-2 py-2 rounded-r-md border text-sm font-medium ${
|
||||
currentPage === totalPages
|
||||
? `border-gray-300 ${isDarkMode ? 'bg-gray-700 text-gray-400' : 'bg-gray-100 text-gray-500'} cursor-not-allowed`
|
||||
: `border-gray-300 ${isDarkMode ? 'bg-gray-800 text-gray-300 hover:bg-gray-700' : 'bg-white text-gray-500 hover:bg-gray-50'}`
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">下一页</span>
|
||||
›
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 品类编辑/添加模态框 */}
|
||||
<CategoryModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
category={selectedCategory}
|
||||
onSuccess={fetchCategories}
|
||||
/>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
{showDeleteConfirm && categoryToDelete && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className={`mx-auto max-w-sm p-6 rounded-lg shadow-xl ${isDarkMode ? 'bg-gray-800 text-white' : 'bg-white text-black'}`}>
|
||||
<h3 className="text-lg font-medium mb-3">确认删除</h3>
|
||||
<p className="mb-4">
|
||||
确定要删除品类 <span className="font-semibold">{categoryToDelete.name}</span> 吗?此操作无法撤销。
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setCategoryToDelete(null);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm rounded-md ${
|
||||
isDarkMode ? 'bg-gray-700 hover:bg-gray-600 text-gray-200' : 'bg-gray-200 hover:bg-gray-300 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 text-sm bg-red-600 hover:bg-red-700 text-white rounded-md"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* 客户分析组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 分析客户数据,展示性别比例、地区占比和统计数据
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { useAccessToken } from '@/store/userStore';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import { Customer, CustomerGender } from '@/models/team/types/old/customer';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { PieChart } from 'echarts/charts';
|
||||
import {
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
GridComponent
|
||||
} from 'echarts/components';
|
||||
import { LabelLayout } from 'echarts/features';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
// 注册必要的组件
|
||||
echarts.use([
|
||||
PieChart,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
GridComponent,
|
||||
LabelLayout,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
interface CustomerAnalyticsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户数据分析组件
|
||||
*/
|
||||
export default function CustomerAnalytics({ className }: CustomerAnalyticsProps) {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const accessToken = useAccessToken();
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
// 图表容器引用
|
||||
const genderChartRef = useRef<HTMLDivElement>(null);
|
||||
const regionChartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 图表实例引用
|
||||
const [genderChart, setGenderChart] = useState<echarts.ECharts | null>(null);
|
||||
const [regionChart, setRegionChart] = useState<echarts.ECharts | null>(null);
|
||||
|
||||
// 统计数据
|
||||
const [statistics, setStatistics] = useState({
|
||||
totalCustomers: 0,
|
||||
totalBalance: 0,
|
||||
customersWithBalance: 0,
|
||||
customersWithoutBalance: 0
|
||||
});
|
||||
|
||||
// 加载状态
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 处理性别数据
|
||||
*/
|
||||
const processGenderData = useCallback((customers: Customer[]) => {
|
||||
const maleCount = customers.filter(c => c.gender === CustomerGender.MALE).length;
|
||||
const femaleCount = customers.filter(c => c.gender === CustomerGender.FEMALE).length;
|
||||
const unknownCount = customers.filter(c => !c.gender || c.gender === undefined).length;
|
||||
|
||||
return [
|
||||
{ value: maleCount, name: '男性' },
|
||||
{ value: femaleCount, name: '女性' },
|
||||
{ value: unknownCount, name: '未知' }
|
||||
];
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理地区数据
|
||||
*/
|
||||
const processRegionData = useCallback((customers: Customer[]) => {
|
||||
const regionMap = new Map<string, number>();
|
||||
|
||||
customers.forEach(customer => {
|
||||
if (!customer.address) return;
|
||||
|
||||
let region = '';
|
||||
|
||||
// 处理字符串形式的地址
|
||||
if (typeof customer.address === 'string') {
|
||||
try {
|
||||
const addressObj = JSON.parse(customer.address);
|
||||
region = addressObj.province || addressObj.city || '未知地区';
|
||||
} catch {
|
||||
region = '未知地区';
|
||||
}
|
||||
} else {
|
||||
// 处理对象形式的地址
|
||||
region = customer.address.province || customer.address.city || '未知地区';
|
||||
}
|
||||
|
||||
regionMap.set(region, (regionMap.get(region) || 0) + 1);
|
||||
});
|
||||
|
||||
// 转换为ECharts需要的数据格式
|
||||
return Array.from(regionMap.entries())
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value) // 按数量降序排序
|
||||
.slice(0, 10); // 只取前10个地区
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 渲染性别比例图表
|
||||
*/
|
||||
const renderGenderChart = useCallback((data: { value: number; name: string }[]) => {
|
||||
if (!genderChartRef.current) return;
|
||||
|
||||
if (!genderChart) {
|
||||
const chart = echarts.init(genderChartRef.current);
|
||||
setGenderChart(chart);
|
||||
}
|
||||
|
||||
const chartInstance = genderChart || echarts.init(genderChartRef.current);
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '客户性别比例',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: isDarkMode ? '#e5e7eb' : '#1f2937'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 'bottom',
|
||||
textStyle: {
|
||||
color: isDarkMode ? '#d1d5db' : '#4b5563'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '性别比例',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: isDarkMode ? '#374151' : '#ffffff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold',
|
||||
color: isDarkMode ? '#f9fafb' : '#111827'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
],
|
||||
color: ['#3b82f6', '#ec4899', '#9ca3af']
|
||||
};
|
||||
|
||||
chartInstance.setOption(option);
|
||||
}, [genderChart, isDarkMode]);
|
||||
|
||||
/**
|
||||
* 渲染地区占比图表
|
||||
*/
|
||||
const renderRegionChart = useCallback((data: { value: number; name: string }[]) => {
|
||||
if (!regionChartRef.current) return;
|
||||
|
||||
if (!regionChart) {
|
||||
const chart = echarts.init(regionChartRef.current);
|
||||
setRegionChart(chart);
|
||||
}
|
||||
|
||||
const chartInstance = regionChart || echarts.init(regionChartRef.current);
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '客户地区分布',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: isDarkMode ? '#e5e7eb' : '#1f2937'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
color: isDarkMode ? '#d1d5db' : '#4b5563'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '地区分布',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: isDarkMode ? '#374151' : '#ffffff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold',
|
||||
color: isDarkMode ? '#f9fafb' : '#111827'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
],
|
||||
color: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#6366f1', '#14b8a6', '#f97316', '#6b7280']
|
||||
};
|
||||
|
||||
chartInstance.setOption(option);
|
||||
}, [regionChart, isDarkMode]);
|
||||
|
||||
/**
|
||||
* 获取客户统计数据
|
||||
*/
|
||||
const fetchCustomerStatistics = useCallback(async () => {
|
||||
if (!teamCode || !accessToken) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/customers?pageSize=1000`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取客户数据失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const customers: Customer[] = data.customers || [];
|
||||
|
||||
// 计算统计数据
|
||||
const totalCustomers = customers.length;
|
||||
|
||||
// 确保balance是数字类型
|
||||
const totalBalance = customers.reduce((sum, customer) => {
|
||||
const balance = typeof customer.balance === 'number' ? customer.balance : parseFloat(customer.balance || '0');
|
||||
return sum + (isNaN(balance) ? 0 : balance);
|
||||
}, 0);
|
||||
|
||||
const customersWithBalance = customers.filter(customer => {
|
||||
const balance = typeof customer.balance === 'number' ? customer.balance : parseFloat(customer.balance || '0');
|
||||
return !isNaN(balance) && balance > 0;
|
||||
}).length;
|
||||
|
||||
const customersWithoutBalance = totalCustomers - customersWithBalance;
|
||||
|
||||
setStatistics({
|
||||
totalCustomers,
|
||||
totalBalance,
|
||||
customersWithBalance,
|
||||
customersWithoutBalance
|
||||
});
|
||||
|
||||
// 处理性别数据
|
||||
const genderData = processGenderData(customers);
|
||||
|
||||
// 处理地区数据
|
||||
const regionData = processRegionData(customers);
|
||||
|
||||
// 渲染图表
|
||||
renderGenderChart(genderData);
|
||||
renderRegionChart(regionData);
|
||||
|
||||
} catch (err) {
|
||||
console.error('获取客户统计数据失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [teamCode, accessToken, processGenderData, processRegionData, renderGenderChart, renderRegionChart]);
|
||||
|
||||
// 首次加载数据
|
||||
useEffect(() => {
|
||||
fetchCustomerStatistics();
|
||||
}, [teamCode, accessToken, fetchCustomerStatistics]);
|
||||
|
||||
// 监听主题变化,更新图表
|
||||
useEffect(() => {
|
||||
if (genderChart && regionChart) {
|
||||
genderChart.dispose();
|
||||
regionChart.dispose();
|
||||
setGenderChart(null);
|
||||
setRegionChart(null);
|
||||
|
||||
setTimeout(() => {
|
||||
fetchCustomerStatistics();
|
||||
}, 100);
|
||||
}
|
||||
}, [themeMode, genderChart, regionChart, fetchCustomerStatistics]);
|
||||
|
||||
// 监听窗口大小变化,调整图表大小
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
genderChart?.resize();
|
||||
regionChart?.resize();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
genderChart?.dispose();
|
||||
regionChart?.dispose();
|
||||
};
|
||||
}, [genderChart, regionChart]);
|
||||
|
||||
// 格式化数字为货币格式
|
||||
const formatCurrency = (value: number): string => {
|
||||
return value.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${className || ''} ${isDarkMode ? 'glass-card-dark' : 'glass-card'} rounded-xl p-4 shadow-sm`}>
|
||||
<h2 className={`text-xl font-bold mb-4 ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
客户数据分析
|
||||
</h2>
|
||||
|
||||
{isLoading && (
|
||||
<div className={`flex justify-center items-center py-12 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||
加载统计数据中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className={`p-4 rounded-xl ${isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50'}`}>
|
||||
<div className={`text-sm font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||
客户总数
|
||||
</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${isDarkMode ? 'text-blue-100' : 'text-blue-900'}`}>
|
||||
{statistics.totalCustomers}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDarkMode ? 'bg-green-900/20' : 'bg-green-50'}`}>
|
||||
<div className={`text-sm font-medium ${isDarkMode ? 'text-green-300' : 'text-green-700'}`}>
|
||||
总余额
|
||||
</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${isDarkMode ? 'text-green-100' : 'text-green-900'}`}>
|
||||
¥ {formatCurrency(statistics.totalBalance)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDarkMode ? 'bg-purple-900/20' : 'bg-purple-50'}`}>
|
||||
<div className={`text-sm font-medium ${isDarkMode ? 'text-purple-300' : 'text-purple-700'}`}>
|
||||
有余额客户
|
||||
</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${isDarkMode ? 'text-purple-100' : 'text-purple-900'}`}>
|
||||
{statistics.customersWithBalance}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDarkMode ? 'bg-gray-800/40' : 'bg-gray-100'}`}>
|
||||
<div className={`text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
无余额客户
|
||||
</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
||||
{statistics.customersWithoutBalance}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图表区域 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div ref={genderChartRef} className="h-72 w-full"></div>
|
||||
<div ref={regionChartRef} className="h-72 w-full"></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
562
src/app/team/[teamCode]/customers/customer-modal.tsx
Normal file
562
src/app/team/[teamCode]/customers/customer-modal.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* 客户模态框组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供客户信息的添加和编辑界面,支持地址解析
|
||||
* 版本: 1.2.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTeam } from '@/hooks/useTeam';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import { Customer, CustomerGender, CustomerAddress } from '@/models/team/types/old/customer';
|
||||
import { formatDate } from '@/utils/date.utils';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
|
||||
// 定义解析地址API返回的数据结构
|
||||
interface ParseLocationResponse {
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
county: string | null;
|
||||
detail: string | null;
|
||||
full_location: string | null;
|
||||
orig_location: string | null;
|
||||
town: string | null;
|
||||
village: string | null;
|
||||
}
|
||||
|
||||
interface CombinedResponse {
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
address: ParseLocationResponse | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CustomerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
customer?: Customer | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户模态框组件
|
||||
*/
|
||||
export default function CustomerModal({ isOpen, onClose, customer, onSuccess }: CustomerModalProps) {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { currentTeam } = useTeam();
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
const isEditing = !!customer;
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<Partial<Customer>>({
|
||||
name: '',
|
||||
gender: undefined,
|
||||
phone: '',
|
||||
wechat: '',
|
||||
address: { detail: '' },
|
||||
birthday: '',
|
||||
followDate: '',
|
||||
balance: 0
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isParsingAddress, setIsParsingAddress] = useState(false);
|
||||
const [parsedMessage, setParsedMessage] = useState<{ type: 'success' | 'error' | 'warning', text: string } | null>(null);
|
||||
|
||||
/**
|
||||
* 初始化编辑表单数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (customer) {
|
||||
setFormData({
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
gender: customer.gender,
|
||||
phone: customer.phone,
|
||||
wechat: customer.wechat || '',
|
||||
address: customer.address || { detail: '' },
|
||||
// 格式化日期为YYYY-MM-DD格式
|
||||
birthday: customer.birthday ? formatDate(customer.birthday, 'YYYY-MM-DD') : '',
|
||||
followDate: customer.followDate ? formatDate(customer.followDate, 'YYYY-MM-DD') : '',
|
||||
balance: customer.balance || 0
|
||||
});
|
||||
} else {
|
||||
// 重置表单
|
||||
setFormData({
|
||||
name: '',
|
||||
gender: undefined,
|
||||
phone: '',
|
||||
wechat: '',
|
||||
address: { detail: '' },
|
||||
birthday: '',
|
||||
followDate: '',
|
||||
balance: 0
|
||||
});
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setParsedMessage(null);
|
||||
}, [customer, isOpen]);
|
||||
|
||||
/**
|
||||
* 处理表单输入变化
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// 特殊处理地址字段
|
||||
if (name === 'address') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
detail: value
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理数字类型字段
|
||||
if (name === 'balance') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value === '' ? 0 : parseFloat(value)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析地址文本,提取姓名、电话和地址信息
|
||||
*/
|
||||
const handleAddressParse = async () => {
|
||||
// 获取当前地址文本
|
||||
const addressText = formData.address?.detail || '';
|
||||
|
||||
if (!addressText.trim()) {
|
||||
setParsedMessage({
|
||||
type: 'error',
|
||||
text: '请输入地址信息后再进行解析'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsParsingAddress(true);
|
||||
setParsedMessage(null);
|
||||
|
||||
try {
|
||||
// 使用 fetch 发送请求到App Router格式的API端点
|
||||
const response = await fetch('/api/tools/parseAddress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: addressText }),
|
||||
});
|
||||
|
||||
const data: CombinedResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setParsedMessage({
|
||||
type: 'error',
|
||||
text: data.error || '解析失败'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查解析结果,提示用户手动填写未解析到的字段
|
||||
const missingFields = [];
|
||||
if (!data.address?.city) {
|
||||
missingFields.push('城市');
|
||||
}
|
||||
if (!data.address?.county) {
|
||||
missingFields.push('区县');
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setParsedMessage({
|
||||
type: 'warning',
|
||||
text: `未能自动解析 ${missingFields.join('、')},请手动填写`
|
||||
});
|
||||
} else {
|
||||
setParsedMessage({
|
||||
type: 'success',
|
||||
text: '地址解析完成'
|
||||
});
|
||||
}
|
||||
|
||||
// 自动填充表单字段
|
||||
const newAddress: CustomerAddress = {
|
||||
province: data.address?.province || undefined,
|
||||
city: data.address?.city || undefined,
|
||||
district: data.address?.county || undefined,
|
||||
detail: data.address?.detail || '',
|
||||
};
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: data.name || prev.name || '',
|
||||
phone: data.phone || prev.phone || '',
|
||||
address: newAddress
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('地址解析失败', error);
|
||||
setParsedMessage({
|
||||
type: 'error',
|
||||
text: '地址解析失败,请稍后重试'
|
||||
});
|
||||
} finally {
|
||||
setIsParsingAddress(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单提交处理
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!currentTeam) {
|
||||
setError('未获取到团队信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
if (!formData.name || !formData.phone) {
|
||||
setError('请填写客户姓名和电话');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const apiUrl = isEditing
|
||||
? `/api/team/${teamCode}/customers/${formData.id}`
|
||||
: `/api/team/${teamCode}/customers`;
|
||||
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
|
||||
// 准备请求数据
|
||||
const requestData = {
|
||||
...formData,
|
||||
// 如果地址只有detail且为空,则设为null
|
||||
address: formData.address &&
|
||||
(!formData.address.detail && !formData.address.province &&
|
||||
!formData.address.city && !formData.address.district)
|
||||
? null
|
||||
: formData.address
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '操作失败');
|
||||
}
|
||||
|
||||
// 操作成功
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('客户操作失败:', err);
|
||||
setError(err instanceof Error ? err.message : '操作失败,请重试');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 模态框尾部按钮
|
||||
const modalFooter = (
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
|
||||
}`}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="customerForm"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '提交中...' : (isEditing ? '保存' : '添加')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? '编辑客户' : '添加客户'}
|
||||
footer={modalFooter}
|
||||
size="xl"
|
||||
isGlass={true}
|
||||
glassLevel={isDarkMode ? 'heavy' : 'medium'}
|
||||
closeOnOutsideClick={false}
|
||||
>
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedMessage && (
|
||||
<div className={`mb-4 p-3 rounded-lg text-sm ${parsedMessage.type === 'success'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: parsedMessage.type === 'warning'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{parsedMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="customerForm" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* 姓名 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
姓名 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 电话 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
电话 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="phone"
|
||||
value={formData.phone || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
性别
|
||||
</label>
|
||||
<select
|
||||
name="gender"
|
||||
value={formData.gender || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
>
|
||||
<option value="">未知</option>
|
||||
<option value={CustomerGender.MALE}>男</option>
|
||||
<option value={CustomerGender.FEMALE}>女</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 微信号 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
微信号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="wechat"
|
||||
value={formData.wechat || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 生日 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
生日
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="birthday"
|
||||
value={formData.birthday || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 加粉日期 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
加粉日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="followDate"
|
||||
value={formData.followDate || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账户余额 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
账户余额
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="balance"
|
||||
value={formData.balance || 0}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 地址区域 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label className={`block text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
详细地址
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddressParse}
|
||||
disabled={isParsingAddress}
|
||||
className={`text-sm px-3 py-1 rounded ${isDarkMode
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors`}
|
||||
>
|
||||
{isParsingAddress ? '解析中...' : '智能解析'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 省市区显示区域 */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
name="province"
|
||||
value={formData.address?.province || ''}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
province: e.target.value
|
||||
}
|
||||
}))}
|
||||
placeholder="省份"
|
||||
className={`px-3 py-2 rounded-lg text-sm ${isDarkMode
|
||||
? 'bg-gray-800/40 border-gray-700 text-gray-300'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-700'
|
||||
} border`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="city"
|
||||
value={formData.address?.city || ''}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
city: e.target.value
|
||||
}
|
||||
}))}
|
||||
placeholder="城市"
|
||||
className={`px-3 py-2 rounded-lg text-sm ${isDarkMode
|
||||
? 'bg-gray-800/40 border-gray-700 text-gray-300'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-700'
|
||||
} border`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="district"
|
||||
value={formData.address?.district || ''}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
district: e.target.value
|
||||
}
|
||||
}))}
|
||||
placeholder="区县"
|
||||
className={`px-3 py-2 rounded-lg text-sm ${isDarkMode
|
||||
? 'bg-gray-800/40 border-gray-700 text-gray-300'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-700'
|
||||
} border`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
name="address"
|
||||
value={formData.address?.detail || ''}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
placeholder="输入客户完整地址,可包含姓名和电话,系统将智能识别"
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
></textarea>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
提示:可输入包含姓名、电话的完整地址,点击"智能解析"自动识别
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
616
src/app/team/[teamCode]/customers/page.tsx
Normal file
616
src/app/team/[teamCode]/customers/page.tsx
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* 客户管理页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供客户数据的展示和管理功能
|
||||
* 版本: 1.2.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTeam } from '@/hooks/useTeam';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { useAccessToken } from '@/store/userStore';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import { MdAdd, MdSearch, MdEdit, MdDelete, MdRefresh, MdPerson, MdLocationOn, MdPhone, MdCalendarMonth } from 'react-icons/md';
|
||||
import { FaWeixin } from 'react-icons/fa';
|
||||
import CustomerModal from './customer-modal';
|
||||
import CustomerAnalytics from './components/CustomerAnalytics';
|
||||
import { Customer } from '@/models/team/types/old/customer';
|
||||
import { formatDate, isValidDate } from '@/utils';
|
||||
|
||||
/**
|
||||
* 客户管理页面组件
|
||||
*/
|
||||
export default function CustomersPage() {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { currentTeam } = useTeam();
|
||||
const accessToken = useAccessToken();
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
// 客户数据状态
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [totalCustomers, setTotalCustomers] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 搜索状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
// 模态框状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
|
||||
// 删除确认状态
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [customerToDelete, setCustomerToDelete] = useState<Customer | null>(null);
|
||||
|
||||
// 定义地址接口
|
||||
interface Address {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
detail?: string;
|
||||
postalCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户列表数据
|
||||
*/
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
if (!currentTeam || !teamCode) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 构建查询参数
|
||||
const queryParams = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
|
||||
if (searchKeyword) queryParams.append('keyword', searchKeyword);
|
||||
|
||||
const response = await fetch(`/api/team/${teamCode}/customers?${queryParams.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取客户列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setCustomers(data.customers || []);
|
||||
setTotalCustomers(data.total || 0);
|
||||
} catch (err) {
|
||||
console.error('获取客户列表失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTeam, teamCode, accessToken, currentPage, pageSize, searchKeyword]);
|
||||
|
||||
/**
|
||||
* 初始加载和依赖变化时获取数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (accessToken && teamCode) {
|
||||
fetchCustomers();
|
||||
}
|
||||
}, [accessToken, teamCode, fetchCustomers]);
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
fetchCustomers();
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空筛选条件
|
||||
*/
|
||||
const handleClearFilters = () => {
|
||||
setSearchKeyword('');
|
||||
setCurrentPage(1);
|
||||
fetchCustomers();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理添加客户按钮点击
|
||||
*/
|
||||
const handleAddCustomer = () => {
|
||||
setSelectedCustomer(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理编辑客户
|
||||
*/
|
||||
const handleEditCustomer = (customer: Customer) => {
|
||||
setSelectedCustomer(customer);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理删除客户确认
|
||||
*/
|
||||
const handleDeleteClick = (customer: Customer) => {
|
||||
setCustomerToDelete(customer);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行删除客户操作
|
||||
*/
|
||||
const confirmDelete = async () => {
|
||||
if (!customerToDelete || !teamCode) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/customers/${customerToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除客户失败');
|
||||
}
|
||||
|
||||
// 移除已删除的客户
|
||||
setCustomers(prev => prev.filter(c => c.id !== customerToDelete.id));
|
||||
setTotalCustomers(prev => prev - 1);
|
||||
setShowDeleteConfirm(false);
|
||||
setCustomerToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('删除客户失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算总页数
|
||||
*/
|
||||
const totalPages = Math.ceil(totalCustomers / pageSize);
|
||||
|
||||
/**
|
||||
* 格式化地址信息
|
||||
*/
|
||||
const formatAddress = (address: Address | undefined): string => {
|
||||
if (!address) return '未设置地址';
|
||||
|
||||
const parts = [
|
||||
address.province,
|
||||
address.city,
|
||||
address.district,
|
||||
address.detail
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : '未设置详细地址';
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查日期是否在本月
|
||||
*/
|
||||
const isCurrentMonth = (dateStr: string | null | undefined): boolean => {
|
||||
if (!dateStr || !isValidDate(dateStr)) return false;
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
|
||||
return date.getMonth() === now.getMonth() &&
|
||||
date.getFullYear() === now.getFullYear();
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查日期是否即将到来(未来7天内)
|
||||
*/
|
||||
const isUpcoming = (dateStr: string | null | undefined): boolean => {
|
||||
if (!dateStr || !isValidDate(dateStr)) return false;
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
|
||||
// 重置年份为当前年份,只比较月和日
|
||||
date.setFullYear(now.getFullYear());
|
||||
|
||||
// 如果日期已经过去了当前日期,设置为下一年
|
||||
if (date < now) {
|
||||
date.setFullYear(now.getFullYear() + 1);
|
||||
}
|
||||
|
||||
// 计算日期差
|
||||
const diffTime = date.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return diffDays >= 0 && diffDays < 7;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化用户友好的日期
|
||||
*/
|
||||
const formatFriendlyDate = (dateStr: string | undefined | null): string => {
|
||||
if (!dateStr || !isValidDate(dateStr)) return '-';
|
||||
return formatDate(dateStr, 'YYYY-MM-DD');
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化数值
|
||||
*/
|
||||
const formatNumber = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0.00';
|
||||
// 确保value是数字类型
|
||||
const numValue = Number(value);
|
||||
// 检查是否为有效数字
|
||||
if (isNaN(numValue)) return '0.00';
|
||||
return numValue.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
客户管理
|
||||
</h1>
|
||||
<p className={`mt-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
管理和查看团队的所有客户数据
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 客户数据分析组件 */}
|
||||
<CustomerAnalytics className="mb-6" />
|
||||
|
||||
{/* 搜索区域 */}
|
||||
<div className={`mb-6 p-4 rounded-xl shadow-sm ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
{/* 搜索表单 */}
|
||||
<form onSubmit={handleSearch} className="flex flex-1">
|
||||
<div className="relative flex-grow mr-2">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MdSearch className={isDarkMode ? 'text-gray-400' : 'text-gray-500'} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索客户姓名、电话或微信..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className={`pl-10 pr-4 py-2 w-full rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center"
|
||||
>
|
||||
<MdSearch className="mr-1" />
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className={`px-3 py-2 rounded-lg flex items-center ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MdRefresh className="mr-1" />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustomer}
|
||||
className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center"
|
||||
>
|
||||
<MdAdd className="mr-1" />
|
||||
添加客户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中提示 */}
|
||||
{isLoading && (
|
||||
<div className={`flex justify-center items-center py-12 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||
正在加载客户数据...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无数据提示 */}
|
||||
{!isLoading && customers.length === 0 && (
|
||||
<div className={`text-center py-16 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<MdPerson className="mx-auto text-5xl mb-3 opacity-50" />
|
||||
<p className="text-lg">暂无客户数据</p>
|
||||
<p className="mt-1 text-sm">点击"添加客户"按钮创建新客户</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 客户列表 - 表格布局 */}
|
||||
{!isLoading && customers.length > 0 && (
|
||||
<div className={`rounded-xl overflow-hidden shadow-sm ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className={`border-b ${isDarkMode ? 'bg-gray-800/50 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
客户信息
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
联系方式
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
地址
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
重要日期
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
余额
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${isDarkMode ? 'divide-gray-700' : 'divide-gray-200'}`}>
|
||||
{customers.map(customer => (
|
||||
<tr key={customer.id} className={`${isDarkMode ? 'hover:bg-gray-800/30' : 'hover:bg-gray-50'} transition-colors`}>
|
||||
{/* 客户信息 */}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className={`flex-shrink-0 h-10 w-10 rounded-full flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-blue-900/30' : 'bg-blue-100'
|
||||
}`}>
|
||||
<MdPerson className={`h-6 w-6 ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{customer.name}
|
||||
</div>
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{customer.gender || '未设置性别'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 联系方式 */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-start space-x-1">
|
||||
<MdPhone className={`mt-0.5 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||
<span className={isDarkMode ? 'text-gray-200' : 'text-gray-900'}>
|
||||
{customer.phone}
|
||||
</span>
|
||||
</div>
|
||||
{customer.wechat && (
|
||||
<div className="flex items-start space-x-1 mt-1">
|
||||
<FaWeixin className={`mt-0.5 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{customer.wechat}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 地址 */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="max-w-xs overflow-hidden">
|
||||
<div className="flex items-start space-x-1">
|
||||
<MdLocationOn className={`mt-0.5 flex-shrink-0 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'} line-clamp-2`}>
|
||||
{formatAddress(customer.address)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 重要日期 */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
生日:{formatFriendlyDate(customer.birthday)}
|
||||
</span>
|
||||
{customer.birthday && isCurrentMonth(customer.birthday) && (
|
||||
<span className={`ml-2 px-2 py-0.5 text-xs rounded-full ${
|
||||
isDarkMode ? 'bg-pink-900/30 text-pink-300' : 'bg-pink-100 text-pink-700'
|
||||
}`}>
|
||||
本月生日
|
||||
</span>
|
||||
)}
|
||||
{customer.birthday && isUpcoming(customer.birthday) && (
|
||||
<span className={`ml-2 px-2 py-0.5 text-xs rounded-full ${
|
||||
isDarkMode ? 'bg-purple-900/30 text-purple-300' : 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
即将过生日
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customer.followDate && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<MdCalendarMonth className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
加粉:{formatFriendlyDate(customer.followDate)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 余额 */}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className={`inline-flex px-2.5 py-1 rounded-full text-sm ${
|
||||
customer.balance > 0
|
||||
? isDarkMode ? 'bg-green-900/30 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDarkMode ? 'bg-gray-800 text-gray-400' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
¥ {formatNumber(customer.balance)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button
|
||||
onClick={() => handleEditCustomer(customer)}
|
||||
className={`px-3 py-1 mr-2 rounded ${
|
||||
isDarkMode
|
||||
? 'bg-blue-900/20 text-blue-400 hover:bg-blue-900/40'
|
||||
: 'bg-blue-50 text-blue-600 hover:bg-blue-100'
|
||||
}`}
|
||||
>
|
||||
<MdEdit className="inline-block mr-1" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(customer)}
|
||||
className={`px-3 py-1 rounded ${
|
||||
isDarkMode
|
||||
? 'bg-red-900/20 text-red-400 hover:bg-red-900/40'
|
||||
: 'bg-red-50 text-red-600 hover:bg-red-100'
|
||||
}`}
|
||||
>
|
||||
<MdDelete className="inline-block mr-1" />
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 0 && (
|
||||
<div className={`px-6 py-4 flex items-center justify-between border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
共 {totalCustomers} 位客户
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-2 rounded ${
|
||||
currentPage === 1
|
||||
? isDarkMode ? 'bg-gray-800 text-gray-500 cursor-not-allowed' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: isDarkMode ? 'bg-gray-700 text-gray-300 hover:bg-gray-600' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
// 显示当前页附近的页码
|
||||
let pageNum = currentPage;
|
||||
if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
// 确保页码在有效范围内
|
||||
if (pageNum > 0 && pageNum <= totalPages) {
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`px-3 py-2 rounded ${
|
||||
currentPage === pageNum
|
||||
? 'bg-blue-600 text-white'
|
||||
: isDarkMode ? 'bg-gray-700 text-gray-300 hover:bg-gray-600' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`px-3 py-2 rounded ${
|
||||
currentPage === totalPages
|
||||
? isDarkMode ? 'bg-gray-800 text-gray-500 cursor-not-allowed' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: isDarkMode ? 'bg-gray-700 text-gray-300 hover:bg-gray-600' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 客户模态框 */}
|
||||
<CustomerModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
customer={selectedCustomer}
|
||||
onSuccess={fetchCustomers}
|
||||
/>
|
||||
|
||||
{/* 删除确认框 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={() => setShowDeleteConfirm(false)}></div>
|
||||
<div className={`relative z-10 w-full max-w-md mx-auto p-6 rounded-xl ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<h3 className={`text-lg font-medium mb-3 ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>确认删除</h3>
|
||||
<p className={`mb-4 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
您确定要删除客户 "{customerToDelete?.name}" 吗?此操作不可撤销。
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
748
src/app/team/[teamCode]/logistics/page.tsx
Normal file
748
src/app/team/[teamCode]/logistics/page.tsx
Normal file
@@ -0,0 +1,748 @@
|
||||
/**
|
||||
* 物流管理页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 显示和管理物流记录,提供物流查询和状态更新
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTheme } from '@/hooks';
|
||||
import { useNotification } from '@/components/ui/Notification';
|
||||
import { MdLocalShipping, MdSearch, MdRefresh, MdOutlineCheckCircleOutline } from 'react-icons/md';
|
||||
import { ILogisticsRecord, ILogisticsRecordListItem, RecordType } from '@/models/team/types/ILogisticsRecord';
|
||||
import { Spin, Tag, Table, Button, Input, Select, DatePicker, Modal, Alert, Collapse, Space } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { RangePickerProps } from 'antd/es/date-picker';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
type LogisticsStatus = '已填单' | '已揽件' | '运输中' | '已签收' | string | null | undefined;
|
||||
|
||||
/**
|
||||
* 格式化日期显示
|
||||
*/
|
||||
const formatDate = (dateStr: string | undefined) => {
|
||||
if (!dateStr) return '-';
|
||||
return dayjs(dateStr).format('YYYY-MM-DD HH:mm');
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态标签的类型和颜色
|
||||
*/
|
||||
const getStatusTag = (status: LogisticsStatus) => {
|
||||
if (!status) return { color: 'default', text: '未知' };
|
||||
|
||||
switch (status) {
|
||||
case '已填单':
|
||||
return { color: 'default', text: '已填单' };
|
||||
case '已揽件':
|
||||
return { color: 'processing', text: '已揽件' };
|
||||
case '运输中':
|
||||
return { color: 'processing', text: '运输中' };
|
||||
case '已签收':
|
||||
return { color: 'success', text: '已签收' };
|
||||
default:
|
||||
return { color: 'processing', text: status };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取记录类型显示内容
|
||||
*/
|
||||
const getRecordTypeDisplay = (type: RecordType) => {
|
||||
switch (type) {
|
||||
case RecordType.SALES_RECORD:
|
||||
return { text: '销售单', color: 'blue' };
|
||||
case RecordType.AFTER_SALES_RECORD:
|
||||
return { text: '售后单', color: 'orange' };
|
||||
default:
|
||||
return { text: '未知', color: 'default' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 定义物流路由项的接口
|
||||
*/
|
||||
interface LogisticsRoute {
|
||||
acceptTime?: string;
|
||||
time?: string;
|
||||
remark?: string;
|
||||
content?: string;
|
||||
acceptAddress?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义物流详情的接口
|
||||
*/
|
||||
interface LogisticsDetails {
|
||||
routes?: LogisticsRoute[];
|
||||
msgData?: {
|
||||
routeResps?: Array<{
|
||||
routes?: LogisticsRoute[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 物流详情查看弹窗组件
|
||||
*/
|
||||
interface LogisticsDetailModalProps {
|
||||
visible: boolean;
|
||||
logisticsRecord: ILogisticsRecord | null;
|
||||
onClose: () => void;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
const LogisticsDetailModal: React.FC<LogisticsDetailModalProps> = ({
|
||||
visible,
|
||||
logisticsRecord,
|
||||
onClose,
|
||||
isDarkMode
|
||||
}) => {
|
||||
const [parsedDetails, setParsedDetails] = useState<LogisticsDetails | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (logisticsRecord?.details) {
|
||||
try {
|
||||
const details = JSON.parse(logisticsRecord.details) as LogisticsDetails;
|
||||
console.log('解析的物流详情数据:', details);
|
||||
setParsedDetails(details);
|
||||
setParseError(null);
|
||||
} catch (error) {
|
||||
console.error('解析物流详情失败:', error, logisticsRecord.details);
|
||||
setParsedDetails(null);
|
||||
setParseError(`解析物流详情失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
} else {
|
||||
setParsedDetails(null);
|
||||
setParseError(null);
|
||||
}
|
||||
}, [logisticsRecord]);
|
||||
|
||||
// 判断是否有物流路由数据
|
||||
const hasRoutesData = parsedDetails?.routes && parsedDetails.routes.length > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<MdLocalShipping className="mr-2 text-blue-500" size={20} />
|
||||
<span>物流详情</span>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
]}
|
||||
width={700}
|
||||
>
|
||||
{logisticsRecord ? (
|
||||
<div className="space-y-4">
|
||||
<div className={`p-4 rounded-lg ${isDarkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-gray-500 text-sm">物流单号:</div>
|
||||
<div className="font-medium">{logisticsRecord.tracking_number || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-sm">物流公司:</div>
|
||||
<div className="font-medium">{logisticsRecord.company || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-sm">状态:</div>
|
||||
<div>
|
||||
<Tag color={getStatusTag(logisticsRecord.status).color}>
|
||||
{getStatusTag(logisticsRecord.status).text}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-sm">发货时间:</div>
|
||||
<div className="font-medium">{formatDate(logisticsRecord.created_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-sm">客户手机尾号:</div>
|
||||
<div className="font-medium">{logisticsRecord.customer_tail_number || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-sm">记录类型:</div>
|
||||
<div>
|
||||
<Tag color={getRecordTypeDisplay(logisticsRecord.record_type).color}>
|
||||
{getRecordTypeDisplay(logisticsRecord.record_type).text}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 原始数据查看 */}
|
||||
<Collapse>
|
||||
<Panel header="查看原始数据" key="1">
|
||||
<pre className={`p-3 rounded text-xs overflow-auto ${isDarkMode ? 'bg-gray-900 text-gray-300' : 'bg-gray-100 text-gray-800'}`} style={{ maxHeight: '200px' }}>
|
||||
{JSON.stringify(logisticsRecord, null, 2)}
|
||||
</pre>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
|
||||
{/* 解析错误提示 */}
|
||||
{parseError && (
|
||||
<Alert
|
||||
message="解析错误"
|
||||
description={parseError}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 物流详情 */}
|
||||
<div className={`p-4 rounded-lg ${isDarkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||
<h3 className="font-medium mb-3 flex items-center">
|
||||
<MdOutlineCheckCircleOutline className="mr-1 text-green-500" />
|
||||
物流跟踪信息
|
||||
</h3>
|
||||
|
||||
{hasRoutesData ? (
|
||||
<div className="relative pl-6">
|
||||
{/* 时间轴线 */}
|
||||
<div className="absolute left-3 top-2 bottom-2 w-0.5 bg-gray-300 dark:bg-gray-600"></div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{parsedDetails?.routes?.map((route, index) => {
|
||||
// 提取路由节点显示数据
|
||||
const time = route.acceptTime || route.time || '-';
|
||||
const content = route.remark || route.content || '未知状态';
|
||||
const location = route.acceptAddress || route.location || null;
|
||||
|
||||
return (
|
||||
<div key={index} className="relative">
|
||||
{/* 时间轴节点 */}
|
||||
<div className={`absolute -left-6 top-1 z-10 w-3 h-3 rounded-full ${
|
||||
index === 0 ? 'bg-green-500' : 'bg-blue-500'
|
||||
}`}></div>
|
||||
|
||||
<div className={`p-3 rounded ${isDarkMode ? 'bg-gray-700' : 'bg-white border border-gray-200'}`}>
|
||||
<div className="text-xs text-gray-500">
|
||||
{time}
|
||||
</div>
|
||||
<div className="font-medium mb-1">
|
||||
{content}
|
||||
</div>
|
||||
{location && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-gray-500">
|
||||
{logisticsRecord.is_queryable ? '暂无物流跟踪信息' : '该物流单号不支持查询'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改调试信息类型
|
||||
*/
|
||||
interface DebugInfo {
|
||||
lastQuery: string | null;
|
||||
lastResponse: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
showDebug: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 物流管理页面组件
|
||||
*/
|
||||
export default function LogisticsPage() {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [logisticsRecords, setLogisticsRecords] = useState<ILogisticsRecordListItem[]>([]);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [selectedRecord, setSelectedRecord] = useState<ILogisticsRecord | null>(null);
|
||||
const [queryLoading, setQueryLoading] = useState<number | null>(null);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
trackingNumber: '',
|
||||
status: '',
|
||||
dateRange: null as [dayjs.Dayjs, dayjs.Dayjs] | null,
|
||||
customerTailNumber: ''
|
||||
});
|
||||
|
||||
// 在组件内使用该类型
|
||||
const [debugInfo, setDebugInfo] = useState<DebugInfo>({
|
||||
lastQuery: null,
|
||||
lastResponse: null,
|
||||
error: null,
|
||||
showDebug: false
|
||||
});
|
||||
|
||||
const notification = useNotification();
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
/**
|
||||
* 加载物流记录列表
|
||||
*/
|
||||
const loadLogisticsRecords = useCallback(async (showLoading = true) => {
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setRefreshing(true);
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建查询参数
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (searchParams.trackingNumber) {
|
||||
queryParams.append('trackingNumber', searchParams.trackingNumber);
|
||||
}
|
||||
|
||||
if (searchParams.status) {
|
||||
queryParams.append('status', searchParams.status);
|
||||
}
|
||||
|
||||
if (searchParams.customerTailNumber) {
|
||||
queryParams.append('customerTailNumber', searchParams.customerTailNumber);
|
||||
}
|
||||
|
||||
if (searchParams.dateRange && searchParams.dateRange[0] && searchParams.dateRange[1]) {
|
||||
queryParams.append('startDate', searchParams.dateRange[0].format('YYYY-MM-DD'));
|
||||
queryParams.append('endDate', searchParams.dateRange[1].format('YYYY-MM-DD'));
|
||||
}
|
||||
|
||||
const url = `/api/team/${teamCode}/logistics?${queryParams.toString()}`;
|
||||
setDebugInfo(prev => ({ ...prev, lastQuery: url, error: null }));
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
setDebugInfo(prev => ({ ...prev, lastResponse: data }));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '获取物流记录失败');
|
||||
}
|
||||
|
||||
setLogisticsRecords(data.records || []);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载物流记录失败:', error);
|
||||
notification.error('加载物流记录失败,请稍后重试');
|
||||
setDebugInfo(prev => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [teamCode, searchParams, notification]);
|
||||
|
||||
/**
|
||||
* 查询单个物流详情
|
||||
*/
|
||||
const queryLogisticsDetails = async (record: ILogisticsRecordListItem) => {
|
||||
if (!record.tracking_number || !record.customer_tail_number) {
|
||||
notification.warning('物流单号或客户手机尾号缺失,无法查询物流信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置当前记录为查询中状态
|
||||
setQueryLoading(record.id);
|
||||
|
||||
try {
|
||||
const url = `/api/team/${teamCode}/logistics/query`;
|
||||
setDebugInfo(prev => ({ ...prev, lastQuery: url, error: null }));
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: record.id,
|
||||
trackingNumber: record.tracking_number,
|
||||
phoneLast4Digits: record.customer_tail_number
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
setDebugInfo(prev => ({ ...prev, lastResponse: data }));
|
||||
|
||||
if (!response.ok) {
|
||||
// 提取详细错误信息
|
||||
const errorDetails = data.details || {};
|
||||
const errorMessage = data.error || '查询物流信息失败';
|
||||
const apiErrorCode = errorDetails.apiResultCode || 'UNKNOWN';
|
||||
const apiErrorMsg = errorDetails.apiErrorMsg || '未知错误';
|
||||
|
||||
throw new Error(`${errorMessage}\n错误码: ${apiErrorCode}\n详情: ${apiErrorMsg}`);
|
||||
}
|
||||
|
||||
// 查询成功,显示结果弹窗
|
||||
setSelectedRecord(data.data);
|
||||
setDetailModalVisible(true);
|
||||
|
||||
// 刷新列表数据
|
||||
loadLogisticsRecords(false);
|
||||
|
||||
notification.success('物流信息查询成功');
|
||||
|
||||
} catch (error) {
|
||||
console.error('查询物流信息失败:', error);
|
||||
notification.error(
|
||||
error instanceof Error ? error.message : '查询物流信息失败'
|
||||
);
|
||||
|
||||
setDebugInfo(prev => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
} finally {
|
||||
setQueryLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理表单字段变更
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSearchChange = (field: string, value: any) => {
|
||||
// 处理日期范围类型,确保格式一致
|
||||
if (field === 'dateRange' && Array.isArray(value) && value[0] && value[1]) {
|
||||
// 只有当两个日期都有效时才设置日期范围
|
||||
setSearchParams(prev => ({
|
||||
...prev,
|
||||
dateRange: [value[0], value[1]]
|
||||
}));
|
||||
} else {
|
||||
setSearchParams(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理表单重置
|
||||
*/
|
||||
const handleResetSearch = () => {
|
||||
setSearchParams({
|
||||
trackingNumber: '',
|
||||
status: '',
|
||||
dateRange: null,
|
||||
customerTailNumber: ''
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理表单提交
|
||||
*/
|
||||
const handleSubmitSearch = () => {
|
||||
loadLogisticsRecords();
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新所有物流状态
|
||||
*/
|
||||
const updateAllLogisticsStatus = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const url = `/api/team/${teamCode}/logistics/update-status`;
|
||||
setDebugInfo(prev => ({ ...prev, lastQuery: url, error: null }));
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
setDebugInfo(prev => ({ ...prev, lastResponse: data }));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '更新物流状态失败');
|
||||
}
|
||||
|
||||
notification.success('物流状态更新成功!');
|
||||
|
||||
// 重新加载物流记录
|
||||
loadLogisticsRecords(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('更新物流状态失败:', error);
|
||||
notification.error('更新物流状态失败,请稍后重试');
|
||||
setDebugInfo(prev => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始加载物流记录
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (teamCode) {
|
||||
loadLogisticsRecords();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [teamCode]);
|
||||
|
||||
/**
|
||||
* 表格列定义
|
||||
*/
|
||||
const columns = [
|
||||
{
|
||||
title: '物流单号',
|
||||
dataIndex: 'tracking_number',
|
||||
key: 'tracking_number',
|
||||
render: (text: string) => text || '-'
|
||||
},
|
||||
{
|
||||
title: '物流公司',
|
||||
dataIndex: 'company',
|
||||
key: 'company',
|
||||
render: (text: string) => text || '-'
|
||||
},
|
||||
{
|
||||
title: '客户手机尾号',
|
||||
dataIndex: 'customer_tail_number',
|
||||
key: 'customer_tail_number',
|
||||
render: (text: string) => text || '-'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string | null | undefined) => (
|
||||
<Tag color={getStatusTag(status).color}>
|
||||
{getStatusTag(status).text}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '记录类型',
|
||||
dataIndex: 'record_type',
|
||||
key: 'record_type',
|
||||
render: (type: RecordType) => (
|
||||
<Tag color={getRecordTypeDisplay(type).color}>
|
||||
{getRecordTypeDisplay(type).text}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '物流详情',
|
||||
dataIndex: 'details',
|
||||
key: 'details',
|
||||
render: (details: string) => {
|
||||
if (!details) return '-';
|
||||
try {
|
||||
const parsedDetails = JSON.parse(details);
|
||||
// 检查是否有路由信息,适配不同格式
|
||||
const routes = parsedDetails?.routes || [];
|
||||
|
||||
if (routes.length > 0) {
|
||||
// 获取最新的物流节点信息
|
||||
const latestRoute = routes[0];
|
||||
// 适配不同的字段名
|
||||
const content = latestRoute.remark || latestRoute.content || '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tag color="blue">{routes.length}条记录</Tag>
|
||||
<div className="text-xs text-gray-500 mt-1 truncate" style={{ maxWidth: '150px' }}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Tag color="default">无详情</Tag>;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析物流详情失败:', e, details);
|
||||
return <Tag color="red">格式错误</Tag>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '可查询',
|
||||
dataIndex: 'is_queryable',
|
||||
key: 'is_queryable',
|
||||
render: (isQueryable: boolean) => (
|
||||
isQueryable ?
|
||||
<Tag color="success">可查询</Tag> :
|
||||
<Tag color="default">不可查询</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (date: string) => formatDate(date)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: ILogisticsRecordListItem) => (
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<MdSearch />}
|
||||
loading={queryLoading === record.id}
|
||||
disabled={!record.is_queryable || !record.customer_tail_number}
|
||||
onClick={() => queryLogisticsDetails(record)}
|
||||
>
|
||||
查询物流
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center">
|
||||
<MdLocalShipping className="mr-2 text-blue-500" />
|
||||
物流管理
|
||||
</h1>
|
||||
<p className={`mt-1 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
管理和查询物流信息,跟踪物流状态
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<MdRefresh />}
|
||||
loading={refreshing}
|
||||
onClick={updateAllLogisticsStatus}
|
||||
>
|
||||
{refreshing ? '更新中...' : '更新物流状态'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 搜索面板 */}
|
||||
<div className={`p-4 mb-6 rounded-lg ${isDarkMode ? 'bg-gray-800' : 'bg-white border border-gray-200'}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<div className="mb-1 text-sm">物流单号</div>
|
||||
<Input
|
||||
placeholder="输入物流单号"
|
||||
value={searchParams.trackingNumber}
|
||||
onChange={(e) => handleSearchChange('trackingNumber', e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-sm">物流状态</div>
|
||||
<Select
|
||||
placeholder="选择物流状态"
|
||||
value={searchParams.status}
|
||||
onChange={(value) => handleSearchChange('status', value)}
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
>
|
||||
<Select.Option value="已填单">已填单</Select.Option>
|
||||
<Select.Option value="已揽件">已揽件</Select.Option>
|
||||
<Select.Option value="运输中">运输中</Select.Option>
|
||||
<Select.Option value="已签收">已签收</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-sm">客户手机尾号</div>
|
||||
<Input
|
||||
placeholder="输入客户手机尾号"
|
||||
value={searchParams.customerTailNumber}
|
||||
onChange={(e) => handleSearchChange('customerTailNumber', e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-sm">日期范围</div>
|
||||
<DatePicker.RangePicker
|
||||
value={searchParams.dateRange}
|
||||
onChange={(dates) => handleSearchChange('dateRange', dates as RangePickerProps['value'])}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end space-x-2">
|
||||
<Button type="primary" onClick={handleSubmitSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
<Button onClick={handleResetSearch}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 物流列表 */}
|
||||
<div className={`rounded-lg overflow-hidden ${isDarkMode ? '' : 'border border-gray-200'}`}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logisticsRecords}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
}}
|
||||
locale={{
|
||||
emptyText: <div className="py-8 text-center text-gray-500">
|
||||
<MdLocalShipping size={32} className="mx-auto mb-2 opacity-50" />
|
||||
{debugInfo.error ? '加载物流记录出错' : '暂无物流记录'}
|
||||
{logisticsRecords.length === 0 && !loading && !debugInfo.error && (
|
||||
<div className="mt-2">
|
||||
<Button type="link" onClick={() => loadLogisticsRecords()}>点击重新加载</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 物流详情弹窗 */}
|
||||
<LogisticsDetailModal
|
||||
visible={detailModalVisible}
|
||||
logisticsRecord={selectedRecord}
|
||||
onClose={() => setDetailModalVisible(false)}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
src/app/team/[teamCode]/page.tsx
Normal file
232
src/app/team/[teamCode]/page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 团队仪表盘页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 显示团队数据概览和统计信息
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTeam, useTheme, useAccessToken } from '@/hooks';
|
||||
import { MdPeople, MdStorefront, MdInventory, MdTrendingUp, MdLocalShipping, MdInfo } from 'react-icons/md';
|
||||
|
||||
/**
|
||||
* 统计卡片组件
|
||||
*/
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
trend?: number;
|
||||
loading?: boolean;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ title, value, icon, trend, loading, isDarkMode }) => (
|
||||
<div className={`${isDarkMode ? 'glass-card-dark' : 'glass-card'} p-5 flex flex-col`}>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className={`text-sm font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
{title}
|
||||
</h3>
|
||||
<div className={`p-3 rounded-full ${isDarkMode ? 'bg-white/5' : 'bg-accent-blue/10'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
) : (
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="text-3xl font-bold">{value}</div>
|
||||
{trend !== undefined && (
|
||||
<div className={`flex items-center text-sm ${trend >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{trend >= 0 ? '↑' : '↓'} {Math.abs(trend)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 团队仪表盘页面组件
|
||||
*/
|
||||
export default function TeamDashboard() {
|
||||
const { teamCode } = useParams();
|
||||
const { currentTeam } = useTeam();
|
||||
const { isDarkMode } = useTheme();
|
||||
const accessToken = useAccessToken();
|
||||
|
||||
const [stats, setStats] = useState({
|
||||
customers: 0,
|
||||
shops: 0,
|
||||
products: 0,
|
||||
orders: 0,
|
||||
suppliers: 0
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 模拟获取统计数据
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
if (!teamCode || !accessToken || !currentTeam) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 这里应该是实际API调用,暂时模拟数据
|
||||
// const response = await fetch(`/api/team/${teamCode}/stats`, {
|
||||
// headers: { Authorization: `Bearer ${accessToken}` }
|
||||
// });
|
||||
// const data = await response.json();
|
||||
|
||||
// 模拟延迟和数据
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
|
||||
// 模拟数据
|
||||
setStats({
|
||||
customers: 128,
|
||||
shops: 5,
|
||||
products: 76,
|
||||
orders: 243,
|
||||
suppliers: 12
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [teamCode, accessToken, currentTeam]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
{currentTeam?.name || ''} 团队仪表盘
|
||||
</h1>
|
||||
<p className={`mt-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
查看团队整体运营状态和关键指标
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="客户总数"
|
||||
value={stats.customers}
|
||||
icon={<MdPeople className="text-accent-blue text-xl" />}
|
||||
trend={8.2}
|
||||
loading={isLoading}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<StatCard
|
||||
title="店铺数量"
|
||||
value={stats.shops}
|
||||
icon={<MdStorefront className="text-accent-purple text-xl" />}
|
||||
loading={isLoading}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<StatCard
|
||||
title="产品数量"
|
||||
value={stats.products}
|
||||
icon={<MdInventory className="text-accent-teal text-xl" />}
|
||||
trend={4.5}
|
||||
loading={isLoading}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<StatCard
|
||||
title="订单总数"
|
||||
value={stats.orders}
|
||||
icon={<MdTrendingUp className="text-accent-orange text-xl" />}
|
||||
trend={12.3}
|
||||
loading={isLoading}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<StatCard
|
||||
title="供应商数量"
|
||||
value={stats.suppliers}
|
||||
icon={<MdLocalShipping className="text-accent-pink text-xl" />}
|
||||
loading={isLoading}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 系统消息和提示 */}
|
||||
<div className={`${isDarkMode ? 'glass-card-dark' : 'glass-card'} p-6 mb-8`}>
|
||||
<div className="flex items-start">
|
||||
<div className="p-3 rounded-full bg-blue-500/10 mr-4">
|
||||
<MdInfo className="text-xl text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-medium text-lg mb-2 ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
开始使用管理系统
|
||||
</h3>
|
||||
<p className={`${isDarkMode ? 'text-gray-300' : 'text-gray-600'} mb-4`}>
|
||||
欢迎使用团队管理系统,您可以从左侧导航菜单访问各个功能模块,管理客户、产品、订单等数据。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
<button className="btn-secondary py-2 px-4">查看使用指南</button>
|
||||
<button className="btn-primary py-2 px-4">导入数据</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最近活动 */}
|
||||
<div className={`${isDarkMode ? 'glass-card-dark' : 'glass-card'} p-6`}>
|
||||
<h2 className={`text-lg font-medium mb-4 ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>最近活动</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-2/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className={`p-3 rounded-lg ${isDarkMode ? 'bg-white/5' : 'bg-gray-50'} flex items-center`}>
|
||||
<div className="p-2 rounded-full bg-green-500/10 mr-3">
|
||||
<MdPeople className="text-lg text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`${isDarkMode ? 'text-white' : 'text-gray-800'}`}>新增客户 "张小姐" (1317620****)</p>
|
||||
<p className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>10分钟前</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-3 rounded-lg ${isDarkMode ? 'bg-white/5' : 'bg-gray-50'} flex items-center`}>
|
||||
<div className="p-2 rounded-full bg-blue-500/10 mr-3">
|
||||
<MdInventory className="text-lg text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`${isDarkMode ? 'text-white' : 'text-gray-800'}`}>新增产品 "高端美白套装"</p>
|
||||
<p className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>30分钟前</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-3 rounded-lg ${isDarkMode ? 'bg-white/5' : 'bg-gray-50'} flex items-center`}>
|
||||
<div className="p-2 rounded-full bg-purple-500/10 mr-3">
|
||||
<MdTrendingUp className="text-lg text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`${isDarkMode ? 'text-white' : 'text-gray-800'}`}>新订单 #2023112 已生成</p>
|
||||
<p className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>2小时前</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
503
src/app/team/[teamCode]/payment-platforms/page.tsx
Normal file
503
src/app/team/[teamCode]/payment-platforms/page.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* 支付平台管理页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供支付平台数据的展示和管理功能
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTeam } from '@/hooks/useTeam';
|
||||
import { useAccessToken } from '@/store/userStore';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import { MdAdd, MdSearch, MdEdit, MdDelete, MdRefresh, MdFilterList, MdPayment } from 'react-icons/md';
|
||||
import { PaymentPlatform, PaymentPlatformStatus } from '@/models/team/types/old/payment-platform';
|
||||
import PaymentPlatformModal from './payment-platform-modal';
|
||||
|
||||
/**
|
||||
* 获取支付平台状态标签
|
||||
*/
|
||||
const getStatusLabel = (status: PaymentPlatformStatus): string => {
|
||||
switch (status) {
|
||||
case PaymentPlatformStatus.DISABLED:
|
||||
return '停用';
|
||||
case PaymentPlatformStatus.NORMAL:
|
||||
return '正常';
|
||||
case PaymentPlatformStatus.BACKUP:
|
||||
return '备用';
|
||||
case PaymentPlatformStatus.OTHER:
|
||||
return '其他';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取支付平台状态类名
|
||||
*/
|
||||
const getStatusClassName = (status: PaymentPlatformStatus): string => {
|
||||
switch (status) {
|
||||
case PaymentPlatformStatus.DISABLED:
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||
case PaymentPlatformStatus.NORMAL:
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||
case PaymentPlatformStatus.BACKUP:
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
|
||||
case PaymentPlatformStatus.OTHER:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 支付平台管理页面组件
|
||||
*/
|
||||
export default function PaymentPlatformsPage() {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { currentTeam } = useTeam();
|
||||
const accessToken = useAccessToken();
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
// 支付平台数据状态
|
||||
const [platforms, setPlatforms] = useState<PaymentPlatform[]>([]);
|
||||
const [totalPlatforms, setTotalPlatforms] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 搜索和筛选状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
// 模态框状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<PaymentPlatform | null>(null);
|
||||
|
||||
// 删除确认状态
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [platformToDelete, setPlatformToDelete] = useState<PaymentPlatform | null>(null);
|
||||
|
||||
/**
|
||||
* 获取支付平台列表数据
|
||||
*/
|
||||
const fetchPlatforms = useCallback(async () => {
|
||||
if (!currentTeam || !teamCode) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 构建查询参数
|
||||
const queryParams = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
|
||||
if (searchKeyword) queryParams.append('keyword', searchKeyword);
|
||||
if (statusFilter) queryParams.append('status', statusFilter);
|
||||
|
||||
const response = await fetch(`/api/team/${teamCode}/payment-platforms?${queryParams.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取支付平台列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setPlatforms(data.paymentPlatforms || []);
|
||||
setTotalPlatforms(data.total || 0);
|
||||
} catch (err) {
|
||||
console.error('获取支付平台列表失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTeam, teamCode, accessToken, currentPage, pageSize, searchKeyword, statusFilter]);
|
||||
|
||||
/**
|
||||
* 初始加载和依赖变化时获取数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (accessToken && teamCode) {
|
||||
fetchPlatforms();
|
||||
}
|
||||
}, [accessToken, teamCode, fetchPlatforms]);
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
fetchPlatforms();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理状态筛选变更
|
||||
*/
|
||||
const handleStatusFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空筛选条件
|
||||
*/
|
||||
const handleClearFilters = () => {
|
||||
setSearchKeyword('');
|
||||
setStatusFilter('');
|
||||
setCurrentPage(1);
|
||||
fetchPlatforms();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理添加支付平台按钮点击
|
||||
*/
|
||||
const handleAddPlatform = () => {
|
||||
setSelectedPlatform(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理编辑支付平台
|
||||
*/
|
||||
const handleEditPlatform = (platform: PaymentPlatform) => {
|
||||
setSelectedPlatform(platform);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理删除支付平台确认
|
||||
*/
|
||||
const handleDeleteClick = (platform: PaymentPlatform) => {
|
||||
setPlatformToDelete(platform);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行删除支付平台操作
|
||||
*/
|
||||
const confirmDelete = async () => {
|
||||
if (!platformToDelete || !teamCode) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/payment-platforms/${platformToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除支付平台失败');
|
||||
}
|
||||
|
||||
// 移除已删除的支付平台
|
||||
setPlatforms(prev => prev.filter(p => p.id !== platformToDelete.id));
|
||||
setTotalPlatforms(prev => prev - 1);
|
||||
setShowDeleteConfirm(false);
|
||||
setPlatformToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('删除支付平台失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算总页数
|
||||
*/
|
||||
const totalPages = Math.ceil(totalPlatforms / pageSize);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 页面标题区域 */}
|
||||
<div className="mb-6">
|
||||
<h1 className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
支付平台管理
|
||||
</h1>
|
||||
<p className={`mt-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
管理和查看支付平台信息
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索区域 */}
|
||||
<div className={`mb-6 p-4 rounded-xl shadow-sm ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
{/* 搜索表单 */}
|
||||
<form onSubmit={handleSearch} className="flex flex-1 flex-col md:flex-row gap-3">
|
||||
<div className="relative flex-grow mr-2">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MdSearch className={isDarkMode ? 'text-gray-400' : 'text-gray-500'} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索平台名称或描述..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className={`pl-10 pr-4 py-2 w-full rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative min-w-[180px]">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MdFilterList className={isDarkMode ? 'text-gray-400' : 'text-gray-500'} />
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={handleStatusFilterChange}
|
||||
className={`appearance-none w-full pl-10 pr-8 py-2 rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value={PaymentPlatformStatus.NORMAL}>正常</option>
|
||||
<option value={PaymentPlatformStatus.DISABLED}>停用</option>
|
||||
<option value={PaymentPlatformStatus.BACKUP}>备用</option>
|
||||
<option value={PaymentPlatformStatus.OTHER}>其他</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className={`h-4 w-4 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center"
|
||||
>
|
||||
<MdSearch className="mr-1" />
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className={`px-3 py-2 rounded-lg flex items-center ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MdRefresh className="mr-1" />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPlatform}
|
||||
className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center"
|
||||
>
|
||||
<MdAdd className="mr-1" />
|
||||
添加平台
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中提示 */}
|
||||
{isLoading && (
|
||||
<div className={`flex justify-center items-center py-12 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||
正在加载支付平台数据...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无数据提示 */}
|
||||
{!isLoading && platforms.length === 0 && (
|
||||
<div className={`text-center py-16 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<MdPayment className="mx-auto text-5xl mb-3 opacity-50" />
|
||||
<p className="text-lg">暂无支付平台数据</p>
|
||||
<p className="mt-1 text-sm">点击"添加平台"按钮创建新支付平台</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 支付平台列表 */}
|
||||
{!isLoading && platforms.length > 0 && (
|
||||
<div className={`rounded-xl overflow-hidden shadow-sm ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className={`border-b ${isDarkMode ? 'bg-gray-800/50 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
ID/排序
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
平台名称
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
描述
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
状态
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${isDarkMode ? 'divide-gray-700' : 'divide-gray-200'}`}>
|
||||
{platforms.map((platform) => (
|
||||
<tr key={platform.id} className={`${isDarkMode ? 'hover:bg-gray-800/30' : 'hover:bg-gray-50'} transition-colors`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
#{platform.id}
|
||||
</div>
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
排序: {platform.order}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className={`flex-shrink-0 h-10 w-10 rounded-full flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-indigo-900/30' : 'bg-indigo-100'
|
||||
}`}>
|
||||
<MdPayment className={`h-6 w-6 ${isDarkMode ? 'text-indigo-300' : 'text-indigo-600'}`} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{platform.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'} max-w-xs truncate`}>
|
||||
{platform.description || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getStatusClassName(platform.status)}`}>
|
||||
{getStatusLabel(platform.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditPlatform(platform)}
|
||||
className={`p-2 rounded-lg ${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'} text-blue-600 hover:text-blue-800 transition-colors`}
|
||||
title="编辑"
|
||||
>
|
||||
<MdEdit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(platform)}
|
||||
className={`p-2 rounded-lg ${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'} text-red-600 hover:text-red-800 transition-colors`}
|
||||
title="删除"
|
||||
>
|
||||
<MdDelete size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 1 && (
|
||||
<div className={`flex items-center justify-between border-t px-4 py-3 ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||
<div className="hidden sm:block">
|
||||
<p className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 至{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(currentPage * pageSize, totalPlatforms)}
|
||||
</span>{' '}
|
||||
条,共 <span className="font-medium">{totalPlatforms}</span> 条
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-between sm:justify-end">
|
||||
<button
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md ${
|
||||
currentPage === 1
|
||||
? `cursor-not-allowed ${isDarkMode ? 'bg-gray-800 text-gray-500 border-gray-700' : 'bg-gray-100 text-gray-400 border-gray-200'}`
|
||||
: `${isDarkMode ? 'bg-gray-700 text-gray-200 border-gray-600 hover:bg-gray-600' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'}`
|
||||
} mr-3`}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md ${
|
||||
currentPage === totalPages
|
||||
? `cursor-not-allowed ${isDarkMode ? 'bg-gray-800 text-gray-500 border-gray-700' : 'bg-gray-100 text-gray-400 border-gray-200'}`
|
||||
: `${isDarkMode ? 'bg-gray-700 text-gray-200 border-gray-600 hover:bg-gray-600' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'}`
|
||||
}`}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 支付平台模态框 */}
|
||||
<PaymentPlatformModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
platform={selectedPlatform}
|
||||
onSuccess={fetchPlatforms}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className={`p-6 rounded-lg shadow-xl max-w-md w-full ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}>
|
||||
<h3 className={`text-lg font-medium mb-3 ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>确认删除</h3>
|
||||
<p className={`mb-4 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
您确定要删除支付平台 "{platformToDelete?.name}" 吗?此操作不可撤销。
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
|
||||
} transition`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 支付平台模态框组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供支付平台信息的添加和编辑界面
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { PaymentPlatform, PaymentPlatformStatus } from '@/models/team/types/old/payment-platform';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
|
||||
interface PaymentPlatformModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
platform?: PaymentPlatform | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付平台模态框组件
|
||||
*/
|
||||
export default function PaymentPlatformModal({ isOpen, onClose, platform, onSuccess }: PaymentPlatformModalProps) {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
|
||||
const isEditing = !!platform;
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<Partial<PaymentPlatform>>({
|
||||
name: '',
|
||||
order: 0,
|
||||
description: '',
|
||||
status: PaymentPlatformStatus.NORMAL
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 初始化编辑表单数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (platform) {
|
||||
setFormData({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
order: platform.order,
|
||||
description: platform.description || '',
|
||||
status: platform.status
|
||||
});
|
||||
} else {
|
||||
// 重置表单
|
||||
setFormData({
|
||||
name: '',
|
||||
order: 0,
|
||||
description: '',
|
||||
status: PaymentPlatformStatus.NORMAL
|
||||
});
|
||||
}
|
||||
|
||||
setError(null);
|
||||
}, [platform, isOpen]);
|
||||
|
||||
/**
|
||||
* 处理表单输入变化
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// 处理数字类型字段
|
||||
if (name === 'order') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value === '' ? 0 : parseInt(value, 10)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理状态选择
|
||||
if (name === 'status') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: parseInt(value, 10)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 提交表单
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 基本验证
|
||||
if (!formData.name) {
|
||||
setError('平台名称为必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.order === undefined || formData.order < 0) {
|
||||
setError('显示顺序必须为非负整数');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (isEditing && platform) {
|
||||
// 更新支付平台
|
||||
response = await fetch(`/api/team/${teamCode}/payment-platforms/${platform.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
} else {
|
||||
// 创建支付平台
|
||||
response = await fetch(`/api/team/${teamCode}/payment-platforms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
}
|
||||
|
||||
// 处理响应
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '操作失败');
|
||||
}
|
||||
|
||||
// 成功处理
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('提交支付平台数据失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? '编辑支付平台' : '添加支付平台'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-4">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 平台名称 */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
平台名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 显示顺序 */}
|
||||
<div>
|
||||
<label htmlFor="order" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
显示顺序 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="order"
|
||||
name="order"
|
||||
value={formData.order}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">数字越小排序越靠前</p>
|
||||
</div>
|
||||
|
||||
{/* 平台状态 */}
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
平台状态
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value={PaymentPlatformStatus.NORMAL}>正常</option>
|
||||
<option value={PaymentPlatformStatus.DISABLED}>停用</option>
|
||||
<option value={PaymentPlatformStatus.BACKUP}>备用</option>
|
||||
<option value={PaymentPlatformStatus.OTHER}>其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 平台描述 */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
平台描述
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description || ''}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表单按钮 */}
|
||||
<div className="flex justify-end space-x-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white dark:bg-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none ${
|
||||
isSubmitting ? 'opacity-70 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
处理中...
|
||||
</span>
|
||||
) : (
|
||||
isEditing ? '保存' : '创建'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
217
src/app/team/[teamCode]/products/components/ProductFilters.tsx
Normal file
217
src/app/team/[teamCode]/products/components/ProductFilters.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 产品筛选组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供产品数据的筛选界面
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ProductFiltersProps {
|
||||
filters: {
|
||||
supplierId: string;
|
||||
brandId: string;
|
||||
categoryId: string;
|
||||
level: string;
|
||||
minPrice: string;
|
||||
maxPrice: string;
|
||||
hasStock: boolean;
|
||||
};
|
||||
onChange: (name: string, value: string | boolean) => void;
|
||||
onApply: () => void;
|
||||
onCancel: () => void;
|
||||
suppliers: { id: number; name: string }[];
|
||||
brands: { id: number; name: string }[];
|
||||
categories: { id: number; name: string }[];
|
||||
availableLevels: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品筛选组件
|
||||
*/
|
||||
export default function ProductFilters({
|
||||
filters,
|
||||
onChange,
|
||||
onApply,
|
||||
onCancel,
|
||||
suppliers,
|
||||
brands,
|
||||
categories,
|
||||
availableLevels
|
||||
}: ProductFiltersProps) {
|
||||
/**
|
||||
* 处理输入变化
|
||||
*/
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
onChange(name, (e.target as HTMLInputElement).checked);
|
||||
} else {
|
||||
onChange(name, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* 供应商筛选 */}
|
||||
<div>
|
||||
<label htmlFor="supplierId" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
供应商
|
||||
</label>
|
||||
<select
|
||||
id="supplierId"
|
||||
name="supplierId"
|
||||
value={filters.supplierId}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">全部供应商</option>
|
||||
{suppliers.map(supplier => (
|
||||
<option key={supplier.id} value={supplier.id}>
|
||||
{supplier.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 品牌筛选 */}
|
||||
<div>
|
||||
<label htmlFor="brandId" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
品牌
|
||||
</label>
|
||||
<select
|
||||
id="brandId"
|
||||
name="brandId"
|
||||
value={filters.brandId}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">全部品牌</option>
|
||||
{brands.map(brand => (
|
||||
<option key={brand.id} value={brand.id}>
|
||||
{brand.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 品类筛选 */}
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
品类
|
||||
</label>
|
||||
<select
|
||||
id="categoryId"
|
||||
name="categoryId"
|
||||
value={filters.categoryId}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">全部品类</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 级别筛选 */}
|
||||
{availableLevels.length > 0 && (
|
||||
<div>
|
||||
<label htmlFor="level" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
产品级别
|
||||
</label>
|
||||
<select
|
||||
id="level"
|
||||
name="level"
|
||||
value={filters.level}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">全部级别</option>
|
||||
{availableLevels.map(level => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 价格区间筛选 */}
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="minPrice" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
最低价格
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="minPrice"
|
||||
name="minPrice"
|
||||
value={filters.minPrice}
|
||||
onChange={handleInputChange}
|
||||
placeholder="最低价"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="maxPrice" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
最高价格
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxPrice"
|
||||
name="maxPrice"
|
||||
value={filters.maxPrice}
|
||||
onChange={handleInputChange}
|
||||
placeholder="最高价"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 库存筛选 */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hasStock"
|
||||
name="hasStock"
|
||||
checked={filters.hasStock}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="hasStock" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
仅显示有库存产品
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="mt-4 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white dark:bg-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApply}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none"
|
||||
>
|
||||
应用筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
693
src/app/team/[teamCode]/products/page.tsx
Normal file
693
src/app/team/[teamCode]/products/page.tsx
Normal file
@@ -0,0 +1,693 @@
|
||||
/**
|
||||
* 产品管理页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供产品数据的展示和管理功能
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTeam } from '@/hooks/useTeam';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import Image from 'next/image';
|
||||
import { MdAdd, MdSearch, MdEdit, MdDelete, MdRefresh, MdFilterList, MdShoppingBag } from 'react-icons/md';
|
||||
import { Product } from '@/models/team/types/old/product';
|
||||
import ProductModal from './product-modal';
|
||||
import ProductFilters from './components/ProductFilters';
|
||||
|
||||
// 定义类型接口
|
||||
interface Supplier {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Brand {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 扩展的产品接口,用于处理不同API可能返回的不同字段
|
||||
interface ExtendedProduct extends Product {
|
||||
category_id?: number;
|
||||
categoryName?: string;
|
||||
category?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品管理页面组件
|
||||
*/
|
||||
export default function ProductsPage() {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { currentTeam } = useTeam();
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
// 产品数据状态
|
||||
const [products, setProducts] = useState<ExtendedProduct[]>([]);
|
||||
const [totalProducts, setTotalProducts] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 搜索和筛选状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
supplierId: '',
|
||||
brandId: '',
|
||||
categoryId: '',
|
||||
level: '',
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
hasStock: false
|
||||
});
|
||||
const [availableLevels, setAvailableLevels] = useState<string[]>([]);
|
||||
|
||||
// 供应商、品牌、品类数据
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
// 模态框状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<ExtendedProduct | null>(null);
|
||||
|
||||
// 删除确认状态
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState<ExtendedProduct | null>(null);
|
||||
|
||||
// 在组件加载时获取供应商、品牌和品类数据
|
||||
useEffect(() => {
|
||||
if (!teamCode) return;
|
||||
|
||||
// 获取供应商列表
|
||||
const fetchSuppliers = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/suppliers?pageSize=100`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSuppliers(data.suppliers.map((s: Supplier) => ({ id: s.id, name: s.name })));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取供应商列表失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取品牌列表
|
||||
const fetchBrands = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/brands?pageSize=100`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBrands(data.brands.map((b: Brand) => ({ id: b.id, name: b.name })));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取品牌列表失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取品类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/categories?pageSize=100`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCategories(data.categories.map((c: { id: number; name: string }) => ({ id: c.id, name: c.name })));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取品类列表失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSuppliers();
|
||||
fetchBrands();
|
||||
fetchCategories();
|
||||
}, [teamCode]);
|
||||
|
||||
/**
|
||||
* 获取产品列表数据
|
||||
*/
|
||||
const fetchProducts = useCallback(async () => {
|
||||
if (!currentTeam || !teamCode) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 构建查询参数
|
||||
const queryParams = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
|
||||
if (searchKeyword) queryParams.append('keyword', searchKeyword);
|
||||
|
||||
// 添加筛选条件
|
||||
if (filters.supplierId) queryParams.append('supplierId', filters.supplierId);
|
||||
if (filters.brandId) queryParams.append('brandId', filters.brandId);
|
||||
if (filters.categoryId) queryParams.append('categoryId', filters.categoryId);
|
||||
if (filters.level) queryParams.append('level', filters.level);
|
||||
if (filters.minPrice) queryParams.append('minPrice', filters.minPrice);
|
||||
if (filters.maxPrice) queryParams.append('maxPrice', filters.maxPrice);
|
||||
if (filters.hasStock) queryParams.append('hasStock', 'true');
|
||||
|
||||
const response = await fetch(`/api/team/${teamCode}/products?${queryParams.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取产品列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProducts(data.products || []);
|
||||
setTotalProducts(data.total || 0);
|
||||
|
||||
// 打印调试信息
|
||||
console.log('API返回的产品数据:', data.products);
|
||||
if (data.products && data.products.length > 0) {
|
||||
console.log('第一个产品的cost字段:', data.products[0].cost);
|
||||
console.log('第一个产品的category_id:', data.products[0].category_id);
|
||||
console.log('第一个产品的categoryId:', data.products[0].categoryId);
|
||||
}
|
||||
|
||||
// 获取可用的产品级别
|
||||
if (data.filters?.levels) {
|
||||
setAvailableLevels(data.filters.levels);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取产品列表失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTeam, teamCode, currentPage, pageSize, searchKeyword, filters]);
|
||||
|
||||
/**
|
||||
* 初始加载和依赖变化时获取数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (teamCode) {
|
||||
fetchProducts();
|
||||
}
|
||||
}, [teamCode, fetchProducts]);
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理筛选条件变更
|
||||
*/
|
||||
const handleFilterChange = (name: string, value: string | boolean) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用筛选条件
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
fetchProducts();
|
||||
setFilterOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空筛选条件
|
||||
*/
|
||||
const handleClearFilters = () => {
|
||||
setSearchKeyword('');
|
||||
setFilters({
|
||||
supplierId: '',
|
||||
brandId: '',
|
||||
categoryId: '',
|
||||
level: '',
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
hasStock: false
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchProducts();
|
||||
setFilterOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理添加产品按钮点击
|
||||
*/
|
||||
const handleAddProduct = () => {
|
||||
setSelectedProduct(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理编辑产品
|
||||
*/
|
||||
const handleEditProduct = (product: ExtendedProduct) => {
|
||||
setSelectedProduct(product);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理删除产品确认
|
||||
*/
|
||||
const handleDeleteClick = (product: ExtendedProduct) => {
|
||||
setProductToDelete(product);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行删除产品操作
|
||||
*/
|
||||
const confirmDelete = async () => {
|
||||
if (!productToDelete || !teamCode) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/products/${productToDelete.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除产品失败');
|
||||
}
|
||||
|
||||
// 移除已删除的产品
|
||||
setProducts(prev => prev.filter(p => p.id !== productToDelete.id));
|
||||
setTotalProducts(prev => prev - 1);
|
||||
setShowDeleteConfirm(false);
|
||||
setProductToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('删除产品失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化价格显示
|
||||
*/
|
||||
const formatPrice = (price: number | undefined | null): string => {
|
||||
if (price === undefined || price === null) return '-';
|
||||
// 确保price是数字类型
|
||||
const numericPrice = Number(price);
|
||||
if (isNaN(numericPrice)) return '-';
|
||||
return numericPrice.toFixed(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取产品成本价
|
||||
*/
|
||||
const getCostPrice = (product: ExtendedProduct): number | undefined => {
|
||||
if (!product.cost) return undefined;
|
||||
|
||||
// 处理不同结构的成本数据
|
||||
if ('costPrice' in product.cost) {
|
||||
return product.cost.costPrice as number | undefined;
|
||||
} else if ('base' in product.cost) {
|
||||
return product.cost.base as number | undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取销售价(即产品的主要价格)
|
||||
*/
|
||||
const getSalePrice = (product: ExtendedProduct): number | undefined => {
|
||||
return product.price;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取运费
|
||||
*/
|
||||
const getShippingFee = (product: ExtendedProduct): number | undefined => {
|
||||
if (!product.cost) return undefined;
|
||||
|
||||
// 处理不同结构的成本数据
|
||||
if ('shippingFee' in product.cost) {
|
||||
return product.cost.shippingFee as number | undefined;
|
||||
} else if ('shipping' in product.cost) {
|
||||
return product.cost.shipping as number | undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算总页数
|
||||
*/
|
||||
const totalPages = Math.ceil(totalProducts / pageSize);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 页面标题区域 */}
|
||||
<div className="mb-6">
|
||||
<h1 className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
产品管理
|
||||
</h1>
|
||||
<p className={`mt-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
管理和查看产品数据
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索区域 */}
|
||||
<div className={`mb-6 p-4 rounded-xl shadow-sm ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
{/* 搜索表单 */}
|
||||
<form onSubmit={handleSearch} className="flex flex-1">
|
||||
<div className="relative flex-grow mr-2">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MdSearch className={isDarkMode ? 'text-gray-400' : 'text-gray-500'} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索产品名称、SKU或描述..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className={`pl-10 pr-4 py-2 w-full rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterOpen(!filterOpen)}
|
||||
className={`px-3 py-2 rounded-lg flex items-center mr-2 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
} ${filterOpen ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : ''}`}
|
||||
title="筛选"
|
||||
>
|
||||
<MdFilterList className="mr-1" />
|
||||
筛选
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center"
|
||||
>
|
||||
<MdSearch className="mr-1" />
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className={`px-3 py-2 rounded-lg flex items-center ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MdRefresh className="mr-1" />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddProduct}
|
||||
className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center"
|
||||
>
|
||||
<MdAdd className="mr-1" />
|
||||
添加产品
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选面板 */}
|
||||
{filterOpen && (
|
||||
<div className={`mt-4 p-4 rounded-lg ${isDarkMode ? 'bg-gray-800/50 border border-gray-700' : 'bg-gray-50 border border-gray-200'}`}>
|
||||
<ProductFilters
|
||||
filters={filters}
|
||||
onChange={handleFilterChange}
|
||||
onApply={applyFilters}
|
||||
onCancel={handleClearFilters}
|
||||
suppliers={suppliers}
|
||||
brands={brands}
|
||||
categories={categories}
|
||||
availableLevels={availableLevels}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中提示 */}
|
||||
{isLoading && (
|
||||
<div className={`flex justify-center items-center py-12 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||
正在加载产品数据...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无数据提示 */}
|
||||
{!isLoading && products.length === 0 && (
|
||||
<div className={`text-center py-16 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<MdShoppingBag className="mx-auto text-5xl mb-3 opacity-50" />
|
||||
<p className="text-lg">暂无产品数据</p>
|
||||
<p className="mt-1 text-sm">点击"添加产品"按钮创建新产品</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 产品列表 */}
|
||||
{!isLoading && products.length > 0 && (
|
||||
<div className={`rounded-xl overflow-hidden shadow-sm ${isDarkMode ? 'glass-card-dark' : 'glass-card'}`}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className={`border-b ${isDarkMode ? 'bg-gray-800/50 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
产品信息
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
品牌/品类
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
价格信息
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
库存
|
||||
</th>
|
||||
<th className={`px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${isDarkMode ? 'divide-gray-700' : 'divide-gray-200'}`}>
|
||||
{products.map((product) => (
|
||||
<tr key={product.id} className={`${isDarkMode ? 'hover:bg-gray-800/30' : 'hover:bg-gray-50'} transition-colors`}>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-16 w-16 rounded overflow-hidden bg-gray-100 dark:bg-gray-700 mr-4 flex items-center justify-center">
|
||||
{product.image ? (
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className={`text-gray-400 dark:text-gray-500`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{product.name}
|
||||
</div>
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
SKU: {product.sku || '-'}
|
||||
</div>
|
||||
{product.aliases && product.aliases.length > 0 && (
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
别名: {product.aliases.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{product.level && (
|
||||
<div className={`mt-1 inline-flex px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
isDarkMode ? 'bg-purple-900/30 text-purple-300' : 'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{product.level}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
<div>品牌: {product.brand?.name || '-'}</div>
|
||||
<div>品类: {
|
||||
// 尝试使用category_id查找品类名称
|
||||
(product.category_id !== undefined && categories.find(c => c.id === product.category_id)?.name) ||
|
||||
// API可能直接返回的category对象
|
||||
product.category?.name ||
|
||||
// 也可能直接返回categoryName字段
|
||||
product.categoryName ||
|
||||
// 旧版API可能使用categoryId而非category_id
|
||||
(product.categoryId !== undefined && categories.find(c => c.id === product.categoryId)?.name) ||
|
||||
'-'
|
||||
}</div>
|
||||
<div>供应商: {product.supplier?.name || '-'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
<div>成本价: ¥{formatPrice(getCostPrice(product))}</div>
|
||||
<div>销售价: ¥{formatPrice(getSalePrice(product))}</div>
|
||||
<div>运费: ¥{formatPrice(getShippingFee(product))}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{product.stock !== undefined && product.stock !== null ? (
|
||||
<span className={product.stock > 0
|
||||
? (isDarkMode ? 'text-green-400' : 'text-green-600')
|
||||
: (isDarkMode ? 'text-red-400' : 'text-red-600')
|
||||
}>
|
||||
{product.stock}
|
||||
</span>
|
||||
) : '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditProduct(product)}
|
||||
className={`p-2 rounded-lg ${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'} text-blue-600 hover:text-blue-800 transition-colors`}
|
||||
title="编辑"
|
||||
>
|
||||
<MdEdit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(product)}
|
||||
className={`p-2 rounded-lg ${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'} text-red-600 hover:text-red-800 transition-colors`}
|
||||
title="删除"
|
||||
>
|
||||
<MdDelete size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 1 && (
|
||||
<div className={`flex items-center justify-between border-t px-4 py-3 ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||
<div className="hidden sm:block">
|
||||
<p className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 至{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(currentPage * pageSize, totalProducts)}
|
||||
</span>{' '}
|
||||
条,共 <span className="font-medium">{totalProducts}</span> 条
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-between sm:justify-end">
|
||||
<button
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md ${
|
||||
currentPage === 1
|
||||
? `cursor-not-allowed ${isDarkMode ? 'bg-gray-800 text-gray-500 border-gray-700' : 'bg-gray-100 text-gray-400 border-gray-200'}`
|
||||
: `${isDarkMode ? 'bg-gray-700 text-gray-200 border-gray-600 hover:bg-gray-600' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'}`
|
||||
} mr-3`}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md ${
|
||||
currentPage === totalPages
|
||||
? `cursor-not-allowed ${isDarkMode ? 'bg-gray-800 text-gray-500 border-gray-700' : 'bg-gray-100 text-gray-400 border-gray-200'}`
|
||||
: `${isDarkMode ? 'bg-gray-700 text-gray-200 border-gray-600 hover:bg-gray-600' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'}`
|
||||
}`}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 产品模态框 */}
|
||||
<ProductModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
product={selectedProduct as Product | null}
|
||||
onSuccess={fetchProducts}
|
||||
suppliers={suppliers}
|
||||
brands={brands}
|
||||
categories={categories}
|
||||
availableLevels={availableLevels}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className={`p-6 rounded-lg shadow-xl max-w-md w-full ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}>
|
||||
<h3 className={`text-lg font-medium mb-3 ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>确认删除</h3>
|
||||
<p className={`mb-4 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
您确定要删除产品 "{productToDelete?.name}" 吗?此操作不可撤销。
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
|
||||
} transition`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
912
src/app/team/[teamCode]/products/product-modal.tsx
Normal file
912
src/app/team/[teamCode]/products/product-modal.tsx
Normal file
@@ -0,0 +1,912 @@
|
||||
/**
|
||||
* 产品模态框组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供产品信息的添加和编辑界面
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Product } from '@/models/team/types/old/product';
|
||||
import UploadImage from '@/components/UploadImage';
|
||||
import { Input, InputNumber, Select, Button, Tag, Form, Card, Row, Col, Alert, Space, Modal, Switch } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import TextArea from 'antd/es/input/TextArea';
|
||||
|
||||
// 定义不同的成本数据结构接口
|
||||
interface ProductCostData {
|
||||
costPrice?: number;
|
||||
packagingFee?: number;
|
||||
shippingFee?: number;
|
||||
}
|
||||
|
||||
interface LegacyCostData {
|
||||
base?: number;
|
||||
packaging?: number;
|
||||
shipping?: number;
|
||||
}
|
||||
|
||||
interface ProductModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
product?: Product | null;
|
||||
onSuccess: () => void;
|
||||
suppliers: { id: number; name: string }[];
|
||||
brands: { id: number; name: string }[];
|
||||
categories: { id: number; name: string }[];
|
||||
availableLevels: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品模态框组件
|
||||
*/
|
||||
export default function ProductModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
product,
|
||||
onSuccess,
|
||||
suppliers,
|
||||
brands,
|
||||
categories}: ProductModalProps) {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const isEditing = !!product;
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<Partial<Product>>({
|
||||
name: '',
|
||||
supplierId: undefined,
|
||||
brandId: undefined,
|
||||
categoryId: undefined,
|
||||
description: '',
|
||||
code: '',
|
||||
image: '',
|
||||
sku: '',
|
||||
aliases: [],
|
||||
level: '',
|
||||
cost: { costPrice: 0, packagingFee: 0, shippingFee: 0 },
|
||||
price: 0,
|
||||
stock: 0
|
||||
});
|
||||
|
||||
// 品牌和品类选择状态
|
||||
const [selectedBrandName, setSelectedBrandName] = useState<string>('');
|
||||
const [selectedCategoryName, setSelectedCategoryName] = useState<string>('');
|
||||
const [customNamePart, setCustomNamePart] = useState<string>('');
|
||||
const [isAutoNameEnabled, setIsAutoNameEnabled] = useState<boolean>(true);
|
||||
const [isUpdatingFormName, setIsUpdatingFormName] = useState<boolean>(false);
|
||||
|
||||
// Tag编辑相关状态
|
||||
const [aliasInput, setAliasInput] = useState('');
|
||||
const [inputVisible, setInputVisible] = useState(false);
|
||||
const [editInputIndex, setEditInputIndex] = useState(-1);
|
||||
const [editInputValue, setEditInputValue] = useState('');
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const editInputRef = useRef<InputRef>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 处理模态框可见性变化
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (product) {
|
||||
// 确保cost字段使用正确的结构
|
||||
let normalizedCost: ProductCostData = { costPrice: 0, packagingFee: 0, shippingFee: 0 };
|
||||
|
||||
if (product.cost) {
|
||||
// 处理可能存在的不同cost字段结构
|
||||
if ('costPrice' in product.cost) {
|
||||
normalizedCost = {
|
||||
costPrice: product.cost.costPrice,
|
||||
packagingFee: product.cost.packagingFee,
|
||||
shippingFee: product.cost.shippingFee
|
||||
};
|
||||
} else if ((product.cost as LegacyCostData).base !== undefined) {
|
||||
normalizedCost = {
|
||||
costPrice: (product.cost as LegacyCostData).base,
|
||||
packagingFee: (product.cost as LegacyCostData).packaging,
|
||||
shippingFee: (product.cost as LegacyCostData).shipping
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const newFormData = {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
supplierId: product.supplierId,
|
||||
brandId: product.brandId,
|
||||
categoryId: product.categoryId,
|
||||
description: product.description || '',
|
||||
code: product.code || '',
|
||||
image: product.image || '',
|
||||
sku: product.sku || '',
|
||||
aliases: product.aliases || [],
|
||||
level: product.level || '',
|
||||
cost: normalizedCost,
|
||||
price: product.price,
|
||||
stock: product.stock
|
||||
};
|
||||
|
||||
setFormData(newFormData);
|
||||
form.setFieldsValue(newFormData);
|
||||
|
||||
// 在编辑模式下,获取品牌和品类名称
|
||||
if (product.brandId) {
|
||||
const brand = brands.find(b => b.id === product.brandId);
|
||||
if (brand) {
|
||||
setSelectedBrandName(brand.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (product.categoryId) {
|
||||
const category = categories.find(c => c.id === product.categoryId);
|
||||
if (category) {
|
||||
setSelectedCategoryName(category.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑模式下默认禁用自动命名,避免覆盖已有名称
|
||||
setIsAutoNameEnabled(false);
|
||||
|
||||
// 从产品名称中提取自定义部分
|
||||
const prefix = `${selectedBrandName}${selectedCategoryName}`;
|
||||
if (prefix && product.name.startsWith(prefix)) {
|
||||
setCustomNamePart(product.name.substring(prefix.length));
|
||||
} else {
|
||||
setCustomNamePart(product.name);
|
||||
}
|
||||
} else {
|
||||
// 重置表单
|
||||
const initialData = {
|
||||
name: '',
|
||||
supplierId: undefined,
|
||||
brandId: undefined,
|
||||
categoryId: undefined,
|
||||
description: '',
|
||||
code: '',
|
||||
image: '',
|
||||
sku: '',
|
||||
aliases: [],
|
||||
level: '',
|
||||
cost: { costPrice: 0, packagingFee: 0, shippingFee: 0 },
|
||||
price: 0,
|
||||
stock: 0
|
||||
};
|
||||
|
||||
setFormData(initialData);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(initialData);
|
||||
|
||||
// 新建模式下重置相关状态
|
||||
setSelectedBrandName('');
|
||||
setSelectedCategoryName('');
|
||||
setCustomNamePart('');
|
||||
setIsAutoNameEnabled(true);
|
||||
}
|
||||
|
||||
setAliasInput('');
|
||||
setInputVisible(false);
|
||||
setEditInputIndex(-1);
|
||||
setEditInputValue('');
|
||||
setError(null);
|
||||
}
|
||||
}, [product, isOpen, form, brands, categories]);
|
||||
|
||||
// 聚焦输入框
|
||||
useEffect(() => {
|
||||
if (inputVisible && isOpen) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [inputVisible, isOpen]);
|
||||
|
||||
// 聚焦编辑输入框
|
||||
useEffect(() => {
|
||||
if (editInputIndex >= 0 && isOpen) {
|
||||
editInputRef.current?.focus();
|
||||
}
|
||||
}, [editInputIndex, isOpen]);
|
||||
|
||||
/**
|
||||
* 显示输入框
|
||||
*/
|
||||
const showInput = () => {
|
||||
setInputVisible(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理输入框值变化
|
||||
*/
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAliasInput(e.target.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理输入确认
|
||||
*/
|
||||
const handleInputConfirm = () => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (aliasInput && aliasInput.trim() && !formData.aliases?.includes(aliasInput.trim())) {
|
||||
const newAliases = [...(formData.aliases || []), aliasInput.trim()];
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
aliases: newAliases
|
||||
}));
|
||||
form.setFieldValue('aliases', newAliases);
|
||||
}
|
||||
setInputVisible(false);
|
||||
setAliasInput('');
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理Tag编辑状态
|
||||
*/
|
||||
const handleEditInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEditInputValue(e.target.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始编辑别名
|
||||
*/
|
||||
const startEdit = (index: number) => {
|
||||
setEditInputIndex(index);
|
||||
setEditInputValue(formData.aliases?.[index] || '');
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理编辑确认
|
||||
*/
|
||||
const handleEditInputConfirm = () => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const newAliases = [...(formData.aliases || [])];
|
||||
if (editInputValue && editInputValue.trim() && !newAliases.includes(editInputValue.trim()) && editInputIndex >= 0) {
|
||||
newAliases[editInputIndex] = editInputValue.trim();
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
aliases: newAliases
|
||||
}));
|
||||
form.setFieldValue('aliases', newAliases);
|
||||
}
|
||||
setEditInputIndex(-1);
|
||||
setEditInputValue('');
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除产品别名
|
||||
*/
|
||||
const handleRemoveAlias = (index: number) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const newAliases = formData.aliases?.filter((_, i) => i !== index) || [];
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
aliases: newAliases
|
||||
}));
|
||||
form.setFieldValue('aliases', newAliases);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理图片上传成功
|
||||
*/
|
||||
const handleImageUploadSuccess = (imageUrl: string) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// 获取旧图片URL
|
||||
const oldImageUrl = formData.image;
|
||||
|
||||
// 更新表单数据和表单字段
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
image: imageUrl
|
||||
}));
|
||||
form.setFieldValue('image', imageUrl);
|
||||
|
||||
// 如果是更新图片(且旧图片以/api/tools/images/开头),则尝试删除旧图片
|
||||
if (oldImageUrl && oldImageUrl.startsWith('/api/tools/images/')) {
|
||||
try {
|
||||
// 发送删除请求
|
||||
fetch(oldImageUrl, {
|
||||
method: 'DELETE'
|
||||
}).catch((error) => {
|
||||
console.error('删除旧图片时出错:', error);
|
||||
// 这里我们不阻止流程继续,即使删除失败
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除旧图片时出错:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 阻止事件冒泡的处理函数
|
||||
*/
|
||||
const stopPropagation = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理品牌选择变更
|
||||
*/
|
||||
const handleBrandChange = (value: string) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// 更新表单数据
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
brandId: Number(value)
|
||||
}));
|
||||
|
||||
// 找到对应的品牌名称
|
||||
const brand = brands.find(b => b.id.toString() === value);
|
||||
const brandName = brand ? brand.name : '';
|
||||
setSelectedBrandName(brandName);
|
||||
|
||||
// 更新产品名称
|
||||
updateProductName(brandName, selectedCategoryName);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理品类选择变更
|
||||
*/
|
||||
const handleCategoryChange = (value: string) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// 更新表单数据
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
categoryId: Number(value)
|
||||
}));
|
||||
|
||||
// 找到对应的品类名称
|
||||
const category = categories.find(c => c.id.toString() === value);
|
||||
const categoryName = category ? category.name : '';
|
||||
setSelectedCategoryName(categoryName);
|
||||
|
||||
// 更新产品名称
|
||||
updateProductName(selectedBrandName, categoryName);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新产品名称
|
||||
*/
|
||||
const updateProductName = (brandName: string, categoryName: string) => {
|
||||
if (!isOpen || !isAutoNameEnabled) return;
|
||||
|
||||
// 构建前缀
|
||||
const prefix = `${brandName}${categoryName}`;
|
||||
|
||||
// 如果有自定义部分,保留它,否则只显示前缀
|
||||
const newName = prefix + customNamePart;
|
||||
|
||||
// 标记当前是通过程序更新表单,避免触发循环
|
||||
setIsUpdatingFormName(true);
|
||||
|
||||
// 使用单一更新方式,避免重复触发更新
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: newName
|
||||
}));
|
||||
|
||||
// 使用setTimeout确保状态更新后再设置表单值
|
||||
setTimeout(() => {
|
||||
form.setFieldValue('name', newName);
|
||||
|
||||
// 在下一个事件循环中重置标记
|
||||
setTimeout(() => {
|
||||
setIsUpdatingFormName(false);
|
||||
}, 0);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理产品名称变更
|
||||
*/
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// 如果当前是通过程序更新表单,不再处理onChange事件
|
||||
if (isUpdatingFormName || !isOpen) return;
|
||||
|
||||
const fullName = e.target.value;
|
||||
|
||||
// 从全名中提取自定义部分(去掉前缀)
|
||||
const prefix = `${selectedBrandName}${selectedCategoryName}`;
|
||||
const newCustomPart = prefix && fullName.startsWith(prefix)
|
||||
? fullName.substring(prefix.length)
|
||||
: fullName;
|
||||
|
||||
// 保存自定义部分
|
||||
setCustomNamePart(newCustomPart);
|
||||
|
||||
// 更新表单数据
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: fullName
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换自动命名功能
|
||||
*/
|
||||
const toggleAutoName = () => {
|
||||
setIsAutoNameEnabled(!isAutoNameEnabled);
|
||||
};
|
||||
|
||||
/**
|
||||
* 提交表单
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (!isOpen) return;
|
||||
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 将表单值与formData合并,确保aliases等特殊字段正确处理
|
||||
const submitData = {
|
||||
...values,
|
||||
aliases: formData.aliases,
|
||||
cost: formData.cost
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
let response;
|
||||
|
||||
if (isEditing && product) {
|
||||
// 更新产品
|
||||
response = await fetch(`/api/team/${teamCode}/products/${product.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(submitData)
|
||||
});
|
||||
} else {
|
||||
// 创建产品
|
||||
response = await fetch(`/api/team/${teamCode}/products`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(submitData)
|
||||
});
|
||||
}
|
||||
|
||||
// 处理响应
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '操作失败');
|
||||
}
|
||||
|
||||
// 成功处理
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('表单验证失败,请检查输入');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理关闭模态框
|
||||
*/
|
||||
const handleModalClose = () => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 定义模态框的页脚
|
||||
const modalFooter = (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<Button onClick={handleModalClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => form.submit()}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isEditing ? '保存' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
title={isEditing ? '编辑产品' : '添加产品'}
|
||||
onCancel={handleModalClose}
|
||||
footer={modalFooter}
|
||||
width="90%"
|
||||
maskClosable={false}
|
||||
style={{ top: 20 }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '16px',
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflowY: 'hidden',
|
||||
height: 'auto'
|
||||
},
|
||||
content: {
|
||||
maxWidth: '100%'
|
||||
}
|
||||
}}
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
{error && (
|
||||
<Alert
|
||||
message="错误"
|
||||
description={error}
|
||||
type="error"
|
||||
showIcon
|
||||
closable
|
||||
className="mb-4"
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={formData}
|
||||
onFinish={handleSubmit}
|
||||
className="product-form"
|
||||
preserve={false}
|
||||
name="productForm"
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
<Row gutter={16} style={{ margin: 0 }}>
|
||||
{/* 左侧:基本信息和其他字段 */}
|
||||
<Col span={18} style={{ paddingLeft: 0 }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{/* 基本信息区域 */}
|
||||
<Card title="基本信息" variant="outlined" styles={{
|
||||
body: {
|
||||
padding: '16px'
|
||||
}
|
||||
}}>
|
||||
<Row gutter={16}>
|
||||
{/* 产品名称 */}
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ marginRight: '8px' }}>产品名称</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={isAutoNameEnabled}
|
||||
onChange={toggleAutoName}
|
||||
checkedChildren="自动"
|
||||
unCheckedChildren="手动"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rules={[{ required: true, message: '请输入产品名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入产品名称" onChange={handleNameChange} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* SKU */}
|
||||
<Col span={6}>
|
||||
<Form.Item
|
||||
name="sku"
|
||||
label="SKU"
|
||||
>
|
||||
<Input placeholder="请输入SKU" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* 产品代码 */}
|
||||
<Col span={6}>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="产品代码"
|
||||
>
|
||||
<Input placeholder="请输入产品代码" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
{/* 产品级别 */}
|
||||
<Col span={6}>
|
||||
<Form.Item
|
||||
name="level"
|
||||
label="产品级别"
|
||||
>
|
||||
<Input placeholder="请输入产品级别" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* 产品别名 */}
|
||||
<Col span={18}>
|
||||
<Form.Item
|
||||
label="产品别名"
|
||||
name="aliases"
|
||||
>
|
||||
<div onClick={stopPropagation} style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', padding: '4px 0' }}>
|
||||
{formData.aliases?.map((alias, index) => {
|
||||
if (editInputIndex === index) {
|
||||
return (
|
||||
<Input
|
||||
ref={editInputRef}
|
||||
key={alias}
|
||||
size="small"
|
||||
value={editInputValue}
|
||||
onChange={handleEditInputChange}
|
||||
onBlur={handleEditInputConfirm}
|
||||
onPressEnter={handleEditInputConfirm}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
key={alias}
|
||||
closable
|
||||
style={{ userSelect: 'none', cursor: 'pointer' }}
|
||||
onClose={() => handleRemoveAlias(index)}
|
||||
onClick={() => startEdit(index)}
|
||||
>
|
||||
{alias}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{inputVisible ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ width: '100px' }}
|
||||
value={aliasInput}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputConfirm}
|
||||
onPressEnter={handleInputConfirm}
|
||||
/>
|
||||
) : (
|
||||
<Tag
|
||||
onClick={showInput}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderStyle: 'dashed',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<PlusOutlined /> 添加别名
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 关联信息 */}
|
||||
<Card title="关联信息" variant="outlined" styles={{
|
||||
body: {
|
||||
padding: '16px'
|
||||
}
|
||||
}}>
|
||||
<Row gutter={16}>
|
||||
{/* 供应商 */}
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="supplierId"
|
||||
label="供应商"
|
||||
rules={[{ required: true, message: '请选择供应商' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择供应商"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string).toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={suppliers.map(supplier => ({
|
||||
label: supplier.name,
|
||||
value: supplier.id.toString()
|
||||
}))}
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}
|
||||
onClick={stopPropagation}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* 品牌 */}
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="brandId"
|
||||
label="品牌"
|
||||
rules={[{ required: true, message: '请选择品牌' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择品牌"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string).toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={brands.map(brand => ({
|
||||
label: brand.name,
|
||||
value: brand.id.toString()
|
||||
}))}
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}
|
||||
onClick={stopPropagation}
|
||||
onChange={handleBrandChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* 品类 */}
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="categoryId"
|
||||
label="品类"
|
||||
rules={[{ required: true, message: '请选择品类' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择品类"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string).toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={categories.map(category => ({
|
||||
label: category.name,
|
||||
value: category.id.toString()
|
||||
}))}
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}
|
||||
onClick={stopPropagation}
|
||||
onChange={handleCategoryChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 价格和库存 */}
|
||||
<Card title="价格和库存" variant="outlined" styles={{
|
||||
body: {
|
||||
padding: '16px'
|
||||
}
|
||||
}}>
|
||||
<Row gutter={16}>
|
||||
{/* 售价 */}
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Form.Item
|
||||
name="price"
|
||||
label="售价"
|
||||
rules={[{ required: true, message: '请输入售价' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* 成本价 */}
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Form.Item
|
||||
label="成本价"
|
||||
name={['cost', 'costPrice']}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* 包装费 */}
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Form.Item
|
||||
label="包装费"
|
||||
name={['cost', 'packagingFee']}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* 运费 */}
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Form.Item
|
||||
label="运费"
|
||||
name={['cost', 'shippingFee']}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* 库存 */}
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Form.Item
|
||||
name="stock"
|
||||
label="库存数量"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
{/* 右侧: 图片 */}
|
||||
<Col span={6} style={{ paddingRight: 0 }}>
|
||||
<Card title="产品图片" variant="outlined" styles={{ body: { padding: '16px' } }}>
|
||||
{/* 上传组件 */}
|
||||
<div>
|
||||
<UploadImage
|
||||
onSuccess={handleImageUploadSuccess}
|
||||
folder="products"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 手动输入图片URL */}
|
||||
<Form.Item
|
||||
name="image"
|
||||
label="图片URL"
|
||||
extra={<>输入图片URL或<span style={{ color: '#ff4d4f' }}>使用上方上传组件</span>自动生成</>}
|
||||
rules={[{ required: true, message: '请上传产品图片或提供图片URL' }]}
|
||||
>
|
||||
<Input placeholder="http://example.com/image.jpg" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 产品描述 - 移动到右侧图片下方 */}
|
||||
<Card title="产品描述" variant="outlined" style={{ marginTop: '16px' }} styles={{ body: { padding: '16px' } }}>
|
||||
<Form.Item
|
||||
name="description"
|
||||
noStyle
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="请输入产品描述"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 产品信息展示组件样式
|
||||
* 作者: 阿瑞
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
/* 产品卡片 */
|
||||
.productCard {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 120px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.productCard.light {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.productCard.dark {
|
||||
background-color: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.productCard:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.productCard.light:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.productCard.dark:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 产品图片容器 */
|
||||
.productImageContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 100%; /* 1:1 宽高比 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 产品图片 */
|
||||
.productImage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.productImageContainer:hover .productImage {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 产品图片占位符 */
|
||||
.productImagePlaceholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.productImagePlaceholder.light {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.productImagePlaceholder.dark {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
/* 通用标签样式 */
|
||||
.supplierTag,
|
||||
.brandCategoryTag,
|
||||
.priceTag,
|
||||
.quantityTag,
|
||||
.exampleTag {
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0.1rem 0.2rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
font-size: 0.6rem;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 供应商标签 */
|
||||
.supplierTag {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
left: 0.3rem;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
/* 品牌分类标签 */
|
||||
.brandCategoryTag {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
right: 0.3rem;
|
||||
max-width: 80%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 价格标签 */
|
||||
.priceTag {
|
||||
position: absolute;
|
||||
bottom: 0.3rem;
|
||||
left: 0.3rem;
|
||||
font-weight: 600;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* 数量标签 */
|
||||
.quantityTag {
|
||||
position: absolute;
|
||||
bottom: calc(0.3rem + 18px);
|
||||
left: 0.3rem;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* 物流状态标签 */
|
||||
.exampleTag {
|
||||
position: absolute;
|
||||
bottom: 0.1rem;
|
||||
right: 0rem;
|
||||
color: white;
|
||||
background: none; /* 移除背景,因为使用Antd Tag */
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 为不同状态的标签添加样式 */
|
||||
.statusTag {
|
||||
line-height: 1.2;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
height: auto;
|
||||
border-radius: 2px;
|
||||
}
|
||||
342
src/app/team/[teamCode]/sales-records/ProductInfoModal.tsx
Normal file
342
src/app/team/[teamCode]/sales-records/ProductInfoModal.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* 产品信息展示组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 展示产品详细信息
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { MdShoppingCart } from 'react-icons/md';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { Tag } from 'antd';
|
||||
import MyTooltip from '@/components/tooltip/MyTooltip';
|
||||
import styles from './ProductInfoModal.module.css';
|
||||
|
||||
interface ProductProps {
|
||||
productId?: number;
|
||||
productName?: string;
|
||||
productCode?: string;
|
||||
productSku?: string;
|
||||
price?: number;
|
||||
quantity?: number;
|
||||
brandId?: number;
|
||||
brandName?: string;
|
||||
categoryId?: number;
|
||||
categoryName?: string;
|
||||
supplierId?: number;
|
||||
supplierName?: string;
|
||||
image?: string;
|
||||
description?: string;
|
||||
logisticsStatus?: string; // 物流状态
|
||||
logisticsDetails?: string; // 物流详情JSON字符串
|
||||
logisticsTrackingNumber?: string; // 物流单号
|
||||
logisticsCompany?: string; // 物流公司
|
||||
}
|
||||
|
||||
// 定义物流轨迹项的类型
|
||||
interface LogisticsRoute {
|
||||
acceptTime?: string;
|
||||
time?: string;
|
||||
remark?: string;
|
||||
content?: string;
|
||||
acceptAddress?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
// 定义物流详情数据接口
|
||||
interface LogisticsDetails {
|
||||
routes?: LogisticsRoute[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取物流状态对应的标签样式
|
||||
*/
|
||||
const getLogisticsStatusStyle = (status?: string) => {
|
||||
if (!status) return { color: 'default', text: '未知' };
|
||||
|
||||
switch (status) {
|
||||
case '已填单':
|
||||
return { color: 'default', text: '已填单' };
|
||||
case '已揽件':
|
||||
return { color: 'processing', text: '已揽件' };
|
||||
case '运输中':
|
||||
return { color: 'processing', text: '运输中' };
|
||||
case '已签收':
|
||||
return { color: 'success', text: '已签收' };
|
||||
default:
|
||||
return { color: 'processing', text: status };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 产品信息展示组件
|
||||
*/
|
||||
const ProductInfoModal: React.FC<ProductProps> = (props) => {
|
||||
const {
|
||||
productName,
|
||||
price = 0,
|
||||
quantity = 0,
|
||||
brandName,
|
||||
categoryName,
|
||||
supplierName,
|
||||
image,
|
||||
logisticsStatus,
|
||||
logisticsDetails,
|
||||
logisticsTrackingNumber,
|
||||
logisticsCompany
|
||||
} = props;
|
||||
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
// 图片预览状态
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
// 获取物流状态样式
|
||||
const statusStyle = getLogisticsStatusStyle(logisticsStatus);
|
||||
|
||||
/**
|
||||
* 解析物流详情数据
|
||||
*/
|
||||
const parsedLogisticsDetails = useMemo(() => {
|
||||
if (!logisticsDetails) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(logisticsDetails) as LogisticsDetails;
|
||||
} catch (e) {
|
||||
console.error('解析物流详情失败:', e);
|
||||
return null;
|
||||
}
|
||||
}, [logisticsDetails]);
|
||||
|
||||
/**
|
||||
* 格式化物流详情显示
|
||||
*/
|
||||
const formatLogisticsDetails = useMemo(() => {
|
||||
try {
|
||||
if (!parsedLogisticsDetails || !parsedLogisticsDetails.routes || !Array.isArray(parsedLogisticsDetails.routes) || parsedLogisticsDetails.routes.length === 0) {
|
||||
return `
|
||||
物流单号: ${logisticsTrackingNumber || '-'}
|
||||
物流公司: ${logisticsCompany || '-'}
|
||||
暂无物流轨迹信息
|
||||
`;
|
||||
}
|
||||
|
||||
const routes = parsedLogisticsDetails.routes;
|
||||
|
||||
// 提取最近的几条物流记录
|
||||
const recentRoutes = routes.slice(0, 3);
|
||||
|
||||
let detailText = `
|
||||
物流单号: ${logisticsTrackingNumber || '-'}
|
||||
物流公司: ${logisticsCompany || '-'}
|
||||
|
||||
物流轨迹:
|
||||
`;
|
||||
|
||||
recentRoutes.forEach((route: LogisticsRoute, index: number) => {
|
||||
const time = route.acceptTime || route.time || '-';
|
||||
const content = route.remark || route.content || '未知状态';
|
||||
const location = route.acceptAddress || route.location || '';
|
||||
|
||||
detailText += `
|
||||
${index + 1}. ${time}
|
||||
${location ? `[${location}] ` : ''}${content}
|
||||
`;
|
||||
});
|
||||
|
||||
if (routes.length > 3) {
|
||||
detailText += `
|
||||
...更多${routes.length - 3}条记录
|
||||
`;
|
||||
}
|
||||
|
||||
return detailText;
|
||||
} catch (e) {
|
||||
console.error('格式化物流详情失败:', e);
|
||||
return '物流详情格式错误';
|
||||
}
|
||||
}, [parsedLogisticsDetails, logisticsTrackingNumber, logisticsCompany]);
|
||||
|
||||
/**
|
||||
* 格式化金额显示
|
||||
*/
|
||||
const formatAmount = (amount: number | undefined) => {
|
||||
if (amount === undefined || amount === null) return '¥0.00';
|
||||
// 确保 amount 是数字类型
|
||||
const numAmount = Number(amount);
|
||||
// 检查是否为有效数字
|
||||
if (isNaN(numAmount)) return '¥0.00';
|
||||
return `¥${numAmount.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开图片预览
|
||||
*/
|
||||
const handleOpenPreview = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭图片预览
|
||||
*/
|
||||
const handleClosePreview = () => {
|
||||
setPreviewOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染产品信息标签
|
||||
*/
|
||||
const renderProductInfoTag = (label: string, value: string | number | undefined) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-center rounded-md border border-slate-200 dark:border-slate-700 px-3 py-1 text-sm">
|
||||
<span className="font-medium mr-1">{label}:</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.productCard} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<div className={styles.productImageContainer} onClick={handleOpenPreview}>
|
||||
{/* 产品图片 */}
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={productName || '产品'}
|
||||
width={120}
|
||||
height={120}
|
||||
className={styles.productImage}
|
||||
/>
|
||||
) : (
|
||||
<div className={`${styles.productImagePlaceholder} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<MdShoppingCart size={48} style={{ color: '#6b7280' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 物流状态标签 - 右下角 - 简化样式 */}
|
||||
<div className={`${styles.exampleTag} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
{logisticsStatus ? (
|
||||
<MyTooltip
|
||||
title={formatLogisticsDetails}
|
||||
placement="top"
|
||||
shouldWrapChildren={false}
|
||||
>
|
||||
<Tag
|
||||
color={statusStyle.color}
|
||||
className={`${styles.statusTag} m-0 cursor-pointer`}
|
||||
>
|
||||
{statusStyle.text}
|
||||
</Tag>
|
||||
</MyTooltip>
|
||||
) : (
|
||||
<Tag
|
||||
color="default"
|
||||
className={`${styles.statusTag} m-0`}
|
||||
>
|
||||
未发货
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 供应商名称 - 左上角 */}
|
||||
{supplierName && (
|
||||
<div className={`${styles.supplierTag} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
{supplierName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 品牌+品类 - 右上角 */}
|
||||
{(brandName || categoryName) && (
|
||||
<div className={`${styles.brandCategoryTag} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
{brandName && categoryName ? `${brandName} · ${categoryName}` : brandName || categoryName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数量 - 左下角价格上方 */}
|
||||
{quantity > 0 && (
|
||||
<div className={`${styles.quantityTag} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
x{quantity}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 价格 - 左下角 */}
|
||||
<div className={`${styles.priceTag} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
{formatAmount(price)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用 Modal 组件进行图片预览 */}
|
||||
<Modal
|
||||
isOpen={previewOpen}
|
||||
onClose={handleClosePreview}
|
||||
size="lg"
|
||||
isGlass={true}
|
||||
glassLevel="medium"
|
||||
title={productName || "产品详情"}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
{/* 图片显示卡片 */}
|
||||
<Card padding="medium" glassEffect="none" hoverEffect={false} className="overflow-hidden">
|
||||
<div className="relative w-full h-[350px] flex items-center justify-center">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={productName || '产品'}
|
||||
fill
|
||||
style={{ objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-slate-100 dark:bg-slate-800">
|
||||
<MdShoppingCart size={64} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 产品信息 */}
|
||||
<div className="w-full">
|
||||
<Card padding="medium" glassEffect="none" hoverEffect={false}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{renderProductInfoTag("供应商", supplierName)}
|
||||
{renderProductInfoTag("品牌", brandName)}
|
||||
{renderProductInfoTag("品类", categoryName)}
|
||||
{renderProductInfoTag("价格", formatAmount(price))}
|
||||
{renderProductInfoTag("数量", quantity > 0 ? `x${quantity}` : undefined)}
|
||||
{logisticsStatus && (
|
||||
<div className="flex items-center rounded-md border border-slate-200 dark:border-slate-700 px-3 py-1 text-sm">
|
||||
<span className="font-medium mr-1">物流状态:</span>
|
||||
<MyTooltip
|
||||
title={formatLogisticsDetails}
|
||||
placement="top"
|
||||
>
|
||||
<Tag
|
||||
color={statusStyle.color}
|
||||
className="m-0 cursor-pointer text-xs py-0 px-2 h-5 leading-5"
|
||||
>
|
||||
{statusStyle.text}
|
||||
</Tag>
|
||||
</MyTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductInfoModal;
|
||||
@@ -0,0 +1,948 @@
|
||||
/**
|
||||
* 销售记录操作模态框组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供销售记录的退货、换货、退差、补发操作界面
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { SalesRecord } from '@/models/team/types/old/sales';
|
||||
import { MdPerson, MdPhone, MdShoppingCart, MdAttachMoney, MdCalendarToday, MdImage, MdSwapHoriz } from 'react-icons/md';
|
||||
import { formatDate } from '@/utils';
|
||||
|
||||
/**
|
||||
* 操作类型枚举
|
||||
*/
|
||||
export enum OperationType {
|
||||
RETURN = 'return', // 退货
|
||||
EXCHANGE = 'exchange', // 换货
|
||||
REFUND = 'refund', // 退差价
|
||||
REPLENISH = 'replenish' // 补发
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作类型中文名映射
|
||||
*/
|
||||
const OperationNames = {
|
||||
[OperationType.RETURN]: '退货',
|
||||
[OperationType.EXCHANGE]: '换货',
|
||||
[OperationType.REFUND]: '退差',
|
||||
[OperationType.REPLENISH]: '补发'
|
||||
};
|
||||
|
||||
/**
|
||||
* 售后处理进度枚举
|
||||
*/
|
||||
enum AfterSalesProgress {
|
||||
PENDING = '待处理',
|
||||
PROCESSING = '处理中',
|
||||
COMPLETED = '已处理'
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易类型枚举
|
||||
*/
|
||||
enum TransactionType {
|
||||
INCOME = '收入',
|
||||
EXPENSE = '支出'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额显示
|
||||
*/
|
||||
const formatAmount = (amount: number | undefined) => {
|
||||
if (amount === undefined || amount === null) return '¥0.00';
|
||||
return `¥${Number(amount).toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 扩展的销售记录接口,使用与页面一致的类型
|
||||
*/
|
||||
interface SalesRecordWithDetails extends Omit<SalesRecord, 'products'> {
|
||||
products?: {
|
||||
productId: number;
|
||||
productName?: string;
|
||||
productCode?: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
brandName?: string;
|
||||
categoryName?: string;
|
||||
supplierName?: string;
|
||||
image?: string;
|
||||
}[];
|
||||
// 客户信息
|
||||
customerName?: string;
|
||||
customerPhone?: string;
|
||||
customerFollowDate?: string;
|
||||
customerAddress?: string;
|
||||
// 店铺信息
|
||||
sourceName?: string;
|
||||
sourceWechat?: string;
|
||||
sourcePhone?: string;
|
||||
sourceAccountNo?: string;
|
||||
// 成交店铺信息
|
||||
dealShopId?: number;
|
||||
dealShopName?: string;
|
||||
dealShopWechat?: string;
|
||||
dealShopPhone?: string;
|
||||
dealShopAccountNo?: string;
|
||||
// 导购信息
|
||||
guideName?: string;
|
||||
guideUsername?: string;
|
||||
guidePhone?: string;
|
||||
guideWechat?: string;
|
||||
// 支付平台信息
|
||||
platformName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件Props接口
|
||||
*/
|
||||
interface SalesRecordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
salesRecord: SalesRecordWithDetails;
|
||||
operationType: OperationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作信息项接口
|
||||
*/
|
||||
interface OperationInfoItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
valueClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 售后记录表单数据接口
|
||||
*/
|
||||
interface AfterSalesFormData {
|
||||
type: string;
|
||||
date: string;
|
||||
reason: string;
|
||||
progress: AfterSalesProgress;
|
||||
transactionType: TransactionType | '';
|
||||
platformId?: number;
|
||||
amount: number;
|
||||
pending: number;
|
||||
remark: string;
|
||||
selectedOriginalProducts: {
|
||||
productId: number;
|
||||
quantity: number;
|
||||
}[];
|
||||
selectedReplacementProducts: {
|
||||
productId: number;
|
||||
quantity: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品项目接口,用于渲染产品列表项
|
||||
*/
|
||||
interface ProductItem {
|
||||
productId: number;
|
||||
productName?: string;
|
||||
productCode?: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
brandName?: string;
|
||||
categoryName?: string;
|
||||
supplierName?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品选择器组件,简化版,仅用于选择替换产品
|
||||
*/
|
||||
const ReplacementProductSelector: React.FC<{
|
||||
onSelectProduct: (product: ProductItem) => void;
|
||||
}> = ({ onSelectProduct }) => {
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<ProductItem[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* 处理搜索产品
|
||||
*/
|
||||
const handleSearch = async () => {
|
||||
if (!searchKeyword.trim()) return;
|
||||
|
||||
setIsSearching(true);
|
||||
|
||||
try {
|
||||
// 模拟API调用,实际项目中应该调用真实API
|
||||
// 这里使用一些示例数据来演示
|
||||
const mockProducts: ProductItem[] = [
|
||||
{
|
||||
productId: 1001,
|
||||
productName: '示例产品 A - ' + searchKeyword,
|
||||
productCode: 'SKU001',
|
||||
price: 199,
|
||||
quantity: 10,
|
||||
brandName: '品牌A',
|
||||
categoryName: '分类A',
|
||||
image: ''
|
||||
},
|
||||
{
|
||||
productId: 1002,
|
||||
productName: '示例产品 B - ' + searchKeyword,
|
||||
productCode: 'SKU002',
|
||||
price: 299,
|
||||
quantity: 5,
|
||||
brandName: '品牌B',
|
||||
categoryName: '分类B',
|
||||
image: ''
|
||||
}
|
||||
];
|
||||
|
||||
// 延迟一下模拟网络请求
|
||||
setTimeout(() => {
|
||||
setSearchResults(mockProducts);
|
||||
setIsDropdownOpen(true);
|
||||
setIsSearching(false);
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('搜索产品失败:', error);
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理选择产品
|
||||
*/
|
||||
const handleSelectProduct = (product: ProductItem) => {
|
||||
onSelectProduct(product);
|
||||
setIsDropdownOpen(false);
|
||||
setSearchKeyword('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="搜索要替换的产品..."
|
||||
className="w-full pl-10 pr-16 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<MdSwapHoriz className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || !searchKeyword.trim()}
|
||||
className={`absolute right-2 top-1/2 transform -translate-y-1/2 px-2 py-1 text-xs rounded ${
|
||||
isSearching || !searchKeyword.trim()
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{isSearching ? '...' : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果下拉菜单 */}
|
||||
{isDropdownOpen && searchResults.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 rounded-md max-h-60 overflow-auto">
|
||||
<ul>
|
||||
{searchResults.map(product => (
|
||||
<li
|
||||
key={product.productId}
|
||||
onClick={() => handleSelectProduct(product)}
|
||||
className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0"
|
||||
>
|
||||
<div className="font-medium text-gray-800 dark:text-white flex items-center justify-between">
|
||||
<span>{product.productName}</span>
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
{formatAmount(product.price)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-300 flex justify-between">
|
||||
<span>{product.productCode && `编码: ${product.productCode}`}</span>
|
||||
<span>库存: {product.quantity}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无搜索结果提示 */}
|
||||
{isDropdownOpen && searchKeyword && searchResults.length === 0 && !isSearching && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 rounded-md p-4 text-center text-gray-500 dark:text-gray-400">
|
||||
未找到匹配的产品
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 销售记录操作模态框组件
|
||||
*/
|
||||
const SalesRecordModal: React.FC<SalesRecordModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
salesRecord,
|
||||
operationType
|
||||
}) => {
|
||||
// 售后记录表单数据
|
||||
const [formData, setFormData] = useState<AfterSalesFormData>({
|
||||
type: OperationNames[operationType],
|
||||
date: formatDate(new Date(), 'YYYY-MM-DD'),
|
||||
reason: '',
|
||||
progress: AfterSalesProgress.PENDING,
|
||||
transactionType: '',
|
||||
platformId: undefined,
|
||||
amount: 0,
|
||||
pending: 0,
|
||||
remark: '',
|
||||
selectedOriginalProducts: [],
|
||||
selectedReplacementProducts: []
|
||||
});
|
||||
|
||||
// 已选择的原始产品
|
||||
const [selectedOriginalProducts, setSelectedOriginalProducts] = useState<Array<{
|
||||
productId: number,
|
||||
productName?: string,
|
||||
price: number,
|
||||
quantity: number,
|
||||
selected: number
|
||||
}>>([]);
|
||||
|
||||
// 已选择的替换产品(仅用于换货和补发)
|
||||
const [selectedReplacementProducts, setSelectedReplacementProducts] = useState<Array<{
|
||||
productId: number,
|
||||
productName?: string,
|
||||
price: number,
|
||||
quantity: number
|
||||
}>>([]);
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
const formatFriendlyDate = (dateStr: string | undefined | null): string => {
|
||||
if (!dateStr) return '-';
|
||||
return formatDate(dateStr, 'YYYY-MM-DD');
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理表单数据变更
|
||||
*/
|
||||
const handleFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理原始产品选择变更
|
||||
*/
|
||||
const handleOriginalProductSelect = (productId: number, quantity: number) => {
|
||||
// 找到对应的产品
|
||||
const product = salesRecord.products?.find(p => p.productId === productId);
|
||||
if (!product) return;
|
||||
|
||||
// 更新已选产品列表
|
||||
if (quantity > 0) {
|
||||
const existingIndex = selectedOriginalProducts.findIndex(p => p.productId === productId);
|
||||
if (existingIndex >= 0) {
|
||||
// 更新已有产品
|
||||
const updatedProducts = [...selectedOriginalProducts];
|
||||
updatedProducts[existingIndex].selected = quantity;
|
||||
setSelectedOriginalProducts(updatedProducts);
|
||||
} else {
|
||||
// 添加新产品
|
||||
setSelectedOriginalProducts([
|
||||
...selectedOriginalProducts,
|
||||
{
|
||||
productId: product.productId,
|
||||
productName: product.productName,
|
||||
price: product.price,
|
||||
quantity: product.quantity,
|
||||
selected: quantity
|
||||
}
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// 移除产品
|
||||
setSelectedOriginalProducts(selectedOriginalProducts.filter(p => p.productId !== productId));
|
||||
}
|
||||
|
||||
// 更新表单数据
|
||||
const updatedSelectedProducts = selectedOriginalProducts
|
||||
.map(p => p.productId === productId ? { ...p, selected: quantity } : p)
|
||||
.filter(p => p.selected > 0);
|
||||
|
||||
if (quantity > 0 && !updatedSelectedProducts.find(p => p.productId === productId)) {
|
||||
updatedSelectedProducts.push({
|
||||
productId: product.productId,
|
||||
productName: product.productName,
|
||||
price: product.price,
|
||||
quantity: product.quantity,
|
||||
selected: quantity
|
||||
});
|
||||
}
|
||||
|
||||
// 计算涉及金额
|
||||
const totalAmount = updatedSelectedProducts.reduce(
|
||||
(sum, p) => sum + (p.price * p.selected), 0
|
||||
);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
amount: totalAmount,
|
||||
selectedOriginalProducts: updatedSelectedProducts.map(p => ({
|
||||
productId: p.productId,
|
||||
quantity: p.selected
|
||||
}))
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染产品图片
|
||||
*/
|
||||
const renderProductImage = (image?: string, productName?: string) => {
|
||||
if (image) {
|
||||
return (
|
||||
<div className="relative w-14 h-14 rounded-md overflow-hidden border border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<Image
|
||||
src={image}
|
||||
alt={productName || '产品图片'}
|
||||
fill
|
||||
sizes="56px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 没有图片时显示占位符
|
||||
return (
|
||||
<div className="w-14 h-14 rounded-md flex items-center justify-center bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<MdImage className="text-gray-400" size={24} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染产品项目
|
||||
*/
|
||||
const renderProductItem = (product: ProductItem, selected: number = 0) => {
|
||||
return (
|
||||
<div key={product.productId} className="flex items-center p-2 bg-gray-50 dark:bg-gray-800 rounded-md gap-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
{/* 产品图片 */}
|
||||
{renderProductImage(product.image, product.productName)}
|
||||
|
||||
{/* 产品信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{product.productName || `产品 #${product.productId}`}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span className="inline-block px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded mr-1">
|
||||
{formatAmount(product.price)}
|
||||
</span>
|
||||
{product.brandName && (
|
||||
<span className="inline-block px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded mr-1">
|
||||
{product.brandName}
|
||||
</span>
|
||||
)}
|
||||
{product.categoryName && (
|
||||
<span className="inline-block px-1.5 py-0.5 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
|
||||
{product.categoryName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数量选择 */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={product.quantity}
|
||||
value={selected}
|
||||
onChange={(e) => handleOriginalProductSelect(product.productId, Number(e.target.value))}
|
||||
className="w-16 px-2 py-1 border border-gray-300 rounded-md shadow-sm text-center focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">/ {product.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理替换产品选择
|
||||
*/
|
||||
const handleReplacementProductSelect = (product: ProductItem) => {
|
||||
// 添加到替换产品列表
|
||||
setSelectedReplacementProducts([
|
||||
...selectedReplacementProducts,
|
||||
{
|
||||
productId: product.productId,
|
||||
productName: product.productName,
|
||||
price: product.price,
|
||||
quantity: 1 // 默认选择1个
|
||||
}
|
||||
]);
|
||||
|
||||
// 更新表单数据
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedReplacementProducts: [
|
||||
...prev.selectedReplacementProducts,
|
||||
{ productId: product.productId, quantity: 1 }
|
||||
]
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理替换产品数量变更
|
||||
*/
|
||||
const handleReplacementQuantityChange = (productId: number, quantity: number) => {
|
||||
if (quantity <= 0) {
|
||||
// 移除产品
|
||||
setSelectedReplacementProducts(
|
||||
selectedReplacementProducts.filter(p => p.productId !== productId)
|
||||
);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedReplacementProducts: prev.selectedReplacementProducts.filter(
|
||||
p => p.productId !== productId
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// 更新数量
|
||||
setSelectedReplacementProducts(
|
||||
selectedReplacementProducts.map(p =>
|
||||
p.productId === productId ? { ...p, quantity } : p
|
||||
)
|
||||
);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedReplacementProducts: prev.selectedReplacementProducts.map(p =>
|
||||
p.productId === productId ? { ...p, quantity } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除替换产品
|
||||
*/
|
||||
const handleRemoveReplacementProduct = (productId: number) => {
|
||||
setSelectedReplacementProducts(
|
||||
selectedReplacementProducts.filter(p => p.productId !== productId)
|
||||
);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedReplacementProducts: prev.selectedReplacementProducts.filter(
|
||||
p => p.productId !== productId
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染替换产品列表
|
||||
*/
|
||||
const renderReplacementProducts = () => {
|
||||
if (selectedReplacementProducts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-700 rounded-md">
|
||||
还没有选择替换产品
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-h-[280px] overflow-y-auto">
|
||||
{selectedReplacementProducts.map(product => (
|
||||
<div key={product.productId} className="flex items-center p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{product.productName || `产品 #${product.productId}`}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
单价: {formatAmount(product.price)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={product.quantity}
|
||||
onChange={(e) => handleReplacementQuantityChange(product.productId, Number(e.target.value))}
|
||||
className="w-16 px-2 py-1 border border-gray-300 rounded-md shadow-sm text-center focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemoveReplacementProduct(product.productId)}
|
||||
className="ml-2 p-1 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<span className="text-sm">替换产品总价:</span>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
||||
{formatAmount(selectedReplacementProducts.reduce((sum, p) => sum + (p.price * p.quantity), 0))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染客户信息卡片
|
||||
*/
|
||||
const renderCustomerCard = () => {
|
||||
return (
|
||||
<Card title="客户信息" padding="medium" className="h-full flex flex-col">
|
||||
<div className="grid grid-cols-1 gap-4 flex-grow">
|
||||
<div className="flex items-center">
|
||||
<MdPerson className="text-blue-500 mr-2" size={20} />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">客户名称</div>
|
||||
<div className="font-medium">{salesRecord.customerName || '未知客户'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MdPhone className="text-green-500 mr-2" size={20} />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">联系电话</div>
|
||||
<div className="font-medium">{salesRecord.customerPhone || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MdShoppingCart className="text-purple-500 mr-2" size={20} />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">商品数量</div>
|
||||
<div className="font-medium">
|
||||
{salesRecord.products ? salesRecord.products.reduce((sum, p) => sum + p.quantity, 0) : 0} 件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MdAttachMoney className="text-yellow-500 mr-2" size={20} />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">订单总额</div>
|
||||
<div className="font-medium">
|
||||
{formatAmount(salesRecord.receivable)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 订单信息 */}
|
||||
<div className="flex items-center">
|
||||
<MdCalendarToday className="text-orange-500 mr-2" size={20} />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">成交日期</div>
|
||||
<div className="font-medium">{formatFriendlyDate(salesRecord.dealDate)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导购信息 */}
|
||||
<div className="flex items-center">
|
||||
<MdPerson className="text-indigo-500 mr-2" size={20} />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">导购信息</div>
|
||||
<div className="font-medium">{salesRecord.guideName || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 总计信息 */}
|
||||
{selectedOriginalProducts.length > 0 && (
|
||||
<div className="mt-auto pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center font-medium">
|
||||
<span>已选商品总价:</span>
|
||||
<span className="text-lg text-blue-600 dark:text-blue-400">
|
||||
{formatAmount(selectedOriginalProducts.reduce((sum, p) => sum + (p.price * p.selected), 0))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 换货时显示差价 */}
|
||||
{operationType === OperationType.EXCHANGE && selectedReplacementProducts.length > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between items-center text-sm mt-2">
|
||||
<span>替换商品总价:</span>
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
{formatAmount(selectedReplacementProducts.reduce((sum, p) => sum + (p.price * p.quantity), 0))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center font-medium mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<span>差额:</span>
|
||||
<span className={`text-lg ${priceDifference >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{formatAmount(priceDifference)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算价格差额
|
||||
*/
|
||||
const priceDifference = selectedReplacementProducts.reduce((sum, p) => sum + (p.price * p.quantity), 0) -
|
||||
selectedOriginalProducts.reduce((sum, p) => sum + (p.price * p.selected), 0);
|
||||
|
||||
/**
|
||||
* 渲染操作信息卡片,根据不同操作类型显示不同内容
|
||||
*/
|
||||
const renderOperationCard = () => {
|
||||
const operationName = OperationNames[operationType];
|
||||
|
||||
// 根据操作类型添加特定字段
|
||||
const specificInfo: OperationInfoItem[] = [];
|
||||
|
||||
switch (operationType) {
|
||||
case OperationType.RETURN:
|
||||
specificInfo.push({ label: '支付方式', value: salesRecord.platformName || '-' });
|
||||
break;
|
||||
case OperationType.EXCHANGE:
|
||||
specificInfo.push({ label: '导购', value: salesRecord.guideName || '-' });
|
||||
break;
|
||||
case OperationType.REFUND:
|
||||
specificInfo.push({
|
||||
label: '实收金额',
|
||||
value: formatAmount(salesRecord.received),
|
||||
valueClass: 'font-medium text-green-600 dark:text-green-400'
|
||||
});
|
||||
break;
|
||||
case OperationType.REPLENISH:
|
||||
specificInfo.push({ label: '店铺', value: salesRecord.dealShopName || '-' });
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title={`${operationName}信息`} padding="medium">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 售后处理日期 */}
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">处理日期</div>
|
||||
<input
|
||||
type="date"
|
||||
name="date"
|
||||
value={formData.date}
|
||||
onChange={handleFormChange}
|
||||
className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 售后处理状态 */}
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">处理状态</div>
|
||||
<select
|
||||
name="progress"
|
||||
value={formData.progress}
|
||||
onChange={handleFormChange}
|
||||
className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value={AfterSalesProgress.PENDING}>{AfterSalesProgress.PENDING}</option>
|
||||
<option value={AfterSalesProgress.PROCESSING}>{AfterSalesProgress.PROCESSING}</option>
|
||||
<option value={AfterSalesProgress.COMPLETED}>{AfterSalesProgress.COMPLETED}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 收支类型 */}
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">交易类型</div>
|
||||
<select
|
||||
name="transactionType"
|
||||
value={formData.transactionType}
|
||||
onChange={handleFormChange}
|
||||
className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">请选择交易类型</option>
|
||||
<option value={TransactionType.INCOME}>{TransactionType.INCOME}</option>
|
||||
<option value={TransactionType.EXPENSE}>{TransactionType.EXPENSE}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 金额信息 */}
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{!formData.transactionType ? '金额' :
|
||||
formData.transactionType === TransactionType.INCOME ? '收入金额' : '支出金额'}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
name="amount"
|
||||
value={formData.amount}
|
||||
onChange={handleFormChange}
|
||||
className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
placeholder={!formData.transactionType ? "请先选择交易类型" : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 售后原因 */}
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{operationName}原因
|
||||
</div>
|
||||
<textarea
|
||||
name="reason"
|
||||
value={formData.reason}
|
||||
onChange={handleFormChange}
|
||||
rows={3}
|
||||
className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
placeholder={`请输入${operationName}原因...`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 选择相关产品 */}
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex justify-between">
|
||||
<span>选择{operationName}商品</span>
|
||||
<span className="text-blue-500">{selectedOriginalProducts.length > 0 ? `已选${selectedOriginalProducts.length}件商品` : ''}</span>
|
||||
</div>
|
||||
{salesRecord.products && salesRecord.products.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[280px] overflow-y-auto p-2 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
{salesRecord.products.map(product => (
|
||||
renderProductItem(
|
||||
product,
|
||||
selectedOriginalProducts.find(p => p.productId === product.productId)?.selected || 0
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500">没有可选的产品</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 换货类型时,显示替换产品选择器 */}
|
||||
{operationType === OperationType.EXCHANGE && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
选择替换产品
|
||||
</div>
|
||||
<ReplacementProductSelector onSelectProduct={handleReplacementProductSelect} />
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex justify-between">
|
||||
<span>已选择的替换产品</span>
|
||||
<span className="text-blue-500">
|
||||
{selectedReplacementProducts.length > 0 ? `${selectedReplacementProducts.length}件产品` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{renderReplacementProducts()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">备注</div>
|
||||
<textarea
|
||||
name="remark"
|
||||
value={formData.remark}
|
||||
onChange={handleFormChange}
|
||||
rows={2}
|
||||
className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
placeholder="其他说明..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理提交表单
|
||||
*/
|
||||
const handleSubmit = () => {
|
||||
// 检查是否选择了商品
|
||||
if (selectedOriginalProducts.length === 0) {
|
||||
alert('请选择至少一件商品');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否选择了交易类型
|
||||
if (!formData.transactionType) {
|
||||
alert('请选择交易类型');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是换货类型,检查是否选择了替换产品
|
||||
if (operationType === OperationType.EXCHANGE && selectedReplacementProducts.length === 0) {
|
||||
alert('请选择替换产品');
|
||||
return;
|
||||
}
|
||||
|
||||
// 这里可以添加表单验证逻辑
|
||||
// 然后提交到API
|
||||
console.log('提交售后记录:', {
|
||||
...formData,
|
||||
priceDifference: operationType === OperationType.EXCHANGE ? priceDifference : undefined
|
||||
});
|
||||
|
||||
// 关闭模态框
|
||||
onClose();
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染模态框页脚
|
||||
*/
|
||||
const renderFooter = () => {
|
||||
return (
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-md text-gray-700 bg-gray-200 hover:bg-gray-300 dark:text-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 rounded-md text-white bg-blue-500 hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`创建${OperationNames[operationType]}记录`}
|
||||
size="xl"
|
||||
footer={renderFooter()}
|
||||
width="80%"
|
||||
className="max-w-[1400px]"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-1">
|
||||
{renderCustomerCard()}
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
{renderOperationCard()}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesRecordModal;
|
||||
598
src/app/team/[teamCode]/sales-records/components/ShipModal.tsx
Normal file
598
src/app/team/[teamCode]/sales-records/components/ShipModal.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* 销售记录发货模态框组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供销售记录的发货信息录入界面
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { useNotification } from '@/components/ui/Notification';
|
||||
import { useTheme } from '@/hooks';
|
||||
import { SalesRecord } from '@/models/team/types/old/sales';
|
||||
import { MdLocalShipping, MdPerson, MdPhone, MdShoppingCart, MdArticle } from 'react-icons/md';
|
||||
|
||||
/**
|
||||
* 扩展的销售记录接口,使用与页面一致的类型
|
||||
*/
|
||||
interface SalesRecordWithDetails extends Omit<SalesRecord, 'products'> {
|
||||
products?: {
|
||||
productId: number;
|
||||
productName?: string;
|
||||
productCode?: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
brandName?: string;
|
||||
categoryName?: string;
|
||||
supplierName?: string;
|
||||
image?: string;
|
||||
}[];
|
||||
// 客户信息
|
||||
customerName?: string;
|
||||
customerPhone?: string;
|
||||
customerFollowDate?: string;
|
||||
customerAddress?: string;
|
||||
// 其他信息省略...
|
||||
}
|
||||
|
||||
// 定义物流记录产品的接口
|
||||
interface LogisticsProductItem {
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 物流记录接口
|
||||
*/
|
||||
interface LogisticsRecord {
|
||||
id: number;
|
||||
tracking_number: string;
|
||||
is_queryable: boolean;
|
||||
customer_tail_number: string;
|
||||
company: string;
|
||||
status?: string;
|
||||
record_id: number;
|
||||
record_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
products?: LogisticsProductItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 物流公司选项
|
||||
*/
|
||||
const LOGISTICS_COMPANIES = [
|
||||
{ value: '顺丰速运', label: '顺丰速运' },
|
||||
{ value: '中通快递', label: '中通快递' },
|
||||
{ value: '圆通速递', label: '圆通速递' },
|
||||
{ value: '韵达快递', label: '韵达快递' },
|
||||
{ value: '申通快递', label: '申通快递' },
|
||||
{ value: '百世快递', label: '百世快递' },
|
||||
{ value: 'EMS', label: 'EMS' },
|
||||
{ value: '京东物流', label: '京东物流' },
|
||||
{ value: '天天快递', label: '天天快递' },
|
||||
{ value: '邮政快递包裹', label: '邮政快递包裹' },
|
||||
{ value: '其他', label: '其他' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 物流记录表单数据接口
|
||||
*/
|
||||
interface LogisticsFormData {
|
||||
trackingNumber: string;
|
||||
company: string;
|
||||
customerTailNumber: string;
|
||||
isQueryable: boolean;
|
||||
selectedProducts: {
|
||||
productId: number;
|
||||
quantity: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件Props接口
|
||||
*/
|
||||
interface ShipModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
salesRecord: SalesRecordWithDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额显示
|
||||
*/
|
||||
const formatAmount = (amount: number | undefined) => {
|
||||
if (amount === undefined || amount === null) return '¥0.00';
|
||||
return `¥${Number(amount).toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 销售记录发货模态框组件
|
||||
*/
|
||||
const ShipModal: React.FC<ShipModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
salesRecord
|
||||
}) => {
|
||||
const notification = useNotification();
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// 物流记录加载状态
|
||||
const [isCheckingLogistics, setIsCheckingLogistics] = useState(false);
|
||||
const [existingLogistics, setExistingLogistics] = useState<LogisticsRecord | null>(null);
|
||||
const [modalTitle, setModalTitle] = useState("发货处理");
|
||||
|
||||
// 提取客户手机尾号
|
||||
const getCustomerTailNumber = useCallback(() => {
|
||||
if (!salesRecord.customerPhone) return '';
|
||||
if (salesRecord.customerPhone.length >= 4) {
|
||||
return salesRecord.customerPhone.slice(-4);
|
||||
}
|
||||
return salesRecord.customerPhone;
|
||||
}, [salesRecord.customerPhone]);
|
||||
|
||||
// 物流表单状态
|
||||
const [formData, setFormData] = useState<LogisticsFormData>({
|
||||
trackingNumber: '',
|
||||
company: LOGISTICS_COMPANIES[0].value,
|
||||
customerTailNumber: getCustomerTailNumber(),
|
||||
isQueryable: true,
|
||||
selectedProducts: salesRecord.products
|
||||
? salesRecord.products.map(p => ({ productId: p.productId, quantity: p.quantity }))
|
||||
: []
|
||||
});
|
||||
|
||||
// 表单错误状态
|
||||
const [errors, setErrors] = useState({
|
||||
trackingNumber: '',
|
||||
company: '',
|
||||
customerTailNumber: ''
|
||||
});
|
||||
|
||||
// 加载状态
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* 检查是否已有物流记录
|
||||
*/
|
||||
const checkExistingLogistics = useCallback(async () => {
|
||||
if (!isOpen || !salesRecord.id) return;
|
||||
|
||||
setIsCheckingLogistics(true);
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/logistics?recordId=${salesRecord.id}&recordType=SalesRecord`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.records && data.records.length > 0) {
|
||||
setExistingLogistics(data.records[0]);
|
||||
setModalTitle("编辑物流信息");
|
||||
|
||||
// 填充表单数据
|
||||
const logistics = data.records[0];
|
||||
setFormData({
|
||||
trackingNumber: logistics.tracking_number || '',
|
||||
company: logistics.company || LOGISTICS_COMPANIES[0].value,
|
||||
customerTailNumber: logistics.customer_tail_number || getCustomerTailNumber(),
|
||||
isQueryable: logistics.is_queryable !== undefined ? logistics.is_queryable : true,
|
||||
selectedProducts: logistics.products
|
||||
? logistics.products.map((p: LogisticsProductItem) => ({
|
||||
productId: p.product_id,
|
||||
quantity: p.quantity
|
||||
}))
|
||||
: salesRecord.products
|
||||
? salesRecord.products.map(p => ({ productId: p.productId, quantity: p.quantity }))
|
||||
: []
|
||||
});
|
||||
} else {
|
||||
setExistingLogistics(null);
|
||||
setModalTitle("发货处理");
|
||||
|
||||
// 重置表单
|
||||
setFormData({
|
||||
trackingNumber: '',
|
||||
company: LOGISTICS_COMPANIES[0].value,
|
||||
customerTailNumber: getCustomerTailNumber(),
|
||||
isQueryable: true,
|
||||
selectedProducts: salesRecord.products
|
||||
? salesRecord.products.map(p => ({ productId: p.productId, quantity: p.quantity }))
|
||||
: []
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查物流记录失败:', error);
|
||||
} finally {
|
||||
setIsCheckingLogistics(false);
|
||||
}
|
||||
}, [isOpen, salesRecord, teamCode, getCustomerTailNumber]);
|
||||
|
||||
// 当模态框打开时检查是否已有物流记录
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
checkExistingLogistics();
|
||||
}
|
||||
}, [isOpen, checkExistingLogistics]);
|
||||
|
||||
/**
|
||||
* 处理表单字段变更
|
||||
*/
|
||||
const handleFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checkbox = e.target as HTMLInputElement;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: checkbox.checked
|
||||
}));
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
|
||||
// 清除错误
|
||||
if (name in errors) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理产品数量变更
|
||||
*/
|
||||
const handleProductQuantityChange = (productId: number, quantity: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedProducts: prev.selectedProducts.map(p =>
|
||||
p.productId === productId ? { ...p, quantity } : p
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理表单验证
|
||||
*/
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors = {
|
||||
trackingNumber: '',
|
||||
company: '',
|
||||
customerTailNumber: ''
|
||||
};
|
||||
|
||||
if (!formData.trackingNumber.trim()) {
|
||||
newErrors.trackingNumber = '请输入物流单号';
|
||||
}
|
||||
|
||||
if (!formData.company) {
|
||||
newErrors.company = '请选择物流公司';
|
||||
}
|
||||
|
||||
if (!formData.customerTailNumber) {
|
||||
newErrors.customerTailNumber = '请输入客户手机尾号';
|
||||
} else if (!/^\d{4}$/.test(formData.customerTailNumber)) {
|
||||
newErrors.customerTailNumber = '尾号必须是4位数字';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return !Object.values(newErrors).some(error => error);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理提交发货信息
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 构建请求数据
|
||||
const requestData = {
|
||||
salesRecordId: salesRecord.id,
|
||||
trackingNumber: formData.trackingNumber,
|
||||
company: formData.company,
|
||||
customerTailNumber: formData.customerTailNumber,
|
||||
isQueryable: formData.isQueryable,
|
||||
products: formData.selectedProducts
|
||||
};
|
||||
|
||||
// 发送API请求
|
||||
const response = await fetch(`/api/team/${teamCode}/logistics`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('发货失败,请稍后重试');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
notification.success(data.message || '物流信息已成功记录', {
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
// 关闭模态框
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('提交发货信息失败:', error);
|
||||
notification.error(error instanceof Error ? error.message : '发货失败,请稍后重试', {
|
||||
duration: 4000
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染产品列表
|
||||
*/
|
||||
const renderProductsList = () => {
|
||||
if (!salesRecord.products || salesRecord.products.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
没有产品信息
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto pr-2">
|
||||
{salesRecord.products.map((product, index) => {
|
||||
const selectedProduct = formData.selectedProducts.find(p => p.productId === product.productId);
|
||||
const quantity = selectedProduct ? selectedProduct.quantity : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center p-2 border rounded-md ${
|
||||
isDarkMode ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{product.productName || '未命名产品'}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{product.productCode ? `编码: ${product.productCode}` : ''}
|
||||
{product.brandName ? ` | 品牌: ${product.brandName}` : ''}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
单价: {formatAmount(product.price)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<label className="text-sm mr-2">数量:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={product.quantity}
|
||||
value={quantity}
|
||||
onChange={(e) => handleProductQuantityChange(product.productId, parseInt(e.target.value) || 0)}
|
||||
className={`w-16 p-1 text-center border rounded ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-700 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-700'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染模态框底部按钮
|
||||
*/
|
||||
const renderFooter = () => {
|
||||
return (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
|
||||
isLoading
|
||||
? 'bg-blue-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
} text-white`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
'处理中...'
|
||||
) : (
|
||||
<>
|
||||
{existingLogistics ? '保存修改' : '确认发货'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 显示加载中
|
||||
if (isCheckingLogistics) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="加载中..."
|
||||
footer={null}
|
||||
size="lg"
|
||||
>
|
||||
<div className="flex justify-center items-center p-10">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<div className="flex items-center text-lg">
|
||||
<MdLocalShipping className="mr-2 text-blue-500" size={22} />
|
||||
<span>{modalTitle}</span>
|
||||
</div>
|
||||
}
|
||||
footer={renderFooter()}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 客户信息卡片 */}
|
||||
<div className={`p-3 rounded-md ${isDarkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||
<div className="flex items-center mb-2">
|
||||
<MdPerson className="mr-2 text-blue-500" size={18} />
|
||||
<h3 className="font-medium">客户信息</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">姓名: </span>
|
||||
<span>{salesRecord.customerName || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MdPhone className="mr-1 text-green-500" size={16} />
|
||||
<span className="text-gray-500 dark:text-gray-400">电话: </span>
|
||||
<span>{salesRecord.customerPhone || '-'}</span>
|
||||
</div>
|
||||
{salesRecord.customerAddress && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500 dark:text-gray-400">地址: </span>
|
||||
<span>{salesRecord.customerAddress}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 发货表单 */}
|
||||
<div className={`p-4 rounded-md ${isDarkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||
<div className="flex items-center mb-3">
|
||||
<MdLocalShipping className="mr-2 text-green-500" size={18} />
|
||||
<h3 className="font-medium">物流信息</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="flex items-center text-sm font-medium mb-1">
|
||||
<MdArticle className="mr-1 text-blue-500" size={16} />
|
||||
物流单号 <span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="trackingNumber"
|
||||
value={formData.trackingNumber}
|
||||
onChange={handleFormChange}
|
||||
placeholder="请输入物流单号"
|
||||
className={`w-full p-2 border rounded-md ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-700 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-700'
|
||||
} ${errors.trackingNumber ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{errors.trackingNumber && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.trackingNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
物流公司 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="company"
|
||||
value={formData.company}
|
||||
onChange={handleFormChange}
|
||||
className={`w-full p-2 border rounded-md ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-700 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-700'
|
||||
} ${errors.company ? 'border-red-500' : ''}`}
|
||||
>
|
||||
{LOGISTICS_COMPANIES.map((company, index) => (
|
||||
<option key={index} value={company.value}>{company.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.company && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.company}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center text-sm font-medium mb-1">
|
||||
<MdPhone className="mr-1 text-purple-500" size={16} />
|
||||
客户手机尾号 <span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="customerTailNumber"
|
||||
value={formData.customerTailNumber}
|
||||
onChange={handleFormChange}
|
||||
placeholder="请输入客户手机尾号(后4位)"
|
||||
maxLength={4}
|
||||
className={`w-full p-2 border rounded-md ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-700 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-700'
|
||||
} ${errors.customerTailNumber ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{errors.customerTailNumber && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.customerTailNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isQueryable"
|
||||
name="isQueryable"
|
||||
checked={formData.isQueryable}
|
||||
onChange={handleFormChange}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="isQueryable" className="ml-2 flex items-center text-sm">
|
||||
查询物流
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品列表 */}
|
||||
<div className={`p-4 rounded-md ${isDarkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||
<div className="flex items-center mb-3">
|
||||
<MdShoppingCart className="mr-2 text-purple-500" size={18} />
|
||||
<h3 className="font-medium">发货产品</h3>
|
||||
</div>
|
||||
{renderProductsList()}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShipModal;
|
||||
974
src/app/team/[teamCode]/sales-records/page.module.css
Normal file
974
src/app/team/[teamCode]/sales-records/page.module.css
Normal file
@@ -0,0 +1,974 @@
|
||||
/*
|
||||
* 销售记录列表页面样式
|
||||
* 作者: 阿瑞
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
/* 页面容器 */
|
||||
.pageContainer {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.5rem 1rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.pageContainer {
|
||||
padding: 1rem 1rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.pageTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 搜索栏容器 */
|
||||
.searchContainer {
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.searchContainer.light {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.searchContainer.dark {
|
||||
background-color: rgba(31, 41, 55, 0.7);
|
||||
}
|
||||
|
||||
/* 搜索表单 */
|
||||
.searchForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* 搜索行 */
|
||||
.searchRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.searchRow {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索输入框容器 */
|
||||
.searchInputContainer {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 搜索输入框 */
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.searchInput.light {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.searchInput.light:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.searchInput.dark {
|
||||
background-color: #374151;
|
||||
color: #f3f4f6;
|
||||
border: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.searchInput.dark:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 搜索图标 */
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 0.625rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 过滤器组 */
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 日期输入框 */
|
||||
.dateInput {
|
||||
width: 140px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.dateInput.light {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.dateInput.light:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.dateInput.dark {
|
||||
background-color: #374151;
|
||||
color: #f3f4f6;
|
||||
border: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.dateInput.dark:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 选择框 */
|
||||
.select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.select.light {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.select.light:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.select.dark {
|
||||
background-color: #374151;
|
||||
color: #f3f4f6;
|
||||
border: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.select.dark:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 搜索按钮 */
|
||||
.searchButton {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.searchButton:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* 次要按钮 */
|
||||
.secondaryButton {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.secondaryButton.light {
|
||||
background-color: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.secondaryButton.light:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.secondaryButton.dark {
|
||||
background-color: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.secondaryButton.dark:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
/* 添加按钮 */
|
||||
.addButton {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.addButton:hover {
|
||||
background-color: #059669;
|
||||
}
|
||||
|
||||
/* 高级筛选容器 */
|
||||
.advancedFiltersContainer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.advancedFiltersContainer.light {
|
||||
border-top-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.advancedFiltersContainer.dark {
|
||||
border-top-color: #4b5563;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.advancedFiltersContainer {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.advancedFiltersContainer {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 筛选项容器 */
|
||||
.filterItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 筛选项标签 */
|
||||
.filterLabel {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 快速筛选按钮组 */
|
||||
.quickFilterButtons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 快速筛选按钮 */
|
||||
.quickFilterButton {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.quickFilterButton.light {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.quickFilterButton.light:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.quickFilterButton.dark {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.quickFilterButton.dark:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.errorMessage {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left-width: 4px;
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.errorMessage.light {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.errorMessage.dark {
|
||||
background-color: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* 加载指示器容器 */
|
||||
.loaderContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
/* 加载指示器 */
|
||||
.loader {
|
||||
animation: spin 1s linear infinite;
|
||||
border-radius: 9999px;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态容器 */
|
||||
.emptyState {
|
||||
padding: 2rem;
|
||||
border-radius: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyState.light {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.emptyState.dark {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
/* 空状态文本 */
|
||||
.emptyStateText {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.emptyStateText.dark {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 表格容器 */
|
||||
.tableContainer {
|
||||
overflow-x: auto;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.table {
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table.light {
|
||||
color: #374151;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.table.dark {
|
||||
color: #d1d5db;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
/* 表头 */
|
||||
.tableHead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tableHead.light {
|
||||
background-color: #f3f4f6;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tableHead.dark {
|
||||
background-color: #1f2937;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 表头单元格 */
|
||||
.tableHeadCell {
|
||||
font-weight: 600;
|
||||
padding: 1rem 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 1.15rem;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
border-bottom: 2px solid;
|
||||
}
|
||||
|
||||
.tableRow.light .tableHeadCell {
|
||||
border-bottom-color: #d1d5db;
|
||||
}
|
||||
|
||||
.tableRow.dark .tableHeadCell {
|
||||
border-bottom-color: #4b5563;
|
||||
}
|
||||
|
||||
/* 表格行 */
|
||||
.tableRow {
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.tableRow.light:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.tableRow.dark:hover {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
/* 表格数据单元格 */
|
||||
.tableCell {
|
||||
padding: 1rem 0.75rem;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
|
||||
.tableCell:last-child {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.tableRow.light .tableCell {
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.tableRow.dark .tableCell {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
/* 客户信息列 */
|
||||
.infoColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 信息项带图标 */
|
||||
.infoItemWithIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* 信息项带图标 - 蓝色 */
|
||||
.iconBlue {
|
||||
color: #3b82f6;
|
||||
margin-right: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 信息项带图标 - 绿色 */
|
||||
.iconGreen {
|
||||
color: #10b981;
|
||||
margin-right: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 信息项带图标 - 橙色 */
|
||||
.iconOrange {
|
||||
color: #f59e0b;
|
||||
margin-right: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 信息项带图标 - 黄色 */
|
||||
.iconYellow {
|
||||
color: #eab308;
|
||||
margin-right: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 信息项带图标 - 灰色 */
|
||||
.iconGray {
|
||||
color: #6b7280;
|
||||
margin-right: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 信息标签 */
|
||||
.infoLabel {
|
||||
font-weight: 600;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* 信息内容 */
|
||||
.infoContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 1.25rem;
|
||||
}
|
||||
|
||||
/* 下级信息项 */
|
||||
.subInfoItem {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subInfoItem.light {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.subInfoItem.dark {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 产品列表容器 */
|
||||
.productListContainer {
|
||||
margin-left: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 产品列表滚动区域 - 旧版列表样式,保留以便兼容 */
|
||||
.productListScroll {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* 产品网格布局滚动区域 - 新版网格样式 */
|
||||
.productGridScroll {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
gap: 0.5rem;
|
||||
max-height: unset;
|
||||
padding: 0.25rem;
|
||||
padding-bottom: 0.5rem;
|
||||
scrollbar-width: thin;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.productGridScroll::-webkit-scrollbar {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.productGridScroll::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* 产品项 */
|
||||
.productItem {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.productItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.productItem.light {
|
||||
border-bottom-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.productItem.dark {
|
||||
border-bottom-color: #4b5563;
|
||||
}
|
||||
|
||||
/* 产品行 */
|
||||
.productRow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 产品图片容器 */
|
||||
.productImageContainer {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 产品图片占位符 */
|
||||
.productImagePlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.productImagePlaceholder.light {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.productImagePlaceholder.dark {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
/* 产品信息容器 */
|
||||
.productInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding-left: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 产品名称 */
|
||||
.productName {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* 产品详情网格 */
|
||||
.productDetailsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.productDetailsGrid.light {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.productDetailsGrid.dark {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 产品详情标签 */
|
||||
.productDetailLabel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 产品详情值 */
|
||||
.productDetailValue {
|
||||
margin-left: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 备注内容区域 */
|
||||
.remarkContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* 备注文本 */
|
||||
.remarkText {
|
||||
font-size: 0.85rem;
|
||||
margin-left: 1.25rem;
|
||||
line-height: 1.4;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.remarkText.dark {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.remarkText.light {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* 操作按钮容器 */
|
||||
.actionsColumn {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 公共操作按钮样式 */
|
||||
.actionButton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
min-width: 56px;
|
||||
height: 54px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.actionButton svg {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* 查看按钮 */
|
||||
.viewButton {
|
||||
background-color: #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.viewButton:hover {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.viewButton.dark {
|
||||
background-color: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.viewButton.dark:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
/* 编辑按钮 */
|
||||
.editButton {
|
||||
background-color: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.editButton:hover {
|
||||
background-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.editButton.dark {
|
||||
background-color: rgba(29, 78, 216, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.editButton.dark:hover {
|
||||
background-color: rgba(29, 78, 216, 0.3);
|
||||
}
|
||||
|
||||
/* 退货按钮 */
|
||||
.returnButton {
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.returnButton:hover {
|
||||
background-color: #fecaca;
|
||||
}
|
||||
|
||||
.returnButton.dark {
|
||||
background-color: rgba(220, 38, 38, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.returnButton.dark:hover {
|
||||
background-color: rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
/* 换货按钮 */
|
||||
.exchangeButton {
|
||||
background-color: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.exchangeButton:hover {
|
||||
background-color: #fde68a;
|
||||
}
|
||||
|
||||
.exchangeButton.dark {
|
||||
background-color: rgba(217, 119, 6, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.exchangeButton.dark:hover {
|
||||
background-color: rgba(217, 119, 6, 0.3);
|
||||
}
|
||||
|
||||
/* 补货按钮 */
|
||||
.replenishButton {
|
||||
background-color: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.replenishButton:hover {
|
||||
background-color: #a7f3d0;
|
||||
}
|
||||
|
||||
.replenishButton.dark {
|
||||
background-color: rgba(5, 150, 105, 0.2);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.replenishButton.dark:hover {
|
||||
background-color: rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
/* 价差按钮 */
|
||||
.priceDiffButton {
|
||||
background-color: #e0e7ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.priceDiffButton:hover {
|
||||
background-color: #c7d2fe;
|
||||
}
|
||||
|
||||
.priceDiffButton.dark {
|
||||
background-color: rgba(79, 70, 229, 0.2);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
.priceDiffButton.dark:hover {
|
||||
background-color: rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
/* 发货按钮 */
|
||||
.shipButton {
|
||||
background-color: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.shipButton:hover {
|
||||
background-color: #ddd6fe;
|
||||
}
|
||||
|
||||
.shipButton.dark {
|
||||
background-color: rgba(124, 58, 237, 0.2);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.shipButton.dark:hover {
|
||||
background-color: rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
/* 分页容器 */
|
||||
.paginationContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* 分页按钮 */
|
||||
.paginationButton {
|
||||
padding: 0.25rem 0.75rem;
|
||||
margin: 0 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.paginationButton.light {
|
||||
background-color: #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.paginationButton.light:hover:not(:disabled) {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.paginationButton.dark {
|
||||
background-color: #374151;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.paginationButton.dark:hover:not(:disabled) {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
/* 当前页按钮 */
|
||||
.paginationButtonCurrent {
|
||||
padding: 0.25rem 0.75rem;
|
||||
margin: 0 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 表格响应式调整 */
|
||||
@media (max-width: 1280px) {
|
||||
.tableContainer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
min-width: 1200px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
font-size: 0.65rem;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.productGridScroll {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.actionButton {
|
||||
font-size: 0.6rem;
|
||||
height: 48px;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.productGridScroll {
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
max-height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 客户信息中的产品汇总信息 */
|
||||
.productSummaryInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.productSummaryInfo.light {
|
||||
border-top-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.productSummaryInfo.dark {
|
||||
border-top-color: #4b5563;
|
||||
}
|
||||
|
||||
/* 内联总价显示 */
|
||||
.productTotalInline {
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.productTotalInline.dark {
|
||||
color: #60a5fa;
|
||||
}
|
||||
861
src/app/team/[teamCode]/sales-records/page.tsx
Normal file
861
src/app/team/[teamCode]/sales-records/page.tsx
Normal file
@@ -0,0 +1,861 @@
|
||||
/**
|
||||
* 销售记录列表页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供销售记录数据的展示和查询功能
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useTeam } from '@/hooks/useTeam';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { useAccessToken } from '@/store/userStore';
|
||||
import { ThemeMode, PaymentTypeNames, PaymentTypeColors } from '@/types/enum';
|
||||
import {
|
||||
MdAdd,
|
||||
MdSearch,
|
||||
MdRefresh,
|
||||
MdPerson,
|
||||
MdCalendarMonth,
|
||||
MdPayment,
|
||||
MdStore,
|
||||
MdEdit,
|
||||
MdOutlineAssignment,
|
||||
MdCompareArrows,
|
||||
MdAddShoppingCart,
|
||||
MdAttachMoney,
|
||||
MdLocalShipping
|
||||
} from 'react-icons/md';
|
||||
import { SalesRecord, PaymentType, SalesRecordProduct } from '@/models/team/types/old/sales';
|
||||
import { formatDate } from '@/utils';
|
||||
import styles from './page.module.css';
|
||||
import ProductInfoModal from './ProductInfoModal';
|
||||
import SalesRecordModal from './components/SalesRecordModal';
|
||||
import ShipModal from './components/ShipModal';
|
||||
import { OperationType } from './components/SalesRecordModal';
|
||||
|
||||
/**
|
||||
* 扩展的销售记录产品接口,包含API返回的额外字段
|
||||
*/
|
||||
interface SalesRecordProductWithDetails extends SalesRecordProduct {
|
||||
productName?: string;
|
||||
productCode?: string;
|
||||
productSku?: string;
|
||||
brandId?: number;
|
||||
brandName?: string;
|
||||
categoryId?: number;
|
||||
categoryName?: string;
|
||||
supplierId?: number;
|
||||
supplierName?: string;
|
||||
image?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展的销售记录接口,使用扩展的产品接口
|
||||
*/
|
||||
interface SalesRecordWithDetails extends Omit<SalesRecord, 'products'> {
|
||||
products?: SalesRecordProductWithDetails[];
|
||||
// 客户信息
|
||||
customerName?: string;
|
||||
customerPhone?: string;
|
||||
customerFollowDate?: string;
|
||||
customerAddress?: string;
|
||||
// 店铺信息
|
||||
sourceName?: string;
|
||||
sourceWechat?: string;
|
||||
sourcePhone?: string;
|
||||
sourceAccountNo?: string;
|
||||
// 成交店铺信息
|
||||
dealShopId?: number;
|
||||
dealShopName?: string;
|
||||
dealShopWechat?: string;
|
||||
dealShopPhone?: string;
|
||||
dealShopAccountNo?: string;
|
||||
// 导购信息
|
||||
guideName?: string;
|
||||
guideUsername?: string;
|
||||
guideId?: number;
|
||||
guidePhone?: string;
|
||||
guideWechat?: string;
|
||||
// 支付平台信息
|
||||
platformName?: string;
|
||||
// 订单信息
|
||||
remark?: string;
|
||||
// 物流信息
|
||||
logisticsStatus?: string;
|
||||
logisticsTrackingNumber?: string;
|
||||
logisticsCompany?: string;
|
||||
logisticsCustomerTailNumber?: string;
|
||||
logisticsIsQueryable?: boolean;
|
||||
logisticsDetails?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销售记录列表页面组件
|
||||
*/
|
||||
export default function SalesRecordsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const { currentTeam } = useTeam();
|
||||
const accessToken = useAccessToken();
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
// 销售记录数据状态
|
||||
const [salesRecords, setSalesRecords] = useState<SalesRecordWithDetails[]>([]);
|
||||
const [totalRecords, setTotalRecords] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 搜索和筛选状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [filterPaymentType, setFilterPaymentType] = useState<string>('');
|
||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
// 操作模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedRecord, setSelectedRecord] = useState<SalesRecordWithDetails | null>(null);
|
||||
const [operationType, setOperationType] = useState<OperationType>(OperationType.RETURN);
|
||||
|
||||
// 发货模态框状态
|
||||
const [shipModalOpen, setShipModalOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* 获取销售记录列表数据
|
||||
*/
|
||||
const fetchSalesRecords = useCallback(async () => {
|
||||
if (!currentTeam || !teamCode) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 构建查询参数
|
||||
const queryParams = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
|
||||
if (searchKeyword) queryParams.append('keyword', searchKeyword);
|
||||
if (startDate) queryParams.append('startDate', startDate);
|
||||
if (endDate) queryParams.append('endDate', endDate);
|
||||
if (filterPaymentType) queryParams.append('paymentType', filterPaymentType);
|
||||
|
||||
const response = await fetch(`/api/team/${teamCode}/sales-records?${queryParams.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取销售记录列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 调试输出,查看API返回的数据结构
|
||||
console.log('API返回的销售记录数据:', data.salesRecords);
|
||||
|
||||
setSalesRecords(data.salesRecords || []);
|
||||
setTotalRecords(data.total || 0);
|
||||
} catch (err) {
|
||||
console.error('获取销售记录列表失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTeam, teamCode, accessToken, currentPage, pageSize, searchKeyword, startDate, endDate, filterPaymentType]);
|
||||
|
||||
/**
|
||||
* 初始加载和依赖变化时获取数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (accessToken && teamCode) {
|
||||
fetchSalesRecords();
|
||||
}
|
||||
}, [accessToken, teamCode, currentPage, fetchSalesRecords]);
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
fetchSalesRecords();
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空筛选条件
|
||||
*/
|
||||
const handleClearFilters = () => {
|
||||
setSearchKeyword('');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setFilterPaymentType('');
|
||||
setCurrentPage(1);
|
||||
fetchSalesRecords();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理高级筛选切换
|
||||
*/
|
||||
const toggleAdvancedFilter = () => {
|
||||
setIsAdvancedFilterOpen(!isAdvancedFilterOpen);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理添加销售记录按钮点击
|
||||
*/
|
||||
const handleAddSalesRecord = () => {
|
||||
router.push(`/team/${teamCode}/sales`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理编辑销售记录
|
||||
*/
|
||||
const handleEditSalesRecord = (salesRecord: SalesRecordWithDetails) => {
|
||||
router.push(`/team/${teamCode}/sales/${salesRecord.id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理页码变更
|
||||
*/
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算总页数
|
||||
*/
|
||||
const totalPages = Math.ceil(totalRecords / pageSize);
|
||||
|
||||
/**
|
||||
* 格式化金额显示
|
||||
*/
|
||||
const formatAmount = (amount: number | undefined) => {
|
||||
if (amount === undefined || amount === null) return '¥0.00';
|
||||
// 确保 amount 是数字类型
|
||||
const numAmount = Number(amount);
|
||||
// 检查是否为有效数字
|
||||
if (isNaN(numAmount)) return '¥0.00';
|
||||
return `¥${numAmount.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化友好日期
|
||||
*/
|
||||
const formatFriendlyDate = (dateStr: string | undefined | null): string => {
|
||||
if (!dateStr) return '-';
|
||||
return formatDate(dateStr, 'YYYY-MM-DD');
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
const formatDateTime = (dateStr: string | undefined | null): string => {
|
||||
if (!dateStr) return '-';
|
||||
return formatDate(dateStr, 'YYYY-MM-DD HH:mm');
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化电话号码,添加隐私保护
|
||||
*/
|
||||
const formatPhone = (phone: string | undefined | null): string => {
|
||||
if (!phone || phone.length < 7) return phone || '-';
|
||||
// 只显示前3位和后4位,中间用星号代替
|
||||
return `${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 截断长文本并添加省略号
|
||||
*/
|
||||
const truncateText = (text: string | undefined | null, maxLength: number = 20): string => {
|
||||
if (!text) return '-';
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染分页控件
|
||||
*/
|
||||
const renderPagination = () => {
|
||||
const pages = [];
|
||||
const maxPagesToShow = 5; // 最多显示的页码数
|
||||
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
|
||||
const endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
|
||||
|
||||
if (endPage - startPage + 1 < maxPagesToShow) {
|
||||
startPage = Math.max(1, endPage - maxPagesToShow + 1);
|
||||
}
|
||||
|
||||
// 首页
|
||||
if (startPage > 1) {
|
||||
pages.push(
|
||||
<button
|
||||
key="first"
|
||||
onClick={() => handlePageChange(1)}
|
||||
className={`${styles.paginationButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
);
|
||||
|
||||
if (startPage > 2) {
|
||||
pages.push(
|
||||
<span key="dots1" className="mx-1">...</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 页码按钮
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handlePageChange(i)}
|
||||
className={currentPage === i
|
||||
? styles.paginationButtonCurrent
|
||||
: `${styles.paginationButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 末页
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) {
|
||||
pages.push(
|
||||
<span key="dots2" className="mx-1">...</span>
|
||||
);
|
||||
}
|
||||
|
||||
pages.push(
|
||||
<button
|
||||
key="last"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
className={`${styles.paginationButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.paginationContainer}>
|
||||
<button
|
||||
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`${styles.paginationButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
style={{ opacity: currentPage === 1 ? 0.5 : 1, cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
|
||||
{pages}
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className={`${styles.paginationButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
style={{ opacity: (currentPage === totalPages || totalPages === 0) ? 0.5 : 1, cursor: (currentPage === totalPages || totalPages === 0) ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理退货操作
|
||||
*/
|
||||
const handleReturn = (record: SalesRecordWithDetails) => {
|
||||
setSelectedRecord(record);
|
||||
setOperationType(OperationType.RETURN);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理换货操作
|
||||
*/
|
||||
const handleExchange = (record: SalesRecordWithDetails) => {
|
||||
setSelectedRecord(record);
|
||||
setOperationType(OperationType.EXCHANGE);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理退差价操作
|
||||
*/
|
||||
const handleRefund = (record: SalesRecordWithDetails) => {
|
||||
setSelectedRecord(record);
|
||||
setOperationType(OperationType.REFUND);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理补发操作
|
||||
*/
|
||||
const handleReplenish = (record: SalesRecordWithDetails) => {
|
||||
setSelectedRecord(record);
|
||||
setOperationType(OperationType.REPLENISH);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理发货操作
|
||||
*/
|
||||
const handleShip = (record: SalesRecordWithDetails) => {
|
||||
setSelectedRecord(record);
|
||||
setShipModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭操作模态框
|
||||
*/
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
setSelectedRecord(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.pageContainer}`}>
|
||||
<h1 className={styles.pageTitle}>销售记录管理</h1>
|
||||
|
||||
{/* 搜索和筛选区域 */}
|
||||
<div className={`${styles.searchContainer} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<form onSubmit={handleSearch} className={styles.searchForm}>
|
||||
{/* 基本搜索栏 */}
|
||||
<div className={styles.searchRow}>
|
||||
<div className={styles.searchInputContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索客户名称、电话、备注..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className={`${styles.searchInput} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
/>
|
||||
<MdSearch className={styles.searchIcon} size={20} />
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<input
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className={`${styles.dateInput} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className={`${styles.dateInput} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={filterPaymentType}
|
||||
onChange={(e) => setFilterPaymentType(e.target.value)}
|
||||
className={`${styles.select} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
<option value="">所有支付类型</option>
|
||||
<option value={PaymentType.FULL_PAYMENT.toString()}>全款</option>
|
||||
<option value={PaymentType.DEPOSIT.toString()}>定金</option>
|
||||
<option value={PaymentType.UNPAID.toString()}>未付</option>
|
||||
<option value={PaymentType.FREE.toString()}>赠送</option>
|
||||
<option value={PaymentType.OTHER.toString()}>其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonGroup}>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.searchButton}
|
||||
>
|
||||
<MdSearch className="mr-1" />
|
||||
搜索
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedFilter}
|
||||
className={`${styles.secondaryButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
{isAdvancedFilterOpen ? '收起筛选' : '高级筛选'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className={`${styles.secondaryButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
<MdRefresh className="mr-1" />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSalesRecord}
|
||||
className={styles.addButton}
|
||||
>
|
||||
<MdAdd className="mr-1" />
|
||||
新增
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 高级筛选选项 */}
|
||||
{isAdvancedFilterOpen && (
|
||||
<div className={`${styles.advancedFiltersContainer} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<div className={styles.filterItem}>
|
||||
<label className={styles.filterLabel}>筛选范围</label>
|
||||
<div className={styles.quickFilterButtons}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// 设置为过去7天
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 7);
|
||||
setStartDate(formatDate(startDate, 'YYYY-MM-DD'));
|
||||
setEndDate(formatDate(endDate, 'YYYY-MM-DD'));
|
||||
}}
|
||||
className={`${styles.quickFilterButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
最近7天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// 设置为过去30天
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
setStartDate(formatDate(startDate, 'YYYY-MM-DD'));
|
||||
setEndDate(formatDate(endDate, 'YYYY-MM-DD'));
|
||||
}}
|
||||
className={`${styles.quickFilterButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
最近30天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// 设置为本月
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
setStartDate(formatDate(startDate, 'YYYY-MM-DD'));
|
||||
setEndDate(formatDate(now, 'YYYY-MM-DD'));
|
||||
}}
|
||||
className={`${styles.quickFilterButton} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
本月
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更多高级筛选选项可以添加在这里 */}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className={`${styles.errorMessage} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoading && (
|
||||
<div className={styles.loaderContainer}>
|
||||
<div className={styles.loader}></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 销售记录列表 */}
|
||||
{!isLoading && salesRecords.length === 0 ? (
|
||||
<div className={`${styles.emptyState} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<p className={`${styles.emptyStateText} ${isDarkMode ? styles.dark : ''}`}>暂无销售记录数据</p>
|
||||
<button
|
||||
onClick={handleAddSalesRecord}
|
||||
className={styles.addButton}
|
||||
>
|
||||
<MdAdd className="mr-1" />
|
||||
创建销售记录
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
!isLoading && (
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={`${styles.table} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<thead className={`${styles.tableHead} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<tr>
|
||||
<th className={styles.tableHeadCell} style={{ width: '10%' }}>客户信息</th>
|
||||
<th className={styles.tableHeadCell} style={{ width: '12%' }}>日期信息</th>
|
||||
<th className={styles.tableHeadCell} style={{ width: '14%' }}>店铺/导购</th>
|
||||
<th className={styles.tableHeadCell} style={{ width: '16%' }}>支付信息</th>
|
||||
<th className={styles.tableHeadCell} style={{ width: '22%' }}>产品信息</th>
|
||||
<th className={styles.tableHeadCell} style={{ width: '12%' }}>备注</th>
|
||||
<th className={styles.tableHeadCell} style={{ textAlign: 'center', width: '16%' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{salesRecords.map(record => (
|
||||
<tr
|
||||
key={record.id}
|
||||
className={`${styles.tableRow} ${isDarkMode ? styles.dark : styles.light}`}
|
||||
>
|
||||
{/* 客户信息 - 优化显示内容 */}
|
||||
<td className={styles.tableCell}>
|
||||
<div className={styles.infoColumn}>
|
||||
<div className={styles.infoItemWithIcon}>
|
||||
<MdPerson className={styles.iconBlue} size={18} />
|
||||
<span className={styles.infoLabel}>ID:</span> {record.customerId || '-'}
|
||||
</div>
|
||||
<div className={styles.infoContent}>
|
||||
<div className={`${styles.subInfoItem} ${isDarkMode ? styles.dark : styles.light}`} title={record.customerName || '-'}>
|
||||
<span className={styles.infoLabel}>姓名:</span> {truncateText(record.customerName, 10) || '-'}
|
||||
</div>
|
||||
<div className={`${styles.subInfoItem} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<span className={styles.infoLabel}>电话:</span> {formatPhone(record.customerPhone)}
|
||||
</div>
|
||||
{record.customerAddress && (
|
||||
<div className={`${styles.subInfoItem} ${isDarkMode ? styles.dark : styles.light}`} title={record.customerAddress}>
|
||||
<span className={styles.infoLabel}>地址:</span> {truncateText(record.customerAddress, 15)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 日期信息 */}
|
||||
<td className={styles.tableCell}>
|
||||
<div className={styles.infoColumn}>
|
||||
<div className={styles.infoItemWithIcon}>
|
||||
<MdCalendarMonth className={styles.iconGreen} size={16} />
|
||||
<span className={styles.infoLabel}>成交日期</span>
|
||||
</div>
|
||||
<div className={styles.infoContent}>
|
||||
<div className={`${styles.subInfoItem} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
{formatFriendlyDate(record.dealDate)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoItemWithIcon} style={{ marginTop: '8px' }}>
|
||||
<MdCalendarMonth className={styles.iconGray} size={16} />
|
||||
<span className={styles.infoLabel}>创建时间</span>
|
||||
</div>
|
||||
<div className={styles.infoContent}>
|
||||
<div className={`${styles.subInfoItem} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
{formatDateTime(record.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 店铺/导购信息 */}
|
||||
<td className={styles.tableCell}>
|
||||
<div className={styles.infoColumn}>
|
||||
<div className={styles.infoItemWithIcon}>
|
||||
<MdStore className={styles.iconBlue} size={16} />
|
||||
<span className={styles.infoLabel}>成交店铺</span>
|
||||
</div>
|
||||
<div className={styles.infoContent}>
|
||||
<div className={`${styles.subInfoItem} ${isDarkMode ? styles.dark : styles.light}`} title={record.dealShopName || '-'}>
|
||||
{record.dealShopName || '-'}
|
||||
</div>
|
||||
<div className={`${styles.subInfoItem} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<span className={styles.infoLabel}>微信:</span> {record.dealShopWechat || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导购信息 */}
|
||||
<div className={styles.infoItemWithIcon} style={{ marginTop: '8px' }}>
|
||||
<MdPerson className={styles.iconGreen} size={16} />
|
||||
<span className={styles.infoLabel}>导购</span>
|
||||
<span className={styles.infoLabel}>ID: {record.guideId || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 支付信息 */}
|
||||
<td className={styles.tableCell}>
|
||||
<div className={styles.infoColumn}>
|
||||
<div className={styles.infoItemWithIcon}>
|
||||
<MdPayment className={styles.iconYellow} size={16} />
|
||||
<span className={styles.infoLabel} style={{ color: PaymentTypeColors[record.paymentType] }}>
|
||||
{PaymentTypeNames[record.paymentType] || '未知'}
|
||||
</span>
|
||||
<span className={styles.infoLabel}>平台:</span> {record.platformName || '-'}
|
||||
</div>
|
||||
|
||||
<div className={styles.infoContent}>
|
||||
<div style={{ marginTop: '8px' }} className={`${styles.subInfoItem} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={styles.infoLabel}>应收:</span>
|
||||
<span style={{ fontWeight: '500' }}>{formatAmount(record.receivable)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={styles.infoLabel}>实收:</span>
|
||||
<span style={{ color: PaymentTypeColors[record.paymentType], fontWeight: '500' }}>
|
||||
{formatAmount(record.received)}
|
||||
</span>
|
||||
</div>
|
||||
{record.pending > 0 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={styles.infoLabel}>待收:</span>
|
||||
<span style={{ color: '#ef4444', fontWeight: '500' }}>
|
||||
{formatAmount(record.pending)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{record.products && record.products.length > 0 && (
|
||||
<div className={`${styles.productSummaryInfo} ${isDarkMode ? styles.dark : styles.light}`}>
|
||||
<div>共 {record.products.reduce((sum, p) => sum + p.quantity, 0)} 件商品</div>
|
||||
<div className={`${styles.productTotalInline} ${isDarkMode ? styles.dark : ''}`}>
|
||||
总价: {formatAmount(record.products.reduce((sum, p) => sum + (p.price * p.quantity), 0))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 产品信息 */}
|
||||
<td className={styles.tableCell}>
|
||||
<div className={styles.infoColumn}>
|
||||
{record.products && record.products.length > 0 ? (
|
||||
<div className={styles.productListContainer}>
|
||||
<div className={styles.productGridScroll}>
|
||||
{record.products.map((product, idx) => (
|
||||
<ProductInfoModal
|
||||
key={idx}
|
||||
productId={product.productId}
|
||||
productName={product.productName}
|
||||
productCode={product.productCode}
|
||||
price={product.price}
|
||||
quantity={product.quantity}
|
||||
brandName={product.brandName}
|
||||
categoryName={product.categoryName}
|
||||
supplierName={product.supplierName}
|
||||
image={product.image}
|
||||
logisticsStatus={record.logisticsStatus}
|
||||
logisticsDetails={record.logisticsDetails}
|
||||
logisticsTrackingNumber={record.logisticsTrackingNumber}
|
||||
logisticsCompany={record.logisticsCompany}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.subInfoItem} ${isDarkMode ? styles.dark : styles.light}`} style={{ marginLeft: '0.5rem', marginTop: '0.25rem', fontSize: '0.8rem' }}>
|
||||
无产品信息
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 备注 */}
|
||||
<td className={styles.tableCell}>
|
||||
<div className={styles.remarkContent}>
|
||||
<p className={`${styles.remarkText} ${isDarkMode ? styles.dark : styles.light}`} title={record.remark || '-'}>
|
||||
{truncateText(record.remark, 10) || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<td className={styles.tableCell}>
|
||||
<div className={styles.actionsColumn}>
|
||||
<button
|
||||
onClick={() => handleEditSalesRecord(record)}
|
||||
className={`${styles.actionButton} ${styles.editButton} ${isDarkMode ? styles.dark : ''}`}
|
||||
title="编辑"
|
||||
>
|
||||
<MdEdit size={18} />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReturn(record)}
|
||||
className={`${styles.actionButton} ${styles.returnButton} ${isDarkMode ? styles.dark : ''}`}
|
||||
title="退货"
|
||||
>
|
||||
<MdOutlineAssignment size={18} />
|
||||
<span>退货</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExchange(record)}
|
||||
className={`${styles.actionButton} ${styles.exchangeButton} ${isDarkMode ? styles.dark : ''}`}
|
||||
title="换货"
|
||||
>
|
||||
<MdCompareArrows size={18} />
|
||||
<span>换货</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRefund(record)}
|
||||
className={`${styles.actionButton} ${styles.priceDiffButton} ${isDarkMode ? styles.dark : ''}`}
|
||||
title="退差"
|
||||
>
|
||||
<MdAttachMoney size={18} />
|
||||
<span>退差</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReplenish(record)}
|
||||
className={`${styles.actionButton} ${styles.replenishButton} ${isDarkMode ? styles.dark : ''}`}
|
||||
title="补发"
|
||||
>
|
||||
<MdAddShoppingCart size={18} />
|
||||
<span>补发</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleShip(record)}
|
||||
className={`${styles.actionButton} ${styles.shipButton} ${isDarkMode ? styles.dark : ''}`}
|
||||
title="发货"
|
||||
>
|
||||
<MdLocalShipping size={18} />
|
||||
<span>发货</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 分页控件 */}
|
||||
{!isLoading && salesRecords.length > 0 && renderPagination()}
|
||||
|
||||
{/* 操作模态框 */}
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<SalesRecordModal
|
||||
isOpen={modalOpen}
|
||||
onClose={handleCloseModal}
|
||||
salesRecord={selectedRecord}
|
||||
operationType={operationType}
|
||||
/>
|
||||
<ShipModal
|
||||
isOpen={shipModalOpen}
|
||||
onClose={() => setShipModalOpen(false)}
|
||||
salesRecord={selectedRecord}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
src/app/team/[teamCode]/sales/components/CustomerSelector.tsx
Normal file
221
src/app/team/[teamCode]/sales/components/CustomerSelector.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 客户选择器组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供客户搜索和选择功能
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Customer } from '@/models/team/types/old/customer';
|
||||
import { MdPerson, MdPhone, MdPersonAddAlt } from 'react-icons/md';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import { Select, Spin } from 'antd';
|
||||
import Button from '@/components/ui/Button';
|
||||
import CustomerModal from '@/app/team/[teamCode]/customers/customer-modal';
|
||||
|
||||
interface CustomerSelectorProps {
|
||||
teamCode: string;
|
||||
selectedCustomer: Customer | null;
|
||||
onSelectCustomer: (customer: Customer) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户选择器组件 - 下拉列表版本
|
||||
*/
|
||||
const CustomerSelector: React.FC<CustomerSelectorProps> = ({
|
||||
teamCode,
|
||||
selectedCustomer,
|
||||
onSelectCustomer
|
||||
}) => {
|
||||
// 主题模式
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
// 客户列表状态
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 客户模态框状态
|
||||
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* 加载客户列表
|
||||
*/
|
||||
useEffect(() => {
|
||||
const loadCustomers = async () => {
|
||||
if (!teamCode) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/customers?pageSize=100`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCustomers(data.customers || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载客户列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCustomers();
|
||||
}, [teamCode]);
|
||||
|
||||
/**
|
||||
* 处理选择客户
|
||||
*/
|
||||
const handleChange = (value: string) => {
|
||||
const selectedCust = customers.find(c => c.id.toString() === value);
|
||||
if (selectedCust) {
|
||||
onSelectCustomer(selectedCust);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开新增客户模态框
|
||||
*/
|
||||
const handleOpenCustomerModal = () => {
|
||||
setIsCustomerModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭新增客户模态框
|
||||
*/
|
||||
const handleCloseCustomerModal = () => {
|
||||
setIsCustomerModalOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理新增客户成功
|
||||
* 刷新客户列表并选中新增的客户
|
||||
*/
|
||||
const handleCustomerSuccess = async () => {
|
||||
// 先关闭模态框
|
||||
setIsCustomerModalOpen(false);
|
||||
|
||||
// 显示"正在加载"状态
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 获取最新创建的客户(第一个)并设为选中状态
|
||||
const response = await fetch(`/api/team/${teamCode}/customers?sortBy=createdAt&sortOrder=desc&pageSize=1`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.customers && data.customers.length > 0) {
|
||||
const newCustomer = data.customers[0];
|
||||
onSelectCustomer(newCustomer);
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新客户列表
|
||||
const allCustomersResponse = await fetch(`/api/team/${teamCode}/customers?pageSize=100`);
|
||||
|
||||
if (allCustomersResponse.ok) {
|
||||
const data = await allCustomersResponse.json();
|
||||
setCustomers(data.customers || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取新增客户失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-grow">
|
||||
<Select
|
||||
showSearch
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择客户..."
|
||||
optionFilterProp="children"
|
||||
notFoundContent={loading ? <Spin size="small" /> : "未找到客户"}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string).toLowerCase().includes(input.toLowerCase()) ||
|
||||
((option?.data as any).phone || '').includes(input)
|
||||
}
|
||||
onChange={handleChange}
|
||||
loading={loading}
|
||||
optionLabelProp="label"
|
||||
value={selectedCustomer?.id.toString()}
|
||||
options={customers.map(customer => ({
|
||||
value: customer.id.toString(),
|
||||
label: customer.name,
|
||||
data: customer
|
||||
}))}
|
||||
popupRender={menu => (
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
optionRender={(option) => {
|
||||
const customer = (option.data as any).data;
|
||||
return (
|
||||
<div style={{ display: 'flex', padding: '4px 0' }}>
|
||||
<div className={`
|
||||
flex items-center justify-center w-7 h-7 rounded-full mr-3 flex-shrink-0
|
||||
${isDarkMode ? 'bg-blue-600/30' : 'bg-blue-100'}
|
||||
text-blue-600 dark:text-blue-400
|
||||
`}>
|
||||
<MdPerson size={16} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ fontWeight: 'bold', display: 'flex', alignItems: 'center' }}>
|
||||
{customer.name}
|
||||
{customer.gender && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '12px',
|
||||
padding: '0px 6px',
|
||||
borderRadius: '10px',
|
||||
background: customer.gender === '男'
|
||||
? (isDarkMode ? 'rgba(37, 99, 235, 0.2)' : '#e0e7ff')
|
||||
: (isDarkMode ? 'rgba(219, 39, 119, 0.2)' : '#fce7f3'),
|
||||
color: customer.gender === '男'
|
||||
? (isDarkMode ? '#93c5fd' : '#1d4ed8')
|
||||
: (isDarkMode ? '#f9a8d4' : '#be185d')
|
||||
}}>
|
||||
{customer.gender}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', fontSize: '12px', color: '#888' }}>
|
||||
<MdPhone style={{ marginRight: '4px' }} size={14} />
|
||||
{customer.phone}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 新增客户按钮 */}
|
||||
<Button
|
||||
variant="success"
|
||||
size="md"
|
||||
icon={<MdPersonAddAlt size={16} />}
|
||||
onClick={handleOpenCustomerModal}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
|
||||
{/* 客户新增模态框 */}
|
||||
<CustomerModal
|
||||
isOpen={isCustomerModalOpen}
|
||||
onClose={handleCloseCustomerModal}
|
||||
customer={null}
|
||||
onSuccess={handleCustomerSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerSelector;
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 客户信息展示组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供客户详细信息展示
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Customer } from '@/models/team/types/old/customer';
|
||||
import { MdPerson, MdPhone, MdCake, MdCalendarToday, MdAccountBalanceWallet } from 'react-icons/md';
|
||||
import { FaWeixin } from 'react-icons/fa';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import { formatDate } from '@/utils';
|
||||
|
||||
interface CustomerSelectorInfoProps {
|
||||
customer: Customer | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数值
|
||||
*/
|
||||
const formatNumber = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0.00';
|
||||
// 确保value是数字类型
|
||||
const numValue = Number(value);
|
||||
// 检查是否为有效数字
|
||||
if (isNaN(numValue)) return '0.00';
|
||||
return numValue.toFixed(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 客户详细信息展示组件
|
||||
*/
|
||||
const CustomerSelectorInfo: React.FC<CustomerSelectorInfoProps> = ({ customer }) => {
|
||||
// 主题模式
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
if (!customer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
p-3 rounded-lg border
|
||||
${isDarkMode ? 'bg-blue-900/10 border-blue-800/30' : 'bg-blue-50 border-blue-100'}
|
||||
`}>
|
||||
<div className="flex items-start">
|
||||
<div className={`
|
||||
flex items-center justify-center w-10 h-10 rounded-full mr-3 shrink-0
|
||||
${isDarkMode ? 'bg-blue-600/30' : 'bg-blue-100'}
|
||||
text-blue-600 dark:text-blue-400
|
||||
`}>
|
||||
<MdPerson size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* 客户名称和性别 */}
|
||||
<div className="flex items-center mb-1">
|
||||
<h3 className="text-base font-medium text-gray-800 dark:text-white">
|
||||
{customer.name}
|
||||
</h3>
|
||||
{customer.gender && (
|
||||
<span className={`
|
||||
ml-2 text-xs px-1.5 py-0.5 rounded-full
|
||||
${customer.gender === '男'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300'
|
||||
: 'bg-pink-100 text-pink-800 dark:bg-pink-900/50 dark:text-pink-300'
|
||||
}
|
||||
`}>
|
||||
{customer.gender}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 客户信息栏目 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{/* 电话 */}
|
||||
{customer.phone && (
|
||||
<div className="flex items-center text-gray-600 dark:text-gray-300">
|
||||
<MdPhone className="mr-1 text-gray-500 dark:text-gray-400" size={14} />
|
||||
<span>{customer.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 微信 */}
|
||||
{customer.wechat && (
|
||||
<div className="flex items-center text-gray-600 dark:text-gray-300">
|
||||
<FaWeixin className="mr-1 text-gray-500 dark:text-gray-400" size={14} />
|
||||
<span>{customer.wechat}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生日 */}
|
||||
{customer.birthday && (
|
||||
<div className="flex items-center text-gray-600 dark:text-gray-300">
|
||||
<MdCake className="mr-1 text-gray-500 dark:text-gray-400" size={14} />
|
||||
<span>生日: {formatDate(customer.birthday, 'MM-DD')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加粉日期 */}
|
||||
{customer.followDate && (
|
||||
<div className="flex items-center text-gray-600 dark:text-gray-300">
|
||||
<MdCalendarToday className="mr-1 text-gray-500 dark:text-gray-400" size={14} />
|
||||
<span>加粉: {formatDate(customer.followDate, 'YYYY-MM-DD')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 余额信息 */}
|
||||
<div className="mt-2 pt-2 border-t border-blue-200/30 dark:border-blue-700/30 flex justify-between items-center">
|
||||
<div className="flex items-center text-gray-600 dark:text-gray-300">
|
||||
<MdAccountBalanceWallet className="mr-1 text-gray-500 dark:text-gray-400" size={14} />
|
||||
<span className="text-sm">账户余额:</span>
|
||||
</div>
|
||||
<span className={`
|
||||
text-sm font-medium
|
||||
${customer.balance > 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-600 dark:text-gray-400'}
|
||||
`}>
|
||||
¥{formatNumber(customer.balance)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerSelectorInfo;
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 产品模态框适配器组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 适配ProductModal组件接口,供销售记录创建页面使用
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Product } from '@/models/team/types/old/product';
|
||||
import ProductModal from '../../products/product-modal';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 产品模态框适配器组件接口
|
||||
*/
|
||||
interface ProductModalAdapterProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
onSuccess: () => void;
|
||||
suppliers: { id: number; name: string }[];
|
||||
brands: { id: number; name: string }[];
|
||||
categories: { id: number; name: string }[];
|
||||
availableLevels: string[];
|
||||
onSelectProduct?: (product: Product) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品模态框适配器组件
|
||||
* 用于在销售记录创建页面中使用ProductModal
|
||||
*/
|
||||
export default function ProductModalAdapter({
|
||||
isOpen,
|
||||
onClose,
|
||||
product,
|
||||
onSuccess,
|
||||
suppliers,
|
||||
brands,
|
||||
categories,
|
||||
availableLevels,
|
||||
onSelectProduct
|
||||
}: ProductModalAdapterProps) {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const [lastProductId, setLastProductId] = useState<number | null>(null);
|
||||
|
||||
/**
|
||||
* 处理产品创建/编辑成功
|
||||
* 当产品创建成功后,自动获取并选择该产品
|
||||
*/
|
||||
const handleSuccess = async () => {
|
||||
// 先调用原始的onSuccess回调
|
||||
onSuccess();
|
||||
|
||||
// 如果没有提供onSelectProduct,则不需要自动选择
|
||||
if (!onSelectProduct) return;
|
||||
|
||||
try {
|
||||
// 获取最新创建的产品
|
||||
const response = await fetch(`/api/team/${teamCode}/products?sortBy=createdAt&sortOrder=desc&pageSize=1`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.products && data.products.length > 0) {
|
||||
const newProduct = data.products[0];
|
||||
// 避免重复选择同一个产品
|
||||
if (newProduct.id !== lastProductId) {
|
||||
setLastProductId(newProduct.id);
|
||||
onSelectProduct(newProduct);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取最新创建的产品失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
product={product}
|
||||
onSuccess={handleSuccess}
|
||||
suppliers={suppliers}
|
||||
brands={brands}
|
||||
categories={categories}
|
||||
availableLevels={availableLevels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
127
src/app/team/[teamCode]/sales/components/ProductSelector.tsx
Normal file
127
src/app/team/[teamCode]/sales/components/ProductSelector.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 产品选择器组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供产品搜索和选择功能
|
||||
* 版本: 2.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Product } from '@/models/team/types/old/product';
|
||||
import { Select, Spin, Typography } from 'antd';
|
||||
|
||||
interface ProductSelectorProps {
|
||||
teamCode: string;
|
||||
onSelectProduct: (product: Product) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品选择器组件实现 - 下拉列表版本
|
||||
*/
|
||||
const ProductSelector = ({
|
||||
teamCode,
|
||||
onSelectProduct
|
||||
}: ProductSelectorProps) => {
|
||||
// 产品列表状态
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* 加载产品列表
|
||||
*/
|
||||
useEffect(() => {
|
||||
const loadProducts = async () => {
|
||||
if (!teamCode) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/products?pageSize=100`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProducts(data.products || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载产品列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProducts();
|
||||
}, [teamCode]);
|
||||
|
||||
/**
|
||||
* 处理选择产品
|
||||
*/
|
||||
const handleChange = (value: string) => {
|
||||
const selectedProd = products.find(p => p.id.toString() === value);
|
||||
if (selectedProd) {
|
||||
onSelectProduct(selectedProd);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化价格显示
|
||||
*/
|
||||
const formatPrice = (price: number | string | undefined | null) => {
|
||||
if (price === undefined || price === null) return "0.00";
|
||||
// 确保price是数字类型
|
||||
const numPrice = typeof price === 'string' ? parseFloat(price) : Number(price);
|
||||
// 检查是否为有效数字
|
||||
if (isNaN(numPrice)) return "0.00";
|
||||
return numPrice.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择产品..."
|
||||
optionFilterProp="children"
|
||||
notFoundContent={loading ? <Spin size="small" /> : "未找到产品"}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string).toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onChange={handleChange}
|
||||
loading={loading}
|
||||
optionLabelProp="label"
|
||||
options={products.map(product => ({
|
||||
value: product.id.toString(),
|
||||
label: product.name,
|
||||
product: product
|
||||
}))}
|
||||
popupRender={menu => (
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
optionRender={(option) => {
|
||||
const product = (option.data as any).product;
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>{product.name}</div>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||
{product.brand?.name && `品牌: ${product.brand.name}`}
|
||||
{product.code && ` | 编码: ${product.code}`}
|
||||
{!product.code && product.sku && ` | SKU: ${product.sku}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||||
<Text type="success" strong>¥{formatPrice(product.price)}</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{product.stock > 0 ? `库存: ${product.stock}` : "无库存"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductSelector;
|
||||
198
src/app/team/[teamCode]/sales/components/SelectedProductList.tsx
Normal file
198
src/app/team/[teamCode]/sales/components/SelectedProductList.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 已选产品列表组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 展示和管理已选择的产品列表
|
||||
* 版本: 1.1.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, List, Avatar, Typography, Button, Statistic, Tag, Badge, Empty, InputNumber, Space } from 'antd';
|
||||
import { DeleteOutlined, ShoppingOutlined, TagOutlined, ShopOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
// 产品详情接口,包含更多产品信息
|
||||
interface ProductItemDetail {
|
||||
productId: number;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
total: number;
|
||||
image?: string;
|
||||
supplierName?: string;
|
||||
brandName?: string;
|
||||
code?: string;
|
||||
sku?: string;
|
||||
stock?: number;
|
||||
}
|
||||
|
||||
interface SelectedProductProps {
|
||||
products: ProductItemDetail[];
|
||||
onRemove: (index: number) => void;
|
||||
onUpdateQuantity?: (index: number, quantity: number) => void; // 新增更新数量的回调
|
||||
}
|
||||
|
||||
/**
|
||||
* 已选产品列表组件
|
||||
*/
|
||||
export default function SelectedProductList({
|
||||
products,
|
||||
onRemove,
|
||||
onUpdateQuantity
|
||||
}: SelectedProductProps) {
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* 格式化金额显示
|
||||
*/
|
||||
const formatAmount = (amount: number | string | undefined | null) => {
|
||||
if (amount === undefined || amount === null) return "0.00";
|
||||
// 确保amount是数字类型
|
||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : Number(amount);
|
||||
// 检查是否为有效数字
|
||||
if (isNaN(numAmount)) return "0.00";
|
||||
return numAmount.toFixed(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理数量变更
|
||||
*/
|
||||
const handleQuantityChange = (index: number, value: number | null) => {
|
||||
if (onUpdateQuantity && value && value > 0) {
|
||||
onUpdateQuantity(index, value);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算总金额
|
||||
const totalAmount = products.reduce((sum, product) => sum + product.total, 0);
|
||||
|
||||
// 如果没有选择产品,则显示提示信息
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<Card
|
||||
title="已选择产品"
|
||||
variant="outlined"
|
||||
style={{ minHeight: '380px' }} // 设定Card最小高度,确保无论是否选择产品,卡片高度保持一致
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}
|
||||
//onClick={onAddProductClick}
|
||||
>
|
||||
新增产品
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂未选择产品,请先添加产品"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ShoppingOutlined style={{ marginRight: 8 }} />
|
||||
<span>已选择产品</span>
|
||||
<Badge count={products.length} style={{ marginLeft: 8, backgroundColor: '#52c41a' }} />
|
||||
</div>
|
||||
}
|
||||
variant="outlined"
|
||||
>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={products}
|
||||
renderItem={(product, index) => (
|
||||
<List.Item
|
||||
key={`${product.productId}-${index}`}
|
||||
actions={[
|
||||
<>
|
||||
<div style={{ width: 100, textAlign: 'center' }}>
|
||||
<Text type="secondary">数量</Text>
|
||||
<div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={product.quantity}
|
||||
onChange={(value) => handleQuantityChange(index, value)}
|
||||
style={{ width: 80 }}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
<>
|
||||
<div style={{ width: 80, textAlign: 'center' }}>
|
||||
<Text type="secondary">小计</Text>
|
||||
<div>
|
||||
<Text strong style={{ color: '#f50' }}>
|
||||
¥{formatAmount(product.total)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onRemove(index)}
|
||||
/>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
shape="square"
|
||||
size={64}
|
||||
src={product.image || '/images/product-placeholder.png'}
|
||||
style={{ borderRadius: 4 }}
|
||||
onError={() => {
|
||||
// Ant Design Avatar 的 onError 需要返回一个布尔值
|
||||
// 返回 true 以使用默认 fallback 行为
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
||||
<Text strong>{product.name}</Text>
|
||||
<Space size={4}>
|
||||
{product.code && (
|
||||
<Tag color="blue" icon={<TagOutlined />}>{product.code}</Tag>
|
||||
)}
|
||||
{product.supplierName && (
|
||||
<Tag color="green" icon={<ShopOutlined />}>{product.supplierName}</Tag>
|
||||
)}
|
||||
{product.brandName && (
|
||||
<Tag color="purple">{product.brandName}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<Text type="secondary">单价: </Text>
|
||||
<Text type="danger">¥{formatAmount(product.price)}</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right', padding: '12px 0' }}>
|
||||
<Space align="center" size="large">
|
||||
<Text>合计 {products.length} 件商品</Text>
|
||||
<Statistic
|
||||
title="总金额"
|
||||
value={totalAmount}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#f50', fontSize: '18px' }}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
903
src/app/team/[teamCode]/sales/page.tsx
Normal file
903
src/app/team/[teamCode]/sales/page.tsx
Normal file
@@ -0,0 +1,903 @@
|
||||
/**
|
||||
* 销售记录创建页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供创建销售记录的表单界面
|
||||
* 版本: 1.6.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTeam } from '@/hooks/useTeam';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { useUserProfile } from '@/hooks/useUser';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import { formatDate } from '@/utils';
|
||||
import { PaymentType } from '@/models/team/types/old/sales';
|
||||
import { Customer } from '@/models/team/types/old/customer';
|
||||
import { Product } from '@/models/team/types/old/product';
|
||||
import { Shop } from '@/models/team/types/old/shop';
|
||||
import { PaymentPlatform } from '@/models/team/types/old/payment-platform';
|
||||
import { MdShoppingCart, MdAddCircleOutline, MdPerson } from 'react-icons/md';
|
||||
import Card from '@/components/ui/Card';
|
||||
import CustomerSelector from './components/CustomerSelector';
|
||||
import CustomerSelectorInfo from './components/CustomerSelectorInfo';
|
||||
import ProductSelector from './components/ProductSelector';
|
||||
import SelectedProductList from './components/SelectedProductList';
|
||||
import ProductModalAdapter from './components/ProductModalAdapter';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
/**
|
||||
* 销售记录创建页面组件
|
||||
* 布局分为上下两部分,上部分为产品选择和已选产品列表,下部分为客户信息和详细信息
|
||||
*/
|
||||
export default function SalesPage() {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
useTeam();
|
||||
const themeMode = useThemeMode();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
const userProfile = useUserProfile(); // 获取用户信息
|
||||
|
||||
// 表单数据状态
|
||||
const [formData, setFormData] = useState({
|
||||
customerId: 0,
|
||||
sourceId: undefined as number | undefined,
|
||||
paymentType: undefined as PaymentType | undefined,
|
||||
dealDate: formatDate(new Date(), 'YYYY-MM-DD'),
|
||||
receivable: 0,
|
||||
received: 0,
|
||||
pending: 0,
|
||||
platformId: undefined as number | undefined,
|
||||
dealShopId: undefined as number | undefined,
|
||||
remark: '',
|
||||
products: [] as {
|
||||
productId: number;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
total: number;
|
||||
image?: string;
|
||||
supplierName?: string;
|
||||
brandName?: string;
|
||||
code?: string;
|
||||
sku?: string;
|
||||
stock?: number;
|
||||
}[]
|
||||
});
|
||||
|
||||
// 选择器数据状态
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
const [, setSelectedProduct] = useState<Product | null>(null);
|
||||
|
||||
// 加载状态
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// 数据列表
|
||||
const [shops, setShops] = useState<Shop[]>([]);
|
||||
const [platforms, setPlatforms] = useState<PaymentPlatform[]>([]);
|
||||
|
||||
// 新增产品模态框状态
|
||||
const [isProductModalOpen, setIsProductModalOpen] = useState(false);
|
||||
const [suppliers, setSuppliers] = useState<{ id: number; name: string }[]>([]);
|
||||
const [brands, setBrands] = useState<{ id: number; name: string }[]>([]);
|
||||
const [categories, setCategories] = useState<{ id: number; name: string }[]>([]);
|
||||
const [availableLevels, setAvailableLevels] = useState<string[]>([]);
|
||||
|
||||
// 视图状态
|
||||
const [isPageLoading, setIsPageLoading] = useState(true);
|
||||
|
||||
/**
|
||||
* 加载支付平台数据
|
||||
*/
|
||||
const fetchPaymentPlatforms = useCallback(async () => {
|
||||
if (!teamCode) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/payment-platforms`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPlatforms(data.paymentPlatforms || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载支付平台失败:', error);
|
||||
}
|
||||
}, [teamCode]);
|
||||
|
||||
/**
|
||||
* 加载店铺数据
|
||||
*/
|
||||
const fetchShops = useCallback(async () => {
|
||||
if (!teamCode) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/team/${teamCode}/shops`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setShops(data.shops || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载店铺失败:', error);
|
||||
}
|
||||
}, [teamCode]);
|
||||
|
||||
/**
|
||||
* 加载产品相关数据(供应商、品牌、品类)
|
||||
*/
|
||||
const fetchProductRelatedData = useCallback(async () => {
|
||||
if (!teamCode) return;
|
||||
|
||||
try {
|
||||
// 加载供应商
|
||||
const suppliersResponse = await fetch(`/api/team/${teamCode}/suppliers?pageSize=100`);
|
||||
|
||||
if (suppliersResponse.ok) {
|
||||
const data = await suppliersResponse.json();
|
||||
setSuppliers(data.suppliers.map((s: { id: number; name: string }) => ({ id: s.id, name: s.name })));
|
||||
}
|
||||
|
||||
// 加载品牌
|
||||
const brandsResponse = await fetch(`/api/team/${teamCode}/brands?pageSize=100`);
|
||||
|
||||
if (brandsResponse.ok) {
|
||||
const data = await brandsResponse.json();
|
||||
setBrands(data.brands.map((b: { id: number; name: string }) => ({ id: b.id, name: b.name })));
|
||||
}
|
||||
|
||||
// 加载品类
|
||||
const categoriesResponse = await fetch(`/api/team/${teamCode}/categories?pageSize=100`);
|
||||
|
||||
if (categoriesResponse.ok) {
|
||||
const data = await categoriesResponse.json();
|
||||
setCategories(data.categories.map((c: { id: number; name: string }) => ({ id: c.id, name: c.name })));
|
||||
}
|
||||
|
||||
// 设置可用级别
|
||||
setAvailableLevels(['初级', '中级', '高级', '奢华']);
|
||||
} catch (error) {
|
||||
console.error('加载产品相关数据失败:', error);
|
||||
}
|
||||
}, [teamCode]);
|
||||
|
||||
/**
|
||||
* 初始加载数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (teamCode) {
|
||||
setIsPageLoading(true);
|
||||
Promise.all([
|
||||
fetchPaymentPlatforms(),
|
||||
fetchShops(),
|
||||
fetchProductRelatedData()
|
||||
]).finally(() => {
|
||||
setIsPageLoading(false);
|
||||
});
|
||||
}
|
||||
}, [teamCode, fetchPaymentPlatforms, fetchShops, fetchProductRelatedData]);
|
||||
|
||||
/**
|
||||
* 处理表单输入变化
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// 处理数字类型字段
|
||||
if (name === 'receivable' || name === 'received') {
|
||||
const numValue = value === '' ? 0 : parseFloat(value);
|
||||
|
||||
// 更新表单数据
|
||||
setFormData(prev => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[name]: numValue
|
||||
};
|
||||
|
||||
// 如果修改了应收或实收金额,自动计算待收金额
|
||||
if (name === 'receivable' || name === 'received') {
|
||||
newData.pending = Math.max(0, newData.receivable - newData.received);
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理支付类型
|
||||
if (name === 'paymentType') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value === '' ? undefined : parseInt(value, 10)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理其他字段
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理选择客户
|
||||
*/
|
||||
const handleSelectCustomer = (customer: Customer) => {
|
||||
setSelectedCustomer(customer);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
customerId: customer.id
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理选择产品
|
||||
*/
|
||||
const handleSelectProduct = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
|
||||
// 直接添加产品到列表中
|
||||
const numPrice = product.price;
|
||||
const quantity = 1;
|
||||
const total = numPrice * quantity;
|
||||
|
||||
// 检查是否已存在该产品
|
||||
const existingProductIndex = formData.products.findIndex(p => p.productId === product.id);
|
||||
|
||||
if (existingProductIndex >= 0) {
|
||||
// 更新现有产品
|
||||
setFormData(prev => {
|
||||
const updatedProducts = [...prev.products];
|
||||
const existingProduct = updatedProducts[existingProductIndex];
|
||||
|
||||
updatedProducts[existingProductIndex] = {
|
||||
...existingProduct,
|
||||
quantity: existingProduct.quantity + quantity,
|
||||
price: numPrice,
|
||||
total: existingProduct.total + total
|
||||
};
|
||||
|
||||
// 重新计算总金额
|
||||
const newReceivable = updatedProducts.reduce((sum, p) => sum + p.total, 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
products: updatedProducts,
|
||||
receivable: newReceivable,
|
||||
pending: Math.max(0, newReceivable - prev.received)
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// 添加新产品
|
||||
setFormData(prev => {
|
||||
const newProducts = [
|
||||
...prev.products,
|
||||
{
|
||||
productId: product.id,
|
||||
name: product.name,
|
||||
price: numPrice,
|
||||
quantity: quantity,
|
||||
total,
|
||||
// 添加更多产品信息
|
||||
image: product.image,
|
||||
supplierName: product.supplier?.name,
|
||||
brandName: product.brand?.name,
|
||||
code: product.code,
|
||||
sku: product.sku,
|
||||
stock: product.stock
|
||||
}
|
||||
];
|
||||
|
||||
// 重新计算总金额
|
||||
const newReceivable = newProducts.reduce((sum, p) => sum + p.total, 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
products: newProducts,
|
||||
receivable: newReceivable,
|
||||
pending: Math.max(0, newReceivable - prev.received)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setError(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理移除产品
|
||||
*/
|
||||
const handleRemoveProduct = (index: number) => {
|
||||
setFormData(prev => {
|
||||
const updatedProducts = prev.products.filter((_, i) => i !== index);
|
||||
|
||||
// 重新计算总金额
|
||||
const newReceivable = updatedProducts.reduce((sum, p) => sum + p.total, 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
products: updatedProducts,
|
||||
receivable: newReceivable,
|
||||
pending: Math.max(0, newReceivable - prev.received)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单提交处理
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 基本验证
|
||||
if (!formData.customerId) {
|
||||
setError('请选择客户');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.dealDate) {
|
||||
setError('请选择成交日期');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.products.length === 0) {
|
||||
setError('请至少添加一个产品');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.paymentType === undefined) {
|
||||
setError('请选择收款类型');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.platformId === undefined) {
|
||||
setError('请选择支付平台');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 准备提交数据,确保所有数值字段都是数字类型
|
||||
const submitData = {
|
||||
customerId: Number(formData.customerId),
|
||||
sourceId: formData.sourceId ? Number(formData.sourceId) : undefined,
|
||||
paymentType: Number(formData.paymentType),
|
||||
dealDate: formData.dealDate,
|
||||
receivable: Number(formData.receivable),
|
||||
received: Number(formData.received),
|
||||
pending: Number(formData.pending),
|
||||
platformId: formData.platformId ? Number(formData.platformId) : undefined,
|
||||
dealShop: formData.dealShopId ? Number(formData.dealShopId) : undefined,
|
||||
remark: formData.remark || null,
|
||||
guideId: userProfile.userId, // 直接使用当前用户ID作为导购ID
|
||||
products: formData.products.map(p => ({
|
||||
productId: Number(p.productId),
|
||||
quantity: Number(p.quantity),
|
||||
price: Number(p.price)
|
||||
}))
|
||||
};
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(`/api/team/${teamCode}/sales-records`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(submitData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '创建销售记录失败');
|
||||
}
|
||||
|
||||
// 显示成功消息
|
||||
setSuccessMessage('销售记录创建成功!');
|
||||
|
||||
// 清空表单
|
||||
setFormData({
|
||||
customerId: 0,
|
||||
sourceId: undefined,
|
||||
paymentType: undefined,
|
||||
dealDate: formatDate(new Date(), 'YYYY-MM-DD'),
|
||||
receivable: 0,
|
||||
received: 0,
|
||||
pending: 0,
|
||||
platformId: undefined,
|
||||
dealShopId: undefined,
|
||||
remark: '',
|
||||
products: []
|
||||
});
|
||||
|
||||
setSelectedCustomer(null);
|
||||
setSelectedProduct(null);
|
||||
} catch (err) {
|
||||
console.error('创建销售记录失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染支付类型选项
|
||||
*/
|
||||
const renderPaymentTypeOptions = () => {
|
||||
return (
|
||||
<>
|
||||
<option value="">请选择</option>
|
||||
<option value={PaymentType.FULL_PAYMENT}>全款</option>
|
||||
<option value={PaymentType.DEPOSIT}>定金</option>
|
||||
<option value={PaymentType.UNPAID}>未付</option>
|
||||
<option value={PaymentType.FREE}>赠送</option>
|
||||
<option value={PaymentType.OTHER}>其他</option>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取支付类型标签颜色
|
||||
*/
|
||||
const getPaymentTypeColor = (type: PaymentType | undefined) => {
|
||||
if (type === undefined) return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
|
||||
switch (type) {
|
||||
case PaymentType.FULL_PAYMENT:
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300';
|
||||
case PaymentType.DEPOSIT:
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300';
|
||||
case PaymentType.UNPAID:
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300';
|
||||
case PaymentType.FREE:
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理产品模态框关闭
|
||||
*/
|
||||
const handleProductModalClose = () => {
|
||||
setIsProductModalOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理产品创建成功
|
||||
*/
|
||||
const handleProductSuccess = () => {
|
||||
setIsProductModalOpen(false);
|
||||
// 这里可以添加提示或其他操作
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mb-10">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white flex items-center">
|
||||
<MdShoppingCart className="mr-2 text-blue-500" size={24} />
|
||||
创建销售记录
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 加载状态显示 */}
|
||||
{isPageLoading ? (
|
||||
<Card
|
||||
glassEffect={isDarkMode ? "medium" : "light"}
|
||||
padding="large"
|
||||
className="flex justify-center items-center py-20"
|
||||
isLoading={true}
|
||||
loadingText="正在加载数据..."
|
||||
>
|
||||
<div></div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* 成功消息 */}
|
||||
{successMessage && (
|
||||
<div className={`
|
||||
mb-4 p-3 rounded-lg border-l-4 border-green-500
|
||||
${isDarkMode ? 'bg-green-900/20' : 'bg-green-50'}
|
||||
text-green-700 dark:text-green-300
|
||||
animate-fadeIn
|
||||
`}>
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p>{successMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className={`
|
||||
mb-4 p-3 rounded-lg border-l-4 border-red-500
|
||||
${isDarkMode ? 'bg-red-900/20' : 'bg-red-50'}
|
||||
text-red-700 dark:text-red-300
|
||||
animate-fadeIn
|
||||
`}>
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
|
||||
{/* 产品列表部分 - 始终显示,无论是否有产品 */}
|
||||
<div className="mb-4">
|
||||
|
||||
<SelectedProductList
|
||||
products={formData.products}
|
||||
onRemove={handleRemoveProduct}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 客户选择部分 */}
|
||||
<Card
|
||||
glassEffect={isDarkMode ? "medium" : "light"}
|
||||
padding="medium"
|
||||
title="客户信息"
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="mb-5 pb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
客户信息 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
{/* 客户选择器 */}
|
||||
<CustomerSelector
|
||||
teamCode={teamCode}
|
||||
selectedCustomer={selectedCustomer}
|
||||
onSelectCustomer={handleSelectCustomer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
选择添加产品 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 产品选择器 - 直接添加产品 */}
|
||||
<div className="flex-grow">
|
||||
<ProductSelector
|
||||
teamCode={teamCode}
|
||||
onSelectProduct={handleSelectProduct}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 新增产品按钮 */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
icon={<MdAddCircleOutline size={16} />}
|
||||
onClick={() => setIsProductModalOpen(true)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 客户详细信息展示 */}
|
||||
{selectedCustomer && (
|
||||
<div className="mt-3">
|
||||
<CustomerSelectorInfo customer={selectedCustomer} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 订单详细信息 */}
|
||||
<Card
|
||||
glassEffect={isDarkMode ? "medium" : "light"}
|
||||
padding="medium"
|
||||
title="订单详细信息"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||
{/* 第一列 */}
|
||||
<div className="space-y-3">
|
||||
{/* 成交店铺 */}
|
||||
<div>
|
||||
<label htmlFor="dealShopId" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
成交店铺
|
||||
</label>
|
||||
<select
|
||||
id="dealShopId"
|
||||
name="dealShopId"
|
||||
value={formData.dealShopId || ''}
|
||||
onChange={handleChange}
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
>
|
||||
<option value="">请选择成交店铺</option>
|
||||
{shops.map(shop => (
|
||||
<option key={shop.id} value={shop.id}>
|
||||
{shop.nickname || shop.wechat || shop.accountNo || `店铺ID: ${shop.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 成交日期 */}
|
||||
<div>
|
||||
<label htmlFor="dealDate" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
成交日期 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dealDate"
|
||||
name="dealDate"
|
||||
value={formData.dealDate}
|
||||
onChange={handleChange}
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二列 */}
|
||||
<div className="space-y-3">
|
||||
{/* 收款类型 */}
|
||||
<div>
|
||||
<label htmlFor="paymentType" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
收款类型 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="paymentType"
|
||||
name="paymentType"
|
||||
value={formData.paymentType === undefined ? '' : formData.paymentType}
|
||||
onChange={handleChange}
|
||||
className={`
|
||||
w-full pl-3 pr-10 py-1.5 rounded-md text-sm appearance-none
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
>
|
||||
{renderPaymentTypeOptions()}
|
||||
</select>
|
||||
<div className="absolute top-1/2 right-3 transform -translate-y-1/2 pointer-events-none">
|
||||
{formData.paymentType !== undefined && (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 text-xs rounded-full ${getPaymentTypeColor(formData.paymentType)}`}>
|
||||
{formData.paymentType === PaymentType.FULL_PAYMENT && "全款"}
|
||||
{formData.paymentType === PaymentType.DEPOSIT && "定金"}
|
||||
{formData.paymentType === PaymentType.UNPAID && "未付"}
|
||||
{formData.paymentType === PaymentType.FREE && "赠送"}
|
||||
{formData.paymentType === PaymentType.OTHER && "其他"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 支付平台 */}
|
||||
<div>
|
||||
<label htmlFor="platformId" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
支付平台 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="platformId"
|
||||
name="platformId"
|
||||
value={formData.platformId || ''}
|
||||
onChange={handleChange}
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
>
|
||||
<option value="">请选择支付平台</option>
|
||||
{platforms.map(platform => (
|
||||
<option key={platform.id} value={platform.id}>
|
||||
{platform.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第三列 */}
|
||||
<div className="space-y-3">
|
||||
{/* 金额信息 */}
|
||||
<div>
|
||||
<label htmlFor="receivable" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
应收金额 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="receivable"
|
||||
name="receivable"
|
||||
value={formData.receivable}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="received" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
实收金额 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="received"
|
||||
name="received"
|
||||
value={formData.received}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="pending" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
待收金额
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="pending"
|
||||
name="pending"
|
||||
value={formData.pending}
|
||||
readOnly
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-gray-700/50' : 'bg-gray-100/70'}
|
||||
backdrop-blur-sm border border-white/10 dark:border-white/5
|
||||
focus:outline-none
|
||||
text-gray-700 dark:text-gray-300
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第四列 */}
|
||||
<div className="space-y-3">
|
||||
{/* 当前用户信息 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<MdPerson className="inline mr-1" size={14} />
|
||||
创建人信息
|
||||
</label>
|
||||
<div className={`
|
||||
w-full px-3 py-2 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
text-gray-800 dark:text-white
|
||||
`}>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">用户名:</span>
|
||||
<span className="font-medium">{userProfile.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">用户ID:</span>
|
||||
<span className="font-medium">{userProfile.userId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<label htmlFor="remark" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
备注
|
||||
</label>
|
||||
<textarea
|
||||
id="remark"
|
||||
name="remark"
|
||||
value={formData.remark}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
placeholder="请输入备注信息"
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
resize-none
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`
|
||||
px-5 py-2 rounded-lg text-white font-medium text-sm
|
||||
flex items-center justify-center
|
||||
transition-all duration-200
|
||||
${isSubmitting
|
||||
? 'bg-blue-400/80 cursor-not-allowed'
|
||||
: `${isDarkMode ? 'bg-blue-600/80 hover:bg-blue-500/80' : 'bg-blue-500 hover:bg-blue-600'}
|
||||
hover:shadow-md hover:scale-[1.02] active:scale-[0.98]`
|
||||
}
|
||||
backdrop-blur-md border border-white/20
|
||||
`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center">
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
提交中...
|
||||
</div>
|
||||
) : '创建销售记录'}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
{/* 产品创建模态框 */}
|
||||
<ProductModalAdapter
|
||||
isOpen={isProductModalOpen}
|
||||
onClose={handleProductModalClose}
|
||||
product={null}
|
||||
onSuccess={handleProductSuccess}
|
||||
suppliers={suppliers}
|
||||
brands={brands}
|
||||
categories={categories}
|
||||
availableLevels={availableLevels}
|
||||
onSelectProduct={handleSelectProduct}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/app/team/[teamCode]/sales/sales.d.ts
vendored
Normal file
25
src/app/team/[teamCode]/sales/sales.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 销售记录相关的类型声明
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供销售记录相关组件的类型定义
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* 已选产品列表组件属性
|
||||
*/
|
||||
declare module './components/SelectedProductList' {
|
||||
export interface SelectedProductProps {
|
||||
products: {
|
||||
productId: number;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
total: number;
|
||||
}[];
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
const SelectedProductList: React.FC<SelectedProductProps>;
|
||||
export default SelectedProductList;
|
||||
}
|
||||
590
src/app/team/[teamCode]/sales2/components/AddCustomer.tsx
Normal file
590
src/app/team/[teamCode]/sales2/components/AddCustomer.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* 新增客户组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供新增客户的表单和功能
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTeam } from '@/hooks/useTeam';
|
||||
import { useTheme } from '@/hooks';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { Customer } from './CustomerSelector';
|
||||
// 定义客户性别枚举
|
||||
enum CustomerGender {
|
||||
MALE = '男',
|
||||
FEMALE = '女'
|
||||
}
|
||||
|
||||
// 定义客户地址接口
|
||||
interface CustomerAddress {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
// 定义解析地址API返回的数据结构
|
||||
interface ParseLocationResponse {
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
county: string | null;
|
||||
detail: string | null;
|
||||
full_location: string | null;
|
||||
orig_location: string | null;
|
||||
town: string | null;
|
||||
village: string | null;
|
||||
}
|
||||
|
||||
interface CombinedResponse {
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
address: ParseLocationResponse | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CustomerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
customer?: Customer | null;
|
||||
onSuccess: () => void;
|
||||
teamCode: string;
|
||||
}
|
||||
|
||||
// 辅助函数 - 格式化日期
|
||||
const formatDate = (date: string | Date, format: string = 'YYYY-MM-DD'): string => {
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
|
||||
if (format === 'YYYY-MM-DD') {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 客户模态框组件
|
||||
*/
|
||||
export default function CustomerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
customer,
|
||||
onSuccess,
|
||||
teamCode
|
||||
}: CustomerModalProps) {
|
||||
const { currentTeam } = useTeam();
|
||||
const { isDarkMode } = useTheme();
|
||||
const isEditing = !!customer;
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<Partial<Customer>>({
|
||||
name: '',
|
||||
gender: undefined,
|
||||
phone: '',
|
||||
wechat: '',
|
||||
address: { detail: '' },
|
||||
birthday: '',
|
||||
followDate: '',
|
||||
balance: 0
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isParsingAddress, setIsParsingAddress] = useState(false);
|
||||
const [parsedMessage, setParsedMessage] = useState<{ type: 'success' | 'error' | 'warning', text: string } | null>(null);
|
||||
|
||||
/**
|
||||
* 初始化编辑表单数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (customer) {
|
||||
setFormData({
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
gender: customer.gender,
|
||||
phone: customer.phone,
|
||||
wechat: customer.wechat || '',
|
||||
address: customer.address || { detail: '' },
|
||||
// 格式化日期为YYYY-MM-DD格式
|
||||
birthday: customer.birthday ? formatDate(customer.birthday, 'YYYY-MM-DD') : '',
|
||||
followDate: customer.followDate ? formatDate(customer.followDate, 'YYYY-MM-DD') : '',
|
||||
balance: customer.balance || 0
|
||||
});
|
||||
} else {
|
||||
// 重置表单
|
||||
setFormData({
|
||||
name: '',
|
||||
gender: undefined,
|
||||
phone: '',
|
||||
wechat: '',
|
||||
address: { detail: '' },
|
||||
birthday: '',
|
||||
followDate: '',
|
||||
balance: 0
|
||||
});
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setParsedMessage(null);
|
||||
}, [customer, isOpen]);
|
||||
|
||||
/**
|
||||
* 处理表单输入变化
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// 特殊处理地址字段
|
||||
if (name === 'address') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
detail: value
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理数字类型字段
|
||||
if (name === 'balance') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value === '' ? 0 : parseFloat(value)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析地址文本,提取姓名、电话和地址信息
|
||||
*/
|
||||
const handleAddressParse = async () => {
|
||||
// 获取当前地址文本
|
||||
const addressText = formData.address?.detail || '';
|
||||
|
||||
if (!addressText.trim()) {
|
||||
setParsedMessage({
|
||||
type: 'error',
|
||||
text: '请输入地址信息后再进行解析'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsParsingAddress(true);
|
||||
setParsedMessage(null);
|
||||
|
||||
try {
|
||||
// 使用 fetch 发送请求到App Router格式的API端点
|
||||
const response = await fetch('/api/tools/parseAddress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: addressText }),
|
||||
});
|
||||
|
||||
const data: CombinedResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setParsedMessage({
|
||||
type: 'error',
|
||||
text: data.error || '解析失败'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查解析结果,提示用户手动填写未解析到的字段
|
||||
const missingFields = [];
|
||||
if (!data.address?.city) {
|
||||
missingFields.push('城市');
|
||||
}
|
||||
if (!data.address?.county) {
|
||||
missingFields.push('区县');
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setParsedMessage({
|
||||
type: 'warning',
|
||||
text: `未能自动解析 ${missingFields.join('、')},请手动填写`
|
||||
});
|
||||
} else {
|
||||
setParsedMessage({
|
||||
type: 'success',
|
||||
text: '地址解析完成'
|
||||
});
|
||||
}
|
||||
|
||||
// 自动填充表单字段
|
||||
const newAddress: CustomerAddress = {
|
||||
province: data.address?.province || undefined,
|
||||
city: data.address?.city || undefined,
|
||||
district: data.address?.county || undefined,
|
||||
detail: data.address?.detail || '',
|
||||
};
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: data.name || prev.name || '',
|
||||
phone: data.phone || prev.phone || '',
|
||||
address: newAddress
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('地址解析失败', error);
|
||||
setParsedMessage({
|
||||
type: 'error',
|
||||
text: '地址解析失败,请稍后重试'
|
||||
});
|
||||
} finally {
|
||||
setIsParsingAddress(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单提交处理
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!currentTeam) {
|
||||
setError('未获取到团队信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
if (!formData.name || !formData.phone) {
|
||||
setError('请填写客户姓名和电话');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const apiUrl = isEditing
|
||||
? `/api/team/${teamCode}/customers/${formData.id}`
|
||||
: `/api/team/${teamCode}/customers`;
|
||||
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
|
||||
// 准备请求数据
|
||||
const requestData = {
|
||||
...formData,
|
||||
// 如果地址只有detail且为空,则设为null
|
||||
address: formData.address &&
|
||||
(!formData.address.detail && !formData.address.province &&
|
||||
!formData.address.city && !formData.address.district)
|
||||
? null
|
||||
: formData.address
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '操作失败');
|
||||
}
|
||||
|
||||
// 操作成功
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('客户操作失败:', err);
|
||||
setError(err instanceof Error ? err.message : '操作失败,请重试');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 模态框尾部按钮
|
||||
const modalFooter = (
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
|
||||
}`}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="customerForm"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '提交中...' : (isEditing ? '保存' : '添加')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? '编辑客户' : '添加客户'}
|
||||
footer={modalFooter}
|
||||
size="xl"
|
||||
isGlass={true}
|
||||
closeOnOutsideClick={false}
|
||||
>
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedMessage && (
|
||||
<div className={`mb-4 p-3 rounded-lg text-sm ${parsedMessage.type === 'success'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: parsedMessage.type === 'warning'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{parsedMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="customerForm" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* 姓名 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
姓名 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 电话 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
电话 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="phone"
|
||||
value={formData.phone || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
性别
|
||||
</label>
|
||||
<select
|
||||
name="gender"
|
||||
value={formData.gender || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
>
|
||||
<option value="">未知</option>
|
||||
<option value={CustomerGender.MALE}>男</option>
|
||||
<option value={CustomerGender.FEMALE}>女</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 微信号 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
微信号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="wechat"
|
||||
value={formData.wechat || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 生日 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
生日
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="birthday"
|
||||
value={formData.birthday || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 加粉日期 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
加粉日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="followDate"
|
||||
value={formData.followDate || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账户余额 */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
账户余额
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="balance"
|
||||
value={formData.balance || 0}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 地址区域 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label className={`block text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
详细地址
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddressParse}
|
||||
disabled={isParsingAddress}
|
||||
className={`text-sm px-3 py-1 rounded ${isDarkMode
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors`}
|
||||
>
|
||||
{isParsingAddress ? '解析中...' : '智能解析'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 省市区显示区域 */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
name="province"
|
||||
value={formData.address?.province || ''}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
province: e.target.value
|
||||
}
|
||||
}))}
|
||||
placeholder="省份"
|
||||
className={`px-3 py-2 rounded-lg text-sm ${isDarkMode
|
||||
? 'bg-gray-800/40 border-gray-700 text-gray-300'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-700'
|
||||
} border`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="city"
|
||||
value={formData.address?.city || ''}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
city: e.target.value
|
||||
}
|
||||
}))}
|
||||
placeholder="城市"
|
||||
className={`px-3 py-2 rounded-lg text-sm ${isDarkMode
|
||||
? 'bg-gray-800/40 border-gray-700 text-gray-300'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-700'
|
||||
} border`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="district"
|
||||
value={formData.address?.district || ''}
|
||||
onChange={e => setFormData(prev => ({
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
district: e.target.value
|
||||
}
|
||||
}))}
|
||||
placeholder="区县"
|
||||
className={`px-3 py-2 rounded-lg text-sm ${isDarkMode
|
||||
? 'bg-gray-800/40 border-gray-700 text-gray-300'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-700'
|
||||
} border`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
name="address"
|
||||
value={formData.address?.detail || ''}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
placeholder="输入客户完整地址,可包含姓名和电话,系统将智能识别"
|
||||
className={`w-full px-3 py-2 rounded-lg ${isDarkMode
|
||||
? 'bg-gray-800/60 border-gray-700 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
} border focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
></textarea>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
提示:可输入包含姓名、电话的完整地址,点击"智能解析"自动识别
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
397
src/app/team/[teamCode]/sales2/components/CustomerSelector.tsx
Normal file
397
src/app/team/[teamCode]/sales2/components/CustomerSelector.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* 客户选择器组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供客户搜索和选择功能
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { MdSearch, MdRefresh, MdPersonAdd, MdPerson, MdArrowDropDown, MdClose } from 'react-icons/md';
|
||||
import { useTheme } from '@/hooks';
|
||||
|
||||
// 地址接口类型
|
||||
interface CustomerAddress {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
detail?: string;
|
||||
postalCode?: string;
|
||||
}
|
||||
|
||||
// 客户接口类型
|
||||
export interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
phone?: string;
|
||||
gender?: string;
|
||||
wechat?: string;
|
||||
address?: CustomerAddress;
|
||||
birthday?: string;
|
||||
followDate?: string;
|
||||
balance?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface CustomerSelectorProps {
|
||||
selectedCustomer: Customer | null;
|
||||
onSelectCustomer: (customer: Customer) => void;
|
||||
teamCode: string;
|
||||
onCreateCustomer?: () => void; // 新增客户回调
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户选择器组件
|
||||
* 用于搜索和选择客户
|
||||
*/
|
||||
const CustomerSelector: React.FC<CustomerSelectorProps> = ({
|
||||
selectedCustomer,
|
||||
onSelectCustomer,
|
||||
teamCode,
|
||||
onCreateCustomer
|
||||
}) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 节流查询,300ms后执行
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQuery(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// 获取客户列表
|
||||
const fetchCustomers = useCallback(async (keyword: string = '') => {
|
||||
if (!teamCode) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// 构建API URL
|
||||
const url = `/api/team/${teamCode}/customers?pageSize=20${keyword ? `&keyword=${encodeURIComponent(keyword)}` : ''}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setCustomers(data.customers || []);
|
||||
} else {
|
||||
setErrorMessage(data.error || '获取客户列表失败');
|
||||
setCustomers([]);
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage('网络请求失败,请稍后重试');
|
||||
setCustomers([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [teamCode]);
|
||||
|
||||
// 初始加载和查询变化时获取数据
|
||||
useEffect(() => {
|
||||
fetchCustomers(debouncedQuery);
|
||||
if (debouncedQuery.length > 0 && !isDropdownOpen) {
|
||||
setIsDropdownOpen(true);
|
||||
}
|
||||
}, [fetchCustomers, debouncedQuery, isDropdownOpen]);
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 手动刷新
|
||||
const handleRefresh = () => {
|
||||
fetchCustomers(debouncedQuery);
|
||||
if (!isDropdownOpen) {
|
||||
setIsDropdownOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理搜索输入变化
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
if (e.target.value.length > 0 && !isDropdownOpen) {
|
||||
setIsDropdownOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新客户
|
||||
const handleCreateCustomer = () => {
|
||||
if (onCreateCustomer) {
|
||||
onCreateCustomer();
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 选择客户
|
||||
const handleSelectCustomer = (customer: Customer) => {
|
||||
onSelectCustomer(customer);
|
||||
setIsDropdownOpen(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
// 清除选择的客户
|
||||
const handleClearSelection = (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
// 使用as null来解决类型问题,因为接口定义是Customer | null
|
||||
onSelectCustomer(null as unknown as Customer);
|
||||
setSearchQuery('');
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// 处理下拉框切换
|
||||
const toggleDropdown = () => {
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
if (!isDropdownOpen) {
|
||||
fetchCustomers(searchQuery);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染客户列表项
|
||||
const renderCustomerList = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (customers.length > 0) {
|
||||
return (
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{customers.map(customer => (
|
||||
<div
|
||||
key={customer.id}
|
||||
onClick={() => handleSelectCustomer(customer)}
|
||||
className={`
|
||||
p-3 cursor-pointer
|
||||
${isDarkMode ? 'hover:bg-slate-700/50' : 'hover:bg-gray-50'}
|
||||
border-b border-gray-100 dark:border-gray-800
|
||||
transition-colors duration-150
|
||||
`}
|
||||
>
|
||||
<div className="font-medium flex items-center">
|
||||
{customer.name}
|
||||
{customer.gender && (
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full ${customer.gender === '女' ?
|
||||
(isDarkMode ? 'bg-pink-900/30 text-pink-300' : 'bg-pink-100 text-pink-700') :
|
||||
(isDarkMode ? 'bg-blue-900/30 text-blue-300' : 'bg-blue-100 text-blue-700')}`
|
||||
}>
|
||||
{customer.gender}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{customer.phone && <div className="text-xs text-gray-500 dark:text-gray-400">{customer.phone}</div>}
|
||||
{customer.wechat && <div className="text-xs text-gray-500 dark:text-gray-400">微信:{customer.wechat}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (customers.length === 0 && debouncedQuery) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
|
||||
没有找到匹配的客户
|
||||
{onCreateCustomer && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateCustomer}
|
||||
className="block mx-auto mt-2 text-blue-500 dark:text-blue-400 hover:underline text-sm"
|
||||
>
|
||||
创建新客户
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (customers.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center items-center flex-col py-6">
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center
|
||||
${isDarkMode ? 'bg-gray-800/50' : 'bg-gray-100'} mb-3`}>
|
||||
<MdPerson className={`w-10 h-10 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||
</div>
|
||||
<div className={`text-center ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} text-sm font-medium`}>
|
||||
请选择一位客户 或
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateCustomer}
|
||||
className={`
|
||||
mt-2 px-3 py-1.5 rounded-lg flex items-center justify-center text-sm
|
||||
${isDarkMode ? 'bg-blue-600 hover:bg-blue-500' : 'bg-blue-500 hover:bg-blue-600'}
|
||||
text-white transition-colors duration-200
|
||||
`}
|
||||
>
|
||||
<MdPersonAdd className="mr-1" size={16} />
|
||||
新增客户
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* 搜索输入框 */}
|
||||
<div className="relative">
|
||||
{selectedCustomer ? (
|
||||
<div
|
||||
className={`
|
||||
w-full px-3 py-2 rounded-lg flex items-center justify-between
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10 cursor-pointer
|
||||
`}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0
|
||||
${isDarkMode ? 'bg-blue-900/30' : 'bg-blue-100'} mr-2`}>
|
||||
<MdPerson className={`w-5 h-5 ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200 flex items-center">
|
||||
{selectedCustomer.name}
|
||||
{selectedCustomer.gender && (
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full ${selectedCustomer.gender === '女' ?
|
||||
(isDarkMode ? 'bg-pink-900/30 text-pink-300' : 'bg-pink-100 text-pink-700') :
|
||||
(isDarkMode ? 'bg-blue-900/30 text-blue-300' : 'bg-blue-100 text-blue-700')}`
|
||||
}>
|
||||
{selectedCustomer.gender}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedCustomer.phone &&
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{selectedCustomer.phone}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSelection}
|
||||
className="p-1.5 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 mr-1"
|
||||
title="清除选择"
|
||||
>
|
||||
<MdClose size={16} className="text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
<MdArrowDropDown
|
||||
className={`text-gray-500 dark:text-gray-400 transform transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="搜索客户(姓名/手机/微信)..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onFocus={() => setIsDropdownOpen(true)}
|
||||
className={`
|
||||
w-full px-3 py-2.5 pl-10 pr-10 rounded-lg
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
/>
|
||||
<MdSearch className="absolute left-3 top-3 text-gray-500 dark:text-gray-400" size={20} />
|
||||
<div className="absolute right-0 top-0 h-full flex items-center pr-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className={`p-1.5 ${isLoading ? 'animate-spin' : ''} text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400`}
|
||||
title="刷新客户列表"
|
||||
>
|
||||
<MdRefresh size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDropdown}
|
||||
className="p-1.5 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<MdArrowDropDown
|
||||
className={`transform transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
size={20}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onCreateCustomer && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateCustomer}
|
||||
className={`
|
||||
ml-2 px-2 rounded-lg flex items-center
|
||||
${isDarkMode ? 'bg-blue-600/70 hover:bg-blue-500/70' : 'bg-blue-100 hover:bg-blue-200'}
|
||||
${isDarkMode ? 'text-white' : 'text-blue-700'}
|
||||
transition-colors duration-200
|
||||
`}
|
||||
title="新建客户"
|
||||
>
|
||||
<MdPersonAdd size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{errorMessage && (
|
||||
<div className={`mt-2 p-3 text-sm rounded-lg ${isDarkMode ? 'bg-red-900/20 text-red-300' : 'bg-red-50 text-red-700'}`}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 下拉列表 */}
|
||||
{isDropdownOpen && (
|
||||
<div className={`
|
||||
absolute z-10 mt-1 w-full overflow-hidden rounded-lg
|
||||
${isDarkMode ? 'bg-gray-900 border border-gray-800' : 'bg-white border border-gray-200'}
|
||||
shadow-lg
|
||||
`}>
|
||||
{renderCustomerList()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerSelector;
|
||||
157
src/app/team/[teamCode]/sales2/components/ProductSelector.tsx
Normal file
157
src/app/team/[teamCode]/sales2/components/ProductSelector.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 产品选择器组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供产品搜索和选择功能
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { MdSearch } from 'react-icons/md';
|
||||
import { useTheme } from '@/hooks';
|
||||
|
||||
// 产品接口类型
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
image?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ProductSelectorProps {
|
||||
selectedProduct: Product | null;
|
||||
onSelectProduct: (product: Product) => void;
|
||||
productPrice: number;
|
||||
onPriceChange: (price: number) => void;
|
||||
productQuantity: number;
|
||||
onQuantityChange: (quantity: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品选择器组件
|
||||
* 用于搜索和选择产品
|
||||
*/
|
||||
const ProductSelector: React.FC<ProductSelectorProps> = ({
|
||||
selectedProduct,
|
||||
onSelectProduct,
|
||||
productPrice,
|
||||
onPriceChange,
|
||||
productQuantity,
|
||||
onQuantityChange
|
||||
}) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 模拟一些产品数据
|
||||
const [products] = useState<Product[]>([
|
||||
{ id: 1, name: "产品A", price: 199.99, description: "这是产品A的描述" },
|
||||
{ id: 2, name: "产品B", price: 299.99, description: "这是产品B的描述" },
|
||||
{ id: 3, name: "产品C", price: 99.99, description: "这是产品C的描述" }
|
||||
]);
|
||||
|
||||
// 过滤后的产品列表
|
||||
const filteredProducts = searchQuery.trim() === ''
|
||||
? products
|
||||
: products.filter(product =>
|
||||
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
product.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// 处理搜索输入变化
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索产品..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className={`
|
||||
w-full px-3 py-2 pl-10 rounded-lg
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
/>
|
||||
<MdSearch className="absolute left-3 top-2.5 text-gray-500 dark:text-gray-400" size={20} />
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredProducts.length > 0 ? (
|
||||
filteredProducts.map(product => (
|
||||
<div
|
||||
key={product.id}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className={`
|
||||
p-2 mb-2 rounded-lg cursor-pointer
|
||||
${selectedProduct?.id === product.id
|
||||
? (isDarkMode ? 'bg-blue-900/30 border-blue-500' : 'bg-blue-50 border-blue-200')
|
||||
: (isDarkMode ? 'bg-slate-800/30 hover:bg-slate-700/30' : 'bg-white hover:bg-gray-50')}
|
||||
border border-transparent
|
||||
${selectedProduct?.id === product.id ? 'border-opacity-100' : 'hover:border-gray-200 dark:hover:border-gray-700'}
|
||||
transition-colors duration-150
|
||||
`}
|
||||
>
|
||||
<div className="font-medium">{product.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">¥{product.price.toFixed(2)}</div>
|
||||
{product.description && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500 mt-1 truncate">{product.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={`p-3 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
没有找到匹配的产品
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="数量"
|
||||
value={productQuantity}
|
||||
onChange={(e) => onQuantityChange(parseInt(e.target.value) || 1)}
|
||||
min="1"
|
||||
className={`
|
||||
w-full px-3 py-2 rounded-lg
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
transition-all duration-200
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="单价"
|
||||
value={productPrice}
|
||||
onChange={(e) => onPriceChange(parseFloat(e.target.value) || 0)}
|
||||
step="0.01"
|
||||
min="0"
|
||||
className={`
|
||||
w-full px-3 py-2 rounded-lg
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
transition-all duration-200
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductSelector;
|
||||
712
src/app/team/[teamCode]/sales2/page.tsx
Normal file
712
src/app/team/[teamCode]/sales2/page.tsx
Normal file
@@ -0,0 +1,712 @@
|
||||
/**
|
||||
* 创建销售记录页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供创建销售记录数据的功能,目前仅做示例数据
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTheme } from '@/hooks';
|
||||
import { useTeam } from '@/hooks/useTeam';
|
||||
import { MdShoppingCart, MdAdd, MdPerson, MdLocalShipping, MdAddCircleOutline, MdDelete } from 'react-icons/md';
|
||||
//从@/utils导入所有工具
|
||||
// 导入UI组件
|
||||
import Card from '@/components/ui/Card';
|
||||
// 导入自定义组件
|
||||
import CustomerSelector, { Customer } from './components/CustomerSelector';
|
||||
import ProductSelector, { Product } from './components/ProductSelector';
|
||||
import CustomerModal from './components/AddCustomer';
|
||||
|
||||
interface SelectedProductItem {
|
||||
product: Product;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
enum PaymentType {
|
||||
FULL_PAYMENT = 0, // 全款
|
||||
DEPOSIT = 1, // 定金
|
||||
UNPAID = 2, // 未付
|
||||
FREE = 3, // 赠送
|
||||
OTHER = 4 // 其他
|
||||
}
|
||||
|
||||
interface SalesFormData {
|
||||
customerId: number | null;
|
||||
products: SelectedProductItem[];
|
||||
dealDate: string;
|
||||
paymentType?: PaymentType;
|
||||
platformId?: number;
|
||||
receivable: number;
|
||||
received: number;
|
||||
pending: number;
|
||||
dealShopId?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已选产品列表组件
|
||||
* 显示已添加到销售记录的产品
|
||||
*/
|
||||
const SelectedProductList = ({
|
||||
products,
|
||||
onRemove
|
||||
}: {
|
||||
products: SelectedProductItem[],
|
||||
onRemove: (index: number) => void
|
||||
}) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
if (products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className={`${isDarkMode ? 'text-gray-300' : 'text-gray-700'} text-sm border-b border-gray-200 dark:border-gray-700`}>
|
||||
<th className="text-left px-2 py-1.5">产品名称</th>
|
||||
<th className="text-right px-2 py-1.5">单价</th>
|
||||
<th className="text-right px-2 py-1.5">数量</th>
|
||||
<th className="text-right px-2 py-1.5">金额</th>
|
||||
<th className="text-center px-2 py-1.5">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((item, index) => (
|
||||
<tr
|
||||
key={`${item.product.id}-${index}`}
|
||||
className={`border-b ${isDarkMode ? 'border-gray-700 hover:bg-gray-800/50' : 'border-gray-100 hover:bg-gray-50'} text-sm`}
|
||||
>
|
||||
<td className="px-2 py-2">{item.product.name}</td>
|
||||
<td className="text-right px-2 py-2">¥{item.price.toFixed(2)}</td>
|
||||
<td className="text-right px-2 py-2">{item.quantity}</td>
|
||||
<td className="text-right px-2 py-2 font-medium">¥{(item.price * item.quantity).toFixed(2)}</td>
|
||||
<td className="text-center px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className={`inline-flex items-center p-1 rounded-full
|
||||
${isDarkMode
|
||||
? 'text-red-300 hover:bg-red-900/40 hover:text-red-200'
|
||||
: 'text-red-500 hover:bg-red-50 hover:text-red-700'}`}
|
||||
>
|
||||
<MdDelete size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className={`font-medium ${isDarkMode ? 'text-white bg-slate-800/70' : 'text-gray-800 bg-gray-50'}`}>
|
||||
<td className="px-2 py-2">总计</td>
|
||||
<td className="px-2 py-2"></td>
|
||||
<td className="text-right px-2 py-2">{products.reduce((sum, item) => sum + item.quantity, 0)}</td>
|
||||
<td className="text-right px-2 py-2">¥{products.reduce((sum, item) => sum + (item.price * item.quantity), 0).toFixed(2)}</td>
|
||||
<td className="px-2 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 销售记录页面组件
|
||||
* 展示销售记录列表和创建销售记录功能
|
||||
*/
|
||||
export default function SalesPage() {
|
||||
// 获取路由参数和团队信息
|
||||
const { isDarkMode } = useTheme();
|
||||
const { currentTeam } = useTeam();
|
||||
const teamCode = currentTeam?.team_code || '';
|
||||
|
||||
// 页面状态管理
|
||||
const [isPageLoading, setIsPageLoading] = useState(true);
|
||||
const [error] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 客户和产品选择状态
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [productQuantity, setProductQuantity] = useState<number>(1);
|
||||
const [productPrice, setProductPrice] = useState<number>(0);
|
||||
|
||||
// 客户模态框状态
|
||||
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState<SalesFormData>({
|
||||
customerId: null,
|
||||
products: [],
|
||||
dealDate: new Date().toISOString().slice(0, 10), // 当前日期
|
||||
receivable: 0,
|
||||
received: 0,
|
||||
pending: 0
|
||||
});
|
||||
|
||||
// 模拟支付平台
|
||||
const [platforms] = useState([
|
||||
{ id: 1, name: "微信支付" },
|
||||
{ id: 2, name: "支付宝" },
|
||||
{ id: 3, name: "银行转账" }
|
||||
]);
|
||||
|
||||
// 模拟店铺
|
||||
const [shops] = useState([
|
||||
{ id: 1, nickname: "官方旗舰店", wechat: "shop1" },
|
||||
{ id: 2, nickname: "直营店", wechat: "shop2" }
|
||||
]);
|
||||
|
||||
// 选择客户处理函数
|
||||
const handleSelectCustomer = (customer: Customer) => {
|
||||
setSelectedCustomer(customer);
|
||||
setFormData(prev => ({...prev, customerId: customer.id}));
|
||||
};
|
||||
|
||||
// 处理创建新客户
|
||||
const handleCreateCustomer = () => {
|
||||
// 打开新建客户模态框
|
||||
setIsCustomerModalOpen(true);
|
||||
};
|
||||
|
||||
// 客户模态框成功回调
|
||||
const handleCustomerSuccess = () => {
|
||||
// 刷新客户列表
|
||||
// TODO: 实现刷新客户列表的逻辑
|
||||
};
|
||||
|
||||
// 选择产品处理函数
|
||||
const handleSelectProduct = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
setProductPrice(product.price);
|
||||
};
|
||||
|
||||
// 添加产品到表单
|
||||
const handleAddProduct = () => {
|
||||
if (!selectedProduct) return;
|
||||
|
||||
const newProduct: SelectedProductItem = {
|
||||
product: selectedProduct,
|
||||
quantity: productQuantity,
|
||||
price: productPrice
|
||||
};
|
||||
|
||||
// 计算新的总额
|
||||
|
||||
setFormData(prev => {
|
||||
const updatedProducts = [...prev.products, newProduct];
|
||||
const newReceivable = updatedProducts.reduce((total, item) =>
|
||||
total + (item.quantity * item.price), 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
products: updatedProducts,
|
||||
receivable: newReceivable,
|
||||
received: newReceivable, // 默认实收等于应收
|
||||
pending: 0 // 默认待收为0
|
||||
};
|
||||
});
|
||||
|
||||
// 重置选择
|
||||
setSelectedProduct(null);
|
||||
setProductQuantity(1);
|
||||
};
|
||||
|
||||
// 移除已选产品
|
||||
const handleRemoveProduct = (index: number) => {
|
||||
setFormData(prev => {
|
||||
const updatedProducts = [...prev.products];
|
||||
updatedProducts.splice(index, 1);
|
||||
|
||||
const newReceivable = updatedProducts.reduce((total, item) =>
|
||||
total + (item.quantity * item.price), 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
products: updatedProducts,
|
||||
receivable: newReceivable,
|
||||
received: newReceivable,
|
||||
pending: 0
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 处理表单输入变化
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
setFormData(prev => {
|
||||
const updated = {...prev, [name]: value};
|
||||
|
||||
// 如果修改了应收或实收,自动计算待收
|
||||
if (name === 'receivable' || name === 'received') {
|
||||
const receivable = name === 'receivable' ? parseFloat(value) || 0 : prev.receivable;
|
||||
const received = name === 'received' ? parseFloat(value) || 0 : prev.received;
|
||||
updated.pending = Math.max(0, receivable - received);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// 这里应该添加表单验证
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// 模拟API请求
|
||||
setTimeout(() => {
|
||||
setSuccessMessage('销售记录创建成功!');
|
||||
setIsSubmitting(false);
|
||||
|
||||
// 重置表单
|
||||
setFormData({
|
||||
customerId: null,
|
||||
products: [],
|
||||
dealDate: new Date().toISOString().slice(0, 10),
|
||||
receivable: 0,
|
||||
received: 0,
|
||||
pending: 0
|
||||
});
|
||||
setSelectedCustomer(null);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// 支付类型选项渲染
|
||||
const renderPaymentTypeOptions = () => {
|
||||
return (
|
||||
<>
|
||||
<option value="">请选择收款类型</option>
|
||||
<option value={PaymentType.FULL_PAYMENT}>全款</option>
|
||||
<option value={PaymentType.DEPOSIT}>定金</option>
|
||||
<option value={PaymentType.UNPAID}>未付</option>
|
||||
<option value={PaymentType.FREE}>赠送</option>
|
||||
<option value={PaymentType.OTHER}>其他</option>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 获取支付类型颜色
|
||||
|
||||
// 数据加载模拟
|
||||
useEffect(() => {
|
||||
// 模拟数据加载
|
||||
const timer = setTimeout(() => {
|
||||
setIsPageLoading(false);
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full mb-10">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white flex items-center">
|
||||
<MdShoppingCart className="mr-2 text-blue-500" size={24} />
|
||||
销售记录
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 客户模态框 */}
|
||||
<CustomerModal
|
||||
isOpen={isCustomerModalOpen}
|
||||
onClose={() => setIsCustomerModalOpen(false)}
|
||||
onSuccess={handleCustomerSuccess}
|
||||
teamCode={teamCode}
|
||||
/>
|
||||
|
||||
{/* 加载状态显示 */}
|
||||
{isPageLoading ? (
|
||||
<Card
|
||||
padding="large"
|
||||
className="flex justify-center items-center py-20"
|
||||
isLoading={true}
|
||||
loadingText="正在加载数据..."
|
||||
>
|
||||
<div></div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* 成功消息 */}
|
||||
{successMessage && (
|
||||
<div className={`
|
||||
mb-4 p-3 rounded-lg border-l-4 border-green-500
|
||||
${isDarkMode ? 'bg-green-900/20' : 'bg-green-50'}
|
||||
text-green-700 dark:text-green-300
|
||||
`}>
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p>{successMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className={`
|
||||
mb-4 p-3 rounded-lg border-l-4 border-red-500
|
||||
${isDarkMode ? 'bg-red-900/20' : 'bg-red-50'}
|
||||
text-red-700 dark:text-red-300
|
||||
`}>
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* 主要内容区域 */}
|
||||
<div className="space-y-4">
|
||||
{/* 上部分 - 客户与产品选择 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 左侧栏 - 客户选择 */}
|
||||
<Card
|
||||
padding="medium"
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<MdPerson className="mr-1.5 text-blue-500" />
|
||||
<span>客户选择</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CustomerSelector
|
||||
selectedCustomer={selectedCustomer}
|
||||
onSelectCustomer={handleSelectCustomer}
|
||||
teamCode={teamCode}
|
||||
onCreateCustomer={handleCreateCustomer}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 右侧栏 - 产品选择 */}
|
||||
<Card
|
||||
padding="medium"
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center">
|
||||
<MdLocalShipping className="mr-1.5 text-blue-500" />
|
||||
<span>产品选择</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
px-2 py-1 rounded-md text-xs
|
||||
flex items-center
|
||||
${isDarkMode
|
||||
? 'bg-green-600/70 hover:bg-green-500/70 text-white'
|
||||
: 'bg-green-100 hover:bg-green-200 text-green-700'}
|
||||
transition-colors duration-200
|
||||
`}
|
||||
>
|
||||
<MdAddCircleOutline className="mr-1" size={16} />
|
||||
新建产品
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ProductSelector
|
||||
selectedProduct={selectedProduct}
|
||||
onSelectProduct={handleSelectProduct}
|
||||
productPrice={productPrice}
|
||||
onPriceChange={setProductPrice}
|
||||
productQuantity={productQuantity}
|
||||
onQuantityChange={setProductQuantity}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddProduct}
|
||||
disabled={!selectedProduct}
|
||||
className={`
|
||||
w-full px-4 py-2 rounded-lg text-white font-medium mt-3
|
||||
flex items-center justify-center
|
||||
transition-all duration-200
|
||||
${selectedProduct
|
||||
? `${isDarkMode ? 'bg-blue-600/80 hover:bg-blue-500/80' : 'bg-blue-500 hover:bg-blue-600'}
|
||||
hover:shadow-md hover:scale-[1.02] active:scale-[0.98]`
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
}
|
||||
backdrop-blur-md border border-white/20
|
||||
`}
|
||||
>
|
||||
<MdAdd className="mr-1.5" size={18} />
|
||||
添加产品
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 产品列表部分 - 仅在有产品时显示 */}
|
||||
{formData.products.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<Card
|
||||
padding="small"
|
||||
title="已选产品"
|
||||
>
|
||||
<SelectedProductList
|
||||
products={formData.products}
|
||||
onRemove={handleRemoveProduct}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 订单详细信息 */}
|
||||
<Card
|
||||
padding="medium"
|
||||
title="订单详细信息"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||
{/* 第一列 */}
|
||||
<div className="space-y-3">
|
||||
{/* 成交店铺 */}
|
||||
<div>
|
||||
<label htmlFor="dealShopId" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
成交店铺
|
||||
</label>
|
||||
<select
|
||||
id="dealShopId"
|
||||
name="dealShopId"
|
||||
value={formData.dealShopId || ''}
|
||||
onChange={handleChange}
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
>
|
||||
<option value="">请选择成交店铺</option>
|
||||
{shops.map(shop => (
|
||||
<option key={shop.id} value={shop.id}>
|
||||
{shop.nickname || shop.wechat || `店铺ID: ${shop.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 成交日期 */}
|
||||
<div>
|
||||
<label htmlFor="dealDate" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
成交日期 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dealDate"
|
||||
name="dealDate"
|
||||
value={formData.dealDate}
|
||||
onChange={handleChange}
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二列 */}
|
||||
<div className="space-y-3">
|
||||
{/* 收款类型 */}
|
||||
<div>
|
||||
<label htmlFor="paymentType" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
收款类型 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="paymentType"
|
||||
name="paymentType"
|
||||
value={formData.paymentType === undefined ? '' : formData.paymentType}
|
||||
onChange={handleChange}
|
||||
className={` w-full pl-3 pr-10 py-1.5 rounded-md text-sm appearance-none
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
>
|
||||
{renderPaymentTypeOptions()}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 支付平台 */}
|
||||
<div>
|
||||
<label htmlFor="platformId" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
支付平台 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="platformId"
|
||||
name="platformId"
|
||||
value={formData.platformId || ''}
|
||||
onChange={handleChange}
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
>
|
||||
<option value="">请选择支付平台</option>
|
||||
{platforms.map(platform => (
|
||||
<option key={platform.id} value={platform.id}>
|
||||
{platform.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第三列 */}
|
||||
<div className="space-y-3">
|
||||
{/* 金额信息 */}
|
||||
<div>
|
||||
<label htmlFor="receivable" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
应收金额 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="receivable"
|
||||
name="receivable"
|
||||
value={formData.receivable}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="received" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
实收金额 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="received"
|
||||
name="received"
|
||||
value={formData.received}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="pending" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
待收金额
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="pending"
|
||||
name="pending"
|
||||
value={formData.pending}
|
||||
readOnly
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-gray-700/50' : 'bg-gray-100/70'}
|
||||
backdrop-blur-sm border border-white/10 dark:border-white/5
|
||||
focus:outline-none
|
||||
text-gray-700 dark:text-gray-300
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第四列 */}
|
||||
<div className="space-y-3">
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<label htmlFor="remark" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
备注
|
||||
</label>
|
||||
<textarea
|
||||
id="remark"
|
||||
name="remark"
|
||||
value={formData.remark || ''}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
placeholder="请输入备注信息"
|
||||
className={`
|
||||
w-full px-3 py-1.5 rounded-md text-sm
|
||||
${isDarkMode ? 'bg-slate-800/50' : 'bg-white/50'}
|
||||
backdrop-blur-sm border border-white/20 dark:border-white/10
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent
|
||||
text-gray-800 dark:text-white
|
||||
resize-none
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`
|
||||
px-5 py-2 rounded-lg text-white font-medium text-sm
|
||||
flex items-center justify-center
|
||||
transition-all duration-200
|
||||
${isSubmitting
|
||||
? 'bg-blue-400/80 cursor-not-allowed'
|
||||
: `${isDarkMode ? 'bg-blue-600/80 hover:bg-blue-500/80' : 'bg-blue-500 hover:bg-blue-600'}
|
||||
hover:shadow-md hover:scale-[1.02] active:scale-[0.98]`
|
||||
}
|
||||
backdrop-blur-md border border-white/20
|
||||
`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center">
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
提交中...
|
||||
</div>
|
||||
) : '创建销售记录'}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 控制面板组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供数据控制和筛选功能
|
||||
* 版本: 1.3.0
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { MdRefresh, MdSave, MdWarning } from 'react-icons/md';
|
||||
|
||||
/**
|
||||
* 控制面板属性
|
||||
*/
|
||||
interface ControlPanelProps {
|
||||
selectedMonth: string;
|
||||
handleMonthChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
refreshData: () => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
saveAllEditedData: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
isLoadingRef: boolean;
|
||||
savingData: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制面板组件 - 使用memo优化渲染性能
|
||||
*/
|
||||
const ControlPanel = memo(({
|
||||
selectedMonth,
|
||||
handleMonthChange,
|
||||
refreshData,
|
||||
hasUnsavedChanges,
|
||||
saveAllEditedData,
|
||||
isLoading,
|
||||
isLoadingRef,
|
||||
savingData,
|
||||
isDarkMode
|
||||
}: ControlPanelProps) => {
|
||||
return (
|
||||
<Card
|
||||
glassEffect={isDarkMode ? 'medium' : 'light'}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Input
|
||||
type="month"
|
||||
value={selectedMonth}
|
||||
onChange={handleMonthChange}
|
||||
className="w-40"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
buttonStyle="outline"
|
||||
icon={<MdRefresh className="text-xl" />}
|
||||
onClick={refreshData}
|
||||
disabled={isLoading || isLoadingRef}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{hasUnsavedChanges && (
|
||||
<div className="flex items-center">
|
||||
<MdWarning className={`text-xl mr-1 ${isDarkMode ? 'text-yellow-300' : 'text-yellow-600'}`} />
|
||||
<span className={`text-sm font-medium ${isDarkMode ? 'text-yellow-300' : 'text-yellow-600'}`}>
|
||||
您有未保存的修改,请点击保存按钮
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant={isDarkMode ? "success" : "primary"}
|
||||
icon={<MdSave className="text-xl" />}
|
||||
onClick={saveAllEditedData}
|
||||
disabled={!hasUnsavedChanges || savingData}
|
||||
className={`${hasUnsavedChanges ? 'animate-pulse' : ''} ${
|
||||
hasUnsavedChanges
|
||||
? isDarkMode
|
||||
? 'bg-green-500 hover:bg-green-600 text-white font-bold shadow-md'
|
||||
: 'bg-blue-500 hover:bg-blue-600 text-white font-bold shadow-md'
|
||||
: isDarkMode
|
||||
? 'bg-green-600 hover:bg-green-700 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
}`}
|
||||
size="lg"
|
||||
>
|
||||
{savingData ? '保存中...' : '保存所有更改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
// 设置组件显示名称,便于调试
|
||||
ControlPanel.displayName = 'ControlPanel';
|
||||
|
||||
export default ControlPanel;
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 店铺数据行组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 展示单个店铺的粉丝增长数据行
|
||||
* 版本: 1.3.1
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { MdStorefront } from 'react-icons/md';
|
||||
import Input from '@/components/ui/Input';
|
||||
|
||||
/**
|
||||
* 店铺粉丝增长数据接口
|
||||
*/
|
||||
interface ShopFollowerGrowth {
|
||||
id: number;
|
||||
shop_id: number;
|
||||
date: string;
|
||||
total: number;
|
||||
deducted: number;
|
||||
daily_increase: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
shop_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期数据接口
|
||||
*/
|
||||
interface DateColumn {
|
||||
date: string;
|
||||
formatted: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺数据接口
|
||||
*/
|
||||
interface Shop {
|
||||
id: number;
|
||||
wechat: string;
|
||||
nickname: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺数据行属性
|
||||
*/
|
||||
interface ShopDataRowProps {
|
||||
shop: Shop;
|
||||
monthDates: DateColumn[];
|
||||
growthRecords: ShopFollowerGrowth[];
|
||||
editedData: Record<number, Record<string, { total: number; deducted: number }>>;
|
||||
isDarkMode: boolean;
|
||||
calculateMonthlyGrowth: (shopId: number) => number;
|
||||
getRecordData: (shopId: number, date: string) => { total: number; deducted: number };
|
||||
handleDataChange: (shopId: number, date: string, field: 'total' | 'deducted', value: number) => void;
|
||||
saveRecord: (shopId: number, date: string, retryCount?: number) => Promise<ShopFollowerGrowth | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺数据行组件 - 使用memo优化渲染性能
|
||||
*/
|
||||
const ShopDataRow = memo(({
|
||||
shop,
|
||||
monthDates,
|
||||
growthRecords,
|
||||
editedData,
|
||||
isDarkMode,
|
||||
calculateMonthlyGrowth,
|
||||
getRecordData,
|
||||
handleDataChange}: ShopDataRowProps) => {
|
||||
/**
|
||||
* 计算日增长量
|
||||
*/
|
||||
const getDailyIncrease = (shopId: number, date: string) => {
|
||||
const recordData = getRecordData(shopId, date);
|
||||
const record = growthRecords.find(r => r.shop_id === shopId && r.date === date);
|
||||
return record ? record.daily_increase : (recordData.total - recordData.deducted);
|
||||
};
|
||||
|
||||
/**
|
||||
* 子列样式配置 - 固定列宽样式,与表头保持一致
|
||||
*/
|
||||
const cellStyles = {
|
||||
width: '70px',
|
||||
minWidth: '70px',
|
||||
maxWidth: '70px'
|
||||
};
|
||||
|
||||
return (
|
||||
<tr>
|
||||
{/* 店铺信息 */}
|
||||
<td
|
||||
className={`sticky left-0 z-10 px-3 py-2 border ${
|
||||
isDarkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MdStorefront className={isDarkMode ? 'text-blue-400' : 'text-blue-500'} />
|
||||
<div>
|
||||
<div className="font-medium">{shop.nickname || '未命名店铺'}</div>
|
||||
<div className="text-xs">{shop.wechat}</div>
|
||||
<div className={`text-xs mt-1 ${
|
||||
calculateMonthlyGrowth(shop.id) > 0
|
||||
? 'text-green-500'
|
||||
: calculateMonthlyGrowth(shop.id) < 0
|
||||
? 'text-red-500'
|
||||
: isDarkMode ? 'text-gray-400' : 'text-gray-500'
|
||||
}`}>
|
||||
月增长: {calculateMonthlyGrowth(shop.id)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 每一天的数据单元格 */}
|
||||
{monthDates.map((dateInfo) => {
|
||||
const recordData = getRecordData(shop.id, dateInfo.date);
|
||||
const isEdited = Boolean(editedData[shop.id]?.[dateInfo.date]);
|
||||
const dailyIncrease = getDailyIncrease(shop.id, dateInfo.date);
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${shop.id}-${dateInfo.date}`}>
|
||||
{/* 总数 */}
|
||||
<td
|
||||
className={`px-1 py-1 border text-center ${
|
||||
isDarkMode ? 'border-gray-700' : 'border-gray-200'
|
||||
} ${isEdited ? (isDarkMode ? 'bg-blue-900/30' : 'bg-blue-50') : ''}`}
|
||||
style={cellStyles}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={recordData.total}
|
||||
onChange={(e) => handleDataChange(shop.id, dateInfo.date, 'total', parseInt(e.target.value || '0'))}
|
||||
className="w-full text-center"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* 扣除 */}
|
||||
<td
|
||||
className={`px-1 py-1 border text-center ${
|
||||
isDarkMode ? 'border-gray-700' : 'border-gray-200'
|
||||
} ${isEdited ? (isDarkMode ? 'bg-blue-900/30' : 'bg-blue-50') : ''}`}
|
||||
style={cellStyles}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={recordData.deducted}
|
||||
onChange={(e) => handleDataChange(shop.id, dateInfo.date, 'deducted', parseInt(e.target.value || '0'))}
|
||||
className="w-full text-center"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* 增长 */}
|
||||
<td
|
||||
className={`px-1 py-1 text-center border ${
|
||||
isDarkMode ? 'border-gray-700' : 'border-gray-200'
|
||||
} ${
|
||||
dailyIncrease > 0
|
||||
? isDarkMode ? 'text-green-400' : 'text-green-600'
|
||||
: dailyIncrease < 0
|
||||
? isDarkMode ? 'text-red-400' : 'text-red-600'
|
||||
: ''
|
||||
}`}
|
||||
style={cellStyles}
|
||||
>
|
||||
{dailyIncrease}
|
||||
</td>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
// 设置组件显示名称,便于调试
|
||||
ShopDataRow.displayName = 'ShopDataRow';
|
||||
|
||||
export default ShopDataRow;
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 表格头部组件
|
||||
* 作者: 阿瑞
|
||||
* 功能: 展示店铺粉丝增长表格的头部
|
||||
* 版本: 1.1.0
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
|
||||
/**
|
||||
* 日期数据接口
|
||||
*/
|
||||
interface DateColumn {
|
||||
date: string;
|
||||
formatted: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格头部属性
|
||||
*/
|
||||
interface TableHeaderProps {
|
||||
monthDates: DateColumn[];
|
||||
isDarkMode: boolean;
|
||||
calculateDateTotalGrowth: (date: string) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格头部组件 - 使用memo优化渲染性能
|
||||
*/
|
||||
const TableHeader = memo(({
|
||||
monthDates,
|
||||
isDarkMode,
|
||||
calculateDateTotalGrowth
|
||||
}: TableHeaderProps) => {
|
||||
/**
|
||||
* 子列样式配置 - 固定列宽样式
|
||||
*/
|
||||
const subColumnStyles = {
|
||||
width: '70px',
|
||||
minWidth: '70px',
|
||||
maxWidth: '70px'
|
||||
};
|
||||
|
||||
return (
|
||||
<thead>
|
||||
<tr>
|
||||
{/* 固定的店铺列 */}
|
||||
<th
|
||||
className={`sticky left-0 z-10 px-4 py-2 border ${
|
||||
isDarkMode ? 'bg-gray-800 border-gray-700' : 'bg-gray-100 border-gray-200'
|
||||
}`}
|
||||
style={{ minWidth: '160px' }}
|
||||
>
|
||||
店铺信息
|
||||
</th>
|
||||
|
||||
{/* 日期列 */}
|
||||
{monthDates.map((dateInfo) => (
|
||||
<th
|
||||
key={dateInfo.date}
|
||||
colSpan={3}
|
||||
className={`px-2 py-1 text-center border ${
|
||||
isDarkMode ? 'border-gray-700' : 'border-gray-200'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isDarkMode
|
||||
? `${dateInfo.backgroundColor}30`
|
||||
: dateInfo.backgroundColor,
|
||||
width: '210px', // 固定为3个子列的总宽度
|
||||
minWidth: '210px'
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{dateInfo.formatted}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
总增长: {calculateDateTotalGrowth(dateInfo.date)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* 子表头 */}
|
||||
<tr>
|
||||
{/* 空的店铺列表头 */}
|
||||
<th
|
||||
className={`sticky left-0 z-10 px-4 py-1 border ${
|
||||
isDarkMode ? 'bg-gray-800 border-gray-700' : 'bg-gray-100 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
月增长
|
||||
</th>
|
||||
|
||||
{/* 每个日期的子列 */}
|
||||
{monthDates.map((dateInfo) => (
|
||||
<React.Fragment key={`sub-${dateInfo.date}`}>
|
||||
<th
|
||||
className={`px-1 py-1 text-xs border text-center ${
|
||||
isDarkMode ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
style={subColumnStyles}
|
||||
>
|
||||
总数
|
||||
</th>
|
||||
<th
|
||||
className={`px-1 py-1 text-xs border text-center ${
|
||||
isDarkMode ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
style={subColumnStyles}
|
||||
>
|
||||
扣除
|
||||
</th>
|
||||
<th
|
||||
className={`px-1 py-1 text-xs border text-center ${
|
||||
isDarkMode ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
style={subColumnStyles}
|
||||
>
|
||||
增长
|
||||
</th>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
});
|
||||
|
||||
// 设置组件显示名称,便于调试
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
export default TableHeader;
|
||||
542
src/app/team/[teamCode]/shop-follower-growth/page.tsx
Normal file
542
src/app/team/[teamCode]/shop-follower-growth/page.tsx
Normal file
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* 店铺粉丝增长页面
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供店铺粉丝增长的记录和管理功能
|
||||
* 版本: 1.6.0
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useAccessToken } from '@/store/userStore';
|
||||
import { useThemeMode } from '@/store/settingStore';
|
||||
import { ThemeMode } from '@/types/enum';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useNotification } from '@/components/ui/Notification';
|
||||
|
||||
// 导入拆分的组件
|
||||
import TableHeader from './components/TableHeader';
|
||||
import ShopDataRow from './components/ShopDataRow';
|
||||
import ControlPanel from './components/ControlPanel';
|
||||
|
||||
// 导入类型
|
||||
import { ShopFollowerGrowth, Shop, EditedData } from './types';
|
||||
|
||||
// 导入工具函数
|
||||
import { generateDatesForMonth } from './utils/dateUtils';
|
||||
import { calculateMonthlyGrowth, calculateDateTotalGrowth } from './utils/dataUtils';
|
||||
import { fetchGrowthRecords, fetchShops, saveGrowthRecord } from './services/apiService';
|
||||
|
||||
// 最大重试次数
|
||||
const MAX_RETRY_COUNT = 3;
|
||||
|
||||
/**
|
||||
* 店铺粉丝增长页面组件
|
||||
*/
|
||||
export default function ShopFollowerGrowthPage() {
|
||||
const params = useParams();
|
||||
const teamCode = params?.teamCode as string;
|
||||
const accessToken = useAccessToken();
|
||||
const themeMode = useThemeMode();
|
||||
const notification = useNotification();
|
||||
const isDarkMode = themeMode === ThemeMode.Dark;
|
||||
|
||||
// 使用 useRef 跟踪请求状态和数据缓存,避免闭包问题
|
||||
const isLoadingGrowthRef = useRef(false);
|
||||
const isLoadingShopsRef = useRef(false);
|
||||
const initialDataLoadedRef = useRef(false);
|
||||
const dataFetchedMonthRef = useRef<string | null>(null);
|
||||
const dataDebounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 数据状态
|
||||
const [growthRecords, setGrowthRecords] = useState<ShopFollowerGrowth[]>([]);
|
||||
const [shops, setShops] = useState<Shop[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [editedData, setEditedData] = useState<EditedData>({});
|
||||
|
||||
// 日期状态
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>(
|
||||
new Date().toISOString().slice(0, 7) // 格式: YYYY-MM
|
||||
);
|
||||
|
||||
// 保存更改状态
|
||||
const [savingData, setSavingData] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* 使用useMemo生成并缓存当月日期列表
|
||||
*/
|
||||
const monthDates = useMemo(() =>
|
||||
generateDatesForMonth(selectedMonth),
|
||||
[selectedMonth]
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载粉丝增长数据的函数
|
||||
*/
|
||||
const loadGrowthRecords = useCallback(async (forceRefresh: boolean = false) => {
|
||||
// 检查是否已在加载中和必要条件
|
||||
if (!teamCode || !accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果没有强制刷新且正在加载中或已加载相同月份的数据,则跳过
|
||||
if (!forceRefresh && (isLoadingGrowthRef.current || dataFetchedMonthRef.current === selectedMonth)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防抖处理,取消之前的计时器
|
||||
if (dataDebounceTimerRef.current) {
|
||||
clearTimeout(dataDebounceTimerRef.current);
|
||||
dataDebounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 设置加载状态
|
||||
isLoadingGrowthRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
// 使用防抖延迟300ms执行,避免频繁请求
|
||||
dataDebounceTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
console.log(`加载${selectedMonth}月份的粉丝增长数据`); // 调试日志
|
||||
const records = await fetchGrowthRecords(teamCode, accessToken, selectedMonth);
|
||||
|
||||
// 将API获取到的原始数据输出到控制台
|
||||
console.group('从API获取的店铺粉丝增长数据');
|
||||
console.log('月份:', selectedMonth);
|
||||
console.log('记录数量:', records.length);
|
||||
console.log('原始数据:', JSON.stringify(records, null, 2));
|
||||
|
||||
// 添加一些数据统计
|
||||
const shopIds = [...new Set(records.map(r => r.shop_id))];
|
||||
console.log('包含的店铺ID:', shopIds);
|
||||
|
||||
// 按日期统计数据
|
||||
const dateGroups = records.reduce((groups: Record<string, number>, record) => {
|
||||
groups[record.date] = (groups[record.date] || 0) + 1;
|
||||
return groups;
|
||||
}, {});
|
||||
console.log('每日数据分布:', dateGroups);
|
||||
|
||||
// 特别标记5月5日的数据
|
||||
const may5thData = records.filter(r => r.date === '2025-05-05');
|
||||
if (may5thData.length > 0) {
|
||||
console.log('2025-05-05日期的数据:', may5thData);
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
|
||||
setGrowthRecords(records);
|
||||
|
||||
// 记录已加载的月份和初始化状态
|
||||
dataFetchedMonthRef.current = selectedMonth;
|
||||
initialDataLoadedRef.current = true;
|
||||
} catch (error) {
|
||||
console.error('获取粉丝增长记录失败:', error);
|
||||
notification.error('获取粉丝增长记录失败', { title: '数据加载错误' });
|
||||
} finally {
|
||||
isLoadingGrowthRef.current = false;
|
||||
setIsLoading(false);
|
||||
dataDebounceTimerRef.current = null;
|
||||
}
|
||||
}, 300);
|
||||
}, [teamCode, accessToken, notification, selectedMonth]);
|
||||
|
||||
/**
|
||||
* 加载店铺列表的函数
|
||||
*/
|
||||
const loadShops = useCallback(async (forceRefresh: boolean = false) => {
|
||||
// 检查是否已在加载中和必要条件
|
||||
if (!teamCode || !accessToken || (!forceRefresh && isLoadingShopsRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingShopsRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const shopsList = await fetchShops(teamCode, accessToken);
|
||||
|
||||
// 将API获取到的店铺数据输出到控制台
|
||||
console.group('从API获取的店铺数据');
|
||||
console.log('店铺数量:', shopsList.length);
|
||||
console.log('店铺列表:', JSON.stringify(shopsList, null, 2));
|
||||
console.groupEnd();
|
||||
|
||||
setShops(shopsList);
|
||||
} catch (error) {
|
||||
console.error('获取店铺列表失败:', error);
|
||||
notification.error('获取店铺列表失败', { title: '数据加载错误' });
|
||||
} finally {
|
||||
isLoadingShopsRef.current = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [teamCode, accessToken, notification]);
|
||||
|
||||
/**
|
||||
* 首次加载和初始化 - 依赖数组不包含loadGrowthRecords和loadShops以避免循环依赖
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 初始化引用变量
|
||||
initialDataLoadedRef.current = false;
|
||||
dataFetchedMonthRef.current = '';
|
||||
isLoadingGrowthRef.current = false;
|
||||
isLoadingShopsRef.current = false;
|
||||
|
||||
// 如果有访问令牌和团队代码,加载初始数据
|
||||
if (accessToken && teamCode) {
|
||||
// 加载商店列表
|
||||
loadShops();
|
||||
|
||||
// 加载增长记录
|
||||
loadGrowthRecords();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// 组件卸载时清理状态和定时器
|
||||
if (dataDebounceTimerRef.current) {
|
||||
clearTimeout(dataDebounceTimerRef.current);
|
||||
}
|
||||
isLoadingGrowthRef.current = false;
|
||||
isLoadingShopsRef.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accessToken, teamCode]); // 移除loadGrowthRecords和loadShops依赖避免循环
|
||||
|
||||
|
||||
/**
|
||||
* 月份变更时重新加载粉丝增长数据 - 确保只在初始数据加载后触发
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 只有在初始数据已加载后才响应月份变化
|
||||
if (accessToken && teamCode && initialDataLoadedRef.current &&
|
||||
dataFetchedMonthRef.current !== selectedMonth) {
|
||||
loadGrowthRecords(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedMonth, accessToken, teamCode]); // 移除loadGrowthRecords依赖避免循环
|
||||
|
||||
/**
|
||||
* 获取指定店铺和日期的记录数据
|
||||
*/
|
||||
const getRecordData = useCallback((shopId: number, date: string) => {
|
||||
// 首先检查编辑状态中是否有数据
|
||||
if (editedData[shopId]?.[date]) {
|
||||
return editedData[shopId][date];
|
||||
}
|
||||
|
||||
// 从记录中查找,确保日期格式匹配
|
||||
const record = growthRecords.find(r =>
|
||||
r.shop_id === shopId && r.date === date
|
||||
);
|
||||
|
||||
if (record) {
|
||||
return {
|
||||
total: record.total,
|
||||
deducted: record.deducted
|
||||
};
|
||||
}
|
||||
|
||||
// 默认值
|
||||
return { total: 0, deducted: 0 };
|
||||
}, [editedData, growthRecords]);
|
||||
|
||||
/**
|
||||
* 处理数据输入变更
|
||||
*/
|
||||
const handleDataChange = useCallback((shopId: number, date: string, field: 'total' | 'deducted', value: number) => {
|
||||
setEditedData(prev => {
|
||||
const shopData = prev[shopId] || {};
|
||||
const dateData = shopData[date] || getRecordData(shopId, date);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[shopId]: {
|
||||
...shopData,
|
||||
[date]: {
|
||||
...dateData,
|
||||
[field]: value
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}, [getRecordData]);
|
||||
|
||||
/**
|
||||
* 包装计算店铺月增长的函数
|
||||
*/
|
||||
const getMonthlyGrowth = useCallback((shopId: number) => {
|
||||
return calculateMonthlyGrowth(shopId, growthRecords);
|
||||
}, [growthRecords]);
|
||||
|
||||
/**
|
||||
* 包装计算日期总增长的函数
|
||||
*/
|
||||
const getDateTotalGrowth = useCallback((date: string) => {
|
||||
return calculateDateTotalGrowth(date, growthRecords);
|
||||
}, [growthRecords]);
|
||||
|
||||
/**
|
||||
* 保存单条记录 - 支持重试机制
|
||||
*/
|
||||
const saveRecord = useCallback(async (shopId: number, date: string, retryCount = 0) => {
|
||||
if (!editedData[shopId]?.[date]) return;
|
||||
|
||||
const { total, deducted } = editedData[shopId][date];
|
||||
if (retryCount === 0) {
|
||||
setSavingData(true);
|
||||
}
|
||||
|
||||
try {
|
||||
// 查找现有记录
|
||||
const existingRecord = growthRecords.find(r =>
|
||||
r.shop_id === shopId && r.date === date
|
||||
);
|
||||
|
||||
// 准备记录数据
|
||||
const recordData = {
|
||||
shop_id: shopId,
|
||||
date,
|
||||
total,
|
||||
deducted,
|
||||
daily_increase: total - deducted
|
||||
};
|
||||
|
||||
// 保存记录
|
||||
const savedRecord = await saveGrowthRecord(
|
||||
teamCode,
|
||||
accessToken,
|
||||
recordData,
|
||||
existingRecord?.id
|
||||
);
|
||||
|
||||
// 清除已保存的编辑数据
|
||||
setEditedData(prev => {
|
||||
const newData = { ...prev };
|
||||
if (newData[shopId]) {
|
||||
const shopData = { ...newData[shopId] };
|
||||
delete shopData[date];
|
||||
|
||||
if (Object.keys(shopData).length === 0) {
|
||||
delete newData[shopId];
|
||||
} else {
|
||||
newData[shopId] = shopData;
|
||||
}
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
// 只在初次尝试成功时显示提示
|
||||
if (retryCount === 0) {
|
||||
notification.success('数据保存成功', { title: '保存成功' });
|
||||
}
|
||||
|
||||
if (existingRecord) {
|
||||
// 更新现有记录
|
||||
setGrowthRecords(prev =>
|
||||
prev.map(record =>
|
||||
record.id === existingRecord.id ? savedRecord : record
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// 添加新记录
|
||||
setGrowthRecords(prev => [...prev, savedRecord]);
|
||||
}
|
||||
|
||||
return savedRecord;
|
||||
} catch (error) {
|
||||
console.error(`保存记录失败 (尝试 ${retryCount + 1}/${MAX_RETRY_COUNT}):`, error);
|
||||
|
||||
// 如果还有重试次数,则重试
|
||||
if (retryCount < MAX_RETRY_COUNT - 1) {
|
||||
console.log(`尝试重新保存记录 (${retryCount + 2}/${MAX_RETRY_COUNT})...`);
|
||||
// 递归调用自身重试,增加重试计数
|
||||
return await saveRecord(shopId, date, retryCount + 1);
|
||||
}
|
||||
|
||||
// 只在最后一次尝试失败时显示错误提示
|
||||
if (retryCount === MAX_RETRY_COUNT - 1) {
|
||||
notification.error(
|
||||
error instanceof Error ? error.message : '保存记录失败',
|
||||
{ title: '保存失败' }
|
||||
);
|
||||
}
|
||||
|
||||
// 重新抛出错误
|
||||
throw error;
|
||||
} finally {
|
||||
// 只在最终尝试后重置状态
|
||||
if (retryCount === 0 || retryCount === MAX_RETRY_COUNT - 1) {
|
||||
setSavingData(false);
|
||||
}
|
||||
}
|
||||
}, [editedData, growthRecords, teamCode, accessToken, notification]);
|
||||
|
||||
/**
|
||||
* 保存所有编辑的数据
|
||||
*/
|
||||
const saveAllEditedData = useCallback(async () => {
|
||||
setSavingData(true);
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let recordsToSave = 0;
|
||||
|
||||
// 计算总记录数
|
||||
for (const shopId in editedData) {
|
||||
recordsToSave += Object.keys(editedData[parseInt(shopId)]).length;
|
||||
}
|
||||
|
||||
// 如果没有记录需要保存,则提前返回
|
||||
if (recordsToSave === 0) {
|
||||
notification.info('没有需要保存的记录', { title: '保存提示' });
|
||||
setSavingData(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 批量保存时使用标准Promise.all以提高性能
|
||||
const savePromises: Promise<void>[] = [];
|
||||
|
||||
// 构建保存任务
|
||||
for (const shopId in editedData) {
|
||||
for (const date in editedData[parseInt(shopId)]) {
|
||||
const shopIdInt = parseInt(shopId);
|
||||
savePromises.push(
|
||||
saveRecord(shopIdInt, date)
|
||||
.then(() => { successCount++; })
|
||||
.catch(() => { errorCount++; })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有保存操作完成
|
||||
await Promise.all(savePromises);
|
||||
|
||||
// 显示结果通知
|
||||
if (errorCount === 0) {
|
||||
notification.success(`成功保存${successCount}条记录`, { title: '保存成功' });
|
||||
} else {
|
||||
notification.warning(`成功:${successCount}条, 失败:${errorCount}条`, { title: '部分保存成功' });
|
||||
}
|
||||
|
||||
// 保存完成后手动刷新一次数据以确保一致性
|
||||
if (successCount > 0) {
|
||||
// 清除月份缓存,强制刷新
|
||||
dataFetchedMonthRef.current = null;
|
||||
await loadGrowthRecords(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量保存失败:', error);
|
||||
notification.error('批量保存失败', { title: '保存失败' });
|
||||
} finally {
|
||||
setSavingData(false);
|
||||
}
|
||||
}, [editedData, saveRecord, notification, loadGrowthRecords]);
|
||||
|
||||
/**
|
||||
* 手动刷新数据
|
||||
*/
|
||||
const refreshData = useCallback(() => {
|
||||
// 清除缓存的月份,强制刷新
|
||||
dataFetchedMonthRef.current = null;
|
||||
loadGrowthRecords(true);
|
||||
}, [loadGrowthRecords]);
|
||||
|
||||
/**
|
||||
* 处理月份变更
|
||||
*/
|
||||
const handleMonthChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSelectedMonth(e.target.value);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 计算当前是否有未保存的数据
|
||||
*/
|
||||
const hasUnsavedChanges = Object.keys(editedData).length > 0;
|
||||
|
||||
// 渲染空状态的组件
|
||||
const renderEmptyState = () => (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={monthDates.length * 3 + 1}
|
||||
className={`px-4 py-8 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
>
|
||||
暂无店铺数据,请先添加店铺
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
// 渲染加载状态
|
||||
const renderLoading = () => (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-3">加载数据中...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
店铺粉丝增长管理
|
||||
</h1>
|
||||
<div className="flex justify-between items-center">
|
||||
<p className={`mt-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
记录和跟踪店铺粉丝的日增长情况
|
||||
</p>
|
||||
<p className={`mt-1 text-sm font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}>
|
||||
注意:数据修改后需要手动点击"保存所有更改"按钮才能保存
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<ControlPanel
|
||||
selectedMonth={selectedMonth}
|
||||
handleMonthChange={handleMonthChange}
|
||||
refreshData={refreshData}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
saveAllEditedData={saveAllEditedData}
|
||||
isLoading={isLoading}
|
||||
isLoadingRef={isLoadingGrowthRef.current}
|
||||
savingData={savingData}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
|
||||
{/* 表格区域 */}
|
||||
<Card
|
||||
glassEffect={isDarkMode ? 'medium' : 'light'}
|
||||
className="mb-6 overflow-hidden"
|
||||
>
|
||||
{isLoading ? renderLoading() : (
|
||||
<div className="overflow-x-auto" style={{ maxHeight: 'calc(100vh - 250px)' }}>
|
||||
<table className={`min-w-full ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
<TableHeader
|
||||
monthDates={monthDates}
|
||||
isDarkMode={isDarkMode}
|
||||
calculateDateTotalGrowth={getDateTotalGrowth}
|
||||
/>
|
||||
|
||||
<tbody>
|
||||
{shops.length > 0 ? shops.map((shop) => (
|
||||
<ShopDataRow
|
||||
key={shop.id}
|
||||
shop={shop}
|
||||
monthDates={monthDates}
|
||||
growthRecords={growthRecords}
|
||||
editedData={editedData}
|
||||
isDarkMode={isDarkMode}
|
||||
calculateMonthlyGrowth={getMonthlyGrowth}
|
||||
getRecordData={getRecordData}
|
||||
handleDataChange={handleDataChange}
|
||||
saveRecord={saveRecord}
|
||||
/>
|
||||
)) : renderEmptyState()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* API服务层
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供与API交互的方法
|
||||
* 版本: 1.4.0
|
||||
*/
|
||||
|
||||
import { ShopFollowerGrowth, Shop } from '../types';
|
||||
import { getMonthDateRange } from '../utils/dateUtils';
|
||||
import { formatDate } from '@/utils/date.utils';
|
||||
|
||||
// 记录缓存的数据
|
||||
const recordsCache: Record<string, { data: ShopFollowerGrowth[], timestamp: number }> = {};
|
||||
const shopsCache: { data: Shop[] | null, timestamp: number } = { data: null, timestamp: 0 };
|
||||
|
||||
// 缓存过期时间 (毫秒)
|
||||
const CACHE_EXPIRY = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
/**
|
||||
* 生成缓存键
|
||||
* @param teamCode 团队代码
|
||||
* @param month 月份
|
||||
*/
|
||||
const generateCacheKey = (teamCode: string, month: string): string => {
|
||||
return `${teamCode}:${month}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取粉丝增长记录
|
||||
* @param teamCode 团队代码
|
||||
* @param accessToken 访问令牌
|
||||
* @param selectedMonth 选中的月份
|
||||
* @returns 增长记录数组
|
||||
*/
|
||||
export const fetchGrowthRecords = async (
|
||||
teamCode: string,
|
||||
accessToken: string,
|
||||
selectedMonth: string
|
||||
): Promise<ShopFollowerGrowth[]> => {
|
||||
const cacheKey = generateCacheKey(teamCode, selectedMonth);
|
||||
const currentTime = Date.now();
|
||||
|
||||
// 检查缓存是否有效
|
||||
if (
|
||||
recordsCache[cacheKey] &&
|
||||
recordsCache[cacheKey].data.length > 0 &&
|
||||
currentTime - recordsCache[cacheKey].timestamp < CACHE_EXPIRY
|
||||
) {
|
||||
console.log(`使用缓存的${selectedMonth}月份粉丝数据`);
|
||||
return recordsCache[cacheKey].data;
|
||||
}
|
||||
|
||||
// 构建查询参数
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
// 获取选定月份的日期范围
|
||||
const { startDate, endDate } = getMonthDateRange(selectedMonth);
|
||||
|
||||
queryParams.append('startDate', startDate);
|
||||
queryParams.append('endDate', endDate);
|
||||
|
||||
console.log(`请求API获取${selectedMonth}月份粉丝数据`);
|
||||
const response = await fetch(`/api/team/${teamCode}/shop-follower-growth?${queryParams.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
// 添加缓存控制,避免浏览器缓存导致的问题
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取粉丝增长记录失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const records = data.records || [];
|
||||
|
||||
// 确保日期字段使用正确的格式,并保持原始字符串格式
|
||||
const formattedRecords = records.map((record: {
|
||||
id: number;
|
||||
shop_id: number;
|
||||
date: string | Date;
|
||||
total: number;
|
||||
deducted: number;
|
||||
daily_increase: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}) => ({
|
||||
...record,
|
||||
date: typeof record.date === 'string' ? record.date : formatDate(record.date, 'YYYY-MM-DD')
|
||||
}));
|
||||
|
||||
// 更新缓存
|
||||
recordsCache[cacheKey] = {
|
||||
data: formattedRecords,
|
||||
timestamp: currentTime
|
||||
};
|
||||
|
||||
return formattedRecords;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取店铺列表
|
||||
* @param teamCode 团队代码
|
||||
* @param accessToken 访问令牌
|
||||
* @returns 店铺数组
|
||||
*/
|
||||
export const fetchShops = async (
|
||||
teamCode: string,
|
||||
accessToken: string
|
||||
): Promise<Shop[]> => {
|
||||
const currentTime = Date.now();
|
||||
|
||||
// 检查缓存是否有效
|
||||
if (
|
||||
shopsCache.data &&
|
||||
shopsCache.data.length > 0 &&
|
||||
currentTime - shopsCache.timestamp < CACHE_EXPIRY
|
||||
) {
|
||||
console.log('使用缓存的店铺数据');
|
||||
return shopsCache.data;
|
||||
}
|
||||
|
||||
console.log('请求API获取店铺数据');
|
||||
const response = await fetch(`/api/team/${teamCode}/shops`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
// 添加缓存控制,避免浏览器缓存导致的问题
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取店铺列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const shops = data.shops || [];
|
||||
|
||||
// 更新缓存
|
||||
shopsCache.data = shops;
|
||||
shopsCache.timestamp = currentTime;
|
||||
|
||||
return shops;
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存粉丝增长记录
|
||||
* @param teamCode 团队代码
|
||||
* @param accessToken 访问令牌
|
||||
* @param record 记录数据
|
||||
* @param existingRecordId 现有记录ID (用于更新)
|
||||
* @returns 保存后的记录
|
||||
*/
|
||||
export const saveGrowthRecord = async (
|
||||
teamCode: string,
|
||||
accessToken: string,
|
||||
record: {
|
||||
shop_id: number;
|
||||
date: string;
|
||||
total: number;
|
||||
deducted: number;
|
||||
daily_increase: number;
|
||||
},
|
||||
existingRecordId?: number
|
||||
): Promise<ShopFollowerGrowth> => {
|
||||
const url = existingRecordId
|
||||
? `/api/team/${teamCode}/shop-follower-growth/${existingRecordId}`
|
||||
: `/api/team/${teamCode}/shop-follower-growth`;
|
||||
|
||||
const method = existingRecordId ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
// 保持原始日期格式,确保不会因时区问题改变日期
|
||||
const formattedRecord = {
|
||||
...record,
|
||||
// 如果日期已经是YYYY-MM-DD格式,直接使用
|
||||
date: typeof record.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(record.date)
|
||||
? record.date
|
||||
: formatDate(record.date, 'YYYY-MM-DD')
|
||||
};
|
||||
|
||||
// 调试日志
|
||||
console.log(`保存记录 - 店铺ID: ${formattedRecord.shop_id}, 日期: ${formattedRecord.date}, 方法: ${method}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify(formattedRecord)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 尝试获取响应中的详细错误信息
|
||||
let errorDetail = '';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorDetail = errorData.message || errorData.error || '';
|
||||
} catch {
|
||||
// 如果无法解析响应JSON,则使用HTTP状态文本
|
||||
errorDetail = response.statusText;
|
||||
}
|
||||
|
||||
throw new Error(`保存记录失败 (${response.status}): ${errorDetail}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 检查API响应中是否包含record字段
|
||||
if (!data.record) {
|
||||
console.warn('API响应中缺少record字段,使用本地构建的记录替代');
|
||||
|
||||
// 如果API响应中没有record,使用本地构建的记录作为替代
|
||||
const localRecord: ShopFollowerGrowth = {
|
||||
id: existingRecordId || Date.now(), // 使用现有ID或生成临时ID
|
||||
shop_id: formattedRecord.shop_id,
|
||||
date: formattedRecord.date,
|
||||
total: formattedRecord.total,
|
||||
deducted: formattedRecord.deducted,
|
||||
daily_increase: formattedRecord.daily_increase,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 更新记录后清除相关月份的缓存
|
||||
const month = formattedRecord.date.substring(0, 7); // 格式: YYYY-MM
|
||||
const cacheKey = generateCacheKey(teamCode, month);
|
||||
|
||||
if (recordsCache[cacheKey]) {
|
||||
delete recordsCache[cacheKey];
|
||||
}
|
||||
|
||||
return localRecord;
|
||||
}
|
||||
|
||||
// 确保返回的记录日期格式正确
|
||||
const savedRecord = {
|
||||
...data.record,
|
||||
date: typeof data.record.date === 'string'
|
||||
? data.record.date
|
||||
: formatDate(data.record.date, 'YYYY-MM-DD')
|
||||
};
|
||||
|
||||
// 更新记录后清除相关月份的缓存
|
||||
const month = formattedRecord.date.substring(0, 7); // 格式: YYYY-MM
|
||||
const cacheKey = generateCacheKey(teamCode, month);
|
||||
|
||||
if (recordsCache[cacheKey]) {
|
||||
delete recordsCache[cacheKey];
|
||||
}
|
||||
|
||||
return savedRecord;
|
||||
} catch (error) {
|
||||
console.error('保存记录错误:', error);
|
||||
// 重新抛出错误以便上层处理
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
52
src/app/team/[teamCode]/shop-follower-growth/types.ts
Normal file
52
src/app/team/[teamCode]/shop-follower-growth/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 店铺粉丝增长页面类型定义
|
||||
* 作者: 阿瑞
|
||||
* 功能: 集中定义相关数据类型
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* 店铺粉丝增长数据接口
|
||||
*/
|
||||
export interface ShopFollowerGrowth {
|
||||
id: number;
|
||||
shop_id: number;
|
||||
date: string;
|
||||
total: number;
|
||||
deducted: number;
|
||||
daily_increase: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
shop_name?: string; // 从关联表获取的数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺数据接口
|
||||
*/
|
||||
export interface Shop {
|
||||
id: number;
|
||||
wechat: string;
|
||||
nickname: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期数据接口
|
||||
*/
|
||||
export interface DateColumn {
|
||||
date: string;
|
||||
formatted: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑中的数据接口
|
||||
*/
|
||||
export interface EditedData {
|
||||
[shopId: number]: {
|
||||
[date: string]: {
|
||||
total: number;
|
||||
deducted: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 数据工具函数
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供数据处理相关的工具函数
|
||||
* 版本: 1.0.0
|
||||
*/
|
||||
|
||||
import { ShopFollowerGrowth, Shop } from '../types';
|
||||
|
||||
/**
|
||||
* 计算店铺在选定月份的总增长
|
||||
* @param shopId 店铺ID
|
||||
* @param growthRecords 增长记录数组
|
||||
* @returns 月总增长数
|
||||
*/
|
||||
export const calculateMonthlyGrowth = (shopId: number, growthRecords: ShopFollowerGrowth[]): number => {
|
||||
const shopRecords = growthRecords.filter(r => r.shop_id === shopId);
|
||||
return shopRecords.reduce((sum, record) => sum + record.daily_increase, 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算指定日期的所有店铺总增长
|
||||
* @param date 指定日期
|
||||
* @param growthRecords 增长记录数组
|
||||
* @returns 当日所有店铺的总增长数
|
||||
*/
|
||||
export const calculateDateTotalGrowth = (date: string, growthRecords: ShopFollowerGrowth[]): number => {
|
||||
const dateRecords = growthRecords.filter(r => r.date === date);
|
||||
return dateRecords.reduce((sum, record) => sum + record.daily_increase, 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取店铺名称
|
||||
* @param shopId 店铺ID
|
||||
* @param shops 店铺数组
|
||||
* @returns 格式化的店铺名称
|
||||
*/
|
||||
export const getShopName = (shopId: number, shops: Shop[]): string => {
|
||||
const shop = shops.find(s => s.id === shopId);
|
||||
return shop ? (shop.nickname || shop.wechat || `店铺ID: ${shopId}`) : `店铺ID: ${shopId}`;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 日期工具函数
|
||||
* 作者: 阿瑞
|
||||
* 功能: 提供日期处理相关的工具函数
|
||||
* 版本: 1.2.0
|
||||
*/
|
||||
|
||||
import { DateColumn } from '../types';
|
||||
|
||||
/**
|
||||
* 生成日期背景色
|
||||
* @param index 日期索引
|
||||
* @returns 背景色十六进制代码
|
||||
*/
|
||||
export const generateBackgroundColor = (index: number): string => {
|
||||
const colors = ['#E6F7FF', '#FFFBE6', '#E6F7FF', '#E6FFFB', '#F0F5FF'];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成当月的日期列
|
||||
* @param selectedMonth 选中的月份(YYYY-MM格式)
|
||||
* @returns 当月所有日期的数组
|
||||
*/
|
||||
export const generateDatesForMonth = (selectedMonth: string): DateColumn[] => {
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
// 获取月份天数
|
||||
const daysInMonth = new Date(Date.UTC(parseInt(year), parseInt(month), 0)).getDate();
|
||||
const dates: DateColumn[] = [];
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
// 使用ISO日期格式,确保日期字符串的一致性
|
||||
const date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||
|
||||
dates.push({
|
||||
date,
|
||||
formatted: `${month}-${day.toString().padStart(2, '0')}`,
|
||||
backgroundColor: generateBackgroundColor((day - 1) % 5)
|
||||
});
|
||||
}
|
||||
|
||||
return dates;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取月份的开始和结束日期
|
||||
* @param selectedMonth 选中的月份(YYYY-MM格式)
|
||||
* @returns 包含开始和结束日期的对象
|
||||
*/
|
||||
export const getMonthDateRange = (selectedMonth: string): { startDate: string; endDate: string } => {
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
// 构建标准格式的日期字符串
|
||||
const startDate = `${year}-${month.padStart(2, '0')}-01`;
|
||||
|
||||
// 获取月份的最后一天
|
||||
const lastDay = new Date(Date.UTC(parseInt(year), parseInt(month), 0)).getDate();
|
||||
const endDate = `${year}-${month.padStart(2, '0')}-${lastDay.toString().padStart(2, '0')}`;
|
||||
|
||||
return { startDate, endDate };
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user