feat(customer): 添加客户详情和邀请好友功能

- 新增客户详情页面,包括公司信息、合同信息、企业信息和联系记录
- 添加邀请好友功能,包括二维码生成、邀请记录和统计图表
- 优化导航栏和首页网格组件,支持跳转到新页面- 调整 app.config.ts,添加新页面的路由配置
This commit is contained in:
2025-08-28 15:15:13 +08:00
parent 217bfacadd
commit 7834124095
21 changed files with 3311 additions and 20 deletions

View File

@@ -3,7 +3,12 @@ export default defineAppConfig({
'pages/index/index',
'pages/cart/cart',
'pages/find/find',
'pages/user/user'
'pages/user/user',
'pages/customer/list',
'pages/customer/sign',
'pages/customer/detail',
'pages/customer/trading',
'pages/customer/invite'
],
"subpackages": [
{
@@ -60,22 +65,22 @@ export default defineAppConfig({
"info"
]
},
{
"root": "shop",
"pages": ['category/index',
'orderDetail/index',
'goodsDetail/index',
'orderConfirm/index',
'orderConfirmCart/index',
'search/index']
},
{
"root": "admin",
"pages": [
"index",
"article/index",
]
}
// {
// "root": "shop",
// "pages": ['category/index',
// 'orderDetail/index',
// 'goodsDetail/index',
// 'orderConfirm/index',
// 'orderConfirmCart/index',
// 'search/index']
// },
// {
// "root": "admin",
// "pages": [
// "index",
// "article/index",
// ]
// }
],
window: {
backgroundTextStyle: 'dark',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -81,7 +81,7 @@ const SimpleQRCodeModal: React.FC<SimpleQRCodeModalProps> = ({
{qrContent ? (
<View className={'flex flex-col justify-center'}>
<img
src={`http://127.0.0.1:9200/api/qr-code/create-encrypted-qr-image?size=300x300&expireMinutes=60&businessType=gift&data=${encodeURIComponent(qrContent)}`}
src={`https://cms-api.websoft.top/api/qr-code/create-encrypted-qr-image?size=300x300&expireMinutes=60&businessType=gift&data=${encodeURIComponent(qrContent)}`}
alt="二维码"
style={{width: '200px', height: '200px'}}
className="mx-auto"

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '客户详情',
navigationBarTextStyle: 'white',
navigationStyle: 'custom',
backgroundColor: '#f5f5f5'
})

View File

@@ -0,0 +1,318 @@
.customer-detail-page {
min-height: 100vh;
background: #f5f5f5;
position: relative;
padding-bottom: 100px;
.header-bg {
background: linear-gradient(to bottom, #03605c, #18ae4f);
width: 100%;
top: 0;
position: absolute;
z-index: 0;
}
.nav-actions {
display: flex;
gap: 16px;
align-items: center;
}
.loading-container,
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 50vh;
color: #999;
.nut-button {
margin-top: 16px;
}
}
.detail-container {
position: relative;
z-index: 10;
padding: 20px 16px;
margin-top: 20px;
.customer-card {
background: #ffffff;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.customer-header {
margin-bottom: 16px;
.company-info {
display: flex;
justify-content: space-between;
align-items: center;
.company-name {
font-size: 18px;
font-weight: bold;
color: #333;
flex: 1;
}
.nut-tag {
margin-left: 12px;
}
}
}
.contact-info {
.contact-item {
display: flex;
align-items: center;
margin-bottom: 12px;
padding: 8px 0;
&:last-child {
margin-bottom: 0;
}
.nut-icon {
margin-right: 8px;
flex-shrink: 0;
}
.contact-text {
font-size: 14px;
color: #333;
line-height: 1.4;
&.phone {
color: #52c41a;
cursor: pointer;
}
}
}
}
}
.info-section {
background: #ffffff;
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 8px;
border-bottom: 1px solid #f0f0f0;
.section-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.nut-button {
height: 28px;
font-size: 12px;
padding: 0 12px;
}
}
.info-content {
.nut-cell {
padding: 12px 16px;
border-bottom: 1px solid #f8f8f8;
&:last-child {
border-bottom: none;
}
.nut-cell__title {
font-size: 14px;
color: #666;
font-weight: normal;
}
.nut-cell__value {
font-size: 14px;
color: #333;
text-align: right;
}
}
}
.contact-history {
padding: 0 16px 16px;
.history-item {
padding: 12px 0;
border-bottom: 1px solid #f8f8f8;
&:last-child {
border-bottom: none;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.history-type {
font-size: 14px;
font-weight: 500;
color: #333;
}
.history-date {
font-size: 12px;
color: #999;
}
}
.history-content {
font-size: 14px;
color: #666;
line-height: 1.4;
margin-bottom: 4px;
}
.history-operator {
font-size: 12px;
color: #999;
}
}
}
}
}
.fixed-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
z-index: 100;
.action-buttons {
display: flex;
gap: 12px;
.action-btn {
flex: 1;
height: 44px;
border-radius: 22px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&.call-btn {
background: #52c41a;
color: #ffffff;
border: none;
}
&.renew-btn {
background: #1890ff;
color: #ffffff;
border: none;
}
}
}
}
}
// 适配安全区域
@supports (bottom: env(safe-area-inset-bottom)) {
.customer-detail-page .fixed-bottom {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
}
// 标签样式优化
.nut-tag {
&.nut-tag--plain {
border-width: 1px;
font-size: 12px;
padding: 2px 8px;
}
}
// 响应式适配
@media (max-width: 375px) {
.customer-detail-page {
.detail-container {
padding: 16px 12px;
.customer-card {
padding: 16px;
.customer-header {
.company-info {
.company-name {
font-size: 16px;
}
}
}
.contact-info {
.contact-item {
.contact-text {
font-size: 13px;
}
}
}
}
.info-section {
.section-header {
padding: 14px 12px 6px;
.section-title {
font-size: 15px;
}
}
.info-content {
.nut-cell {
padding: 10px 12px;
.nut-cell__title,
.nut-cell__value {
font-size: 13px;
}
}
}
.contact-history {
padding: 0 12px 12px;
.history-item {
.history-content {
font-size: 13px;
}
}
}
}
}
.fixed-bottom {
.action-buttons {
.action-btn {
height: 40px;
font-size: 13px;
}
}
}
}
}

View File

@@ -0,0 +1,346 @@
import { useEffect, useState } from "react";
// import Taro, { useRouter } from '@tarojs/taro';
import Taro from '@tarojs/taro'
import { View, Text } from '@tarojs/components';
import {
NavBar,
Button,
Cell,
Tag,
Toast
} from '@nutui/nutui-react-taro';
import {
Phone,
Location,
Calendar,
User,
Edit,
Share
} from '@nutui/icons-react-taro';
import './detail.scss';
// 已签约客户详情数据类型
interface CustomerDetail {
id: string;
companyName: string;
contactPerson: string;
phone: string;
address: string;
signDate: string;
contractInfo: {
contractType: string;
contractAmount: string;
startDate: string;
endDate: string;
paymentMethod: string;
paymentCycle: string;
status: 'active' | 'expired' | 'terminated';
};
businessInfo: {
industry: string;
scale: string;
registeredCapital: string;
businessScope: string;
};
contactHistory: Array<{
id: string;
date: string;
type: string;
content: string;
operator: string;
}>;
}
const CustomerDetail = () => {
// const router = useRouter();
const [statusBarHeight, setStatusBarHeight] = useState<number>(0);
const [customerDetail, setCustomerDetail] = useState<CustomerDetail | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [showToast, setShowToast] = useState(false);
const [toastMsg, setToastMsg] = useState('');
// 模拟客户详情数据
const mockCustomerDetail: CustomerDetail = {
id: '1',
companyName: '广州雅虎信息科技公司',
contactPerson: '张经理',
phone: '13882223433',
address: '广西南宁市良庆区五象大道401号五象新城1号楼1226室',
signDate: '2025-08-15 10:23:33',
contractInfo: {
contractType: '服务合同',
contractAmount: '500,000',
startDate: '2025-08-15',
endDate: '2026-08-14',
paymentMethod: '分期付款',
paymentCycle: '按季度付款',
status: 'active'
},
businessInfo: {
industry: '信息技术服务',
scale: '中型企业',
registeredCapital: '1000万元',
businessScope: '软件开发、技术咨询、系统集成'
},
contactHistory: [
{
id: '1',
date: '2025-08-20',
type: '电话沟通',
content: '讨论项目进度,客户反馈良好',
operator: '李销售'
},
{
id: '2',
date: '2025-08-18',
type: '现场拜访',
content: '实地考察客户需求,确认技术方案',
operator: '王工程师'
},
{
id: '3',
date: '2025-08-15',
type: '合同签署',
content: '正式签署服务合同,项目启动',
operator: '张经理'
}
]
};
const showToastMsg = (msg: string) => {
setToastMsg(msg);
setShowToast(true);
setTimeout(() => setShowToast(false), 2000);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return '#52c41a';
case 'expired':
return '#ff6b35';
case 'terminated':
return '#ff4d4f';
default:
return '#999';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'active':
return '履行中';
case 'expired':
return '已到期';
case 'terminated':
return '已终止';
default:
return '未知';
}
};
const handleCall = () => {
if (customerDetail?.phone) {
Taro.makePhoneCall({
phoneNumber: customerDetail.phone
});
}
};
const handleEdit = () => {
showToastMsg('编辑功能开发中');
};
const handleShare = () => {
showToastMsg('分享功能开发中');
};
const handleAddContact = () => {
showToastMsg('添加联系记录功能开发中');
};
const handleViewContract = () => {
showToastMsg('查看合同详情功能开发中');
};
const handleRenewContract = () => {
showToastMsg('续约功能开发中');
};
const loadCustomerDetail = async () => {
setLoading(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 800));
setCustomerDetail(mockCustomerDetail);
} catch (error) {
showToastMsg('加载失败,请重试');
} finally {
setLoading(false);
}
};
useEffect(() => {
Taro.getSystemInfo({
success: (res) => {
setStatusBarHeight(Number(res.statusBarHeight));
},
});
loadCustomerDetail();
}, []);
if (loading) {
return (
<View className="customer-detail-page">
<View className="loading-container">
<Text>...</Text>
</View>
</View>
);
}
if (!customerDetail) {
return (
<View className="customer-detail-page">
<View className="error-container">
<Text></Text>
<Button onClick={loadCustomerDetail}></Button>
</View>
</View>
);
}
return (
<View className="customer-detail-page">
{/* 头部背景 */}
<View className="header-bg" style={{ height: '180px' }} />
{/* 导航栏 */}
<NavBar
style={{
marginTop: `${statusBarHeight}px`,
backgroundColor: 'transparent',
color: '#ffffff'
}}
onBackClick={() => Taro.navigateBack()}
right={
<View className="nav-actions">
<Edit size={20} color="#ffffff" onClick={handleEdit} />
<Share size={20} color="#ffffff" onClick={handleShare} />
</View>
}
>
<Text style={{ color: '#ffffff', fontSize: '18px', fontWeight: 'bold' }}>
</Text>
</NavBar>
{/* 客户基本信息 */}
<View className="detail-container">
<View className="customer-card">
<View className="customer-header">
<View className="company-info">
<Text className="company-name">{customerDetail.companyName}</Text>
<Tag
color={getStatusColor(customerDetail.contractInfo.status)}
plain
>
{getStatusText(customerDetail.contractInfo.status)}
</Tag>
</View>
</View>
<View className="contact-info">
<View className="contact-item">
<User size={16} color="#666" />
<Text className="contact-text">{customerDetail.contactPerson}</Text>
</View>
<View className="contact-item" onClick={handleCall}>
<Phone size={16} color="#52c41a" />
<Text className="contact-text phone">{customerDetail.phone}</Text>
</View>
<View className="contact-item">
<Location size={16} color="#666" />
<Text className="contact-text">{customerDetail.address}</Text>
</View>
<View className="contact-item">
<Calendar size={16} color="#666" />
<Text className="contact-text">{customerDetail.signDate}</Text>
</View>
</View>
</View>
{/* 合同信息 */}
<View className="info-section">
<View className="section-header">
<Text className="section-title"></Text>
<Button size="small" onClick={handleViewContract}></Button>
</View>
<View className="info-content">
<Cell title="合同类型" extra={customerDetail.contractInfo.contractType} />
<Cell title="合同金额" extra={`¥${customerDetail.contractInfo.contractAmount}`} />
<Cell title="合同期限" extra={`${customerDetail.contractInfo.startDate}${customerDetail.contractInfo.endDate}`} />
<Cell title="付款方式" extra={customerDetail.contractInfo.paymentMethod} />
<Cell title="付款周期" extra={customerDetail.contractInfo.paymentCycle} />
</View>
</View>
{/* 企业信息 */}
<View className="info-section">
<View className="section-header">
<Text className="section-title"></Text>
</View>
<View className="info-content">
<Cell title="所属行业" extra={customerDetail.businessInfo.industry} />
<Cell title="企业规模" extra={customerDetail.businessInfo.scale} />
<Cell title="注册资本" extra={customerDetail.businessInfo.registeredCapital} />
<Cell title="经营范围" extra={customerDetail.businessInfo.businessScope} />
</View>
</View>
{/* 联系记录 */}
<View className="info-section">
<View className="section-header">
<Text className="section-title"></Text>
<Button size="small" onClick={handleAddContact}></Button>
</View>
<View className="contact-history">
{customerDetail.contactHistory.map((record) => (
<View key={record.id} className="history-item">
<View className="history-header">
<Text className="history-type">{record.type}</Text>
<Text className="history-date">{record.date}</Text>
</View>
<Text className="history-content">{record.content}</Text>
<Text className="history-operator">{record.operator}</Text>
</View>
))}
</View>
</View>
</View>
{/* 底部操作按钮 */}
<View className="fixed-bottom">
<View className="action-buttons">
<Button className="action-btn call-btn" onClick={handleCall}>
<Phone size={16} />
</Button>
<Button className="action-btn renew-btn" onClick={handleRenewContract}>
</Button>
</View>
</View>
{/* Toast提示 */}
<Toast
visible={showToast}
content={toastMsg}
duration={2000}
/>
</View>
);
};
export default CustomerDetail;

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '邀请好友'
})

View File

@@ -0,0 +1,429 @@
.customer-invite-page {
min-height: 100vh;
background: #f5f5f5;
position: relative;
.header-bg {
background: linear-gradient(to bottom, #03605c, #18ae4f);
width: 100%;
top: 0;
position: absolute;
z-index: 0;
}
.tabs-container {
position: relative;
z-index: 10;
.nut-tabs {
background: transparent;
.nut-tabs__titles {
background: transparent;
border: none;
.nut-tabs__titles-item {
color: rgba(255, 255, 255, 0.8);
padding: 12px 16px;
&.nut-tabs__titles-item--active {
color: #ffffff;
font-weight: bold;
}
}
}
.nut-tabs__line {
background: #ffffff;
height: 3px;
border-radius: 2px;
}
}
}
.content-container {
position: relative;
z-index: 10;
padding: 20px 16px;
margin-top: 20px;
}
// 二维码标签页样式
.qrcode-container {
.qr-card {
background: #ffffff;
border-radius: 16px;
padding: 24px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 20px;
.qr-header {
margin-bottom: 24px;
.qr-title {
font-weight: bold;
color: #333;
display: block;
margin-bottom: 8px;
}
.qr-subtitle {
color: #666;
}
}
.qr-code {
margin-bottom: 24px;
.qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 280px;
height: 280px;
margin: 0 auto;
border: 2px dashed #ddd;
border-radius: 12px;
background: #fafafa;
.qr-text {
margin-top: 8px;
color: #999;
}
}
.qr-loading {
width: 160px;
height: 160px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 12px;
color: #999;
}
}
.qr-actions {
display: flex;
justify-content: center;
gap: 12px;
.action-btn {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
padding: 8px 16px;
border-radius: 20px;
background: #f0f0f0;
color: #666;
border: none;
&:active {
background: #e0e0e0;
}
}
}
}
.invite-tips {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.tips-header {
display: flex;
align-items: center;
margin-bottom: 12px;
.tips-title {
font-weight: bold;
color: #333;
margin-left: 8px;
}
}
.tips-content {
.tip-item {
display: block;
color: #666;
line-height: 1.6;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
}
}
}
.manual-invite {
.manual-btn {
width: 100%;
background: #52c41a;
color: #ffffff;
font-weight: bold;
border: none;
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.3);
}
}
}
// 邀请记录标签页样式
.records-container {
.record-item {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.user-info {
display: flex;
align-items: center;
.user-name {
font-weight: bold;
color: #333;
margin-left: 8px;
}
}
}
.record-details {
.detail-item {
display: flex;
align-items: center;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.detail-text {
color: #666;
margin-left: 8px;
}
}
}
}
.empty-records {
text-align: center;
padding: 60px 20px;
color: #999;
}
}
// 邀请统计标签页样式
.stats-container {
.stats-overview {
display: flex;
background: #ffffff;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.stat-item {
flex: 1;
text-align: center;
.stat-number {
display: block;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.stat-label {
color: #999;
}
}
}
.reward-summary {
margin-bottom: 16px;
.reward-card {
background: linear-gradient(135deg, #52c41a, #73d13d);
border-radius: 12px;
padding: 20px;
text-align: center;
color: #ffffff;
.reward-title {
opacity: 0.9;
margin-bottom: 8px;
}
.reward-amount {
font-weight: bold;
margin-bottom: 16px;
}
.withdraw-btn {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
padding: 8px 24px;
}
}
}
.stats-chart {
background: #ffffff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.chart-title {
font-weight: bold;
color: #333;
margin-bottom: 16px;
}
.chart-placeholder {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
color: #999;
}
}
}
// 邀请表单弹窗样式
.invite-form {
padding: 20px;
background: #ffffff;
border-radius: 16px 16px 0 0;
.form-header {
text-align: center;
margin-bottom: 20px;
.form-title {
font-weight: bold;
color: #333;
}
}
.nut-form-item {
margin-bottom: 16px;
.nut-form-item__label {
color: #333;
font-weight: 500;
}
.nut-input {
&::placeholder {
color: #999;
}
}
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
.cancel-btn,
.submit-btn {
flex: 1;
height: 44px;
border-radius: 22px;
font-weight: 500;
}
.cancel-btn {
background: #f5f5f5;
color: #666;
border: none;
}
.submit-btn {
background: #52c41a;
color: #ffffff;
border: none;
}
}
}
}
// 标签样式优化
.nut-tag {
&.nut-tag--plain {
border-width: 1px;
padding: 2px 6px;
&.nut-tag--small {
padding: 1px 4px;
}
}
}
// 响应式适配
@media (max-width: 375px) {
.customer-invite-page {
.content-container {
padding: 16px 12px;
}
.qrcode-container {
.qr-card {
padding: 20px;
.qr-code {
.qr-placeholder {
width: 140px;
height: 140px;
}
}
.qr-actions {
.action-btn {
padding: 6px 12px;
}
}
}
}
.stats-container {
.stats-overview {
padding: 16px;
.stat-item {
.stat-number {
}
}
}
.reward-summary {
.reward-card {
padding: 16px;
.reward-amount {
}
}
}
}
}
}

View File

@@ -0,0 +1,459 @@
import { useEffect, useState } from "react";
import Taro from '@tarojs/taro';
import { View, Text } from '@tarojs/components';
import {
Space,
Tabs,
Button,
Tag,
Toast,
Popup,
Input,
Form
} from '@nutui/nutui-react-taro';
import {
QrCode,
Share,
Copy,
Download,
User,
Phone,
Calendar,
Gift
} from '@nutui/icons-react-taro';
import './invite.scss';
// 邀请记录数据类型
interface InviteRecord {
id: string;
inviteeName: string;
inviteePhone: string;
inviteDate: string;
status: 'pending' | 'registered' | 'signed';
reward: string;
}
// 邀请统计数据类型
interface InviteStats {
totalInvites: number;
registeredCount: number;
signedCount: number;
totalReward: string;
}
const CustomerInvite = () => {
const [statusBarHeight, setStatusBarHeight] = useState<number>(0);
const [activeTab, setActiveTab] = useState<string>('qrcode');
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const [inviteRecords, setInviteRecords] = useState<InviteRecord[]>([]);
const [inviteStats, setInviteStats] = useState<InviteStats | null>(null);
const [showToast, setShowToast] = useState(false);
const [toastMsg, setToastMsg] = useState('');
const [showInviteForm, setShowInviteForm] = useState(false);
const [inviteFormData, setInviteFormData] = useState({
name: '',
phone: ''
});
// 模拟邀请记录数据
const mockInviteRecords: InviteRecord[] = [
{
id: '1',
inviteeName: '张三',
inviteePhone: '138****1234',
inviteDate: '2025-08-20 14:30:00',
status: 'signed',
reward: '500'
},
{
id: '2',
inviteeName: '李四',
inviteePhone: '139****5678',
inviteDate: '2025-08-19 10:15:00',
status: 'registered',
reward: '200'
},
{
id: '3',
inviteeName: '王五',
inviteePhone: '136****9012',
inviteDate: '2025-08-18 16:45:00',
status: 'pending',
reward: '0'
}
];
// 模拟邀请统计数据
const mockInviteStats: InviteStats = {
totalInvites: 15,
registeredCount: 8,
signedCount: 3,
totalReward: '2,800'
};
const tabList = [
{ title: '邀请二维码', value: 'qrcode' },
{ title: '邀请记录', value: 'records' },
{ title: '邀请统计', value: 'stats' }
];
const showToastMsg = (msg: string) => {
setToastMsg(msg);
setShowToast(true);
setTimeout(() => setShowToast(false), 2000);
};
const getStatusText = (status: string) => {
switch (status) {
case 'pending':
return '待注册';
case 'registered':
return '已注册';
case 'signed':
return '已签约';
default:
return '';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return '#ff6b35';
case 'registered':
return '#1890ff';
case 'signed':
return '#52c41a';
default:
return '#999';
}
};
// 生成二维码
const generateQRCode = () => {
// 模拟生成二维码URL
const inviteCode = 'INV' + Date.now().toString().slice(-6);
const qrUrl = `https://example.com/invite?code=${inviteCode}`;
setQrCodeUrl(qrUrl);
// 实际项目中这里应该调用二维码生成库
showToastMsg('二维码已生成');
};
// 复制邀请链接
const copyInviteLink = () => {
const inviteLink = `https://example.com/invite?code=INV123456`;
Taro.setClipboardData({
data: inviteLink,
success: () => {
showToastMsg('邀请链接已复制到剪贴板');
},
fail: () => {
showToastMsg('复制失败,请重试');
}
});
};
// 分享邀请
const shareInvite = () => {
Taro.showShareMenu({
withShareTicket: true,
success: () => {
showToastMsg('分享成功');
},
fail: () => {
showToastMsg('分享失败');
}
});
};
// 下载二维码
const downloadQRCode = () => {
showToastMsg('下载功能开发中');
};
// 手动邀请
const handleManualInvite = () => {
setShowInviteForm(true);
};
// 提交邀请表单
const submitInviteForm = () => {
if (!inviteFormData.name || !inviteFormData.phone) {
showToastMsg('请填写完整信息');
return;
}
// 模拟发送邀请
showToastMsg('邀请已发送');
setShowInviteForm(false);
setInviteFormData({ name: '', phone: '' });
// 刷新邀请记录
loadInviteData().then();
};
// 加载邀请数据
const loadInviteData = async () => {
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500));
setInviteRecords(mockInviteRecords);
setInviteStats(mockInviteStats);
} catch (error) {
showToastMsg('加载失败,请重试');
}
};
useEffect(() => {
Taro.getSystemInfo({
success: (res) => {
setStatusBarHeight(Number(res.statusBarHeight))
},
});
generateQRCode();
loadInviteData().then();
}, []);
const renderQRCodeTab = () => (
<View className="qrcode-container">
<View className="qr-card">
<View className="qr-header">
<View className="qr-title pt-2"></View>
<View className="qr-subtitle py-2"></View>
</View>
<View className="qr-code">
{qrCodeUrl ? (
<View className="qr-placeholder">
<QrCode size={80} color="#333" />
<Text className="qr-text"></Text>
</View>
) : (
<View className="qr-loading">
<Text>...</Text>
</View>
)}
</View>
<Space className="qr-actions mt-4">
<Button
className="action-btn"
size="small"
onClick={copyInviteLink}
>
<View className={'flex items-center justify-center px-1'}>
<Copy size={14} />
<Text className="ml-1"></Text>
</View>
</Button>
<Button
className="action-btn"
size="small"
onClick={shareInvite}
>
<View className={'flex items-center justify-center px-1'}>
<Share size={14} />
<Text className="ml-1"></Text>
</View>
</Button>
<Button
size="small"
onClick={downloadQRCode}
>
<View className={'flex items-center justify-center px-1'}>
<Download size={14} />
<Text className="ml-1"></Text>
</View>
</Button>
</Space>
</View>
<View className="invite-tips">
<View className="tips-header">
<Gift size={16} color="#52c41a" />
<Text className="tips-title"></Text>
</View>
<View className="tips-content">
<Text className="tip-item"> 200</Text>
<Text className="tip-item"> 300</Text>
<Text className="tip-item"> 24</Text>
</View>
</View>
<View className="manual-invite">
<Button
className="manual-btn"
onClick={handleManualInvite}
>
</Button>
</View>
</View>
);
const renderRecordsTab = () => (
<View className="records-container">
{inviteRecords.length > 0 ? (
inviteRecords.map((record) => (
<View key={record.id} className="record-item">
<View className="record-header">
<View className="user-info">
<User size={16} color="#666" />
<Text className="user-name">{record.inviteeName}</Text>
</View>
<Tag
color={getStatusColor(record.status)}
plain
>
{getStatusText(record.status)}
</Tag>
</View>
<View className="record-details">
<View className="detail-item">
<Phone size={14} color="#999" />
<Text className="detail-text">{record.inviteePhone}</Text>
</View>
<View className="detail-item">
<Calendar size={14} color="#999" />
<Text className="detail-text">{record.inviteDate}</Text>
</View>
<View className="detail-item">
<Gift size={14} color="#52c41a" />
<Text className="detail-text">
¥{record.reward}
</Text>
</View>
</View>
</View>
))
) : (
<View className="empty-records">
<Text></Text>
</View>
)}
</View>
);
const renderStatsTab = () => (
<View className="stats-container">
{inviteStats && (
<>
<View className="stats-overview">
<View className="stat-item">
<Text className="stat-number">{inviteStats.totalInvites}</Text>
<Text className="stat-label"></Text>
</View>
<View className="stat-item">
<Text className="stat-number">{inviteStats.registeredCount}</Text>
<Text className="stat-label"></Text>
</View>
<View className="stat-item">
<Text className="stat-number">{inviteStats.signedCount}</Text>
<Text className="stat-label"></Text>
</View>
</View>
<View className="reward-summary">
<Space className="reward-card flex items-center justify-center">
<Text></Text>
<Text>¥{inviteStats.totalReward}</Text>
<Button type={'success'} size="small">
</Button>
</Space>
</View>
<View className="stats-chart">
<Text className="chart-title"></Text>
<View className="chart-placeholder">
<Text></Text>
</View>
</View>
</>
)}
</View>
);
return (
<View className="customer-invite-page">
{/* 头部背景 */}
<View className="header-bg" style={{ height: '180px' }} />
{/* 标签页 */}
<View className="tabs-container">
<Tabs
value={activeTab}
onChange={(value) => setActiveTab(value as string)}
>
{tabList.map(tab => (
<Tabs.TabPane key={tab.value} title={tab.title} value={tab.value} />
))}
</Tabs>
</View>
{/* 内容区域 */}
<View className="content-container">
{activeTab === 'qrcode' && renderQRCodeTab()}
{activeTab === 'records' && renderRecordsTab()}
{activeTab === 'stats' && renderStatsTab()}
</View>
{/* 手动邀请弹窗 */}
<Popup
visible={showInviteForm}
position="bottom"
onClose={() => setShowInviteForm(false)}
>
<View className="invite-form">
<View className="form-header">
<Text className="form-title"></Text>
</View>
<Form>
<Form.Item label="姓名" required>
<Input
placeholder="请输入好友姓名"
value={inviteFormData.name}
onChange={(value) => setInviteFormData(prev => ({ ...prev, name: value }))}
/>
</Form.Item>
<Form.Item label="手机号" required>
<Input
placeholder="请输入好友手机号"
type="tel"
value={inviteFormData.phone}
onChange={(value) => setInviteFormData(prev => ({ ...prev, phone: value }))}
/>
</Form.Item>
</Form>
<View className="form-actions">
<Button
className="cancel-btn"
onClick={() => setShowInviteForm(false)}
>
</Button>
<Button
className="submit-btn"
onClick={submitInviteForm}
>
</Button>
</View>
</View>
</Popup>
{/* Toast提示 */}
<Toast
visible={showToast}
content={toastMsg}
duration={2000}
/>
</View>
);
};
export default CustomerInvite;

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '客户列表'
})

View File

@@ -0,0 +1,197 @@
.customer-list-page {
min-height: 100vh;
background: #f5f5f5;
position: relative;
.header-bg {
width: 100%;
top: 0;
position: absolute;
z-index: 0;
}
.tabs-container {
position: relative;
z-index: 10;
.nut-tabs {
background: transparent;
.nut-tabs__titles {
background: transparent;
border: none;
.nut-tabs__titles-item {
color: rgba(255, 255, 255, 0.8);
padding: 12px 20px;
&.nut-tabs__titles-item--active {
color: #ffffff;
font-weight: bold;
}
}
}
.nut-tabs__line {
background: #ffffff;
height: 3px;
border-radius: 2px;
}
}
}
.customer-list {
position: relative;
z-index: 10;
padding: 20px 16px 100px;
margin-top: 20px;
.loading-container {
text-align: center;
padding: 40px 0;
color: #999;
}
.customer-item {
background: #ffffff;
border-radius: 12px;
padding: 24px 30px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.customer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.company-name {
font-weight: bold;
color: #333;
flex: 1;
}
.status-tag {
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.05);
}
}
.customer-info {
.info-row {
display: flex;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
.label {
color: #666;
margin-right: 8px;
}
.contact-label {
margin-left: 16px;
}
.value {
color: #333;
margin-right: 8px;
}
.phone {
color: #52c41a;
}
.phone-icon {
cursor: pointer;
padding: 4px;
border-radius: 50%;
background: rgba(82, 196, 26, 0.1);
}
}
.address-row {
display: flex;
margin-bottom: 8px;
.label {
color: #666;
margin-right: 8px;
flex-shrink: 0;
}
.address {
color: #333;
line-height: 1.4;
flex: 1;
}
}
.time-row {
.time {
color: #999;
}
}
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
.action-btn {
border-radius: 6px;
border: none;
&.sign-btn {
background: #52c41a;
color: #ffffff;
}
&.cancel-btn {
background: #ff4d4f;
color: #ffffff;
}
&.detail-btn {
background: #1890ff;
color: #ffffff;
}
}
}
}
}
.fixed-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
z-index: 100;
.report-btn {
width: 100%;
background: #52c41a;
color: #ffffff;
font-weight: bold;
border-radius: 24px;
border: none;
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.3);
}
}
}
// 适配安全区域
@supports (bottom: env(safe-area-inset-bottom)) {
.customer-list-page .fixed-bottom {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
}

279
src/pages/customer/list.tsx Normal file
View File

@@ -0,0 +1,279 @@
import {useEffect, useState} from "react";
import Taro from '@tarojs/taro';
import {View, Text} from '@tarojs/components';
import {Space, Tabs, Button, Empty} from '@nutui/nutui-react-taro';
import {Phone} from '@nutui/icons-react-taro';
import './list.scss';
// 客户数据类型定义
interface Customer {
id: string;
companyName: string;
contactPerson: string;
phone: string;
address: string;
addTime: string;
status: 'pending' | 'confirmed' | 'cancelled';
}
const CustomerList = () => {
const [statusBarHeight, setStatusBarHeight] = useState<number>(0);
const [activeTab, setActiveTab] = useState<string>('all');
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState<boolean>(false);
// 模拟客户数据
const mockCustomers: Customer[] = [
{
id: '1',
companyName: '广州雅虎信息科技公司',
contactPerson: 'XXX',
phone: '13882223433',
address: '广西南宁市良庆区五象大道401号五象新城1号楼1226室XXXXXX',
addTime: '2025-08-15 10:23:33',
status: 'pending'
},
{
id: '2',
companyName: '广州雅虎信息科技公司',
contactPerson: 'XXX',
phone: '13882223433',
address: '广西南宁市良庆区五象大道401号五象新城1号楼1226室XXXXXX',
addTime: '2025-08-15 10:23:33',
status: 'confirmed'
},
{
id: '3',
companyName: '广州雅虎信息科技公司',
contactPerson: 'XXX',
phone: '13882223433',
address: '广西南宁市良庆区五象大道401号五象新城1号楼1226室XXXXXX',
addTime: '2025-08-15 10:23:33',
status: 'cancelled'
}
];
const tabList = [
{title: '全部', value: 'all'},
{title: '跟进中', value: 'pending'},
{title: '已签约', value: 'confirmed'},
{title: '已取消', value: 'cancelled'}
];
const reload = async () => {
setLoading(true);
// 模拟API调用
setTimeout(() => {
setCustomers(mockCustomers);
setLoading(false);
}, 500);
};
const getFilteredCustomers = () => {
if (activeTab === 'all') {
return customers;
}
return customers.filter(customer => customer.status === activeTab);
};
const getStatusText = (status: string) => {
switch (status) {
case 'pending':
return '跟进中';
case 'confirmed':
return '已签约';
case 'cancelled':
return '已取消';
default:
return '';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return '#ff6b35';
case 'confirmed':
return '#52c41a';
case 'cancelled':
return '#999';
default:
return '#999';
}
};
const handleCall = (phone: string) => {
Taro.makePhoneCall({
phoneNumber: phone
});
};
const handleAction = (customer: Customer, action: 'sign' | 'cancel' | 'detail') => {
switch (action) {
case 'sign':
// 跳转到签约页面
Taro.navigateTo({
url: `/pages/customer/sign?customerId=${customer.id}`
});
break;
case 'cancel':
Taro.showModal({
title: '确认取消',
content: '确定要取消该客户吗?',
success: (res) => {
if (res.confirm) {
// 这里应该调用取消客户的API
Taro.showToast({
title: '已取消',
icon: 'success'
});
// 刷新列表
reload().then();
}
}
});
break;
case 'detail':
// 跳转到客户详情页面
Taro.navigateTo({
url: `/pages/customer/detail?customerId=${customer.id}`
});
break;
}
};
const handleReport = () => {
// 跳转到邀请页面
Taro.navigateTo({
url: '/pages/customer/invite'
});
};
const handleTrading = () => {
// 跳转到入市交易页面
Taro.navigateTo({
url: '/pages/customer/trading'
});
};
useEffect(() => {
Taro.getSystemInfo({
success: (res) => {
setStatusBarHeight(Number(res.statusBarHeight));
},
});
reload().then();
}, []);
return (
<View className="customer-list-page">
{/* 头部背景 */}
<View className="header-bg" style={{
height: '180px'
}} />
{/* 标签页 */}
<View className="tabs-container">
<Tabs
value={activeTab}
onChange={(value) => setActiveTab(value as string)}
>
{tabList.map(tab => (
<Tabs.TabPane key={tab.value} title={tab.title} value={tab.value} />
))}
</Tabs>
</View>
{/* 客户列表 */}
<View className="customer-list">
{loading ? (
<View className="loading-container">
<Text>...</Text>
</View>
) : getFilteredCustomers().length > 0 ? (
getFilteredCustomers().map((customer) => (
<View key={customer.id} className="customer-item">
<View className="customer-header">
<Text className="company-name">{customer.companyName}</Text>
<Text
className="status-tag"
style={{color: getStatusColor(customer.status)}}
>
{getStatusText(customer.status)}
</Text>
</View>
<View className="customer-info">
<View className="info-row">
<Text className="label"></Text>
<Text className="value">{customer.contactPerson}</Text>
<Text className="label contact-label"></Text>
<Text className="value">{customer.phone}</Text>
<Phone
size={14}
className={'text-green-500'}
onClick={() => handleCall(customer.phone)}
/>
</View>
<View className="address-row">
<Text className="label"></Text>
<Text className="address">{customer.address}</Text>
</View>
<View className="time-row">
<Text className="time">{customer.addTime}</Text>
</View>
</View>
{/* 操作按钮 */}
<View className="action-buttons">
{customer.status === 'pending' && (
<Space>
<Button
className="action-btn sign-btn"
size="small"
onClick={() => handleAction(customer, 'sign')}
>
</Button>
<Button
className="action-btn cancel-btn"
size="small"
onClick={() => handleAction(customer, 'cancel')}
>
</Button>
</Space>
)}
{customer.status === 'confirmed' && (
<Button
className="action-btn detail-btn"
size="small"
onClick={() => handleAction(customer, 'detail')}
>
</Button>
)}
</View>
</View>
))
) : (
<Empty description="暂无客户数据" />
)}
</View>
{/* 底部邀请好友按钮 */}
<View className="fixed-bottom">
<Button
className="report-btn"
onClick={handleReport}
>
</Button>
</View>
</View>
);
};
export default CustomerList;

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '客户签约',
navigationBarTextStyle: 'white',
navigationStyle: 'custom',
backgroundColor: '#f5f5f5'
})

View File

@@ -0,0 +1,212 @@
.customer-sign-page {
min-height: 100vh;
background: #f5f5f5;
position: relative;
padding-bottom: 100px;
.header-bg {
background: linear-gradient(to bottom, #03605c, #18ae4f);
width: 100%;
top: 0;
position: absolute;
z-index: 0;
}
.form-container {
position: relative;
z-index: 10;
padding: 20px 16px;
margin-top: 20px;
.form-section {
background: #ffffff;
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.section-title {
padding: 16px 16px 8px;
font-size: 16px;
font-weight: bold;
color: #333;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.nut-form-item {
padding: 12px 16px;
border-bottom: 1px solid #f8f8f8;
&:last-child {
border-bottom: none;
}
.nut-form-item__label {
font-size: 14px;
color: #333;
font-weight: 500;
min-width: 80px;
}
.nut-form-item__body {
flex: 1;
}
.nut-input {
font-size: 14px;
color: #333;
&::placeholder {
color: #999;
}
}
.nut-textarea {
font-size: 14px;
color: #333;
&::placeholder {
color: #999;
}
}
.picker-cell {
padding: 0;
background: transparent;
border: none;
.nut-cell__title {
font-size: 14px;
color: #333;
}
.nut-cell__value {
color: #999;
}
}
.nut-switch {
transform: scale(0.8);
}
}
}
}
.fixed-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
z-index: 100;
.submit-btn {
width: 100%;
height: 48px;
background: #52c41a;
color: #ffffff;
font-size: 16px;
font-weight: bold;
border-radius: 24px;
border: none;
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.3);
&.nut-button--loading {
background: #52c41a;
opacity: 0.8;
}
}
}
}
// 适配安全区域
@supports (bottom: env(safe-area-inset-bottom)) {
.customer-sign-page .fixed-bottom {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
}
// 表单验证错误样式
.nut-form-item--error {
.nut-form-item__label {
color: #ff4d4f !important;
}
.nut-input {
border-color: #ff4d4f !important;
}
}
// 选择器样式优化
.nut-picker {
.nut-picker__toolbar {
background: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.nut-picker__confirm {
color: #52c41a;
}
.nut-picker__cancel {
color: #999;
}
}
// 日期选择器样式
.nut-date-picker {
.nut-picker__toolbar {
background: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.nut-picker__confirm {
color: #52c41a;
}
.nut-picker__cancel {
color: #999;
}
}
// Toast样式
.nut-toast {
.nut-toast__text {
font-size: 14px;
}
}
// 开关组件样式
.nut-switch {
&.nut-switch--active {
background: #52c41a;
}
}
// 响应式适配
@media (max-width: 375px) {
.customer-sign-page {
.form-container {
padding: 16px 12px;
.form-section {
.nut-form-item {
padding: 10px 12px;
.nut-form-item__label {
min-width: 70px;
font-size: 13px;
}
.nut-input,
.nut-textarea {
font-size: 13px;
}
}
}
}
}
}

391
src/pages/customer/sign.tsx Normal file
View File

@@ -0,0 +1,391 @@
import { useEffect, useState } from "react";
import Taro, { useRouter } from '@tarojs/taro';
import { View, Text } from '@tarojs/components';
import {
NavBar,
Form,
Input,
Button,
DatePicker,
Picker,
TextArea,
Cell,
Switch,
Toast
} from '@nutui/nutui-react-taro';
import { ArrowDown } from '@nutui/icons-react-taro';
import './sign.scss';
// 签约表单数据类型
interface SignFormData {
customerId: string;
contractType: string;
contractAmount: string;
contractDate: string;
startDate: string;
endDate: string;
paymentMethod: string;
paymentCycle: string[];
specialTerms: string;
isUrgent: boolean;
contactPerson: string;
contactPhone: string;
signLocation: string;
remarks: string;
}
const CustomerSign = () => {
const router = useRouter();
const [statusBarHeight, setStatusBarHeight] = useState<number>(0);
const [formData, setFormData] = useState<SignFormData>({
customerId: '',
contractType: '',
contractAmount: '',
contractDate: '',
startDate: '',
endDate: '',
paymentMethod: '',
paymentCycle: [],
specialTerms: '',
isUrgent: false,
contactPerson: '',
contactPhone: '',
signLocation: '',
remarks: ''
});
const [loading, setLoading] = useState(false);
const [showToast, setShowToast] = useState(false);
const [toastMsg, setToastMsg] = useState('');
// 合同类型选项
const contractTypes = [
{ text: '服务合同', value: 'service' },
{ text: '销售合同', value: 'sales' },
{ text: '代理合同', value: 'agency' },
{ text: '合作协议', value: 'cooperation' }
];
// 付款方式选项
const paymentMethods = [
{ text: '一次性付款', value: 'onetime' },
{ text: '分期付款', value: 'installment' },
{ text: '月付', value: 'monthly' },
{ text: '季付', value: 'quarterly' },
{ text: '年付', value: 'yearly' }
];
// 付款周期选项
const paymentCycles = [
{ text: '签约后立即付款', value: 'immediate' },
{ text: '签约后7天内', value: '7days' },
{ text: '签约后15天内', value: '15days' },
{ text: '签约后30天内', value: '30days' },
{ text: '按月付款', value: 'monthly' }
];
const showToastMsg = (msg: string) => {
setToastMsg(msg);
setShowToast(true);
setTimeout(() => setShowToast(false), 2000);
};
const handleInputChange = (field: keyof SignFormData, value: any) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const validateForm = (): boolean => {
if (!formData.contractType) {
showToastMsg('请选择合同类型');
return false;
}
if (!formData.contractAmount) {
showToastMsg('请输入合同金额');
return false;
}
if (!formData.contractDate) {
showToastMsg('请选择签约日期');
return false;
}
if (!formData.startDate) {
showToastMsg('请选择合同开始日期');
return false;
}
if (!formData.endDate) {
showToastMsg('请选择合同结束日期');
return false;
}
if (!formData.paymentMethod) {
showToastMsg('请选择付款方式');
return false;
}
if (!formData.contactPerson) {
showToastMsg('请输入联系人姓名');
return false;
}
if (!formData.contactPhone) {
showToastMsg('请输入联系电话');
return false;
}
if (!formData.signLocation) {
showToastMsg('请输入签约地点');
return false;
}
return true;
};
const handleSubmit = async () => {
if (!validateForm()) return;
setLoading(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1500));
showToastMsg('签约成功!');
// 延迟跳转,让用户看到成功提示
setTimeout(() => {
Taro.navigateBack();
}, 2000);
} catch (error) {
showToastMsg('签约失败,请重试');
} finally {
setLoading(false);
}
};
const formatDate = (date: Date): string => {
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}`;
};
useEffect(() => {
Taro.getSystemInfo({
success: (res) => {
setStatusBarHeight(Number(res.statusBarHeight));
},
});
// 获取客户ID
const { customerId } = router.params;
if (customerId) {
setFormData(prev => ({ ...prev, customerId }));
}
}, []);
return (
<View className="customer-sign-page">
{/* 头部背景 */}
<View className="header-bg" style={{ height: '120px' }} />
{/* 导航栏 */}
<NavBar
style={{
marginTop: `${statusBarHeight}px`,
backgroundColor: 'transparent',
color: '#ffffff'
}}
onBackClick={() => Taro.navigateBack()}
>
<Text style={{ color: '#ffffff', fontSize: '18px', fontWeight: 'bold' }}>
</Text>
</NavBar>
{/* 表单内容 */}
<View className="form-container">
<Form>
{/* 基本信息 */}
<View className="form-section">
<View className="section-title"></View>
<Form.Item label="合同类型" required>
<Picker
options={contractTypes}
value={formData.contractType}
onConfirm={(options) => handleInputChange('contractType', options[0]?.value)}
>
<Cell
className="picker-cell"
title={contractTypes.find(item => item.value === formData.contractType)?.text || '请选择合同类型'}
extra={<ArrowDown />}
/>
</Picker>
</Form.Item>
<Form.Item label="合同金额" required>
<Input
placeholder="请输入合同金额"
type="digit"
value={formData.contractAmount}
onChange={(value) => handleInputChange('contractAmount', value)}
/>
</Form.Item>
<Form.Item label="签约日期" required>
<DatePicker
value={formData.contractDate ? new Date(formData.contractDate) : new Date()}
onConfirm={(options, value) => handleInputChange('contractDate', formatDate(value))}
>
<Cell
className="picker-cell"
title={formData.contractDate || '请选择签约日期'}
extra={<ArrowDown />}
/>
</DatePicker>
</Form.Item>
</View>
{/* 合同期限 */}
<View className="form-section">
<View className="section-title"></View>
<Form.Item label="开始日期" required>
<DatePicker
value={formData.startDate ? new Date(formData.startDate) : new Date()}
onConfirm={(options, value) => handleInputChange('startDate', formatDate(value))}
>
<Cell
className="picker-cell"
title={formData.startDate || '请选择开始日期'}
extra={<ArrowDown />}
/>
</DatePicker>
</Form.Item>
<Form.Item label="结束日期" required>
<DatePicker
value={formData.endDate ? new Date(formData.endDate) : new Date()}
onConfirm={(options, value) => handleInputChange('endDate', formatDate(value))}
>
<Cell
className="picker-cell"
title={formData.endDate || '请选择结束日期'}
extra={<ArrowDown />}
/>
</DatePicker>
</Form.Item>
</View>
{/* 付款信息 */}
<View className="form-section">
<View className="section-title"></View>
<Form.Item label="付款方式" required>
<Picker
options={paymentMethods}
value={formData.paymentMethod}
onConfirm={(options) => handleInputChange('paymentMethod', options[0]?.value)}
>
<Cell
className="picker-cell"
title={paymentMethods.find(item => item.value === formData.paymentMethod)?.text || '请选择付款方式'}
extra={<ArrowDown />}
/>
</Picker>
</Form.Item>
<Form.Item label="付款周期">
<Picker
options={paymentCycles}
value={formData.paymentCycle}
onConfirm={(options) => handleInputChange('paymentCycle', options[0]?.value)}
>
<Cell
className="picker-cell"
title={paymentCycles.find(item => item.value === formData.paymentCycle)?.text || '请选择付款周期'}
extra={<ArrowDown />}
/>
</Picker>
</Form.Item>
</View>
{/* 联系信息 */}
<View className="form-section">
<View className="section-title"></View>
<Form.Item label="联系人" required>
<Input
placeholder="请输入联系人姓名"
value={formData.contactPerson}
onChange={(value) => handleInputChange('contactPerson', value)}
/>
</Form.Item>
<Form.Item label="联系电话" required>
<Input
placeholder="请输入联系电话"
type="tel"
value={formData.contactPhone}
onChange={(value) => handleInputChange('contactPhone', value)}
/>
</Form.Item>
<Form.Item label="签约地点" required>
<Input
placeholder="请输入签约地点"
value={formData.signLocation}
onChange={(value) => handleInputChange('signLocation', value)}
/>
</Form.Item>
</View>
{/* 其他信息 */}
<View className="form-section">
<View className="section-title"></View>
<Form.Item label="特殊条款">
<TextArea
placeholder="请输入特殊条款(可选)"
value={formData.specialTerms}
onChange={(value) => handleInputChange('specialTerms', value)}
rows={3}
/>
</Form.Item>
<Form.Item label="紧急处理">
<Switch
checked={formData.isUrgent}
onChange={(value) => handleInputChange('isUrgent', value)}
/>
</Form.Item>
<Form.Item label="备注">
<TextArea
placeholder="请输入备注信息(可选)"
value={formData.remarks}
onChange={(value) => handleInputChange('remarks', value)}
rows={3}
/>
</Form.Item>
</View>
</Form>
</View>
{/* 底部提交按钮 */}
<View className="fixed-bottom">
<Button
className="submit-btn"
loading={loading}
onClick={handleSubmit}
>
{loading ? '提交中...' : '确认签约'}
</Button>
</View>
{/* Toast提示 */}
<Toast
visible={showToast}
content={toastMsg}
duration={2000}
/>
</View>
);
};
export default CustomerSign;

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '入市交易'
})

View File

@@ -0,0 +1,251 @@
.customer-trading-page {
min-height: 100vh;
background: #f5f5f5;
position: relative;
padding-bottom: 100px;
.header-bg {
background: linear-gradient(to bottom, #03605c, #18ae4f);
width: 100%;
top: 0;
position: absolute;
z-index: 0;
}
.search-container {
position: relative;
z-index: 10;
padding: 0 16px;
margin-top: 16px;
.nut-searchbar {
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.tabs-container {
position: relative;
z-index: 10;
padding: 0 16px;
margin-top: 16px;
.nut-tabs {
background: transparent;
.nut-tabs__titles {
background: transparent;
border: none;
.nut-tabs__titles-item {
color: rgba(255, 255, 255, 0.8);
padding: 12px 20px;
&.nut-tabs__titles-item--active {
color: #ffffff;
font-weight: bold;
}
}
}
.nut-tabs__line {
background: #ffffff;
height: 3px;
border-radius: 2px;
}
}
}
.trading-list {
position: relative;
z-index: 10;
padding: 20px 16px;
margin-top: 20px;
.loading-container {
text-align: center;
padding: 40px 0;
color: #999;
}
.trading-item {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: transform 0.2s ease;
&:active {
transform: scale(0.98);
}
.trading-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.customer-info {
display: flex;
align-items: center;
flex: 1;
.customer-name {
font-weight: bold;
color: #333;
margin-right: 8px;
}
.nut-tag {
margin-left: 8px;
}
}
.status-info {
.nut-tag {
}
}
}
.trading-details {
margin-bottom: 12px;
.detail-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.detail-item {
flex: 1;
display: flex;
flex-direction: column;
.label {
color: #999;
margin-bottom: 4px;
}
.value {
color: #333;
font-weight: 500;
&.amount {
font-weight: bold;
color: #333;
}
&.profit {
font-weight: bold;
}
}
}
}
}
.trading-footer {
padding-top: 8px;
border-top: 1px solid #f8f8f8;
.time-info {
display: flex;
align-items: center;
.nut-icon {
margin-right: 4px;
}
.time-text {
color: #999;
}
}
}
}
}
.fixed-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
z-index: 100;
.add-btn {
width: 100%;
background: #52c41a;
color: #ffffff;
font-weight: bold;
border: none;
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.3);
}
}
}
// 适配安全区域
@supports (bottom: env(safe-area-inset-bottom)) {
.customer-trading-page .fixed-bottom {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
}
// 标签样式优化
.nut-tag {
&.nut-tag--plain {
border-width: 1px;
padding: 2px 6px;
&.nut-tag--small {
padding: 1px 4px;
}
}
}
// 下拉刷新样式
.nut-pulltorefresh {
.nut-pulltorefresh__track {
min-height: auto;
}
}
// 无限加载样式
.nut-infiniteloading {
padding: 16px 0;
text-align: center;
color: #999;
}
// 空状态样式
.nut-empty {
padding: 60px 20px;
.nut-empty__description {
color: #999;
}
}
// 响应式适配
@media (max-width: 375px) {
.customer-trading-page {
.search-container,
.tabs-container {
padding: 0 12px;
}
.trading-list {
padding: 16px 12px;
.trading-item {
padding: 12px;
}
}
}
}

View File

@@ -0,0 +1,381 @@
import { useEffect, useState } from "react";
import Taro from '@tarojs/taro';
import { View, Text } from '@tarojs/components';
import {
NavBar,
SearchBar,
Tabs,
Button,
Tag,
Empty,
PullToRefresh,
InfiniteLoading
} from '@nutui/nutui-react-taro';
import {
Filter,
Calendar
} from '@nutui/icons-react-taro';
import './trading.scss';
// 交易记录数据类型
interface TradingRecord {
id: string;
customerName: string;
customerId: string;
tradingType: 'buy' | 'sell';
amount: string;
price: string;
quantity: string;
tradingDate: string;
status: 'pending' | 'completed' | 'cancelled';
profit: string;
profitRate: string;
}
const CustomerTrading = () => {
const [statusBarHeight, setStatusBarHeight] = useState<number>(0);
const [activeTab, setActiveTab] = useState<string>('all');
const [searchValue, setSearchValue] = useState<string>('');
const [tradingRecords, setTradingRecords] = useState<TradingRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(true);
const [page, setPage] = useState<number>(1);
// 模拟交易数据
const mockTradingRecords: TradingRecord[] = [
{
id: '1',
customerName: '广州雅虎信息科技公司',
customerId: '1',
tradingType: 'buy',
amount: '100,000',
price: '15.50',
quantity: '6,451',
tradingDate: '2025-08-21 09:30:15',
status: 'completed',
profit: '+8,500',
profitRate: '+8.5%'
},
{
id: '2',
customerName: '深圳腾讯科技有限公司',
customerId: '2',
tradingType: 'sell',
amount: '250,000',
price: '28.80',
quantity: '8,680',
tradingDate: '2025-08-21 10:15:30',
status: 'completed',
profit: '+15,200',
profitRate: '+6.1%'
},
{
id: '3',
customerName: '阿里巴巴网络技术有限公司',
customerId: '3',
tradingType: 'buy',
amount: '500,000',
price: '42.30',
quantity: '11,820',
tradingDate: '2025-08-21 11:45:20',
status: 'pending',
profit: '0',
profitRate: '0%'
},
{
id: '4',
customerName: '百度在线网络技术公司',
customerId: '4',
tradingType: 'sell',
amount: '180,000',
price: '22.10',
quantity: '8,144',
tradingDate: '2025-08-21 14:20:45',
status: 'cancelled',
profit: '-2,800',
profitRate: '-1.6%'
}
];
const tabList = [
{ title: '全部', value: 'all' },
{ title: '买入', value: 'buy' },
{ title: '卖出', value: 'sell' },
{ title: '待处理', value: 'pending' }
];
const getFilteredRecords = () => {
let filtered = tradingRecords;
// 按标签页筛选
if (activeTab === 'buy') {
filtered = filtered.filter(record => record.tradingType === 'buy');
} else if (activeTab === 'sell') {
filtered = filtered.filter(record => record.tradingType === 'sell');
} else if (activeTab === 'pending') {
filtered = filtered.filter(record => record.status === 'pending');
}
// 按搜索关键词筛选
if (searchValue) {
filtered = filtered.filter(record =>
record.customerName.toLowerCase().includes(searchValue.toLowerCase()) ||
record.id.includes(searchValue)
);
}
return filtered;
};
const getStatusText = (status: string) => {
switch (status) {
case 'pending':
return '待处理';
case 'completed':
return '已完成';
case 'cancelled':
return '已取消';
default:
return '';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return '#ff6b35';
case 'completed':
return '#52c41a';
case 'cancelled':
return '#ff4d4f';
default:
return '#999';
}
};
const getTradingTypeText = (type: string) => {
return type === 'buy' ? '买入' : '卖出';
};
const getTradingTypeColor = (type: string) => {
return type === 'buy' ? '#52c41a' : '#ff4d4f';
};
const getProfitColor = (profit: string) => {
if (profit.startsWith('+')) return '#52c41a';
if (profit.startsWith('-')) return '#ff4d4f';
return '#999';
};
const loadTradingRecords = async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
setPage(1);
} else {
setLoading(true);
}
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 800));
if (isRefresh) {
setTradingRecords(mockTradingRecords);
setHasMore(true);
} else {
// 模拟分页加载
if (page === 1) {
setTradingRecords(mockTradingRecords);
} else {
setHasMore(false);
}
}
} catch (error) {
Taro.showToast({
title: '加载失败,请重试',
icon: 'none'
});
} finally {
setLoading(false);
setRefreshing(false);
}
};
const loadMore = async () => {
if (!hasMore || loading) return;
setPage(prev => prev + 1);
await loadTradingRecords();
};
const handleSearch = (value: string) => {
setSearchValue(value);
};
const handleFilter = () => {
Taro.showToast({
title: '筛选功能开发中',
icon: 'none'
});
};
const handleRecordClick = (_: TradingRecord) => {
Taro.showToast({
title: '查看交易详情功能开发中',
icon: 'none'
});
};
const handleAddTrading = () => {
Taro.showToast({
title: '新增交易功能开发中',
icon: 'none'
});
};
useEffect(() => {
Taro.getSystemInfo({
success: (res) => {
setStatusBarHeight(Number(res.statusBarHeight));
},
});
loadTradingRecords();
}, []);
return (
<View className="customer-trading-page">
{/* 头部背景 */}
<View className="header-bg" style={{ height: '200px' }} />
{/* 搜索栏 */}
<View className="search-container">
<SearchBar
placeholder="搜索客户名称或交易编号"
value={searchValue}
onChange={handleSearch}
style={{
backgroundColor: 'transparent'
}}
/>
</View>
{/* 标签页 */}
<View className="tabs-container">
<Tabs
value={activeTab}
onChange={(value) => setActiveTab(value as string)}
activeColor="#ffffff"
style={{
backgroundColor: 'transparent',
color: '#ffffff',
}}
>
{tabList.map(tab => (
<Tabs.TabPane key={tab.value} title={tab.title} value={tab.value} />
))}
</Tabs>
</View>
{/* 交易列表 */}
<View className="trading-list">
<PullToRefresh
onRefresh={() => loadTradingRecords(true)}
disabled={refreshing}
>
{loading && page === 1 ? (
<View className="loading-container">
<Text>...</Text>
</View>
) : getFilteredRecords().length > 0 ? (
<>
{getFilteredRecords().map((record) => (
<View
key={record.id}
className="trading-item"
onClick={() => handleRecordClick(record)}
>
<View className="trading-header">
<View className="customer-info">
<Text className="customer-name">{record.customerName}</Text>
<Tag
color={getTradingTypeColor(record.tradingType)}
plain
>
{getTradingTypeText(record.tradingType)}
</Tag>
</View>
<View className="status-info">
<Tag
color={getStatusColor(record.status)}
plain
>
{getStatusText(record.status)}
</Tag>
</View>
</View>
<View className="trading-details">
<View className="detail-row">
<View className="detail-item">
<Text className="label"></Text>
<Text className="value amount">¥{record.amount}</Text>
</View>
<View className="detail-item">
<Text className="label"></Text>
<Text className="value">¥{record.price}</Text>
</View>
</View>
<View className="detail-row">
<View className="detail-item">
<Text className="label"></Text>
<Text className="value">{record.quantity}</Text>
</View>
<View className="detail-item">
<Text className="label"></Text>
<Text
className="value profit"
style={{ color: getProfitColor(record.profit) }}
>
{record.profit} ({record.profitRate})
</Text>
</View>
</View>
</View>
<View className="trading-footer">
<View className="time-info">
<Calendar size={12} color="#999" />
<Text className="time-text">{record.tradingDate}</Text>
</View>
</View>
</View>
))}
<InfiniteLoading
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={loading && page > 1}
/>
</>
) : (
<Empty description="暂无交易记录" />
)}
</PullToRefresh>
</View>
{/* 底部新增交易按钮 */}
<View className="fixed-bottom">
<Button
className="add-btn"
onClick={handleAddTrading}
>
</Button>
</View>
</View>
);
};
export default CustomerTrading;

View File

@@ -4,6 +4,7 @@ import {Avatar, Divider} from '@nutui/nutui-react-taro'
import {View, Text} from '@tarojs/components'
import {listCmsNavigation} from "@/api/cms/cmsNavigation";
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
import navTo from "@/utils/common";
const MyGrid = () => {
const [list, setList] = useState<CmsNavigation[]>([])
@@ -36,7 +37,7 @@ const MyGrid = () => {
}}>
{
list.map((item) => (
<Grid.Item key={item.navigationId}>
<Grid.Item key={item.navigationId} onClick={() => navTo(`${item.path}`)}>
<Avatar src={item.icon} className={'mb-2'} shape="square" style={{
backgroundColor: 'transparent',
}}/>

View File

@@ -4,7 +4,8 @@ import Taro from '@tarojs/taro';
import {useShareAppMessage, useShareTimeline} from "@tarojs/taro"
import {useEffect, useState} from "react";
import {getShopInfo} from "@/api/layout";
import {Sticky} from '@nutui/nutui-react-taro'
import {Sticky, Button} from '@nutui/nutui-react-taro'
import {View} from '@tarojs/components'
import Menu from "./Menu";
import Banner from "./Banner";
import './index.scss'