feat(admin): 从文章详情页面改为文章管理页面

- 修改页面配置,设置新的导航栏标题和样式
- 重新设计页面布局,增加搜索栏、文章列表和操作按钮
- 添加文章搜索、分页加载和删除功能
- 优化文章列表项的样式和交互
- 新增礼品卡相关API和组件
- 更新优惠券组件,增加到期提醒和筛选功能
This commit is contained in:
2025-08-13 10:11:57 +08:00
parent 0e457f66d8
commit a1cacc04e8
67 changed files with 6278 additions and 2816 deletions

View File

@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '文章详情' navigationBarTitleText: '商品文章管理',
navigationBarTextStyle: 'black'
}) })

View File

@@ -1,53 +1,271 @@
import Taro from '@tarojs/taro' import {useState} from "react";
import {useEffect, useState} from 'react' import Taro, {useDidShow} from '@tarojs/taro'
import {useRouter} from '@tarojs/taro' import {Button, Cell, CellGroup, Empty, ConfigProvider, SearchBar, Tag, InfiniteLoading, Loading, PullToRefresh} from '@nutui/nutui-react-taro'
import {Loading} from '@nutui/nutui-react-taro' import {Edit, Del, Eye} from '@nutui/icons-react-taro'
import {View, RichText} from '@tarojs/components' import {View} from '@tarojs/components'
import {wxParse} from "@/utils/common"; import {CmsArticle} from "@/api/cms/cmsArticle/model";
import {getCmsArticle} from "@/api/cms/cmsArticle"; import {pageCmsArticle, removeCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model" import FixedButton from "@/components/FixedButton";
import Line from "@/components/Gap"; import dayjs from "dayjs";
import './index.scss'
function Detail() { const ArticleArticleManage = () => {
const {params} = useRouter(); const [list, setList] = useState<CmsArticle[]>([])
const [loading, setLoading] = useState<boolean>(true) const [loading, setLoading] = useState(false)
// 文章详情 // const [refreshing, setRefreshing] = useState(false)
const [item, setItem] = useState<CmsArticle>() const [hasMore, setHasMore] = useState(true)
const reload = async () => { const [searchValue, setSearchValue] = useState('')
const item = await getCmsArticle(Number(params.id)) const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
if (item) { const reload = async (isRefresh = false) => {
item.content = wxParse(item.content) if (isRefresh) {
setItem(item) setPage(1)
Taro.setNavigationBarTitle({ setList([])
title: `${item?.categoryName}` setHasMore(true)
}
setLoading(true)
try {
const currentPage = isRefresh ? 1 : page
const res = await pageCmsArticle({
page: currentPage,
limit: 10,
keywords: searchValue
})
if (res && res.list) {
const newList = isRefresh ? res.list : [...list, ...res.list]
setList(newList)
setTotal(res.count || 0)
// 判断是否还有更多数据
setHasMore(res.list.length === 10) // 如果返回的数据等于limit说明可能还有更多
if (!isRefresh) {
setPage(currentPage + 1)
} else {
setPage(2) // 刷新后下一页是第2页
}
} else {
setHasMore(false)
setTotal(0)
}
} catch (error) {
console.error('获取文章失败:', error)
Taro.showToast({
title: '获取文章失败',
icon: 'error'
});
} finally {
setLoading(false)
}
}
// 搜索功能
const handleSearch = (value: string) => {
setSearchValue(value)
reload(true)
}
// 下拉刷新
const handleRefresh = async () => {
// setRefreshing(true)
await reload(true)
// setRefreshing(false)
}
// 删除文章
const handleDelete = async (id?: number) => {
Taro.showModal({
title: '确认删除',
content: '确定要删除这篇文章吗?',
success: async (res) => {
if (res.confirm) {
try {
await removeCmsArticle(id)
Taro.showToast({
title: '删除成功',
icon: 'success'
});
reload(true);
} catch (error) {
Taro.showToast({
title: '删除失败',
icon: 'error'
});
}
}
}
});
}
// 编辑文章
const handleEdit = (item: CmsArticle) => {
Taro.navigateTo({
url: `/shop/shopArticle/add?id=${item.articleId}`
});
}
// 查看文章详情
const handleView = (item: CmsArticle) => {
// 这里可以跳转到文章详情页面
Taro.navigateTo({
url: `/cms/detail/index?id=${item.articleId}`
}) })
} }
// 获取状态标签
const getStatusTag = (status?: number) => {
switch (status) {
case 0:
return <Tag type="success"></Tag>
case 1:
return <Tag type="warning"></Tag>
case 2:
return <Tag type="danger"></Tag>
case 3:
return <Tag type="danger"></Tag>
default:
return <Tag></Tag>
}
} }
useEffect(() => { // 加载更多
reload().then(() => { const loadMore = async () => {
setLoading(false) if (!loading && hasMore) {
await reload(false) // 不刷新,追加数据
}
}
useDidShow(() => {
reload(true).then()
}); });
}, []);
if (loading) {
return (
<Loading className={'px-2'}></Loading>
)
}
return ( return (
<div className={'bg-white'}> <ConfigProvider>
<div className={'p-4 font-bold text-lg'}>{item?.title}</div> {/* 搜索栏 */}
<div className={'text-gray-400 text-sm px-4 '}>{item?.createTime}</div> <View className="py-2">
<View className={'content p-4'}> <SearchBar
<RichText nodes={item?.content}/> placeholder="搜索关键词"
value={searchValue}
onChange={setSearchValue}
onSearch={handleSearch}
/>
</View> </View>
<Line height={44}/>
</div>
)
}
export default Detail {/* 统计信息 */}
{total > 0 && (
<View className="px-4 py-2 text-sm text-gray-500">
{total}
</View>
)}
{/* 文章列表 */}
<PullToRefresh
onRefresh={handleRefresh}
headHeight={60}
>
<View className="px-4" style={{ height: 'calc(100vh - 160px)', overflowY: 'auto' }} id="article-scroll">
{list.length === 0 && !loading ? (
<View className="flex flex-col justify-center items-center" style={{height: 'calc(100vh - 200px)'}}>
<Empty
description="暂无文章数据"
style={{backgroundColor: 'transparent'}}
/>
</View>
) : (
<InfiniteLoading
target="article-scroll"
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? "暂无数据" : "没有更多了"}
</View>
}
>
{list.map((item, index) => (
<CellGroup key={item.articleId || index} className="mb-4">
<Cell>
<View className="flex flex-col gap-3 w-full">
{/* 文章标题和状态 */}
<View className="flex justify-between items-start">
<View className="flex-1 pr-2">
<View className="text-lg font-bold text-gray-900 line-clamp-2">
{item.title}
</View>
</View>
{getStatusTag(item.status)}
</View>
{/* 文章概述 */}
{item.overview && (
<View className="text-sm text-gray-600 line-clamp-2">
{item.overview}
</View>
)}
{/* 文章信息 */}
<View className="flex justify-between items-center text-xs text-gray-500">
<View className="flex items-center gap-4">
<View>: {item.actualViews || 0}</View>
{item.price && <View>: ¥{item.price}</View>}
<View>: {dayjs(item.createTime).format('MM-DD HH:mm')}</View>
</View>
</View>
{/* 操作按钮 */}
<View className="flex justify-end gap-2 pt-2 border-t border-gray-100">
<Button
size="small"
fill="outline"
icon={<Eye/>}
onClick={() => handleView(item)}
>
</Button>
<Button
size="small"
fill="outline"
icon={<Edit/>}
onClick={() => handleEdit(item)}
>
</Button>
<Button
size="small"
type="danger"
fill="outline"
icon={<Del/>}
onClick={() => handleDelete(item.articleId)}
>
</Button>
</View>
</View>
</Cell>
</CellGroup>
))}
</InfiniteLoading>
)}
</View>
</PullToRefresh>
{/* 底部浮动按钮 */}
<FixedButton
text="发布文章"
icon={<Edit />}
onClick={() => Taro.navigateTo({url: '/shop/shopArticle/add'})}
/>
</ConfigProvider>
);
};
export default ArticleArticleManage;

View File

@@ -84,8 +84,6 @@ export interface ShopArticle {
bmUsers?: number; bmUsers?: number;
// 用户ID // 用户ID
userId?: number; userId?: number;
// 商户ID
merchantId?: number;
// 项目ID // 项目ID
projectId?: number; projectId?: number;
// 语言 // 语言

View File

@@ -54,6 +54,7 @@ export interface ShopCoupon {
limitPerUser?: number; limitPerUser?: number;
// 是否启用(0禁用 1启用) // 是否启用(0禁用 1启用)
enabled?: string; enabled?: string;
sortBy?: string;
} }
/** /**

View File

@@ -0,0 +1,180 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api/index';
import type { ShopGift, ShopGiftParam, GiftRedeemParam, GiftUseParam } from './model';
/**
* 分页查询礼品卡
*/
export async function pageShopGift(params: ShopGiftParam) {
const res = await request.get<ApiResult<PageResult<ShopGift>>>(
'/shop/shop-gift/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询礼品卡列表
*/
export async function listShopGift(params?: ShopGiftParam) {
const res = await request.get<ApiResult<ShopGift[]>>(
'/shop/shop-gift',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加礼品卡
*/
export async function addShopGift(data: ShopGift) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-gift',
data
);
if (res.code === 0) {
return res.message;
}
}
/**
* 生成礼品卡
*/
export async function makeShopGift(data: ShopGift) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-gift/make',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改礼品卡
*/
export async function updateShopGift(data: ShopGift) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-gift',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除礼品卡
*/
export async function removeShopGift(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-gift/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除礼品卡
*/
export async function removeBatchShopGift(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-gift/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询礼品卡
*/
export async function getShopGift(id: number) {
const res = await request.get<ApiResult<ShopGift>>(
'/shop/shop-gift/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 兑换礼品卡
*/
export async function redeemGift(params: GiftRedeemParam) {
const res = await request.post<ApiResult<ShopGift>>(
'/shop/shop-gift/redeem',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 使用礼品卡
*/
export async function useGift(params: GiftUseParam) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-gift/use',
params
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 获取用户的礼品卡列表
*/
export async function getUserGifts(params: ShopGiftParam) {
const res = await request.get<ApiResult<PageResult<ShopGift>>>(
'/shop/shop-gift/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 验证礼品卡兑换码
*/
export async function validateGiftCode(code: string) {
const res = await request.get<ApiResult<ShopGift>>(
`/shop/shop-gift/validate/${code}`
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
export async function exportShopGift(ids?: number[]) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-gift/export',
ids
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,115 @@
import type { PageParam } from '@/api/index';
/**
* 礼品卡
*/
export interface ShopGift {
// 礼品卡ID
id?: number;
// 礼品卡名称
name?: string;
// 礼品卡描述
description?: string;
// 礼品卡兑换码
code?: string;
// 关联商品ID
goodsId?: number;
// 商品名称
goodsName?: string;
// 商品图片
goodsImage?: string;
// 礼品卡面值
faceValue?: string;
// 礼品卡类型 (10实物礼品卡 20虚拟礼品卡 30服务礼品卡)
type?: number;
// 领取时间
takeTime?: string;
// 使用时间
useTime?: string;
// 过期时间
expireTime?: string;
// 有效期天数
validDays?: number;
// 操作人
operatorUserId?: number;
// 是否展示
isShow?: string;
// 状态 (0未使用 1已使用 2已过期 3已失效)
status?: number;
// 使用状态 (0可用 1已使用 2已过期)
useStatus?: number;
// 备注
comments?: string;
// 使用说明
instructions?: string;
// 排序号
sortNumber?: number;
// 拥有者用户ID
userId?: number;
// 发放者用户ID
issuerUserId?: number;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
// 数量
num?: number;
// 已发放数量
issuedCount?: number;
// 总发放数量
totalCount?: number;
// 使用门店/地址
useLocation?: string;
// 客服联系方式
contactInfo?: string;
}
/**
* 礼品卡搜索条件
*/
export interface ShopGiftParam extends PageParam {
id?: number;
keywords?: string;
// 礼品卡类型筛选
type?: number;
// 状态筛选
status?: number;
// 使用状态筛选
useStatus?: number;
// 用户ID筛选
userId?: number;
// 商品ID筛选
goodsId?: number;
// 是否过期筛选
isExpired?: boolean;
// 排序字段
sortBy?: 'createTime' | 'expireTime' | 'faceValue' | 'useTime';
// 排序方向
sortOrder?: 'asc' | 'desc';
}
/**
* 礼品卡兑换参数
*/
export interface GiftRedeemParam {
// 兑换码
code: string;
// 用户ID
userId?: number;
}
/**
* 礼品卡使用参数
*/
export interface GiftUseParam {
// 礼品卡ID
giftId: number;
// 使用地址/门店
useLocation?: string;
// 使用备注
useNote?: string;
}

View File

@@ -38,8 +38,9 @@ export default defineAppConfig({
"help/index", "help/index",
"about/index", "about/index",
"wallet/wallet", "wallet/wallet",
"coupon/coupon", // "coupon/index",
"points/points" // "points/points",
"gift/index"
] ]
}, },
{ {
@@ -54,15 +55,17 @@ export default defineAppConfig({
}, },
{ {
"root": "shop", "root": "shop",
"pages": [ "pages": ['category/index',
'category/index',
'orderDetail/index', 'orderDetail/index',
'goodsDetail/index', 'goodsDetail/index',
'orderConfirm/index', 'orderConfirm/index',
'orderConfirmCart/index', 'orderConfirmCart/index',
'search/index', 'search/index']
'shopArticle/index', },
'shopArticle/add' {
"root": "admin",
"pages": [
"article/index",
] ]
} }
], ],

View File

@@ -2,12 +2,18 @@
position: relative; position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
height: 100px; height: 120px;
margin-bottom: 12px; margin-bottom: 16px;
border-radius: 8px; border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background: #fff; background: #fff;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
&.disabled { &.disabled {
opacity: 0.6; opacity: 0.6;
@@ -15,7 +21,7 @@
.coupon-left { .coupon-left {
flex-shrink: 0; flex-shrink: 0;
width: 100px; width: 110px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -24,23 +30,23 @@
position: relative; position: relative;
&.theme-red { &.theme-red {
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
} }
&.theme-orange { &.theme-orange {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%); background: linear-gradient(135deg, #ffa726 0%, #ff9800 100%);
} }
&.theme-blue { &.theme-blue {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%); background: linear-gradient(135deg, #42a5f5 0%, #2196f3 100%);
} }
&.theme-purple { &.theme-purple {
background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%); background: linear-gradient(135deg, #ab47bc 0%, #9c27b0 100%);
} }
&.theme-green { &.theme-green {
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%); background: linear-gradient(135deg, #66bb6a 0%, #4caf50 100%);
} }
.amount-wrapper { .amount-wrapper {
@@ -49,22 +55,23 @@
margin-bottom: 8px; margin-bottom: 8px;
.currency { .currency {
font-size: 24px; font-size: 28px;
font-weight: 500; font-weight: 600;
margin-right: 2px; margin-right: 2px;
} }
.amount { .amount {
font-size: 30px; font-size: 36px;
font-weight: bold; font-weight: bold;
line-height: 1; line-height: 1;
} }
} }
.condition { .condition {
font-size: 24px; font-size: 22px;
opacity: 0.9; opacity: 0.9;
margin-top: 2px; text-align: center;
line-height: 1.2;
} }
} }
@@ -112,21 +119,23 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
padding: 12px; padding: 16px;
.coupon-info { .coupon-info {
flex: 1; flex: 1;
.coupon-title { .coupon-title {
font-size: 28px; font-size: 32px;
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
margin-bottom: 4px; margin-bottom: 6px;
line-height: 1.3;
} }
.coupon-validity { .coupon-validity {
font-size: 24px; font-size: 26px;
color: #6b7280; color: #6b7280;
line-height: 1.2;
} }
} }
@@ -136,38 +145,45 @@
align-items: center; align-items: center;
.coupon-btn { .coupon-btn {
min-width: 48px; min-width: 120px;
height: 24px; height: 60px;
border-radius: 12px; border-radius: 30px;
font-size: 24px; font-size: 26px;
border: none; border: none;
color: #fff; color: #fff;
font-weight: 600;
transition: all 0.2s ease;
&:active {
transform: scale(0.95);
}
&.theme-red { &.theme-red {
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
} }
&.theme-orange { &.theme-orange {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%); background: linear-gradient(135deg, #ffa726 0%, #ff9800 100%);
} }
&.theme-blue { &.theme-blue {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%); background: linear-gradient(135deg, #42a5f5 0%, #2196f3 100%);
} }
&.theme-purple { &.theme-purple {
background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%); background: linear-gradient(135deg, #ab47bc 0%, #9c27b0 100%);
} }
&.theme-green { &.theme-green {
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%); background: linear-gradient(135deg, #66bb6a 0%, #4caf50 100%);
} }
} }
.status-text { .status-text {
font-size: 24px; font-size: 26px;
color: #6b7280; color: #9ca3af;
padding: 4px 8px; padding: 8px 12px;
font-weight: 500;
} }
} }
} }

View File

@@ -79,13 +79,41 @@ const CouponCard: React.FC<CouponCardProps> = ({
// 获取使用条件文本 // 获取使用条件文本
const getConditionText = () => { const getConditionText = () => {
if (type === 3) return '无门槛' if (type === 3) return '免费使用' // 免费券
if (minAmount && minAmount > 0) { if (minAmount && minAmount > 0) {
return `${minAmount}可用` return `${minAmount}可用`
} }
return '无门槛' return '无门槛'
} }
// 格式化有效期显示
const formatValidityPeriod = () => {
if (!startTime || !endTime) return ''
const start = new Date(startTime)
const end = new Date(endTime)
const now = new Date()
// 如果还未开始
if (now < start) {
return `${start.getMonth() + 1}.${start.getDate()} 开始生效`
}
// 计算剩余天数
const diffTime = end.getTime() - now.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays <= 0) {
return '已过期'
} else if (diffDays <= 3) {
return `${diffDays}天后过期`
} else {
return `${end.getMonth() + 1}.${end.getDate()} 过期`
}
}
// 格式化日期 // 格式化日期
const formatDate = (dateStr?: string) => { const formatDate = (dateStr?: string) => {
if (!dateStr) return '' if (!dateStr) return ''
@@ -108,8 +136,8 @@ const CouponCard: React.FC<CouponCardProps> = ({
{/* 左侧金额区域 */} {/* 左侧金额区域 */}
<View className={`coupon-left ${themeClass}`}> <View className={`coupon-left ${themeClass}`}>
<View className="amount-wrapper"> <View className="amount-wrapper">
<Text className="currency">¥</Text> {type !== 3 && <Text className="currency">¥</Text>}
<Text className="amount">{amount}</Text> <Text className="amount">{formatAmount()}</Text>
</View> </View>
<View className="condition"> <View className="condition">
{getConditionText()} {getConditionText()}
@@ -130,7 +158,7 @@ const CouponCard: React.FC<CouponCardProps> = ({
{title || (type === 1 ? '满减券' : type === 2 ? '折扣券' : '免费券')} {title || (type === 1 ? '满减券' : type === 2 ? '折扣券' : '免费券')}
</View> </View>
<View className="coupon-validity"> <View className="coupon-validity">
{getValidityText()} {formatValidityPeriod()}
</View> </View>
</View> </View>
@@ -151,7 +179,7 @@ const CouponCard: React.FC<CouponCardProps> = ({
size="small" size="small"
onClick={onUse} onClick={onUse}
> >
使 使
</Button> </Button>
)} )}
{status !== 0 && ( {status !== 0 && (

View File

@@ -0,0 +1,174 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Clock, Close, Agenda } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
export interface ExpiringSoon {
id: number
name: string
type: number
amount: string
minAmount?: string
endTime: string
daysLeft: number
}
export interface CouponExpireNoticeProps {
/** 是否显示提醒 */
visible: boolean
/** 即将过期的优惠券列表 */
expiringSoonCoupons: ExpiringSoon[]
/** 关闭回调 */
onClose: () => void
/** 使用优惠券回调 */
onUseCoupon: (coupon: ExpiringSoon) => void
}
const CouponExpireNotice: React.FC<CouponExpireNoticeProps> = ({
visible,
expiringSoonCoupons,
onClose,
onUseCoupon
}) => {
// 获取优惠券金额显示
const getCouponAmountDisplay = (coupon: ExpiringSoon) => {
switch (coupon.type) {
case 10: // 满减券
return `¥${coupon.amount}`
case 20: // 折扣券
return `${coupon.amount}`
case 30: // 免费券
return '免费'
default:
return `¥${coupon.amount}`
}
}
// 获取使用条件文本
const getConditionText = (coupon: ExpiringSoon) => {
if (coupon.type === 30) return '无门槛使用'
if (coupon.minAmount && parseFloat(coupon.minAmount) > 0) {
return `${coupon.minAmount}元可用`
}
return '无门槛使用'
}
// 获取到期时间显示
const getExpireTimeDisplay = (coupon: ExpiringSoon) => {
if (coupon.daysLeft === 0) {
return '今天到期'
} else if (coupon.daysLeft === 1) {
return '明天到期'
} else {
return `${coupon.daysLeft}天后到期`
}
}
// 去购物
const handleGoShopping = () => {
onClose()
Taro.navigateTo({
url: '/pages/index/index'
})
}
return (
<Popup
visible={visible}
position="center"
style={{ width: '90%', maxWidth: '400px' }}
round
>
<View className="p-6">
{/* 头部 */}
<View className="flex items-center justify-between mb-4">
<View className="flex items-center">
<Clock size="24" className="text-orange-500 mr-2" />
<Text className="text-lg font-semibold text-gray-900">
</Text>
</View>
<View onClick={onClose}>
<Close size="20" className="text-gray-500" />
</View>
</View>
{/* 提醒文案 */}
<View className="text-center mb-6">
<Text className="text-gray-600">
{expiringSoonCoupons.length} 使
</Text>
</View>
{/* 优惠券列表 */}
<View className="max-h-80 overflow-y-auto mb-6">
{expiringSoonCoupons.map((coupon, _) => (
<View
key={coupon.id}
className="flex items-center justify-between p-3 mb-3 bg-gray-50 rounded-lg"
>
<View className="flex-1">
<View className="flex items-center mb-1">
<Text className="font-semibold text-gray-900 mr-2">
{coupon.name}
</Text>
<View className="px-2 py-1 bg-red-100 rounded text-red-600 text-xs">
{getCouponAmountDisplay(coupon)}
</View>
</View>
<Text className="text-sm text-gray-600 mb-1">
{getConditionText(coupon)}
</Text>
<Text className={`text-xs ${
coupon.daysLeft === 0 ? 'text-red-500' :
coupon.daysLeft === 1 ? 'text-orange-500' : 'text-gray-500'
}`}>
{getExpireTimeDisplay(coupon)}
</Text>
</View>
<Button
size="small"
type="primary"
onClick={() => onUseCoupon(coupon)}
>
使
</Button>
</View>
))}
</View>
{/* 底部按钮 */}
<View className="flex gap-3">
<Button
fill="outline"
size="large"
className="flex-1"
onClick={onClose}
>
</Button>
<Button
type="primary"
size="large"
className="flex-1"
icon={<Agenda />}
onClick={handleGoShopping}
>
</Button>
</View>
{/* 底部提示 */}
<View className="text-center mt-4">
<Text className="text-xs text-gray-400">
使
</Text>
</View>
</View>
</Popup>
)
}
export default CouponExpireNotice

View File

@@ -0,0 +1,210 @@
import React, { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup, Radio, RadioGroup, Divider } from '@nutui/nutui-react-taro'
import { Filter, Close } from '@nutui/icons-react-taro'
export interface CouponFilterProps {
/** 是否显示筛选器 */
visible: boolean
/** 当前筛选条件 */
filters: {
type?: number[]
minAmount?: number
sortBy?: 'createTime' | 'amount' | 'expireTime'
sortOrder?: 'asc' | 'desc'
}
/** 筛选条件变更回调 */
onFiltersChange: (filters: any) => void
/** 关闭回调 */
onClose: () => void
}
const CouponFilter: React.FC<CouponFilterProps> = ({
visible,
filters,
onFiltersChange,
onClose
}) => {
const [tempFilters, setTempFilters] = useState(filters)
// 优惠券类型选项
const typeOptions = [
{ label: '全部类型', value: '' },
{ label: '满减券', value: '10' },
{ label: '折扣券', value: '20' },
{ label: '免费券', value: '30' }
]
// 最低金额选项
const minAmountOptions = [
{ label: '不限', value: '' },
{ label: '10元以上', value: '10' },
{ label: '50元以上', value: '50' },
{ label: '100元以上', value: '100' },
{ label: '200元以上', value: '200' }
]
// 排序选项
const sortOptions = [
{ label: '创建时间', value: 'createTime' },
{ label: '优惠金额', value: 'amount' },
{ label: '到期时间', value: 'expireTime' }
]
// 排序方向选项
const sortOrderOptions = [
{ label: '升序', value: 'asc' },
{ label: '降序', value: 'desc' }
]
// 重置筛选条件
const handleReset = () => {
const resetFilters = {
type: [],
minAmount: undefined,
sortBy: 'createTime' as const,
sortOrder: 'desc' as const
}
setTempFilters(resetFilters)
}
// 应用筛选条件
const handleApply = () => {
onFiltersChange(tempFilters)
onClose()
}
// 更新临时筛选条件
const updateTempFilters = (key: string, value: any) => {
setTempFilters(prev => ({
...prev,
[key]: value
}))
}
return (
<Popup
visible={visible}
position="right"
style={{ width: '80%', height: '100%' }}
>
<View className="h-full flex flex-col">
{/* 头部 */}
<View className="flex items-center justify-between p-4 border-b border-gray-100">
<View className="flex items-center">
<Filter size="20" className="text-gray-600 mr-2" />
<Text className="text-lg font-semibold"></Text>
</View>
<View onClick={onClose}>
<Close size="20" className="text-gray-600" />
</View>
</View>
{/* 筛选内容 */}
<View className="flex-1 overflow-y-auto p-4">
{/* 优惠券类型 */}
<View className="mb-6">
<Text className="text-base font-semibold mb-3 text-gray-900">
</Text>
<RadioGroup
value={tempFilters.type?.[0]?.toString() || ''}
onChange={(value) => {
updateTempFilters('type', value ? [parseInt(value)] : [])
}}
>
{typeOptions.map(option => (
<Radio key={option.value} value={option.value} className="mb-2">
{option.label}
</Radio>
))}
</RadioGroup>
</View>
<Divider />
{/* 最低消费金额 */}
<View className="mb-6">
<Text className="text-base font-semibold mb-3 text-gray-900">
</Text>
<RadioGroup
value={tempFilters.minAmount?.toString() || ''}
onChange={(value) => {
updateTempFilters('minAmount', value ? parseInt(value) : undefined)
}}
>
{minAmountOptions.map(option => (
<Radio key={option.value} value={option.value} className="mb-2">
{option.label}
</Radio>
))}
</RadioGroup>
</View>
<Divider />
{/* 排序方式 */}
<View className="mb-6">
<Text className="text-base font-semibold mb-3 text-gray-900">
</Text>
<RadioGroup
value={tempFilters.sortBy || 'createTime'}
onChange={(value) => updateTempFilters('sortBy', value)}
>
{sortOptions.map(option => (
<Radio key={option.value} value={option.value} className="mb-2">
{option.label}
</Radio>
))}
</RadioGroup>
</View>
<Divider />
{/* 排序方向 */}
<View className="mb-6">
<Text className="text-base font-semibold mb-3 text-gray-900">
</Text>
<RadioGroup
value={tempFilters.sortOrder || 'desc'}
onChange={(value) => updateTempFilters('sortOrder', value)}
>
{sortOrderOptions.map(option => (
<Radio key={option.value} value={option.value} className="mb-2">
{option.label}
</Radio>
))}
</RadioGroup>
</View>
</View>
{/* 底部按钮 */}
<View className="p-4 border-t border-gray-100">
<View className="flex gap-3">
<Button
fill="outline"
size="large"
className="flex-1"
onClick={handleReset}
>
</Button>
<Button
type="primary"
size="large"
className="flex-1"
onClick={handleApply}
>
</Button>
</View>
</View>
</View>
</Popup>
)
}
export default CouponFilter

View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Ask, Ticket, Clock, Gift } from '@nutui/icons-react-taro'
export interface CouponGuideProps {
/** 是否显示指南 */
visible: boolean
/** 关闭回调 */
onClose: () => void
}
const CouponGuide: React.FC<CouponGuideProps> = ({
visible,
onClose
}) => {
const [currentStep, setCurrentStep] = useState(0)
const guideSteps = [
{
title: '如何获取优惠券?',
icon: <Gift size="24" className="text-red-500" />,
content: [
'1. 点击"领取"按钮浏览可领取的优惠券',
'2. 关注商家活动和促销信息',
'3. 完成指定任务获得优惠券奖励',
'4. 邀请好友注册获得推荐奖励'
]
},
{
title: '如何使用优惠券?',
icon: <Ticket size="24" className="text-green-500" />,
content: [
'1. 选择心仪商品加入购物车',
'2. 在结算页面选择可用优惠券',
'3. 确认优惠金额后完成支付',
'4. 优惠券使用后不可退回'
]
},
{
title: '优惠券使用规则',
icon: <Clock size="24" className="text-blue-500" />,
content: [
'1. 每张优惠券只能使用一次',
'2. 优惠券有使用期限,过期作废',
'3. 满减券需达到最低消费金额',
'4. 部分优惠券仅限指定商品使用'
]
},
{
title: '常见问题解答',
icon: <Ask size="24" className="text-purple-500" />,
content: [
'Q: 优惠券可以叠加使用吗?',
'A: 一般情况下不支持叠加,具体以活动规则为准',
'Q: 优惠券过期了怎么办?',
'A: 过期优惠券无法使用,请及时关注有效期',
'Q: 退款时优惠券会退回吗?',
'A: 已使用的优惠券不会退回,请谨慎使用'
]
}
]
const handleNext = () => {
if (currentStep < guideSteps.length - 1) {
setCurrentStep(currentStep + 1)
} else {
onClose()
}
}
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = () => {
onClose()
}
const currentGuide = guideSteps[currentStep]
return (
<Popup
visible={visible}
position="center"
closeable={false}
style={{ width: '90%', maxWidth: '400px' }}
>
<View className="p-6">
{/* 头部 */}
<View className="text-center mb-6">
<View className="flex justify-center mb-3">
{currentGuide.icon}
</View>
<Text className="text-xl font-bold text-gray-900">
{currentGuide.title}
</Text>
</View>
{/* 内容 */}
<View className="mb-6">
{currentGuide.content.map((item, index) => (
<View key={index} className="mb-3">
<Text className="text-gray-700 leading-relaxed">
{item}
</Text>
</View>
))}
</View>
{/* 进度指示器 */}
<View className="flex justify-center mb-6">
{guideSteps.map((_, index) => (
<View
key={index}
className={`w-2 h-2 rounded-full mx-1 ${
index === currentStep ? 'bg-red-500' : 'bg-gray-300'
}`}
/>
))}
</View>
{/* 底部按钮 */}
<View className="flex justify-between">
<View className="flex gap-2">
{currentStep > 0 && (
<Button
size="small"
fill="outline"
onClick={handlePrev}
>
</Button>
)}
<Button
size="small"
fill="outline"
onClick={handleSkip}
>
</Button>
</View>
<Button
size="small"
type="primary"
onClick={handleNext}
>
{currentStep === guideSteps.length - 1 ? '完成' : '下一步'}
</Button>
</View>
</View>
</Popup>
)
}
export default CouponGuide

View File

@@ -0,0 +1,182 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Share, Wechat, QQ, Weibo, Link, Close } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
export interface CouponShareProps {
/** 是否显示分享弹窗 */
visible: boolean
/** 优惠券信息 */
coupon: {
id: number
name: string
type: number
amount: string
minAmount?: string
description?: string
}
/** 关闭回调 */
onClose: () => void
}
const CouponShare: React.FC<CouponShareProps> = ({
visible,
coupon,
onClose
}) => {
// 生成分享文案
const generateShareText = () => {
const typeText = coupon.type === 10 ? '满减券' : coupon.type === 20 ? '折扣券' : '免费券'
const amountText = coupon.type === 10 ? `¥${coupon.amount}` :
coupon.type === 20 ? `${coupon.amount}` : '免费'
const conditionText = coupon.minAmount ? `${coupon.minAmount}元可用` : '无门槛'
return `🎁 ${coupon.name}\n💰 ${amountText} ${typeText}\n📋 ${conditionText}\n快来领取吧`
}
// 生成分享链接
const generateShareUrl = () => {
// 这里应该是实际的分享链接包含优惠券ID等参数
return `https://your-domain.com/coupon/share?id=${coupon.id}`
}
// 微信分享
const handleWechatShare = () => {
Taro.showShareMenu({
withShareTicket: true,
success: () => {
Taro.showToast({
title: '分享成功',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '分享失败',
icon: 'error'
})
}
})
}
// 复制链接
const handleCopyLink = () => {
const shareUrl = generateShareUrl()
const shareText = generateShareText()
const fullText = `${shareText}\n\n${shareUrl}`
Taro.setClipboardData({
data: fullText,
success: () => {
Taro.showToast({
title: '已复制到剪贴板',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '复制失败',
icon: 'error'
})
}
})
}
// 保存图片分享
const handleSaveImage = async () => {
try {
// 这里可以生成优惠券图片并保存到相册
// 实际实现需要canvas绘制优惠券图片
Taro.showToast({
title: '功能开发中',
icon: 'none'
})
} catch (error) {
Taro.showToast({
title: '保存失败',
icon: 'error'
})
}
}
const shareOptions = [
{
icon: <Wechat size="32" className="text-green-500" />,
label: '微信好友',
onClick: handleWechatShare
},
{
icon: <Link size="32" className="text-blue-500" />,
label: '复制链接',
onClick: handleCopyLink
},
{
icon: <Share size="32" className="text-purple-500" />,
label: '保存图片',
onClick: handleSaveImage
}
]
return (
<Popup
visible={visible}
position="bottom"
style={{ height: 'auto' }}
round
>
<View className="p-6">
{/* 头部 */}
<View className="flex items-center justify-between mb-6">
<Text className="text-lg font-semibold"></Text>
<View onClick={onClose}>
<Close size="20" className="text-gray-500" />
</View>
</View>
{/* 优惠券预览 */}
<View className="bg-gradient-to-r from-red-400 to-red-500 rounded-xl p-4 mb-6 text-white">
<Text className="text-xl font-bold mb-2">{coupon.name}</Text>
<View className="flex items-center justify-between">
<View>
<Text className="text-2xl font-bold">
{coupon.type === 10 ? `¥${coupon.amount}` :
coupon.type === 20 ? `${coupon.amount}` : '免费'}
</Text>
<Text className="text-sm opacity-90">
{coupon.minAmount ? `${coupon.minAmount}元可用` : '无门槛使用'}
</Text>
</View>
<Share size="24" />
</View>
</View>
{/* 分享选项 */}
<View className="grid grid-cols-3 gap-4 mb-4">
{shareOptions.map((option, index) => (
<View
key={index}
className="flex flex-col items-center py-4 bg-gray-50 rounded-lg"
onClick={option.onClick}
>
<View className="mb-2">{option.icon}</View>
<Text className="text-sm text-gray-700">{option.label}</Text>
</View>
))}
</View>
{/* 分享文案预览 */}
<View className="bg-gray-50 rounded-lg p-3">
<Text className="text-xs text-gray-500 mb-2"></Text>
<Text className="text-sm text-gray-700 leading-relaxed">
{generateShareText()}
</Text>
</View>
</View>
</Popup>
)
}
export default CouponShare

View File

@@ -0,0 +1,71 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Gift, Voucher, Clock } from '@nutui/icons-react-taro'
export interface CouponStatsProps {
/** 可用优惠券数量 */
availableCount: number
/** 已使用优惠券数量 */
usedCount: number
/** 已过期优惠券数量 */
expiredCount: number
/** 点击统计项的回调 */
onStatsClick?: (type: 'available' | 'used' | 'expired') => void
}
const CouponStats: React.FC<CouponStatsProps> = ({
availableCount,
usedCount,
expiredCount,
onStatsClick
}) => {
const handleStatsClick = (type: 'available' | 'used' | 'expired') => {
onStatsClick?.(type)
}
return (
<View className="bg-white mx-4 my-3 rounded-xl p-4">
<Text className="text-lg font-semibold mb-4 text-gray-800"></Text>
<View className="flex justify-between">
{/* 可用优惠券 */}
<View
className="flex-1 text-center py-3 mx-1 bg-red-50 rounded-lg"
onClick={() => handleStatsClick('available')}
>
<View className="flex justify-center mb-2">
<Gift size="24" className="text-red-500" />
</View>
<Text className="text-2xl font-bold text-red-500 block">{availableCount}</Text>
<Text className="text-sm text-gray-600 mt-1"></Text>
</View>
{/* 已使用优惠券 */}
<View
className="flex-1 text-center py-3 mx-1 bg-green-50 rounded-lg"
onClick={() => handleStatsClick('used')}
>
<View className="flex justify-center mb-2">
<Voucher size="24" className="text-green-500" />
</View>
<Text className="text-2xl font-bold text-green-500 block">{usedCount}</Text>
<Text className="text-sm text-gray-600 mt-1">使</Text>
</View>
{/* 已过期优惠券 */}
<View
className="flex-1 text-center py-3 mx-1 bg-gray-50 rounded-lg"
onClick={() => handleStatsClick('expired')}
>
<View className="flex justify-center mb-2">
<Clock size="24" className="text-gray-500" />
</View>
<Text className="text-2xl font-bold text-gray-500 block">{expiredCount}</Text>
<Text className="text-sm text-gray-600 mt-1"></Text>
</View>
</View>
</View>
)
}
export default CouponStats

View File

@@ -0,0 +1,162 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Tag } from '@nutui/nutui-react-taro'
import { Voucher, Clock, Agenda } from '@nutui/icons-react-taro'
import dayjs from 'dayjs'
export interface CouponUsageRecordProps {
/** 优惠券ID */
couponId: number
/** 优惠券名称 */
couponName: string
/** 优惠券类型 */
couponType: number
/** 优惠券金额 */
couponAmount: string
/** 使用时间 */
usedTime: string
/** 订单号 */
orderNo?: string
/** 订单金额 */
orderAmount?: string
/** 节省金额 */
savedAmount?: string
/** 使用状态1-已使用 2-已过期 */
status: 1 | 2
/** 点击事件 */
onClick?: () => void
}
const CouponUsageRecord: React.FC<CouponUsageRecordProps> = ({
couponName,
couponType,
couponAmount,
usedTime,
orderNo,
orderAmount,
savedAmount,
status,
onClick
}) => {
// 获取优惠券类型文本
const getCouponTypeText = () => {
switch (couponType) {
case 10: return '满减券'
case 20: return '折扣券'
case 30: return '免费券'
default: return '优惠券'
}
}
// 获取优惠券金额显示
const getCouponAmountDisplay = () => {
switch (couponType) {
case 10: // 满减券
return `¥${couponAmount}`
case 20: // 折扣券
return `${couponAmount}`
case 30: // 免费券
return '免费'
default:
return `¥${couponAmount}`
}
}
// 获取状态信息
const getStatusInfo = () => {
switch (status) {
case 1:
return {
text: '已使用',
color: 'success' as const,
icon: <Voucher size="16" className="text-green-500" />
}
case 2:
return {
text: '已过期',
color: 'danger' as const,
icon: <Clock size="16" className="text-red-500" />
}
default:
return {
text: '未知',
color: 'default' as const,
icon: <Clock size="16" className="text-gray-500" />
}
}
}
const statusInfo = getStatusInfo()
return (
<View
className="bg-white mx-4 mb-3 rounded-xl p-4 border border-gray-100"
onClick={onClick}
>
{/* 头部信息 */}
<View className="flex items-center justify-between mb-3">
<View className="flex items-center">
<View className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mr-3">
<Text className="text-red-500 font-bold text-lg">
{getCouponAmountDisplay()}
</Text>
</View>
<View>
<Text className="font-semibold text-gray-900 text-base">{couponName}</Text>
<Text className="text-gray-500 text-sm">{getCouponTypeText()}</Text>
</View>
</View>
<View className="flex items-center">
{statusInfo.icon}
<Tag type={statusInfo.color} className="ml-2">
{statusInfo.text}
</Tag>
</View>
</View>
{/* 使用详情 */}
<View className="bg-gray-50 rounded-lg p-3">
<View className="flex items-center justify-between mb-2">
<Text className="text-gray-600 text-sm">使</Text>
<Text className="text-gray-900 text-sm">
{dayjs(usedTime).format('YYYY-MM-DD HH:mm')}
</Text>
</View>
{orderNo && (
<View className="flex items-center justify-between mb-2">
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-gray-900 text-sm">{orderNo}</Text>
</View>
)}
{orderAmount && (
<View className="flex items-center justify-between mb-2">
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-gray-900 text-sm">¥{orderAmount}</Text>
</View>
)}
{savedAmount && (
<View className="flex items-center justify-between">
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-red-500 text-sm font-semibold">¥{savedAmount}</Text>
</View>
)}
</View>
{/* 底部操作 */}
{orderNo && (
<View className="flex justify-end mt-3">
<View className="flex items-center text-blue-500 text-sm">
<Agenda size="14" className="mr-1" />
<Text></Text>
</View>
</View>
)}
</View>
)
}
export default CouponUsageRecord

View File

@@ -0,0 +1,307 @@
.gift-card {
position: relative;
width: 100%;
margin-bottom: 16px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background: #fff;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
&.disabled {
opacity: 0.7;
}
// 主题色彩
&.gift-card-gold {
.gift-card-header {
background: #ffd700;
}
.use-btn {
background: #ffd700;
border: none;
color: #333;
}
}
&.gift-card-silver {
.gift-card-header {
background: #c0c0c0;
}
.use-btn {
background: #c0c0c0;
border: none;
color: #333;
}
}
&.gift-card-bronze {
.gift-card-header {
background: #cd7f32;
}
.use-btn {
background: #cd7f32;
border: none;
color: #fff;
}
}
&.gift-card-blue {
.gift-card-header {
background: #4a90e2;
}
.use-btn {
background: #4a90e2;
border: none;
color: #fff;
}
}
&.gift-card-green {
.gift-card-header {
background: #5cb85c;
}
.use-btn {
background: #5cb85c;
border: none;
color: #fff;
}
}
&.gift-card-purple {
.gift-card-header {
background: #9b59b6;
}
.use-btn {
background: #9b59b6;
border: none;
color: #fff;
}
}
.gift-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
color: #fff;
position: relative;
.gift-card-logo {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.gift-card-title {
flex: 1;
margin-left: 12px;
.title-text {
display: block;
font-size: 18px;
font-weight: 600;
line-height: 1.3;
margin-bottom: 2px;
}
.type-text {
display: block;
font-size: 14px;
opacity: 0.9;
}
}
.gift-card-status {
.nut-tag {
background: rgba(255, 255, 255, 0.9);
color: #333;
border: none;
}
}
}
.gift-card-body {
padding: 20px;
.gift-card-content {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
.gift-image {
margin-right: 16px;
flex-shrink: 0;
}
.gift-info {
flex: 1;
.gift-value {
display: flex;
align-items: baseline;
margin-bottom: 8px;
.value-label {
font-size: 14px;
color: #666;
margin-right: 8px;
}
.value-amount {
font-size: 24px;
font-weight: bold;
color: #333;
}
}
.gift-description {
font-size: 14px;
color: #666;
line-height: 1.4;
margin-bottom: 12px;
}
.gift-code {
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
border: 1px dashed #ddd;
.code-label {
display: block;
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.code-value {
font-size: 16px;
font-weight: 600;
color: #333;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
}
}
}
.gift-time-info {
.time-item {
display: flex;
align-items: center;
margin-bottom: 6px;
.time-text {
font-size: 12px;
color: #666;
margin-left: 6px;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
.gift-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px 20px;
.footer-info {
.contact-info {
display: flex;
align-items: center;
.contact-text {
font-size: 12px;
color: #999;
margin-left: 4px;
}
}
}
.footer-actions {
display: flex;
gap: 8px;
.use-btn {
min-width: 80px;
font-weight: 600;
transition: all 0.2s ease;
&:active {
transform: scale(0.95);
}
}
}
}
.gift-card-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.overlay-badge {
background: rgba(0, 0, 0, 0.7);
color: #fff;
padding: 8px 16px;
border-radius: 20px;
font-size: 16px;
font-weight: 600;
}
}
}
// 礼品卡基础样式
.gift-card {
opacity: 1;
transform: translateY(0);
}
// 使用按钮效果
.use-btn {
transition: all 0.3s ease;
}
// 响应式设计
@media (max-width: 768px) {
.gift-card {
.gift-card-body {
padding: 16px;
.gift-card-content {
.gift-info {
.gift-value {
.value-amount {
font-size: 20px;
}
}
}
}
}
.gift-card-footer {
padding: 0 16px 16px;
}
}
}

282
src/components/GiftCard.tsx Normal file
View File

@@ -0,0 +1,282 @@
import React from 'react'
import { View, Text, Image } from '@tarojs/components'
import { Button, Tag } from '@nutui/nutui-react-taro'
import { Gift, Clock, Location, Phone } from '@nutui/icons-react-taro'
import dayjs from 'dayjs'
import './GiftCard.scss'
export interface GiftCardProps {
/** 礼品卡ID */
id: number
/** 礼品卡名称 */
name: string
/** 礼品卡描述 */
description?: string
/** 礼品卡兑换码 */
code?: string
/** 商品图片 */
goodsImage?: string
/** 礼品卡面值 */
faceValue?: string
/** 礼品卡类型10-实物礼品卡 20-虚拟礼品卡 30-服务礼品卡 */
type?: number
/** 使用状态0-可用 1-已使用 2-已过期 */
useStatus?: number
/** 过期时间 */
expireTime?: string
/** 使用时间 */
useTime?: string
/** 使用地址 */
useLocation?: string
/** 客服联系方式 */
contactInfo?: string
/** 是否显示兑换码 */
showCode?: boolean
/** 是否显示使用按钮 */
showUseBtn?: boolean
/** 是否显示详情按钮 */
showDetailBtn?: boolean
/** 卡片主题色 */
theme?: 'gold' | 'silver' | 'bronze' | 'blue' | 'green' | 'purple'
/** 使用按钮点击事件 */
onUse?: () => void
/** 详情按钮点击事件 */
onDetail?: () => void
/** 卡片点击事件 */
onClick?: () => void
}
const GiftCard: React.FC<GiftCardProps> = ({
id,
name,
description,
code,
goodsImage,
faceValue,
type = 10,
useStatus = 0,
expireTime,
useTime,
useLocation,
contactInfo,
showCode = false,
showUseBtn = false,
showDetailBtn = true,
theme = 'gold',
onUse,
onDetail,
onClick
}) => {
// 获取礼品卡类型文本
const getTypeText = () => {
switch (type) {
case 10: return '实物礼品卡'
case 20: return '虚拟礼品卡'
case 30: return '服务礼品卡'
default: return '礼品卡'
}
}
// 获取使用状态信息
const getStatusInfo = () => {
switch (useStatus) {
case 0:
return {
text: '可使用',
color: 'success' as const,
bgColor: 'bg-green-100',
textColor: 'text-green-600'
}
case 1:
return {
text: '已使用',
color: 'warning' as const,
bgColor: 'bg-gray-100',
textColor: 'text-gray-600'
}
case 2:
return {
text: '已过期',
color: 'danger' as const,
bgColor: 'bg-red-100',
textColor: 'text-red-600'
}
default:
return {
text: '未知',
color: 'default' as const,
bgColor: 'bg-gray-100',
textColor: 'text-gray-600'
}
}
}
// 获取主题样式类名
const getThemeClass = () => {
return `gift-card-${theme}`
}
// 格式化过期时间显示
const formatExpireTime = () => {
if (!expireTime) return ''
const expire = dayjs(expireTime)
const now = dayjs()
const diffDays = expire.diff(now, 'day')
if (diffDays < 0) {
return '已过期'
} else if (diffDays === 0) {
return '今天过期'
} else if (diffDays <= 7) {
return `${diffDays}天后过期`
} else {
return expire.format('YYYY-MM-DD 过期')
}
}
// 格式化兑换码显示
const formatCode = () => {
if (!code) return ''
if (!showCode) return code.replace(/(.{4})/g, '$1 ').trim()
return code.replace(/(.{4})/g, '$1 ').trim()
}
const statusInfo = getStatusInfo()
return (
<View
className={`gift-card ${getThemeClass()} ${useStatus !== 0 ? 'disabled' : ''}`}
onClick={onClick}
>
{/* 卡片头部 */}
<View className="gift-card-header">
<View className="gift-card-logo">
<Gift size="24" className="text-white" />
</View>
<View className="gift-card-title">
<Text className="title-text">{name}</Text>
<Text className="type-text">{getTypeText()}</Text>
</View>
<View className="gift-card-status">
<Tag type={statusInfo.color}>{statusInfo.text}</Tag>
</View>
</View>
{/* 卡片主体 */}
<View className="gift-card-body">
<View className="gift-card-content">
{/* 商品图片 */}
{goodsImage && (
<View className="gift-image">
<Image
src={goodsImage}
className="w-16 h-16 rounded-lg object-cover"
mode="aspectFill"
/>
</View>
)}
<View className="gift-info">
{/* 面值 */}
{faceValue && (
<View className="gift-value">
<Text className="value-label"></Text>
<Text className="value-amount">¥{faceValue}</Text>
</View>
)}
{/* 描述 */}
{description && (
<Text className="gift-description">{description}</Text>
)}
{/* 兑换码 */}
{code && (
<View className="gift-code">
<Text className="code-label"></Text>
<Text className="code-value">{formatCode()}</Text>
</View>
)}
</View>
</View>
{/* 时间信息 */}
<View className="gift-time-info">
{useStatus === 1 && useTime && (
<View className="time-item">
<Clock size="14" className="text-gray-400" />
<Text className="time-text">使{dayjs(useTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
)}
{useStatus === 0 && expireTime && (
<View className="time-item">
<Clock size="14" className="text-orange-500" />
<Text className="time-text">{formatExpireTime()}</Text>
</View>
)}
{useLocation && (
<View className="time-item">
<Location size="14" className="text-gray-400" />
<Text className="time-text">使{useLocation}</Text>
</View>
)}
</View>
</View>
{/* 卡片底部操作 */}
<View className="gift-card-footer">
<View className="footer-info">
{contactInfo && (
<View className="contact-info">
<Phone size="12" className="text-gray-400" />
<Text className="contact-text">{contactInfo}</Text>
</View>
)}
</View>
<View className="footer-actions">
{showDetailBtn && (
<Button
size="small"
fill="outline"
onClick={(e) => {
e.stopPropagation()
onDetail?.()
}}
>
</Button>
)}
{showUseBtn && useStatus === 0 && (
<Button
size="small"
type="primary"
className={`use-btn ${getThemeClass()}`}
onClick={(e) => {
e.stopPropagation()
onUse?.()
}}
>
使
</Button>
)}
</View>
</View>
{/* 状态遮罩 */}
{useStatus !== 0 && (
<View className="gift-card-overlay">
<View className="overlay-badge">
{statusInfo.text}
</View>
</View>
)}
</View>
)
}
export default GiftCard

View File

@@ -0,0 +1,169 @@
import React, { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Gift, QrCode, Voucher, Service } from '@nutui/icons-react-taro'
export interface GiftCardGuideProps {
/** 是否显示指南 */
visible: boolean
/** 关闭回调 */
onClose: () => void
}
const GiftCardGuide: React.FC<GiftCardGuideProps> = ({
visible,
onClose
}) => {
const [currentStep, setCurrentStep] = useState(0)
const guideSteps = [
{
title: '如何获取礼品卡?',
icon: <Gift size="24" className="text-yellow-500" />,
content: [
'1. 通过兑换码兑换礼品卡',
'2. 扫描二维码快速兑换',
'3. 参与活动获得礼品卡奖励',
'4. 朋友赠送的礼品卡'
]
},
{
title: '如何兑换礼品卡?',
icon: <QrCode size="24" className="text-blue-500" />,
content: [
'1. 点击"兑换"按钮进入兑换页面',
'2. 输入礼品卡兑换码或扫码输入',
'3. 验证兑换码有效性',
'4. 确认兑换,礼品卡添加到账户'
]
},
{
title: '如何使用礼品卡?',
icon: <Voucher size="24" className="text-green-500" />,
content: [
'1. 选择可用状态的礼品卡',
'2. 点击"立即使用"按钮',
'3. 填写使用信息(地址、备注等)',
'4. 确认使用,完成礼品卡消费'
]
},
{
title: '礼品卡类型说明',
icon: <Gift size="24" className="text-purple-500" />,
content: [
'🎁 实物礼品卡:需到指定地址领取商品',
'💻 虚拟礼品卡:自动发放到账户余额',
'🛎️ 服务礼品卡:联系客服预约服务',
'⏰ 注意查看有效期,过期无法使用'
]
},
{
title: '常见问题解答',
icon: <Service size="24" className="text-red-500" />,
content: [
'Q: 礼品卡可以转赠他人吗?',
'A: 未使用的礼品卡可以通过分享功能转赠',
'Q: 礼品卡过期了怎么办?',
'A: 过期礼品卡无法使用,请及时关注有效期',
'Q: 使用礼品卡后可以退款吗?',
'A: 已使用的礼品卡不支持退款操作'
]
}
]
const handleNext = () => {
if (currentStep < guideSteps.length - 1) {
setCurrentStep(currentStep + 1)
} else {
onClose()
}
}
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = () => {
onClose()
}
const currentGuide = guideSteps[currentStep]
return (
<Popup
visible={visible}
position="center"
closeable={false}
style={{ width: '90%', maxWidth: '400px' }}
>
<View className="p-6">
{/* 头部 */}
<View className="text-center mb-6">
<View className="flex justify-center mb-3">
{currentGuide.icon}
</View>
<Text className="text-xl font-bold text-gray-900">
{currentGuide.title}
</Text>
</View>
{/* 内容 */}
<View className="mb-6">
{currentGuide.content.map((item, index) => (
<View key={index} className="mb-3">
<Text className="text-gray-700 leading-relaxed">
{item}
</Text>
</View>
))}
</View>
{/* 进度指示器 */}
<View className="flex justify-center mb-6">
{guideSteps.map((_, index) => (
<View
key={index}
className={`w-2 h-2 rounded-full mx-1 ${
index === currentStep ? 'bg-yellow-500' : 'bg-gray-300'
}`}
/>
))}
</View>
{/* 底部按钮 */}
<View className="flex justify-between">
<View className="flex gap-2">
{currentStep > 0 && (
<Button
size="small"
fill="outline"
onClick={handlePrev}
>
</Button>
)}
<Button
size="small"
fill="outline"
onClick={handleSkip}
>
</Button>
</View>
<Button
size="small"
type="primary"
onClick={handleNext}
>
{currentStep === guideSteps.length - 1 ? '完成' : '下一步'}
</Button>
</View>
</View>
</Popup>
)
}
export default GiftCardGuide

View File

@@ -0,0 +1,173 @@
import React from 'react'
import { View, ScrollView } from '@tarojs/components'
import GiftCard, { GiftCardProps } from './GiftCard'
export interface GiftCardListProps {
/** 礼品卡列表数据 */
gifts: GiftCardProps[]
/** 列表标题 */
title?: string
/** 布局方式vertical-垂直布局 horizontal-水平滚动 grid-网格布局 */
layout?: 'vertical' | 'horizontal' | 'grid'
/** 网格列数仅在grid布局时有效 */
columns?: number
/** 是否显示空状态 */
showEmpty?: boolean
/** 空状态文案 */
emptyText?: string
/** 礼品卡点击事件 */
onGiftClick?: (gift: GiftCardProps, index: number) => void
/** 礼品卡使用事件 */
onGiftUse?: (gift: GiftCardProps, index: number) => void
/** 礼品卡详情事件 */
onGiftDetail?: (gift: GiftCardProps, index: number) => void
}
const GiftCardList: React.FC<GiftCardListProps> = ({
gifts = [],
title,
layout = 'vertical',
columns = 2,
showEmpty = true,
emptyText = '暂无礼品卡',
onGiftClick,
onGiftUse,
onGiftDetail
}) => {
const handleGiftClick = (gift: GiftCardProps, index: number) => {
onGiftClick?.(gift, index)
}
const handleGiftUse = (gift: GiftCardProps, index: number) => {
onGiftUse?.(gift, index)
}
const handleGiftDetail = (gift: GiftCardProps, index: number) => {
onGiftDetail?.(gift, index)
}
// 垂直布局
if (layout === 'vertical') {
return (
<View className="p-4">
{title && (
<View className="font-semibold text-gray-800 mb-4 text-lg">{title}</View>
)}
{gifts.length === 0 ? (
showEmpty && (
<View className="text-center py-16 px-5">
<View className="text-gray-400 mb-4">
<View className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center">
<View className="text-2xl">🎁</View>
</View>
</View>
<View className="text-gray-500 text-base">{emptyText}</View>
</View>
)
) : (
gifts.map((gift, index) => (
<GiftCard
key={gift.id || index}
{...gift}
onClick={() => handleGiftClick(gift, index)}
onUse={() => handleGiftUse(gift, index)}
onDetail={() => handleGiftDetail(gift, index)}
/>
))
)}
</View>
)
}
// 网格布局
if (layout === 'grid') {
return (
<View className="p-4">
{title && (
<View className="font-semibold text-gray-800 mb-4 text-lg">{title}</View>
)}
{gifts.length === 0 ? (
showEmpty && (
<View className="text-center py-16 px-5">
<View className="text-gray-400 mb-4">
<View className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center">
<View className="text-2xl">🎁</View>
</View>
</View>
<View className="text-gray-500 text-base">{emptyText}</View>
</View>
)
) : (
<View
className="flex flex-wrap"
style={{gap: '12px'}}
>
{gifts.map((gift, index) => (
<View
key={gift.id || index}
className="w-full"
style={{width: `calc(${100/columns}% - ${12*(columns-1)/columns}px)`}}
>
<GiftCard
{...gift}
onClick={() => handleGiftClick(gift, index)}
onUse={() => handleGiftUse(gift, index)}
onDetail={() => handleGiftDetail(gift, index)}
/>
</View>
))}
</View>
)}
</View>
)
}
// 水平滚动布局
return (
<View>
{title && (
<View className="font-semibold text-gray-800 mb-4 pl-4 text-lg">
{title}
</View>
)}
{gifts.length === 0 ? (
showEmpty && (
<View className="text-center py-16 px-5">
<View className="text-gray-400 mb-4">
<View className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center">
<View className="text-2xl">🎁</View>
</View>
</View>
<View className="text-gray-500 text-base">{emptyText}</View>
</View>
)
) : (
<ScrollView
scrollX
className="flex p-4 gap-3 overflow-x-auto"
showScrollbar={false}
style={{}}
>
{gifts.map((gift, index) => (
<View
key={gift.id || index}
className="flex-shrink-0 w-80 mb-0"
>
<GiftCard
{...gift}
onClick={() => handleGiftClick(gift, index)}
onUse={() => handleGiftUse(gift, index)}
onDetail={() => handleGiftDetail(gift, index)}
/>
</View>
))}
</ScrollView>
)}
</View>
)
}
export default GiftCardList

View File

@@ -0,0 +1,227 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Popup } from '@nutui/nutui-react-taro'
import { Share, Wechat, QQ, Weibo, Link, Close, Gift } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
export interface GiftCardShareProps {
/** 是否显示分享弹窗 */
visible: boolean
/** 礼品卡信息 */
giftCard: {
id: number
name: string
type: number
faceValue: string
code?: string
description?: string
}
/** 关闭回调 */
onClose: () => void
}
const GiftCardShare: React.FC<GiftCardShareProps> = ({
visible,
giftCard,
onClose
}) => {
// 获取礼品卡类型文本
const getTypeText = () => {
switch (giftCard.type) {
case 10: return '实物礼品卡'
case 20: return '虚拟礼品卡'
case 30: return '服务礼品卡'
default: return '礼品卡'
}
}
// 生成分享文案
const generateShareText = () => {
const typeText = getTypeText()
const valueText = `¥${giftCard.faceValue}`
return `🎁 ${giftCard.name}\n💰 面值 ${valueText}\n🏷 ${typeText}\n${giftCard.description ? `📝 ${giftCard.description}\n` : ''}快来领取这份礼品卡吧!`
}
// 生成分享链接
const generateShareUrl = () => {
// 这里应该是实际的分享链接包含礼品卡ID等参数
return `https://your-domain.com/gift/share?id=${giftCard.id}`
}
// 微信分享
const handleWechatShare = () => {
Taro.showShareMenu({
withShareTicket: true,
success: () => {
Taro.showToast({
title: '分享成功',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '分享失败',
icon: 'error'
})
}
})
}
// 复制链接
const handleCopyLink = () => {
const shareUrl = generateShareUrl()
const shareText = generateShareText()
const fullText = `${shareText}\n\n${shareUrl}`
Taro.setClipboardData({
data: fullText,
success: () => {
Taro.showToast({
title: '已复制到剪贴板',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '复制失败',
icon: 'error'
})
}
})
}
// 复制兑换码
const handleCopyCode = () => {
if (!giftCard.code) {
Taro.showToast({
title: '暂无兑换码',
icon: 'none'
})
return
}
Taro.setClipboardData({
data: giftCard.code,
success: () => {
Taro.showToast({
title: '兑换码已复制',
icon: 'success'
})
onClose()
},
fail: () => {
Taro.showToast({
title: '复制失败',
icon: 'error'
})
}
})
}
// 保存图片分享
const handleSaveImage = async () => {
try {
// 这里可以生成礼品卡图片并保存到相册
// 实际实现需要canvas绘制礼品卡图片
Taro.showToast({
title: '功能开发中',
icon: 'none'
})
} catch (error) {
Taro.showToast({
title: '保存失败',
icon: 'error'
})
}
}
const shareOptions = [
{
icon: <Wechat size="32" className="text-green-500" />,
label: '微信好友',
onClick: handleWechatShare
},
{
icon: <Link size="32" className="text-blue-500" />,
label: '复制链接',
onClick: handleCopyLink
},
{
icon: <Gift size="32" className="text-purple-500" />,
label: '复制兑换码',
onClick: handleCopyCode,
disabled: !giftCard.code
},
{
icon: <Share size="32" className="text-orange-500" />,
label: '保存图片',
onClick: handleSaveImage
}
]
return (
<Popup
visible={visible}
position="bottom"
style={{ height: 'auto' }}
round
>
<View className="p-6">
{/* 头部 */}
<View className="flex items-center justify-between mb-6">
<Text className="text-lg font-semibold"></Text>
<View onClick={onClose}>
<Close size="20" className="text-gray-500" />
</View>
</View>
{/* 礼品卡预览 */}
<View className="rounded-xl p-4 mb-6 text-white" style={{backgroundColor: '#fbbf24'}}>
<Text className="text-xl font-bold mb-2">{giftCard.name}</Text>
<View className="flex items-center justify-between">
<View>
<Text className="text-2xl font-bold">¥{giftCard.faceValue}</Text>
<Text className="text-sm opacity-90">{getTypeText()}</Text>
</View>
<Gift size="24" />
</View>
{giftCard.code && (
<View className="mt-3 p-2 bg-white bg-opacity-20 rounded">
<Text className="text-xs opacity-80"></Text>
<Text className="font-mono font-bold">{giftCard.code}</Text>
</View>
)}
</View>
{/* 分享选项 */}
<View className="flex justify-between mb-4" style={{gap: '16px'}}>
{shareOptions.map((option, index) => (
<View
key={index}
className={`flex-1 flex flex-col items-center py-4 bg-gray-50 rounded-lg ${
option.disabled ? 'opacity-50' : ''
}`}
onClick={option.disabled ? undefined : option.onClick}
>
<View className="mb-2">{option.icon}</View>
<Text className="text-sm text-gray-700">{option.label}</Text>
</View>
))}
</View>
{/* 分享文案预览 */}
<View className="bg-gray-50 rounded-lg p-3">
<Text className="text-xs text-gray-500 mb-2"></Text>
<Text className="text-sm text-gray-700 leading-relaxed">
{generateShareText()}
</Text>
</View>
</View>
</Popup>
)
}
export default GiftCardShare

View File

@@ -0,0 +1,72 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Gift, Voucher, Clock } from '@nutui/icons-react-taro'
export interface GiftCardStatsProps {
/** 可用礼品卡数量 */
availableCount: number
/** 已使用礼品卡数量 */
usedCount: number
/** 已过期礼品卡数量 */
expiredCount: number
/** 礼品卡总价值 */
totalValue?: number
/** 点击统计项的回调 */
onStatsClick?: (type: 'available' | 'used' | 'expired' | 'total') => void
}
const GiftCardStats: React.FC<GiftCardStatsProps> = ({
availableCount,
usedCount,
expiredCount,
onStatsClick
}) => {
const handleStatsClick = (type: 'available' | 'used' | 'expired' | 'total') => {
onStatsClick?.(type)
}
return (
<View className="bg-white mx-4 my-3 rounded-xl p-3 shadow-sm">
{/* 紧凑的统计卡片 - 2x2 网格 */}
<View className="grid grid-cols-3 gap-2">
{/* 可用礼品卡 */}
<View
className="flex items-center justify-between p-2 bg-yellow-50 rounded-lg border border-yellow-200"
onClick={() => handleStatsClick('available')}
>
<View className="flex items-center">
<Gift size="16" className="text-yellow-600 mr-1" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-yellow-600">{availableCount}</Text>
</View>
{/* 已使用礼品卡 */}
<View
className="flex items-center justify-between p-2 bg-green-50 rounded-lg border border-green-200"
onClick={() => handleStatsClick('used')}
>
<View className="flex items-center">
<Voucher size="16" className="text-green-600 mr-1" />
<Text className="text-sm text-gray-600">使</Text>
</View>
<Text className="text-lg font-bold text-green-600">{usedCount}</Text>
</View>
{/* 已过期礼品卡 */}
<View
className="flex items-center justify-between p-2 bg-gray-50 rounded-lg border border-gray-200"
onClick={() => handleStatsClick('expired')}
>
<View className="flex items-center">
<Clock size="16" className="text-gray-500 mr-1" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-gray-500">{expiredCount}</Text>
</View>
</View>
</View>
)
}
export default GiftCardStats

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Gift, Voucher, Clock, Star } from '@nutui/icons-react-taro'
export interface GiftCardStatsProps {
/** 可用礼品卡数量 */
availableCount: number
/** 已使用礼品卡数量 */
usedCount: number
/** 已过期礼品卡数量 */
expiredCount: number
/** 礼品卡总价值 */
totalValue?: number
/** 点击统计项的回调 */
onStatsClick?: (type: 'available' | 'used' | 'expired' | 'total') => void
}
const GiftCardStats: React.FC<GiftCardStatsProps> = ({
availableCount,
usedCount,
expiredCount,
totalValue,
onStatsClick
}) => {
const handleStatsClick = (type: 'available' | 'used' | 'expired' | 'total') => {
onStatsClick?.(type)
}
return (
<View className="bg-white mx-4 my-3 rounded-xl p-3 shadow-sm">
{/* 紧凑的统计卡片 - 2x2 网格 */}
<View className="grid grid-cols-2 gap-2">
{/* 可用礼品卡 */}
<View
className="flex items-center justify-between p-3 bg-yellow-50 rounded-lg border border-yellow-200"
onClick={() => handleStatsClick('available')}
>
<View className="flex items-center">
<Gift size="20" className="text-yellow-600 mr-2" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-yellow-600">{availableCount}</Text>
</View>
{/* 已使用礼品卡 */}
<View
className="flex items-center justify-between p-3 bg-green-50 rounded-lg border border-green-200"
onClick={() => handleStatsClick('used')}
>
<View className="flex items-center">
<Voucher size="20" className="text-green-600 mr-2" />
<Text className="text-sm text-gray-600">使</Text>
</View>
<Text className="text-lg font-bold text-green-600">{usedCount}</Text>
</View>
{/* 已过期礼品卡 */}
<View
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200"
onClick={() => handleStatsClick('expired')}
>
<View className="flex items-center">
<Clock size="20" className="text-gray-500 mr-2" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-gray-500">{expiredCount}</Text>
</View>
{/* 总价值 */}
{totalValue !== undefined && (
<View
className="flex items-center justify-between p-3 bg-purple-50 rounded-lg border border-purple-200"
onClick={() => handleStatsClick('total')}
>
<View className="flex items-center">
<Star size="20" className="text-purple-600 mr-2" />
<Text className="text-sm text-gray-600"></Text>
</View>
<Text className="text-lg font-bold text-purple-600">¥{totalValue}</Text>
</View>
)}
</View>
</View>
)
}
export default GiftCardStats

View File

@@ -1,75 +0,0 @@
import { View, Button } from '@tarojs/components'
import { useState, useEffect } from 'react'
interface EarningsCardProps {
availableAmount?: number
pendingAmount?: number
onWithdraw?: () => void
}
function EarningsCard({
availableAmount = 0.00,
pendingAmount = 0.00,
onWithdraw
}: EarningsCardProps) {
const handleWithdraw = () => {
if (onWithdraw) {
onWithdraw()
}
}
return (
<View className="mx-4 mb-4">
<View
className="rounded-2xl p-4 relative overflow-hidden"
style={{
background: 'linear-gradient(135deg, #8B5CF6 0%, #A855F7 50%, #C084FC 100%)'
}}
>
{/* 装饰性背景元素 */}
<View
className="absolute -top-4 -right-4 w-20 h-20 rounded-full opacity-20"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.2)' }}
/>
<View
className="absolute -bottom-6 -left-6 w-16 h-16 rounded-full opacity-10"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.2)' }}
/>
<View className="relative z-10">
{/* 可提现金额 */}
<View className="mb-4">
<View className="text-white text-base opacity-90 mb-1">
{availableAmount.toFixed(2)}
</View>
<View className="text-white text-base opacity-90">
{pendingAmount.toFixed(2)}
</View>
</View>
{/* 提现按钮 */}
<View className="flex justify-end">
<Button
className="bg-white text-purple-600 px-6 py-2 rounded-full text-sm font-medium border-0"
style={{
backgroundColor: 'white',
color: '#8B5CF6',
fontSize: '14px',
fontWeight: '500',
borderRadius: '20px',
padding: '8px 24px',
border: 'none'
}}
onClick={handleWithdraw}
>
</Button>
</View>
</View>
</View>
</View>
)
}
export default EarningsCard

View File

@@ -1,102 +0,0 @@
import { View } from '@tarojs/components'
import Taro from '@tarojs/taro'
interface MenuItem {
id: string
title: string
icon: string
color: string
bgColor: string
onClick?: () => void
}
function FunctionMenu() {
const menuItems: MenuItem[] = [
{
id: 'withdraw-detail',
title: '提现明细',
icon: '💰',
color: '#F59E0B',
bgColor: '#FEF3C7',
onClick: () => {
Taro.navigateTo({
url: '/dealer/withdraw/index'
})
}
},
{
id: 'distribution-orders',
title: '分销订单',
icon: '📋',
color: '#EF4444',
bgColor: '#FEE2E2',
onClick: () => {
Taro.navigateTo({
url: '/dealer/orders/index'
})
}
},
{
id: 'my-team',
title: '我的团队',
icon: '👥',
color: '#10B981',
bgColor: '#D1FAE5',
onClick: () => {
Taro.navigateTo({
url: '/dealer/team/index'
})
}
},
{
id: 'promotion-qr',
title: '推广二维码',
icon: '📱',
color: '#3B82F6',
bgColor: '#DBEAFE',
onClick: () => {
Taro.navigateTo({
url: '/dealer/qrcode/index'
})
}
}
]
const handleMenuClick = (item: MenuItem) => {
if (item.onClick) {
item.onClick()
}
}
return (
<View className="mx-4 mb-4">
<View className="bg-white rounded-2xl p-4 shadow-sm">
<View className="grid grid-cols-2 gap-4">
{menuItems.map((item) => (
<View
key={item.id}
className="flex flex-col items-center justify-center py-6 rounded-xl cursor-pointer"
style={{ backgroundColor: item.bgColor }}
onClick={() => handleMenuClick(item)}
>
<View
className="w-12 h-12 rounded-full flex items-center justify-center mb-2 text-2xl"
style={{ backgroundColor: 'white' }}
>
{item.icon}
</View>
<View
className="text-base font-medium"
style={{ color: item.color }}
>
{item.title}
</View>
</View>
))}
</View>
</View>
</View>
)
}
export default FunctionMenu

View File

@@ -1,78 +0,0 @@
import { View } from '@tarojs/components'
import Taro from '@tarojs/taro'
interface NavigationBarProps {
title?: string
showBack?: boolean
showMore?: boolean
onBack?: () => void
onMore?: () => void
}
function NavigationBar({
title = '分销中心',
showBack = true,
showMore = true,
onBack,
onMore
}: NavigationBarProps) {
const handleBack = () => {
if (onBack) {
onBack()
} else {
Taro.navigateBack()
}
}
const handleMore = () => {
if (onMore) {
onMore()
} else {
Taro.showActionSheet({
itemList: ['分享', '设置', '帮助']
})
}
}
return (
<View className="relative">
{/* 状态栏占位 */}
<View style={{ height: Taro.getSystemInfoSync().statusBarHeight + 'px' }} />
{/* 导航栏 */}
<View className="flex items-center justify-between px-4 py-3 relative z-10">
{/* 左侧返回按钮 */}
<View className="w-8 h-8 flex items-center justify-center">
{showBack && (
<View
className="w-6 h-6 flex items-center justify-center cursor-pointer"
onClick={handleBack}
>
<View className="text-white text-lg"></View>
</View>
)}
</View>
{/* 中间标题 */}
<View className="flex-1 text-center">
<View className="text-white text-xl font-medium">{title}</View>
</View>
{/* 右侧更多按钮 */}
<View className="w-8 h-8 flex items-center justify-center">
{showMore && (
<View
className="w-6 h-6 flex items-center justify-center cursor-pointer"
onClick={handleMore}
>
<View className="text-white text-lg"></View>
</View>
)}
</View>
</View>
</View>
)
}
export default NavigationBar

View File

@@ -1,211 +0,0 @@
import {useEffect, useState} from 'react'
import {navigateTo} from '@tarojs/taro'
import Taro from '@tarojs/taro'
import {Button} from '@tarojs/components';
import {Image} from '@nutui/nutui-react-taro'
import {getUserInfo, getWxOpenId} from "@/api/layout";
import {TenantId} from "@/config/app";
import {User} from "@/api/system/user/model";
// import News from "./News";
import {myPageBszxBm} from "@/api/bszx/bszxBm";
import {listCmsNavigation} from "@/api/cms/cmsNavigation";
const OrderIcon = () => {
const [loading, setLoading] = useState(true)
const [isLogin, setIsLogin] = useState<boolean>(false)
const [userInfo, setUserInfo] = useState<User>()
const [bmLogs, setBmLogs] = useState<any>()
const [navItems, setNavItems] = useState<any>([])
/* 获取用户手机号 */
const handleGetPhoneNumber = ({detail}) => {
const {code, encryptedData, iv} = detail
Taro.login({
success: function () {
if (code) {
Taro.request({
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
code,
encryptedData,
iv,
notVerifyPhone: true,
refereeId: 0,
sceneType: 'save_referee',
tenantId: TenantId
},
header: {
'content-type': 'application/json',
TenantId
},
success: function (res) {
Taro.setStorageSync('access_token', res.data.data.access_token)
Taro.setStorageSync('UserId', res.data.data.user.userId)
setUserInfo(res.data.data.user)
Taro.setStorageSync('Phone', res.data.data.user.phone)
setIsLogin(true)
Taro.showToast({
title: '登录成功',
icon: 'success'
});
}
})
} else {
console.log('登录失败!')
}
}
})
}
const onLogin = (item: any, index: number) => {
if(!isLogin){
return navigateTo({url: `/pages/category/category?id=${item.navigationId}`})
}else {
// 报名链接
if(index == 0){
console.log(bmLogs,'bmLogs')
if(bmLogs && bmLogs.length > 0){
return navigateTo({url: `/bszx/bm-cert/bm-cert?id=${bmLogs[0].id}`})
}else {
navigateTo({url: `/user/profile/profile`})
}
}
// 善款明细
if(item.navigationId == 4119){
return navigateTo({url: `/bszx/pay-record/pay-record`})
}
return navigateTo({url: `/pages/category/category?id=${item.navigationId}`})
}
}
const reload = () => {
// 读取栏目
listCmsNavigation({parentId: 2828,hide: 0}).then(res => {
console.log(res,'9999')
setNavItems(res);
})
Taro.getUserInfo({
success: (res) => {
const avatar = res.userInfo.avatarUrl;
setUserInfo({
avatar,
nickname: res.userInfo.nickName,
sexName: res.userInfo.gender == 1 ? '男' : '女'
})
getUserInfo().then((data) => {
if (data) {
setUserInfo(data)
setIsLogin(true);
console.log(userInfo, 'userInfo...')
Taro.setStorageSync('UserId', data.userId)
// 获取openId
if (!data.openid) {
Taro.login({
success: (res) => {
getWxOpenId({code: res.code}).then(() => {
})
}
})
}
}
}).catch(() => {
console.log('未登录')
});
}
});
// 报名日志
myPageBszxBm({limit: 1}).then(res => {
if (res.list) {
setBmLogs(res.list);
}
})
setLoading(false);
};
const showAuthModal = () => {
Taro.showModal({
title: '授权提示',
content: '需要获取您的用户信息',
confirmText: '去授权',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
// 用户点击确认,打开授权设置页面
openSetting();
}
}
});
};
const openSetting = () => {
// Taro.openSetting调起客户端小程序设置界面返回用户设置的操作结果。设置界面只会出现小程序已经向用户请求过的权限。
Taro.openSetting({
success: (res) => {
if (res.authSetting['scope.userInfo']) {
// 用户授权成功,可以获取用户信息
reload();
} else {
// 用户拒绝授权,提示授权失败
Taro.showToast({
title: '授权失败',
icon: 'none'
});
}
}
});
};
useEffect(() => {
Taro.getSetting({
success: (res) => {
if (res.authSetting['scope.userInfo']) {
// 用户已经授权过,可以直接获取用户信息
console.log('用户已经授权过,可以直接获取用户信息')
reload();
} else {
// 用户未授权,需要弹出授权窗口
console.log('用户未授权,需要弹出授权窗口')
showAuthModal();
}
}
});
reload();
}, [])
return (
<div className={'my-3'}>
<div className={'pt-4 bg-yellow-50 rounded-2xl'}
style={{background: 'linear-gradient(to bottom, #ffffff, #ffffcc)'}}>
<div className={'flex justify-between pb-2 px-1'}>
{
navItems.map((item, index) => (
<div key={index} className={'text-center'}>
{
isLogin && !loading ?
<div className={'flex flex-col justify-center items-center'} onClick={() => {
onLogin(item, index)
}}>
<Image src={item.icon} height={28} width={28} lazyLoad={false}/>
<div className={'mt-2'} style={{fontSize: '15px'}}>{item?.title}</div>
</div>
:
<Button className={'text-white'} open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}>
<div className={'flex flex-col justify-center items-center'}>
<Image src={item.icon} height={28} width={28} lazyLoad={false}/>
<div className={'mt-2 text-gray-700'} style={{fontSize: '15px'}}>{item?.title}</div>
</div>
</Button>
}
</div>
))
}
</div>
</div>
{/*<image src={'https://oss.wsdns.cn/20250224/18a2f3b807c94aac8a67af34e95534d6.jpeg'} className={'book'}>倡议书</image>*/}
{/*<News id={categoryId}/>*/}
</div>
)
}
export default OrderIcon

View File

@@ -1,135 +0,0 @@
import {Avatar} from '@nutui/nutui-react-taro'
import {getUserInfo, getWxOpenId} from '@/api/layout';
import Taro from '@tarojs/taro';
import {useEffect, useState} from "react";
import {User} from "@/api/system/user/model";
import {View} from '@tarojs/components'
function UserCard() {
const [IsLogin, setIsLogin] = useState<boolean>(false)
const [userInfo, setUserInfo] = useState<User>()
const [referrerName, setReferrerName] = useState<string>('平台')
useEffect(() => {
// Taro.getSetting获取用户的当前设置。返回值中只会出现小程序已经向用户请求过的权限。
Taro.getSetting({
success: (res) => {
if (res.authSetting['scope.userInfo']) {
// 用户已经授权过,可以直接获取用户信息
console.log('用户已经授权过,可以直接获取用户信息')
reload();
} else {
// 用户未授权,需要弹出授权窗口
console.log('用户未授权,需要弹出授权窗口')
showAuthModal();
}
}
});
}, []);
const reload = () => {
Taro.getUserInfo({
success: (res) => {
const avatar = res.userInfo.avatarUrl;
setUserInfo({
avatar,
nickname: res.userInfo.nickName,
sexName: res.userInfo.gender == 1 ? '男' : '女'
})
getUserInfo().then((data) => {
if (data) {
setUserInfo(data)
setIsLogin(true);
Taro.setStorageSync('UserId', data.userId)
// 获取openId
if (!data.openid) {
Taro.login({
success: (res) => {
getWxOpenId({code: res.code}).then(() => {
})
}
})
}
// 获取推荐人信息
const referrer = data.nickname || '平台';
setReferrerName(referrer)
}
}).catch(() => {
console.log('未登录')
});
}
});
};
const showAuthModal = () => {
Taro.showModal({
title: '授权提示',
content: '需要获取您的用户信息',
confirmText: '去授权',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
// 用户点击确认,打开授权设置页面
openSetting();
}
}
});
};
const openSetting = () => {
// Taro.openSetting调起客户端小程序设置界面返回用户设置的操作结果。设置界面只会出现小程序已经向用户请求过的权限。
Taro.openSetting({
success: (res) => {
if (res.authSetting['scope.userInfo']) {
// 用户授权成功,可以获取用户信息
reload();
} else {
// 用户拒绝授权,提示授权失败
Taro.showToast({
title: '授权失败',
icon: 'none'
});
}
}
});
};
return (
<View className="mx-4 mb-4">
<View className="bg-white rounded-2xl p-4 shadow-sm">
<View className="flex items-center mb-3">
<Avatar
size="60"
src={userInfo?.avatar || 'https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/116794/5fc6f541Edebf8a57/4138097748889987.png'}
shape="round"
/>
<View className="ml-3 flex-1">
<View className="text-xl font-medium text-gray-800 mb-1">
{IsLogin ? (userInfo?.nickname || '小程序用户') : '小程序.App.网站.系统开发-邓'}
</View>
<View className="text-base text-gray-500">
{referrerName}
</View>
</View>
</View>
<View className="border-t border-gray-100 pt-3">
<View className="flex justify-between items-center">
<View className="text-base text-gray-600">
</View>
<View className="text-xl font-medium text-gray-800">
0.00
</View>
</View>
</View>
</View>
</View>
)
}
export default UserCard;

View File

@@ -1,148 +0,0 @@
import {Cell} from '@nutui/nutui-react-taro'
import navTo from "@/utils/common";
import Taro from '@tarojs/taro'
import {ArrowRight, ShieldCheck, LogisticsError, Location, Reward, Tips, Ask} from '@nutui/icons-react-taro'
const UserCell = () => {
const onLogout = () => {
Taro.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: function (res) {
if (res.confirm) {
Taro.removeStorageSync('access_token')
Taro.removeStorageSync('TenantId')
Taro.removeStorageSync('UserId')
Taro.removeStorageSync('userInfo')
Taro.reLaunch({
url: '/pages/index/index'
})
}
}
})
}
return (
<>
<div className={'px-4'}>
<Cell
className="nutui-cell-clickable"
style={{
backgroundImage: 'linear-gradient(to right bottom, #54a799, #177b73)',
}}
title={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<Reward className={'text-orange-100 '} size={16}/>
<div>
<span style={{ fontSize: '16px'}} className={'pl-3 text-orange-100 font-medium'}></span>
</div>
<span className={'text-white opacity-80 pl-3'}></span>
</div>
}
extra={<ArrowRight color="#cccccc" size={18}/>}
/>
<Cell.Group divider={true} description={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<span style={{marginTop: '12px'}}></span>
</div>
}>
<Cell
className="nutui-cell-clickable"
style={{
display: 'none'
}}
title={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<LogisticsError size={16}/>
<span className={'pl-3 text-sm'}></span>
</div>
}
align="center"
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => {
navTo('/user/wallet/index', true)
}}
/>
<Cell
className="nutui-cell-clickable"
title={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<Location size={16}/>
<span className={'pl-3 text-sm'}></span>
</div>
}
align="center"
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => {
navTo('/user/address/index', true)
}}
/>
<Cell
className="nutui-cell-clickable"
title={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<ShieldCheck size={16}/>
<span className={'pl-3 text-sm'}></span>
</div>
}
align="center"
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => {
navTo('/user/userVerify/index', true)
}}
/>
<Cell
className="nutui-cell-clickable"
title={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<Ask size={16}/>
<span className={'pl-3 text-sm'}></span>
</div>
}
align="center"
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => {
navTo('/user/help/index')
}}
/>
<Cell
className="nutui-cell-clickable"
title={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<Tips size={16}/>
<span className={'pl-3 text-sm'}></span>
</div>
}
align="center"
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => {
navTo('/user/about/index')
}}
/>
</Cell.Group>
<Cell.Group divider={true} description={
<div style={{display: 'inline-flex', alignItems: 'center'}}>
<span style={{marginTop: '12px'}}></span>
</div>
}>
<Cell
className="nutui-cell-clickable"
title="账号安全"
align="center"
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => navTo('/user/profile/profile', true)}
/>
<Cell
className="nutui-cell-clickable"
title="退出登录"
align="center"
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={onLogout}
/>
</Cell.Group>
</div>
</>
)
}
export default UserCell

View File

@@ -1,102 +0,0 @@
import {loginBySms} from "@/api/passport/login";
import {useState} from "react";
import Taro from '@tarojs/taro'
import {Popup} from '@nutui/nutui-react-taro'
import {UserParam} from "@/api/system/user/model";
import {Button} from '@nutui/nutui-react-taro'
import {Form, Input} from '@nutui/nutui-react-taro'
import {Copyright, Version} from "@/config/app";
const UserFooter = () => {
const [openLoginByPhone, setOpenLoginByPhone] = useState(false)
const [clickNum, setClickNum] = useState<number>(0)
const [FormData, setFormData] = useState<UserParam>(
{
phone: undefined,
password: undefined
}
)
const onLoginByPhone = () => {
setFormData({})
setClickNum(clickNum + 1);
if (clickNum > 10) {
setOpenLoginByPhone(true);
}
}
const closeLoginByPhone = () => {
setClickNum(0)
setOpenLoginByPhone(false)
}
// 提交表单
const submitByPhone = (values: any) => {
loginBySms({
phone: values.phone,
code: values.code
}).then(() => {
setOpenLoginByPhone(false);
setTimeout(() => {
Taro.reLaunch({
url: '/pages/index/index'
})
},1000)
})
}
return (
<>
<div className={'text-center py-4 w-full text-gray-300'} onClick={onLoginByPhone}>
<div className={'text-xs text-gray-400 py-1'}>{Version}</div>
<div className={'text-xs text-gray-400 py-1'}>Copyright © { new Date().getFullYear() } {Copyright}</div>
</div>
<Popup
style={{width: '350px', padding: '10px'}}
visible={openLoginByPhone}
closeOnOverlayClick={false}
closeable={true}
onClose={closeLoginByPhone}
>
<Form
style={{width: '350px',padding: '10px'}}
divider
initialValues={FormData}
labelPosition="left"
onFinish={(values) => submitByPhone(values)}
footer={
<div
style={{
display: 'flex',
justifyContent: 'center',
width: '100%'
}}
>
<Button nativeType="submit" block style={{backgroundColor: '#000000',color: '#ffffff'}}>
</Button>
</div>
}
>
<Form.Item
label={'手机号码'}
name="phone"
required
rules={[{message: '手机号码'}]}
>
<Input placeholder="请输入手机号码" maxLength={11} type="text"/>
</Form.Item>
<Form.Item
label={'短信验证码'}
name="code"
required
rules={[{message: '短信验证码'}]}
>
<Input placeholder="请输入短信验证码" maxLength={6} type="text"/>
</Form.Item>
</Form>
</Popup>
</>
)
}
export default UserFooter

View File

@@ -1,77 +0,0 @@
// 分销中心页面样式
.dealer-page {
min-height: 100vh;
background: linear-gradient(180deg, #60A5FA 0%, #3B82F6 50%, #1D4ED8 100%);
}
// 导航栏样式
.navigation-bar {
position: relative;
z-index: 100;
}
// 用户卡片样式
.user-card {
background: white;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
margin: 16px;
padding: 16px;
}
// 收益卡片样式
.earnings-card {
background: linear-gradient(135deg, #8B5CF6 0%, #A855F7 50%, #C084FC 100%);
border-radius: 16px;
margin: 16px;
padding: 16px;
position: relative;
overflow: hidden;
}
// 功能菜单样式
.function-menu {
background: white;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
margin: 16px;
padding: 16px;
.menu-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
.menu-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 16px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:active {
transform: scale(0.98);
}
.menu-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 8px;
}
.menu-text {
font-size: 14px;
font-weight: 500;
}
}
}
}

View File

@@ -1,64 +1,37 @@
import {useEffect} from 'react' import React from 'react'
import { View } from '@tarojs/components' import { View, Text } from '@tarojs/components'
import { Cell, Button } from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import NavigationBar from "./components/NavigationBar"
import UserCard from "./components/UserCard"
import EarningsCard from "./components/EarningsCard"
import FunctionMenu from "./components/FunctionMenu"
import './index.scss'
function Index() {
useEffect(() => {
// 设置页面标题
Taro.setNavigationBarTitle({
title: '分销中心'
})
}, []);
const handleWithdraw = () => {
Taro.showModal({
title: '提现',
content: '确定要进行提现操作吗?',
success: (res) => {
if (res.confirm) {
Taro.showToast({
title: '提现申请已提交',
icon: 'success'
})
}
}
})
}
const handleBack = () => {
Taro.navigateBack({
delta: 1
})
}
const handleMore = () => {
Taro.showActionSheet({
itemList: ['分享给朋友', '客服咨询', '使用帮助'],
success: (res) => {
const actions = ['分享给朋友', '客服咨询', '使用帮助']
Taro.showToast({
title: actions[res.tapIndex],
icon: 'none'
})
}
})
}
const DealerIndex: React.FC = () => {
return ( return (
<View className="min-h-screen" style={{ <View className="p-4">
paddingTop: '20px', <Text className="text-lg font-bold mb-4"></Text>
background: 'linear-gradient(180deg, #60A5FA 0%, #3B82F6 50%, #1D4ED8 100%)'
}}> <Cell.Group>
<UserCard /> <Cell
<EarningsCard onWithdraw={handleWithdraw} /> title="我的团队"
<FunctionMenu /> description="查看团队成员"
extra={<Button size="small" onClick={() => Taro.navigateTo({url: '/dealer/team/index'})}></Button>}
/>
<Cell
title="我的订单"
description="查看分销订单"
extra={<Button size="small" onClick={() => Taro.navigateTo({url: '/dealer/orders/index'})}></Button>}
/>
<Cell
title="提现管理"
description="申请提现"
extra={<Button size="small" onClick={() => Taro.navigateTo({url: '/dealer/withdraw/index'})}></Button>}
/>
<Cell
title="推广二维码"
description="生成推广码"
extra={<Button size="small" onClick={() => Taro.navigateTo({url: '/dealer/qrcode/index'})}></Button>}
/>
</Cell.Group>
</View> </View>
) )
} }
export default Index export default DealerIndex

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '分销订单'
})

View File

@@ -1,145 +0,0 @@
.distribution-orders-page {
min-height: 100vh;
background-color: #f5f5f5;
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.stats-card {
background: white;
margin: 16px;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
.stat-item {
text-align: center;
.stat-value {
font-size: 20px; // 对应 text-xl
font-weight: bold;
color: #1f2937;
margin-bottom: 4px;
}
.stat-label {
font-size: 16px; // 对应 text-base
color: #6b7280;
}
}
}
}
.orders-container {
margin: 0 16px;
.empty-container {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
}
.orders-list {
margin-top: 16px;
.order-item {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s ease;
&:active {
transform: scale(0.98);
background-color: #f9f9f9;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.order-no {
font-size: 14px; // 对应 text-sm
color: #6b7280;
}
.order-status {
font-size: 14px; // 对应 text-sm
font-weight: 500;
padding: 4px 12px;
border-radius: 20px;
background-color: rgba(0, 0, 0, 0.05);
}
}
.order-content {
display: flex;
margin-bottom: 12px;
.product-image {
width: 60px;
height: 60px;
border-radius: 8px;
margin-right: 12px;
background-color: #f3f4f6;
}
.order-info {
flex: 1;
.product-name {
font-size: 20px; // 对应 text-xl主要标题
font-weight: 500;
color: #1f2937;
margin-bottom: 6px;
line-height: 1.4;
}
.buyer-info {
font-size: 16px; // 对应 text-base
color: #6b7280;
margin-bottom: 8px;
}
.amount-info {
display: flex;
justify-content: space-between;
align-items: center;
.order-amount {
font-size: 16px; // 对应 text-base
color: #6b7280;
}
.commission {
font-size: 20px; // 对应 text-xl重要数字
font-weight: bold;
color: #ef4444;
}
}
}
}
.order-time {
font-size: 14px; // 对应 text-sm
color: #9ca3af;
text-align: right;
}
}
}
}
}

View File

@@ -1,238 +1,15 @@
import { useState, useEffect } from 'react' import React from 'react'
import { View, Image } from '@tarojs/components' import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro' import { Cell, Empty } from '@nutui/nutui-react-taro'
import { Empty, Tabs, TabPane } from '@nutui/nutui-react-taro'
import './index.scss'
interface DistributionOrder { const DealerOrders: React.FC = () => {
id: string
orderNo: string
productName: string
productImage: string
buyerName: string
orderAmount: number
commission: number
commissionRate: number
status: 'pending' | 'confirmed' | 'settled'
statusText: string
createTime: string
settleTime?: string
}
function DistributionOrders() {
const [activeTab, setActiveTab] = useState('0')
const [orders, setOrders] = useState<DistributionOrder[]>([])
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState({
totalCommission: 0,
pendingCommission: 0,
settledCommission: 0,
totalOrders: 0
})
useEffect(() => {
Taro.setNavigationBarTitle({
title: '分销订单'
})
loadOrders()
}, [])
const loadOrders = async () => {
try {
setLoading(true)
// 模拟数据
const mockData: DistributionOrder[] = [
{
id: '1',
orderNo: 'DD202401150001',
productName: '有机蔬菜礼盒装',
productImage: 'https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/116794/5fc6f541Edebf8a57/4138097748889987.png',
buyerName: '张***',
orderAmount: 299.00,
commission: 29.90,
commissionRate: 10,
status: 'settled',
statusText: '已结算',
createTime: '2024-01-15 14:30:00',
settleTime: '2024-01-16 10:00:00'
},
{
id: '2',
orderNo: 'DD202401140002',
productName: '新鲜水果组合',
productImage: 'https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/116794/5fc6f541Edebf8a57/4138097748889987.png',
buyerName: '李***',
orderAmount: 158.00,
commission: 15.80,
commissionRate: 10,
status: 'confirmed',
statusText: '已确认',
createTime: '2024-01-14 09:20:00'
},
{
id: '3',
orderNo: 'DD202401130003',
productName: '农家土鸡蛋',
productImage: 'https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/116794/5fc6f541Edebf8a57/4138097748889987.png',
buyerName: '王***',
orderAmount: 88.00,
commission: 8.80,
commissionRate: 10,
status: 'pending',
statusText: '待确认',
createTime: '2024-01-13 16:45:00'
}
]
// 计算统计数据
const totalCommission = mockData.reduce((sum, order) => sum + order.commission, 0)
const pendingCommission = mockData.filter(o => o.status === 'pending').reduce((sum, order) => sum + order.commission, 0)
const settledCommission = mockData.filter(o => o.status === 'settled').reduce((sum, order) => sum + order.commission, 0)
setTimeout(() => {
setOrders(mockData)
setStats({
totalCommission,
pendingCommission,
settledCommission,
totalOrders: mockData.length
})
setLoading(false)
}, 1000)
} catch (error) {
console.error('加载分销订单失败:', error)
setLoading(false)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'settled':
return '#10B981'
case 'confirmed':
return '#3B82F6'
case 'pending':
return '#F59E0B'
default:
return '#6B7280'
}
}
const getFilteredOrders = () => {
switch (activeTab) {
case '1':
return orders.filter(order => order.status === 'pending')
case '2':
return orders.filter(order => order.status === 'confirmed')
case '3':
return orders.filter(order => order.status === 'settled')
default:
return orders
}
}
const handleOrderClick = (order: DistributionOrder) => {
Taro.showModal({
title: '订单详情',
content: `
订单号:${order.orderNo}
商品:${order.productName}
购买人:${order.buyerName}
订单金额:¥${order.orderAmount.toFixed(2)}
佣金比例:${order.commissionRate}%
佣金金额:¥${order.commission.toFixed(2)}
下单时间:${order.createTime}
${order.settleTime ? `结算时间:${order.settleTime}` : ''}
`.trim(),
showCancel: false
})
}
if (loading) {
return ( return (
<View className="distribution-orders-page"> <View className="p-4">
<View className="loading-container"> <Text className="text-lg font-bold mb-4"></Text>
<View className="text-center text-gray-500">...</View>
</View>
</View>
)
}
return ( <Empty description="暂无分销订单" />
<View className="distribution-orders-page">
{/* 统计卡片 */}
<View className="stats-card">
<View className="stats-grid">
<View className="stat-item">
<View className="stat-value">¥{stats.totalCommission.toFixed(2)}</View>
<View className="stat-label"></View>
</View>
<View className="stat-item">
<View className="stat-value">¥{stats.settledCommission.toFixed(2)}</View>
<View className="stat-label"></View>
</View>
<View className="stat-item">
<View className="stat-value">¥{stats.pendingCommission.toFixed(2)}</View>
<View className="stat-label"></View>
</View>
<View className="stat-item">
<View className="stat-value">{stats.totalOrders}</View>
<View className="stat-label"></View>
</View>
</View>
</View>
{/* 订单列表 */}
<View className="orders-container">
<Tabs value={activeTab} onChange={(value) => setActiveTab(value)}>
<TabPane title="全部" />
<TabPane title="待确认" />
<TabPane title="已确认" />
<TabPane title="已结算" />
</Tabs>
<View className="orders-list">
{getFilteredOrders().length === 0 ? (
<View className="empty-container">
<Empty description="暂无订单" />
</View>
) : (
getFilteredOrders().map((order) => (
<View
key={order.id}
className="order-item"
onClick={() => handleOrderClick(order)}
>
<View className="order-header">
<View className="order-no">{order.orderNo}</View>
<View
className="order-status"
style={{ color: getStatusColor(order.status) }}
>
{order.statusText}
</View>
</View>
<View className="order-content">
<Image className="product-image" src={order.productImage} />
<View className="order-info">
<View className="product-name">{order.productName}</View>
<View className="buyer-info">{order.buyerName}</View>
<View className="amount-info">
<View className="order-amount">¥{order.orderAmount.toFixed(2)}</View>
<View className="commission">¥{order.commission.toFixed(2)}</View>
</View>
</View>
</View>
<View className="order-time">{order.createTime}</View>
</View>
))
)}
</View>
</View>
</View> </View>
) )
} }
export default DistributionOrders export default DealerOrders

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '推广二维码'
})

View File

@@ -1,247 +0,0 @@
.promotion-qrcode-page {
min-height: 100vh;
background-color: #f5f5f5;
padding: 16px;
.user-card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
.user-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
margin-right: 16px;
}
.user-info {
flex: 1;
.user-name {
font-size: 20px; // 对应 text-xl
font-weight: bold;
color: #1f2937;
margin-bottom: 6px;
}
.invite-code {
font-size: 16px; // 对应 text-base
color: #6b7280;
background-color: #f3f4f6;
padding: 6px 10px;
border-radius: 6px;
display: inline-block;
}
}
}
.qrcode-container {
.qrcode-card {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
.qrcode-header {
margin-bottom: 24px;
.title {
font-size: 20px; // 对应 text-xl
font-weight: bold;
color: #1f2937;
margin-bottom: 6px;
}
.subtitle {
font-size: 16px; // 对应 text-base
color: #6b7280;
}
}
.qrcode-wrapper {
margin-bottom: 20px;
.qrcode-loading {
width: 200px;
height: 200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9fafb;
border-radius: 12px;
.loading-text {
color: #6b7280;
}
}
.qrcode-image-container {
.qrcode-placeholder {
width: 200px;
height: 200px;
margin: 0 auto 12px;
background: white;
border: 2px solid #e5e7eb;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
.qr-pattern {
width: 160px;
height: 160px;
position: relative;
.qr-corner {
position: absolute;
width: 30px;
height: 30px;
border: 3px solid #1f2937;
&.top-left {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
&.top-right {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
}
&.bottom-left {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
}
}
.qr-dots {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
padding: 40px 20px;
.qr-dot {
width: 8px;
height: 8px;
background-color: #1f2937;
border-radius: 1px;
}
}
}
}
.qrcode-tip {
font-size: 14px; // 对应 text-sm
color: #9ca3af;
}
}
}
.qrcode-info {
.info-item {
text-align: left;
padding: 12px;
background-color: #f9fafb;
border-radius: 8px;
.info-label {
font-size: 16px; // 对应 text-base
color: #6b7280;
margin-bottom: 4px;
}
.info-value {
font-size: 14px; // 对应 text-sm
color: #1f2937;
word-break: break-all;
line-height: 1.4;
}
}
}
}
}
.action-buttons {
margin-bottom: 24px;
.action-btn {
width: 100%;
margin-bottom: 12px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
&.primary {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: white;
border: none;
}
&.secondary {
background: white;
color: #3b82f6;
border: 1px solid #3b82f6;
}
&.tertiary {
background: #f3f4f6;
color: #6b7280;
border: none;
}
}
}
.usage-tips {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.tips-title {
font-size: 20px; // 对应 text-xl
font-weight: bold;
color: #1f2937;
margin-bottom: 16px;
}
.tips-list {
.tip-item {
font-size: 16px; // 对应 text-base
color: #6b7280;
line-height: 1.6;
margin-bottom: 8px;
padding-left: 8px;
position: relative;
&:before {
content: '';
position: absolute;
left: 0;
top: 8px;
width: 4px;
height: 4px;
background-color: #3b82f6;
border-radius: 50%;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
}

View File

@@ -1,221 +1,31 @@
import { useState, useEffect } from 'react' import React from 'react'
import { View, Canvas, Image } from '@tarojs/components' import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro' import { Cell, Button } from '@nutui/nutui-react-taro'
import { Button } from '@nutui/nutui-react-taro'
import './index.scss'
interface UserInfo {
id: string
nickname: string
avatar: string
inviteCode: string
}
function PromotionQRCode() {
const [userInfo, setUserInfo] = useState<UserInfo>({
id: '12345',
nickname: '分销达人',
avatar: 'https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/116794/5fc6f541Edebf8a57/4138097748889987.png',
inviteCode: 'INV12345'
})
const [qrCodeUrl, setQrCodeUrl] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
Taro.setNavigationBarTitle({
title: '推广二维码'
})
generateQRCode()
}, [])
const generateQRCode = async () => {
try {
setLoading(true)
// 模拟生成二维码
// 实际项目中应该调用后端API生成包含邀请码的二维码
const inviteUrl = `https://your-domain.com/invite?code=${userInfo.inviteCode}`
// 这里使用一个模拟的二维码图片
// 实际项目中可以使用二维码生成库或调用API
const mockQRCode = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
setTimeout(() => {
setQrCodeUrl(mockQRCode)
setLoading(false)
}, 1000)
} catch (error) {
console.error('生成二维码失败:', error)
setLoading(false)
Taro.showToast({
title: '生成失败',
icon: 'error'
})
}
}
const handleSaveImage = async () => {
try {
if (!qrCodeUrl) {
Taro.showToast({
title: '二维码未生成',
icon: 'none'
})
return
}
// 在实际项目中,这里应该将二维码保存到相册
Taro.showModal({
title: '保存二维码',
content: '是否保存二维码到相册?',
success: (res) => {
if (res.confirm) {
// 实际保存逻辑
Taro.showToast({
title: '保存成功',
icon: 'success'
})
}
}
})
} catch (error) {
console.error('保存图片失败:', error)
Taro.showToast({
title: '保存失败',
icon: 'error'
})
}
}
const handleShareQRCode = () => {
Taro.showActionSheet({
itemList: ['分享给朋友', '分享到朋友圈', '复制邀请链接'],
success: (res) => {
const actions = ['分享给朋友', '分享到朋友圈', '复制邀请链接']
const action = actions[res.tapIndex]
if (action === '复制邀请链接') {
const inviteUrl = `https://your-domain.com/invite?code=${userInfo.inviteCode}`
Taro.setClipboardData({
data: inviteUrl,
success: () => {
Taro.showToast({
title: '链接已复制',
icon: 'success'
})
}
})
} else {
Taro.showToast({
title: action,
icon: 'none'
})
}
}
})
}
const handleRefreshQRCode = () => {
Taro.showModal({
title: '刷新二维码',
content: '确定要重新生成二维码吗?',
success: (res) => {
if (res.confirm) {
generateQRCode()
}
}
})
}
const DealerQrcode: React.FC = () => {
return ( return (
<View className="promotion-qrcode-page"> <View className="p-4">
{/* 用户信息卡片 */} <Text className="text-lg font-bold mb-4">广</Text>
<View className="user-card">
<Image className="user-avatar" src={userInfo.avatar} /> <View className="text-center">
<View className="user-info"> <View className="bg-gray-100 w-48 h-48 mx-auto mb-4 flex items-center justify-center">
<View className="user-name">{userInfo.nickname}</View> <Text className="text-gray-500"></Text>
<View className="invite-code">{userInfo.inviteCode}</View>
</View>
</View> </View>
{/* 二维码展示区域 */} <Text className="text-sm text-gray-600 mb-4">
<View className="qrcode-container">
<View className="qrcode-card"> </Text>
<View className="qrcode-header">
<View className="title">广</View>
<View className="subtitle"></View>
</View>
<View className="qrcode-wrapper"> <Button type="primary" className="mb-2">
{loading ? (
<View className="qrcode-loading">
<View className="loading-text">...</View>
</View>
) : (
<View className="qrcode-image-container">
{/* 实际项目中这里应该显示真实的二维码 */}
<View className="qrcode-placeholder">
<View className="qr-pattern">
<View className="qr-corner top-left"></View>
<View className="qr-corner top-right"></View>
<View className="qr-corner bottom-left"></View>
<View className="qr-dots">
{Array.from({ length: 25 }).map((_, index) => (
<View key={index} className="qr-dot"></View>
))}
</View>
</View>
</View>
<View className="qrcode-tip"></View>
</View>
)}
</View>
<View className="qrcode-info">
<View className="info-item">
<View className="info-label"></View>
<View className="info-value">https://your-domain.com/invite?code={userInfo.inviteCode}</View>
</View>
</View>
</View>
</View>
{/* 操作按钮 */}
<View className="action-buttons">
<Button
className="action-btn primary"
onClick={handleSaveImage}
disabled={loading}
>
</Button> </Button>
<Button
className="action-btn secondary"
onClick={handleShareQRCode}
disabled={loading}
>
</Button>
<Button
className="action-btn tertiary"
onClick={handleRefreshQRCode}
disabled={loading}
>
</Button>
</View>
{/* 使用说明 */} <Button>
<View className="usage-tips">
<View className="tips-title">使</View> </Button>
<View className="tips-list">
<View className="tip-item">1. </View>
<View className="tip-item">2. </View>
<View className="tip-item">3. 广</View>
<View className="tip-item">4. 使</View>
</View>
</View> </View>
</View> </View>
) )
} }
export default PromotionQRCode export default DealerQrcode

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的团队'
})

View File

@@ -1,176 +0,0 @@
.my-team-page {
min-height: 100vh;
background-color: #f5f5f5;
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.team-stats {
background: white;
margin: 16px;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
.stat-item {
text-align: center;
.stat-value {
font-size: 20px; // 对应 text-xl
font-weight: bold;
color: #1f2937;
margin-bottom: 4px;
}
.stat-label {
font-size: 16px; // 对应 text-base
color: #6b7280;
}
}
}
}
.level-stats {
background: white;
margin: 0 16px 16px;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.level-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
&:not(:last-child) {
border-bottom: 1px solid #f3f4f6;
}
.level-info {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.level-title {
font-size: 20px; // 对应 text-xl
color: #1f2937;
}
.level-count {
font-size: 20px; // 对应 text-xl
font-weight: bold;
color: #3b82f6;
}
}
}
}
.members-container {
margin: 0 16px;
.empty-container {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
}
.members-list {
margin-top: 16px;
.member-item {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
&:active {
transform: scale(0.98);
background-color: #f9f9f9;
}
.member-info {
flex: 1;
margin-left: 12px;
.member-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.member-name {
font-size: 20px; // 对应 text-xl成员名称是重要信息
font-weight: 500;
color: #1f2937;
}
.member-level {
font-size: 14px; // 对应 text-sm
padding: 4px 10px;
border-radius: 12px;
color: white;
&.level-1 {
background-color: #3b82f6;
}
&.level-2 {
background-color: #10b981;
}
}
}
.member-stats {
display: flex;
gap: 16px;
margin-bottom: 6px;
.stat {
font-size: 16px; // 对应 text-base
color: #6b7280;
}
}
.member-time {
font-size: 14px; // 对应 text-sm
color: #9ca3af;
}
}
.member-status {
font-size: 14px; // 对应 text-sm
padding: 6px 10px;
border-radius: 12px;
margin-left: 12px;
&.active {
background-color: #d1fae5;
color: #10b981;
}
&.inactive {
background-color: #fee2e2;
color: #ef4444;
}
}
}
}
}
}

View File

@@ -1,244 +1,15 @@
import { useState, useEffect } from 'react' import React from 'react'
import { View } from '@tarojs/components' import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro' import { Empty } from '@nutui/nutui-react-taro'
import { Avatar, Empty, Tabs, TabPane } from '@nutui/nutui-react-taro'
import './index.scss'
interface TeamMember { const DealerTeam: React.FC = () => {
id: string
nickname: string
avatar: string
joinTime: string
level: number
orderCount: number
totalCommission: number
status: 'active' | 'inactive'
}
interface TeamStats {
totalMembers: number
activeMembers: number
level1Members: number
level2Members: number
totalCommission: number
monthCommission: number
}
function MyTeam() {
const [activeTab, setActiveTab] = useState('0')
const [members, setMembers] = useState<TeamMember[]>([])
const [stats, setStats] = useState<TeamStats>({
totalMembers: 0,
activeMembers: 0,
level1Members: 0,
level2Members: 0,
totalCommission: 0,
monthCommission: 0
})
const [loading, setLoading] = useState(true)
useEffect(() => {
Taro.setNavigationBarTitle({
title: '我的团队'
})
loadTeamData()
}, [])
const loadTeamData = async () => {
try {
setLoading(true)
// 模拟数据
const mockMembers: TeamMember[] = [
{
id: '1',
nickname: '张小明',
avatar: 'https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/116794/5fc6f541Edebf8a57/4138097748889987.png',
joinTime: '2024-01-15',
level: 1,
orderCount: 15,
totalCommission: 150.50,
status: 'active'
},
{
id: '2',
nickname: '李小红',
avatar: 'https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/116794/5fc6f541Edebf8a57/4138097748889987.png',
joinTime: '2024-01-10',
level: 1,
orderCount: 8,
totalCommission: 89.20,
status: 'active'
},
{
id: '3',
nickname: '王小华',
avatar: 'https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/116794/5fc6f541Edebf8a57/4138097748889987.png',
joinTime: '2024-01-08',
level: 2,
orderCount: 3,
totalCommission: 25.80,
status: 'inactive'
},
{
id: '4',
nickname: '赵小刚',
avatar: 'https://img12.360buyimg.com/imagetools/jfs/t1/143702/31/16654/116794/5fc6f541Edebf8a57/4138097748889987.png',
joinTime: '2024-01-05',
level: 2,
orderCount: 12,
totalCommission: 98.60,
status: 'active'
}
]
// 计算统计数据
const totalMembers = mockMembers.length
const activeMembers = mockMembers.filter(m => m.status === 'active').length
const level1Members = mockMembers.filter(m => m.level === 1).length
const level2Members = mockMembers.filter(m => m.level === 2).length
const totalCommission = mockMembers.reduce((sum, m) => sum + m.totalCommission, 0)
setTimeout(() => {
setMembers(mockMembers)
setStats({
totalMembers,
activeMembers,
level1Members,
level2Members,
totalCommission,
monthCommission: totalCommission * 0.3 // 模拟本月佣金
})
setLoading(false)
}, 1000)
} catch (error) {
console.error('加载团队数据失败:', error)
setLoading(false)
}
}
const getFilteredMembers = () => {
switch (activeTab) {
case '1':
return members.filter(member => member.level === 1)
case '2':
return members.filter(member => member.level === 2)
case '3':
return members.filter(member => member.status === 'active')
default:
return members
}
}
const handleMemberClick = (member: TeamMember) => {
Taro.showModal({
title: '成员详情',
content: `
昵称:${member.nickname}
加入时间:${member.joinTime}
等级:${member.level}级下线
订单数量:${member.orderCount}
累计佣金:¥${member.totalCommission.toFixed(2)}
状态:${member.status === 'active' ? '活跃' : '不活跃'}
`.trim(),
showCancel: false
})
}
if (loading) {
return ( return (
<View className="my-team-page"> <View className="p-4">
<View className="loading-container"> <Text className="text-lg font-bold mb-4"></Text>
<View className="text-center text-gray-500">...</View>
</View>
</View>
)
}
return (
<View className="my-team-page">
{/* 团队统计 */}
<View className="team-stats">
<View className="stats-grid">
<View className="stat-item">
<View className="stat-value">{stats.totalMembers}</View>
<View className="stat-label"></View>
</View>
<View className="stat-item">
<View className="stat-value">{stats.activeMembers}</View>
<View className="stat-label"></View>
</View>
<View className="stat-item">
<View className="stat-value">¥{stats.totalCommission.toFixed(2)}</View>
<View className="stat-label"></View>
</View>
<View className="stat-item">
<View className="stat-value">¥{stats.monthCommission.toFixed(2)}</View>
<View className="stat-label"></View>
</View>
</View>
</View>
{/* 等级统计 */}
<View className="level-stats">
<View className="level-item">
<View className="level-info">
<View className="level-title">线</View>
<View className="level-count">{stats.level1Members}</View>
</View>
</View>
<View className="level-item">
<View className="level-info">
<View className="level-title">线</View>
<View className="level-count">{stats.level2Members}</View>
</View>
</View>
</View>
{/* 成员列表 */}
<View className="members-container">
<Tabs value={activeTab} onChange={(value) => setActiveTab(value)}>
<TabPane title="全部" />
<TabPane title="一级" />
<TabPane title="二级" />
<TabPane title="活跃" />
</Tabs>
<View className="members-list">
{getFilteredMembers().length === 0 ? (
<View className="empty-container">
<Empty description="暂无团队成员" /> <Empty description="暂无团队成员" />
</View> </View>
) : (
getFilteredMembers().map((member) => (
<View
key={member.id}
className="member-item"
onClick={() => handleMemberClick(member)}
>
<Avatar size="50" src={member.avatar} shape="round" />
<View className="member-info">
<View className="member-header">
<View className="member-name">{member.nickname}</View>
<View className={`member-level level-${member.level}`}>
{member.level}
</View>
</View>
<View className="member-stats">
<View className="stat">{member.orderCount}</View>
<View className="stat">¥{member.totalCommission.toFixed(2)}</View>
</View>
<View className="member-time">{member.joinTime}</View>
</View>
<View className={`member-status ${member.status}`}>
{member.status === 'active' ? '活跃' : '不活跃'}
</View>
</View>
))
)}
</View>
</View>
</View>
) )
} }
export default MyTeam export default DealerTeam

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '提现申请'
})

View File

@@ -1,75 +0,0 @@
.withdraw-detail-page {
min-height: 100vh;
background-color: #f5f5f5;
padding: 16px;
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.empty-container {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.records-list {
.record-item {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s ease;
&:active {
transform: scale(0.98);
background-color: #f9f9f9;
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.amount {
font-size: 20px; // 对应 text-xl重要金额
font-weight: bold;
color: #1f2937;
}
.status {
font-size: 14px; // 对应 text-sm
font-weight: 500;
padding: 4px 12px;
border-radius: 20px;
background-color: rgba(0, 0, 0, 0.05);
}
}
.record-info {
.time {
font-size: 16px; // 对应 text-base
color: #6b7280;
margin-bottom: 4px;
}
.remark {
font-size: 16px; // 对应 text-base
color: #ef4444;
margin-top: 8px;
padding: 8px;
background-color: #fef2f2;
border-radius: 6px;
border-left: 3px solid #ef4444;
}
}
}
}
}

View File

@@ -1,146 +1,35 @@
import { useState, useEffect } from 'react' import React from 'react'
import { View } from '@tarojs/components' import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro' import { Cell, Button, Form, Input } from '@nutui/nutui-react-taro'
import { Empty } from '@nutui/nutui-react-taro'
import './index.scss'
interface WithdrawRecord { const DealerWithdraw: React.FC = () => {
id: string
amount: number
status: 'pending' | 'success' | 'failed'
statusText: string
createTime: string
completeTime?: string
remark?: string
}
function WithdrawDetail() {
const [records, setRecords] = useState<WithdrawRecord[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Taro.setNavigationBarTitle({
title: '提现明细'
})
loadWithdrawRecords()
}, [])
const loadWithdrawRecords = async () => {
try {
setLoading(true)
// 模拟数据实际应该调用API
const mockData: WithdrawRecord[] = [
{
id: '1',
amount: 100.00,
status: 'success',
statusText: '提现成功',
createTime: '2024-01-15 14:30:00',
completeTime: '2024-01-15 16:45:00'
},
{
id: '2',
amount: 50.00,
status: 'pending',
statusText: '处理中',
createTime: '2024-01-10 09:20:00'
},
{
id: '3',
amount: 200.00,
status: 'failed',
statusText: '提现失败',
createTime: '2024-01-05 11:15:00',
remark: '银行卡信息有误'
}
]
setTimeout(() => {
setRecords(mockData)
setLoading(false)
}, 1000)
} catch (error) {
console.error('加载提现记录失败:', error)
setLoading(false)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'success':
return '#10B981'
case 'pending':
return '#F59E0B'
case 'failed':
return '#EF4444'
default:
return '#6B7280'
}
}
const handleRecordClick = (record: WithdrawRecord) => {
const content = `
提现金额:¥${record.amount.toFixed(2)}
申请时间:${record.createTime}
${record.completeTime ? `完成时间:${record.completeTime}` : ''}
${record.remark ? `备注:${record.remark}` : ''}
`.trim()
Taro.showModal({
title: '提现详情',
content,
showCancel: false
})
}
if (loading) {
return ( return (
<View className="withdraw-detail-page"> <View className="p-4">
<View className="loading-container"> <Text className="text-lg font-bold mb-4"></Text>
<View className="text-center text-gray-500">...</View>
</View>
</View>
)
}
return ( <Form>
<View className="withdraw-detail-page"> <Cell.Group>
{records.length === 0 ? ( <Form.Item name="amount" label="提现金额" required>
<View className="empty-container"> <Input placeholder="请输入提现金额" type="number" />
<Empty description="暂无提现记录" /> </Form.Item>
<Form.Item name="account" label="提现账户" required>
<Input placeholder="请输入提现账户" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input placeholder="请输入备注信息" />
</Form.Item>
</Cell.Group>
<View className="mt-4">
<Button block type="primary">
</Button>
</View> </View>
) : ( </Form>
<View className="records-list">
{records.map((record) => (
<View
key={record.id}
className="record-item"
onClick={() => handleRecordClick(record)}
>
<View className="record-header">
<View className="amount">¥{record.amount.toFixed(2)}</View>
<View
className="status"
style={{ color: getStatusColor(record.status) }}
>
{record.statusText}
</View>
</View>
<View className="record-info">
<View className="time">{record.createTime}</View>
{record.completeTime && (
<View className="time">{record.completeTime}</View>
)}
{record.remark && (
<View className="remark">{record.remark}</View>
)}
</View>
</View>
))}
</View>
)}
</View> </View>
) )
} }
export default WithdrawDetail export default DealerWithdraw

View File

@@ -15,6 +15,7 @@ function UserCard() {
const [roleName, setRoleName] = useState<string>('注册用户') const [roleName, setRoleName] = useState<string>('注册用户')
const [couponCount, setCouponCount] = useState(0) const [couponCount, setCouponCount] = useState(0)
const [pointsCount, setPointsCount] = useState(0) const [pointsCount, setPointsCount] = useState(0)
const [giftCount, setGiftCount] = useState(0)
useEffect(() => { useEffect(() => {
@@ -52,6 +53,11 @@ function UserCard() {
.catch((error: any) => { .catch((error: any) => {
console.error('Points stats error:', error) console.error('Points stats error:', error)
}) })
// 加载礼品劵数量
setGiftCount(0)
// pageUserGiftLog({userId, page: 1, limit: 1}).then(res => {
// setGiftCount(res.count || 0)
// })
} }
const reload = () => { const reload = () => {
@@ -85,7 +91,7 @@ function UserCard() {
} }
// 判断身份 // 判断身份
const roleName = Taro.getStorageSync('RoleName'); const roleName = Taro.getStorageSync('RoleName');
if(roleName){ if (roleName) {
setRoleName(roleName) setRoleName(roleName)
} }
} }
@@ -153,7 +159,7 @@ function UserCard() {
TenantId TenantId
}, },
success: function (res) { success: function (res) {
if(res.data.code == 1){ if (res.data.code == 1) {
Taro.showToast({ Taro.showToast({
title: res.data.message, title: res.data.message,
icon: 'error', icon: 'error',
@@ -216,15 +222,23 @@ function UserCard() {
</div> </div>
</div> </div>
<div className={'flex justify-around mt-1'}> <div className={'flex justify-around mt-1'}>
<div className={'item flex justify-center flex-col items-center'} onClick={() => navTo('/user/wallet/wallet', true)}> <div className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/wallet/wallet', true)}>
<span className={'text-sm text-gray-500'}></span> <span className={'text-sm text-gray-500'}></span>
<span className={'text-xl'}>¥ {userInfo?.balance || '0.00'}</span> <span className={'text-xl'}>¥ {userInfo?.balance || '0.00'}</span>
</div> </div>
<div className={'item flex justify-center flex-col items-center'} onClick={() => navTo('/user/coupon/coupon', true)}> <div className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/coupon/index', true)}>
<span className={'text-sm text-gray-500'}></span> <span className={'text-sm text-gray-500'}></span>
<span className={'text-xl'}>{couponCount}</span> <span className={'text-xl'}>{couponCount}</span>
</div> </div>
<div className={'item flex justify-center flex-col items-center'} onClick={() => navTo('/user/points/points', true)}> <div className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/gift/index', true)}>
<span className={'text-sm text-gray-500'}></span>
<span className={'text-xl'}>{giftCount}</span>
</div>
<div className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/points/points', true)}>
<span className={'text-sm text-gray-500'}></span> <span className={'text-sm text-gray-500'}></span>
<span className={'text-xl'}>{pointsCount}</span> <span className={'text-xl'}>{pointsCount}</span>
</div> </div>

View File

@@ -1,272 +0,0 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/taro'
import {Button, Cell, CellGroup, Empty, ConfigProvider, SearchBar, Tag, InfiniteLoading, Loading, PullToRefresh} from '@nutui/nutui-react-taro'
import {Edit, Del, Eye} from '@nutui/icons-react-taro'
import {View} from '@tarojs/components'
import {ShopArticle} from "@/api/shop/shopArticle/model";
import {pageShopArticle, removeShopArticle} from "@/api/shop/shopArticle";
import FixedButton from "@/components/FixedButton";
import dayjs from "dayjs";
const ShopArticleManage = () => {
const [list, setList] = useState<ShopArticle[]>([])
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [searchValue, setSearchValue] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const reload = async (isRefresh = false) => {
if (isRefresh) {
setPage(1)
setList([])
setHasMore(true)
}
setLoading(true)
try {
const currentPage = isRefresh ? 1 : page
const res = await pageShopArticle({
page: currentPage,
limit: 10,
keywords: searchValue
})
if (res && res.list) {
const newList = isRefresh ? res.list : [...list, ...res.list]
setList(newList)
setTotal(res.count || 0)
// 判断是否还有更多数据
setHasMore(res.list.length === 10) // 如果返回的数据等于limit说明可能还有更多
if (!isRefresh) {
setPage(currentPage + 1)
} else {
setPage(2) // 刷新后下一页是第2页
}
} else {
setHasMore(false)
setTotal(0)
}
} catch (error) {
console.error('获取文章失败:', error)
Taro.showToast({
title: '获取文章失败',
icon: 'error'
});
} finally {
setLoading(false)
}
}
// 搜索功能
const handleSearch = (value: string) => {
setSearchValue(value)
reload(true)
}
// 下拉刷新
const handleRefresh = async () => {
setRefreshing(true)
await reload(true)
setRefreshing(false)
}
// 删除文章
const handleDelete = async (id?: number) => {
Taro.showModal({
title: '确认删除',
content: '确定要删除这篇文章吗?',
success: async (res) => {
if (res.confirm) {
try {
await removeShopArticle(id)
Taro.showToast({
title: '删除成功',
icon: 'success'
});
reload(true);
} catch (error) {
Taro.showToast({
title: '删除失败',
icon: 'error'
});
}
}
}
});
}
// 编辑文章
const handleEdit = (item: ShopArticle) => {
Taro.navigateTo({
url: `/shop/shopArticle/add?id=${item.articleId}`
});
}
// 查看文章详情
const handleView = (item: ShopArticle) => {
// 这里可以跳转到文章详情页面
Taro.navigateTo({
url: `/cms/detail/index?id=${item.articleId}`
})
}
// 获取状态标签
const getStatusTag = (status?: number) => {
switch (status) {
case 0:
return <Tag type="success"></Tag>
case 1:
return <Tag type="warning"></Tag>
case 2:
return <Tag type="danger"></Tag>
case 3:
return <Tag type="danger"></Tag>
default:
return <Tag></Tag>
}
}
// 加载更多
const loadMore = async () => {
if (!loading && hasMore) {
await reload(false) // 不刷新,追加数据
}
}
useDidShow(() => {
reload(true).then()
});
return (
<ConfigProvider>
{/* 搜索栏 */}
<View className="py-2">
<SearchBar
placeholder="搜索关键词"
value={searchValue}
onChange={setSearchValue}
onSearch={handleSearch}
/>
</View>
{/* 统计信息 */}
{total > 0 && (
<View className="px-4 py-2 text-sm text-gray-500">
{total}
</View>
)}
{/* 文章列表 */}
<PullToRefresh
onRefresh={handleRefresh}
pullDistance={60}
headHeight={60}
>
<View className="px-4" style={{ height: 'calc(100vh - 160px)', overflowY: 'auto' }} id="article-scroll">
{list.length === 0 && !loading ? (
<View className="flex flex-col justify-center items-center" style={{height: 'calc(100vh - 200px)'}}>
<Empty
description="暂无文章数据"
style={{backgroundColor: 'transparent'}}
/>
</View>
) : (
<InfiniteLoading
target="article-scroll"
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? "暂无数据" : "没有更多了"}
</View>
}
>
{list.map((item, index) => (
<CellGroup key={item.articleId || index} className="mb-4">
<Cell>
<View className="flex flex-col gap-3 w-full">
{/* 文章标题和状态 */}
<View className="flex justify-between items-start">
<View className="flex-1 pr-2">
<View className="text-lg font-bold text-gray-900 line-clamp-2">
{item.title}
</View>
</View>
{getStatusTag(item.status)}
</View>
{/* 文章概述 */}
{item.overview && (
<View className="text-sm text-gray-600 line-clamp-2">
{item.overview}
</View>
)}
{/* 文章信息 */}
<View className="flex justify-between items-center text-xs text-gray-500">
<View className="flex items-center gap-4">
<View>: {item.actualViews || 0}</View>
{item.price && <View>: ¥{item.price}</View>}
<View>: {dayjs(item.createTime).format('MM-DD HH:mm')}</View>
</View>
</View>
{/* 操作按钮 */}
<View className="flex justify-end gap-2 pt-2 border-t border-gray-100">
<Button
size="small"
fill="outline"
icon={<Eye/>}
onClick={() => handleView(item)}
>
</Button>
<Button
size="small"
fill="outline"
icon={<Edit/>}
onClick={() => handleEdit(item)}
>
</Button>
<Button
size="small"
type="danger"
fill="outline"
icon={<Del/>}
onClick={() => handleDelete(item.articleId)}
>
</Button>
</View>
</View>
</Cell>
</CellGroup>
))}
</InfiniteLoading>
)}
</View>
</PullToRefresh>
{/* 底部浮动按钮 */}
<FixedButton
text="发布文章"
icon={<Edit />}
onClick={() => Taro.navigateTo({url: '/shop/shopArticle/add'})}
/>
</ConfigProvider>
);
};
export default ShopArticleManage;

View File

@@ -1,4 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '商品文章管理', navigationBarTitleText: '新增收货地址',
navigationBarTextStyle: 'black' navigationBarTextStyle: 'black'
}) })

323
src/user/coupon/add.tsx Normal file
View File

@@ -0,0 +1,323 @@
import {useEffect, useState, useRef} from "react";
import {useRouter} from '@tarojs/taro'
import {Button, Loading, CellGroup, Input, TextArea, Form, Switch, InputNumber, Radio, Image} from '@nutui/nutui-react-taro'
import {Edit, Upload as UploadIcon} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {View} from '@tarojs/components'
import {ShopArticle} from "@/api/shop/shopArticle/model";
import {getShopArticle, addShopArticle, updateShopArticle} from "@/api/shop/shopArticle";
import FixedButton from "@/components/FixedButton";
const AddShopArticle = () => {
const {params} = useRouter();
const [loading, setLoading] = useState<boolean>(true)
const [formData, setFormData] = useState<ShopArticle>({
type: 0, // 默认常规文章
status: 0, // 默认已发布
permission: 0, // 默认所有人可见
recommend: 0, // 默认不推荐
showType: 10, // 默认小图展示
virtualViews: 0, // 默认虚拟阅读量
actualViews: 0, // 默认实际阅读量
sortNumber: 0 // 默认排序
})
const formRef = useRef<any>(null)
// 判断是编辑还是新增模式
const isEditMode = !!params.id
const articleId = params.id ? Number(params.id) : undefined
// 文章类型选项
const typeOptions = [
{ text: '常规文章', value: 0 },
{ text: '视频文章', value: 1 }
]
// 状态选项
const statusOptions = [
{ text: '已发布', value: 0 },
{ text: '待审核', value: 1 },
{ text: '已驳回', value: 2 },
{ text: '违规内容', value: 3 }
]
// 可见性选项
const permissionOptions = [
{ text: '所有人可见', value: 0 },
{ text: '登录可见', value: 1 },
{ text: '密码可见', value: 2 }
]
// 显示方式选项
const showTypeOptions = [
{ text: '小图展示', value: 10 },
{ text: '大图展示', value: 20 }
]
const reload = async () => {
// 如果是编辑模式,加载文章数据
if (isEditMode && articleId) {
try {
const article = await getShopArticle(articleId)
setFormData(article)
// 更新表单值
if (formRef.current) {
formRef.current.setFieldsValue(article)
}
} catch (error) {
console.error('加载文章失败:', error)
Taro.showToast({
title: '加载文章失败',
icon: 'error'
});
}
}
}
// 图片上传处理
const handleImageUpload = async () => {
try {
const res = await Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera']
});
if (res.tempFilePaths && res.tempFilePaths.length > 0) {
// 这里应该调用上传接口,暂时使用本地路径
const imagePath = res.tempFilePaths[0];
setFormData({
...formData,
image: imagePath
});
Taro.showToast({
title: '图片选择成功',
icon: 'success'
});
}
} catch (error) {
Taro.showToast({
title: '图片选择失败',
icon: 'error'
});
}
};
// 提交表单
const submitSucceed = async (values: any) => {
try {
// 准备提交的数据
const submitData = {
...formData,
...values,
};
// 如果是编辑模式添加id
if (isEditMode && articleId) {
submitData.articleId = articleId;
}
// 执行新增或更新操作
if (isEditMode) {
await updateShopArticle(submitData);
} else {
await addShopArticle(submitData);
}
Taro.showToast({
title: `${isEditMode ? '更新' : '保存'}成功`,
icon: 'success'
});
setTimeout(() => {
Taro.navigateBack();
}, 1000);
} catch (error) {
console.error('保存失败:', error);
Taro.showToast({
title: `${isEditMode ? '更新' : '保存'}失败`,
icon: 'error'
});
}
}
const submitFailed = (error: any) => {
console.log(error, 'err...')
}
useEffect(() => {
// 动态设置页面标题
Taro.setNavigationBarTitle({
title: isEditMode ? '编辑文章' : '新增文章'
});
reload().then(() => {
setLoading(false)
})
}, [isEditMode]);
if (loading) {
return <Loading className={'px-2'}></Loading>
}
return (
<>
<Form
ref={formRef}
divider
initialValues={formData}
labelPosition="left"
onFinish={(values) => submitSucceed(values)}
onFinishFailed={(errors) => submitFailed(errors)}
>
{/* 基本信息 */}
<CellGroup title="基本信息">
<Form.Item
name="title"
label="文章标题"
required
rules={[{ required: true, message: '请输入文章标题' }]}
initialValue={formData.title}
>
<Input placeholder="请输入文章标题" maxLength={100}/>
</Form.Item>
<Form.Item name="overview" label="文章概述" initialValue={formData.overview}>
<TextArea placeholder="请输入文章概述,用于列表展示" maxLength={200} rows={3}/>
</Form.Item>
<Form.Item
name="detail"
label="文章内容"
required
rules={[{ required: true, message: '请输入文章内容' }]}
initialValue={formData.detail}
>
<TextArea placeholder="请输入文章内容" maxLength={10000} rows={8}/>
</Form.Item>
<Form.Item name="author" label="作者" initialValue={formData.author}>
<Input placeholder="请输入作者名称" maxLength={50}/>
</Form.Item>
<Form.Item name="source" label="来源" initialValue={formData.source}>
<Input placeholder="请输入文章来源" maxLength={100}/>
</Form.Item>
</CellGroup>
{/* 文章设置 */}
<CellGroup title="文章设置">
<Form.Item name="type" label="文章类型" initialValue={formData.type}>
<Radio.Group direction="horizontal" value={formData.type}>
{typeOptions.map(option => (
<Radio key={option.value} value={option.value}>
{option.text}
</Radio>
))}
</Radio.Group>
</Form.Item>
<Form.Item name="status" label="发布状态" initialValue={formData.status}>
<Radio.Group direction="horizontal" value={formData.status}>
{statusOptions.map(option => (
<Radio key={option.value} value={option.value}>
{option.text}
</Radio>
))}
</Radio.Group>
</Form.Item>
<Form.Item name="permission" label="可见性" initialValue={formData.permission}>
<Radio.Group direction="horizontal" value={formData.permission}>
{permissionOptions.map(option => (
<Radio key={option.value} value={option.value}>
{option.text}
</Radio>
))}
</Radio.Group>
</Form.Item>
<Form.Item name="showType" label="显示方式" initialValue={formData.showType}>
<Radio.Group direction="horizontal" value={formData.showType}>
{showTypeOptions.map(option => (
<Radio key={option.value} value={option.value}>
{option.text}
</Radio>
))}
</Radio.Group>
</Form.Item>
</CellGroup>
{/* 高级设置 */}
<CellGroup title="高级设置">
<Form.Item name="recommend" label="推荐文章" initialValue={formData.recommend}>
<Switch
checked={formData.recommend === 1}
onChange={(checked) =>
setFormData({...formData, recommend: checked ? 1 : 0})
}
/>
</Form.Item>
<Form.Item name="price" label="付费金额" initialValue={formData.price}>
<Input placeholder="0.00" type="number"/>
<View className="text-xs text-gray-500 mt-1"></View>
</Form.Item>
<Form.Item name="virtualViews" label="虚拟阅读量" initialValue={formData.virtualViews}>
<InputNumber min={0} defaultValue={formData.virtualViews || 0}/>
</Form.Item>
<Form.Item name="actualViews" label="实际阅读量" initialValue={formData.actualViews}>
<InputNumber min={0} defaultValue={formData.actualViews || 0}/>
</Form.Item>
<Form.Item name="sortNumber" label="排序" initialValue={formData.sortNumber}>
<InputNumber min={0} defaultValue={formData.sortNumber || 0}/>
<View className="text-xs text-gray-500 mt-1"></View>
</Form.Item>
<Form.Item name="tags" label="标签" initialValue={formData.tags}>
<Input placeholder="请输入标签,多个标签用逗号分隔" maxLength={200}/>
</Form.Item>
<Form.Item name="topic" label="话题" initialValue={formData.topic}>
<Input placeholder="请输入话题" maxLength={100}/>
</Form.Item>
</CellGroup>
{/* 图片上传 */}
<CellGroup title="文章图片">
<Form.Item name="image" label="封面图片" initialValue={formData.image}>
<View className="flex items-center gap-3">
{formData.image && (
<Image
src={formData.image}
width="80"
height="80"
radius="8"
/>
)}
<Button
size="small"
type="primary"
fill="outline"
icon={<UploadIcon />}
onClick={handleImageUpload}
>
{formData.image ? '更换图片' : '上传图片'}
</Button>
</View>
</Form.Item>
</CellGroup>
{/* 提交按钮 */}
<FixedButton text={isEditMode ? '更新文章' : '发布文章'} onClick={submitSucceed} icon={<Edit />} />
</Form>
</>
);
};
export default AddShopArticle;

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '优惠券详情',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff',
navigationStyle: 'custom'
})

259
src/user/coupon/detail.tsx Normal file
View File

@@ -0,0 +1,259 @@
import {useState, useEffect} from "react";
import {useRouter} from '@tarojs/taro'
import {Button, ConfigProvider, Tag, Divider} from '@nutui/nutui-react-taro'
import {ArrowLeft, Gift, Clock, CartCheck, Share} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {View, Text} from '@tarojs/components'
import {ShopCoupon} from "@/api/shop/shopCoupon/model";
import {getShopCoupon} from "@/api/shop/shopCoupon";
import CouponShare from "@/components/CouponShare";
import dayjs from "dayjs";
const CouponDetail = () => {
const router = useRouter()
const [coupon, setCoupon] = useState<ShopCoupon | null>(null)
const [loading, setLoading] = useState(true)
const [showShare, setShowShare] = useState(false)
const couponId = router.params.id
useEffect(() => {
if (couponId) {
loadCouponDetail()
}
}, [couponId])
const loadCouponDetail = async () => {
try {
setLoading(true)
const data = await getShopCoupon(Number(couponId))
setCoupon(data)
} catch (error) {
console.error('获取优惠券详情失败:', error)
Taro.showToast({
title: '获取优惠券详情失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}
// 获取优惠券类型文本
const getCouponTypeText = (type?: number) => {
switch (type) {
case 10: return '满减券'
case 20: return '折扣券'
case 30: return '免费券'
default: return '优惠券'
}
}
// 获取优惠券金额显示
const getCouponAmountDisplay = () => {
if (!coupon) return ''
switch (coupon.type) {
case 10: // 满减券
return `¥${coupon.reducePrice}`
case 20: // 折扣券
return `${coupon.discount}`
case 30: // 免费券
return '免费'
default:
return `¥${coupon.reducePrice || 0}`
}
}
// 获取使用条件文本
const getConditionText = () => {
if (!coupon) return ''
if (coupon.type === 30) return '无门槛使用'
if (coupon.minPrice && parseFloat(coupon.minPrice) > 0) {
return `${coupon.minPrice}元可用`
}
return '无门槛使用'
}
// 获取有效期文本
const getValidityText = () => {
if (!coupon) return ''
if (coupon.expireType === 10) {
return `领取后${coupon.expireDay}天内有效`
} else {
return `${dayjs(coupon.startTime).format('YYYY年MM月DD日')}${dayjs(coupon.endTime).format('YYYY年MM月DD日')}`
}
}
// 获取适用范围文本
const getApplyRangeText = () => {
if (!coupon) return ''
switch (coupon.applyRange) {
case 10: return '全部商品'
case 20: return '指定商品'
case 30: return '指定分类'
default: return '全部商品'
}
}
// 获取优惠券状态
const getCouponStatus = () => {
if (!coupon) return { status: 0, text: '未知', color: 'default' }
if (coupon.isExpire === 1) {
return { status: 2, text: '已过期', color: 'danger' }
} else if (coupon.status === 1) {
return { status: 1, text: '已使用', color: 'warning' }
} else {
return { status: 0, text: '可使用', color: 'success' }
}
}
// 使用优惠券
const handleUseCoupon = () => {
if (!coupon) return
Taro.showModal({
title: '使用优惠券',
content: `确定要使用"${coupon.name}"吗?`,
success: (res) => {
if (res.confirm) {
// 跳转到商品页面或购物车页面
Taro.navigateTo({
url: '/pages/index/index'
})
}
}
})
}
// 返回上一页
const handleBack = () => {
Taro.navigateBack()
}
if (loading) {
return (
<ConfigProvider>
<View className="flex justify-center items-center h-screen">
<Text>...</Text>
</View>
</ConfigProvider>
)
}
if (!coupon) {
return (
<ConfigProvider>
<View className="flex flex-col justify-center items-center h-screen">
<Text className="text-gray-500 mb-4"></Text>
<Button onClick={handleBack}></Button>
</View>
</ConfigProvider>
)
}
const statusInfo = getCouponStatus()
return (
<ConfigProvider>
{/* 自定义导航栏 */}
<View className="flex items-center justify-between p-4 bg-white border-b border-gray-100">
<View className="flex items-center" onClick={handleBack}>
<ArrowLeft size="20" />
<Text className="ml-2 text-lg"></Text>
</View>
<View className="flex items-center gap-3">
<View onClick={() => setShowShare(true)}>
<Share size="20" className="text-gray-600" />
</View>
<Tag type={statusInfo.color as any}>{statusInfo.text}</Tag>
</View>
</View>
{/* 优惠券卡片 */}
<View className="m-4 p-6 bg-gradient-to-r from-red-400 to-red-500 rounded-2xl text-white">
<View className="flex items-center justify-between mb-4">
<View>
<Text className="text-4xl font-bold">{getCouponAmountDisplay()}</Text>
<Text className="text-lg opacity-90 mt-1">{getCouponTypeText(coupon.type)}</Text>
</View>
<Gift size="40" />
</View>
<Text className="text-xl font-semibold mb-2">{coupon.name}</Text>
<Text className="text-base opacity-90">{getConditionText()}</Text>
</View>
{/* 详细信息 */}
<View className="bg-white mx-4 rounded-xl p-4">
<Text className="text-lg font-semibold mb-4">使</Text>
<View className="space-y-3">
<View className="flex items-center">
<Clock size="16" className="text-gray-400 mr-3" />
<View>
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-gray-900">{getValidityText()}</Text>
</View>
</View>
<Divider />
<View className="flex items-center">
<CartCheck size="16" className="text-gray-400 mr-3" />
<View>
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-gray-900">{getApplyRangeText()}</Text>
</View>
</View>
{coupon.description && (
<>
<Divider />
<View>
<Text className="text-gray-600 text-sm mb-2">使</Text>
<Text className="text-gray-900 leading-relaxed">{coupon.description}</Text>
</View>
</>
)}
</View>
</View>
{/* 底部操作按钮 */}
{statusInfo.status === 0 && (
<View className="fixed bottom-0 left-0 right-0 p-4 bg-white border-t border-gray-100">
<Button
type="primary"
size="large"
block
onClick={handleUseCoupon}
>
使
</Button>
</View>
)}
{/* 分享弹窗 */}
{coupon && (
<CouponShare
visible={showShare}
coupon={{
id: coupon.id || 0,
name: coupon.name || '',
type: coupon.type || 10,
amount: coupon.type === 10 ? coupon.reducePrice || '0' :
coupon.type === 20 ? coupon.discount?.toString() || '0' : '0',
minAmount: coupon.minPrice,
description: coupon.description
}}
onClose={() => setShowShare(false)}
/>
)}
</ConfigProvider>
);
};
export default CouponDetail;

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '我的优惠券',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

470
src/user/coupon/index.tsx Normal file
View File

@@ -0,0 +1,470 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/taro'
import {Button, Empty, ConfigProvider, SearchBar, InfiniteLoading, Loading, PullToRefresh, Tabs, TabPane} from '@nutui/nutui-react-taro'
import {Gift, Search, Plus, Filter} from '@nutui/icons-react-taro'
import {View} from '@tarojs/components'
import {ShopCoupon} from "@/api/shop/shopCoupon/model";
import {pageShopCoupon} from "@/api/shop/shopCoupon";
import CouponList from "@/components/CouponList";
import CouponStats from "@/components/CouponStats";
import CouponGuide from "@/components/CouponGuide";
import CouponFilter from "@/components/CouponFilter";
import CouponExpireNotice, {ExpiringSoon} from "@/components/CouponExpireNotice";
import {CouponCardProps} from "@/components/CouponCard";
import dayjs from "dayjs";
const CouponManage = () => {
const [list, setList] = useState<ShopCoupon[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [searchValue, setSearchValue] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [activeTab, setActiveTab] = useState('0') // 0-可用 1-已使用 2-已过期
const [stats, setStats] = useState({
available: 0,
used: 0,
expired: 0
})
const [showGuide, setShowGuide] = useState(false)
const [showFilter, setShowFilter] = useState(false)
const [showExpireNotice, setShowExpireNotice] = useState(false)
const [expiringSoonCoupons, setExpiringSoonCoupons] = useState<ExpiringSoon[]>([])
const [filters, setFilters] = useState({
type: [] as number[],
minAmount: undefined as number | undefined,
sortBy: 'createTime' as 'createTime' | 'amount' | 'expireTime',
sortOrder: 'desc' as 'asc' | 'desc'
})
// 获取优惠券状态过滤条件
const getStatusFilter = () => {
switch (activeTab) {
case '0': // 可用
return { status: 0, isExpire: 0 }
case '1': // 已使用
return { status: 1 }
case '2': // 已过期
return { isExpire: 1 }
default:
return {}
}
}
const reload = async (isRefresh = false) => {
if (isRefresh) {
setPage(1)
setList([])
setHasMore(true)
}
setLoading(true)
try {
const currentPage = isRefresh ? 1 : page
const statusFilter = getStatusFilter()
const res = await pageShopCoupon({
page: currentPage,
limit: 10,
keywords: searchValue,
...statusFilter,
// 应用筛选条件
...(filters.type.length > 0 && { type: filters.type[0] }),
...(filters.minAmount && { minAmount: filters.minAmount }),
sortBy: filters.sortBy,
sortOrder: filters.sortOrder
})
if (res && res.list) {
const newList = isRefresh ? res.list : [...list, ...res.list]
setList(newList)
setTotal(res.count || 0)
// 判断是否还有更多数据
setHasMore(res.list.length === 10) // 如果返回的数据等于limit说明可能还有更多
if (!isRefresh) {
setPage(currentPage + 1)
} else {
setPage(2) // 刷新后下一页是第2页
}
} else {
setHasMore(false)
setTotal(0)
}
} catch (error) {
console.error('获取优惠券失败:', error)
Taro.showToast({
title: '获取优惠券失败',
icon: 'error'
});
} finally {
setLoading(false)
}
}
// 搜索功能
const handleSearch = (value: string) => {
setSearchValue(value)
reload(true)
}
// 下拉刷新
const handleRefresh = async () => {
await reload(true)
}
// Tab切换
const handleTabChange = (value: string) => {
setActiveTab(value)
setPage(1)
setList([])
setHasMore(true)
// 延迟执行reload确保状态更新完成
setTimeout(() => {
reload(true)
}, 100)
}
// 转换优惠券数据为CouponCard组件所需格式
const transformCouponData = (coupon: ShopCoupon): CouponCardProps => {
// 判断优惠券状态
let status: 0 | 1 | 2 = 0 // 默认未使用
if (coupon.isExpire === 1) {
status = 2 // 已过期
} else if (coupon.status === 1) {
status = 1 // 已使用
}
// 根据优惠券类型计算金额显示
let amount = 0
let type: 1 | 2 | 3 = 1
if (coupon.type === 10) { // 满减券
type = 1
amount = parseFloat(coupon.reducePrice || '0')
} else if (coupon.type === 20) { // 折扣券
type = 2
amount = coupon.discount || 0
} else if (coupon.type === 30) { // 免费券
type = 3
amount = 0
}
return {
amount,
type,
status,
minAmount: parseFloat(coupon.minPrice || '0'),
title: coupon.name || '优惠券',
startTime: coupon.startTime,
endTime: coupon.endTime,
showUseBtn: status === 0, // 只有未使用的券显示使用按钮
onUse: () => handleUseCoupon(coupon),
theme: getThemeByType(coupon.type)
}
}
// 根据优惠券类型获取主题色
const getThemeByType = (type?: number): 'red' | 'orange' | 'blue' | 'purple' | 'green' => {
switch (type) {
case 10: return 'red' // 满减券
case 20: return 'orange' // 折扣券
case 30: return 'green' // 免费券
default: return 'blue'
}
}
// 使用优惠券
const handleUseCoupon = (coupon: ShopCoupon) => {
Taro.showModal({
title: '使用优惠券',
content: `确定要使用"${coupon.name}"吗?`,
success: (res) => {
if (res.confirm) {
// 这里可以跳转到商品页面或购物车页面
Taro.navigateTo({
url: '/pages/index/index'
})
}
}
})
}
// 优惠券点击事件
const handleCouponClick = (coupon: CouponCardProps, index: number) => {
const originalCoupon = list[index]
if (originalCoupon) {
// 显示优惠券详情
showCouponDetail(originalCoupon)
}
}
// 显示优惠券详情
const showCouponDetail = (coupon: ShopCoupon) => {
// 跳转到优惠券详情页
Taro.navigateTo({
url: `/user/coupon/detail?id=${coupon.id}`
})
}
// 加载优惠券统计数据
const loadCouponStats = async () => {
try {
// 并行获取各状态的优惠券数量
const [availableRes, usedRes, expiredRes] = await Promise.all([
pageShopCoupon({ page: 1, limit: 1, status: 0, isExpire: 0 }),
pageShopCoupon({ page: 1, limit: 1, status: 1 }),
pageShopCoupon({ page: 1, limit: 1, isExpire: 1 })
])
setStats({
available: availableRes?.count || 0,
used: usedRes?.count || 0,
expired: expiredRes?.count || 0
})
} catch (error) {
console.error('获取优惠券统计失败:', error)
}
}
// 统计卡片点击事件
const handleStatsClick = (type: 'available' | 'used' | 'expired') => {
const tabMap = {
available: '0',
used: '1',
expired: '2'
}
handleTabChange(tabMap[type])
}
// 筛选条件变更
const handleFiltersChange = (newFilters: any) => {
setFilters(newFilters)
reload(true)
}
// 检查即将过期的优惠券
const checkExpiringSoonCoupons = async () => {
try {
// 获取即将过期的优惠券3天内过期
const res = await pageShopCoupon({
page: 1,
limit: 50,
status: 0, // 未使用
isExpire: 0 // 未过期
})
if (res && res.list) {
const now = dayjs()
const expiringSoon = res.list
.map(coupon => {
const endTime = dayjs(coupon.endTime)
const daysLeft = endTime.diff(now, 'day')
return {
id: coupon.id || 0,
name: coupon.name || '',
type: coupon.type || 10,
amount: coupon.type === 10 ? coupon.reducePrice || '0' :
coupon.type === 20 ? coupon.discount?.toString() || '0' : '0',
minAmount: coupon.minPrice,
endTime: coupon.endTime || '',
daysLeft
}
})
.filter(coupon => coupon.daysLeft >= 0 && coupon.daysLeft <= 3)
.sort((a, b) => a.daysLeft - b.daysLeft)
if (expiringSoon.length > 0) {
setExpiringSoonCoupons(expiringSoon)
// 延迟显示提醒,避免与页面加载冲突
setTimeout(() => {
setShowExpireNotice(true)
}, 1000)
}
}
} catch (error) {
console.error('检查即将过期优惠券失败:', error)
}
}
// 使用即将过期的优惠券
const handleUseExpiringSoonCoupon = (coupon: ExpiringSoon) => {
setShowExpireNotice(false)
// 跳转到商品页面
Taro.navigateTo({
url: '/pages/index/index'
})
}
// 加载更多
const loadMore = async () => {
if (!loading && hasMore) {
await reload(false) // 不刷新,追加数据
}
}
useDidShow(() => {
reload(true).then()
loadCouponStats().then()
// 只在可用优惠券tab时检查即将过期的优惠券
if (activeTab === '0') {
checkExpiringSoonCoupons().then()
}
});
return (
<ConfigProvider>
{/* 搜索栏和领取入口 */}
<View className="bg-white px-4 py-3">
<View className="flex items-center gap-3">
<View className="flex-1">
<SearchBar
placeholder="搜索优惠券名称"
value={searchValue}
onChange={setSearchValue}
onSearch={handleSearch}
leftIcon={<Search />}
/>
</View>
<Button
size="small"
type="primary"
icon={<Plus />}
onClick={() => Taro.navigateTo({url: '/user/coupon/receive'})}
>
</Button>
<Button
size="small"
fill="outline"
icon={<Filter />}
onClick={() => setShowFilter(true)}
>
</Button>
<Button
size="small"
fill="outline"
onClick={() => setShowGuide(true)}
>
</Button>
</View>
</View>
{/* 优惠券统计 */}
<CouponStats
availableCount={stats.available}
usedCount={stats.used}
expiredCount={stats.expired}
onStatsClick={handleStatsClick}
/>
{/* Tab切换 */}
<View className="bg-white">
<Tabs value={activeTab} onChange={handleTabChange}>
<TabPane title="可用" value="0">
</TabPane>
<TabPane title="已使用" value="1">
</TabPane>
<TabPane title="已过期" value="2">
</TabPane>
</Tabs>
</View>
{/* 统计信息 */}
{total > 0 && (
<View className="px-4 py-2 text-sm text-gray-500 bg-gray-50">
{total}
</View>
)}
{/* 优惠券列表 */}
<PullToRefresh
onRefresh={handleRefresh}
headHeight={60}
>
<View style={{ height: 'calc(100vh - 200px)', overflowY: 'auto' }} id="coupon-scroll">
{list.length === 0 && !loading ? (
<View className="flex flex-col justify-center items-center" style={{height: 'calc(100vh - 300px)'}}>
<Empty
description={
activeTab === '0' ? "暂无可用优惠券" :
activeTab === '1' ? "暂无已使用优惠券" :
"暂无已过期优惠券"
}
style={{backgroundColor: 'transparent'}}
/>
</View>
) : (
<InfiniteLoading
target="coupon-scroll"
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? "暂无数据" : "没有更多了"}
</View>
}
>
<CouponList
coupons={list.map(transformCouponData)}
onCouponClick={handleCouponClick}
showEmpty={false}
/>
</InfiniteLoading>
)}
</View>
</PullToRefresh>
{/* 底部提示 */}
{activeTab === '0' && list.length === 0 && !loading && (
<View className="text-center py-8">
<View className="text-gray-400 mb-4">
<Gift size="48" />
</View>
<View className="text-gray-500 mb-2"></View>
<Button
size="small"
type="primary"
onClick={() => Taro.navigateTo({url: '/pages/index/index'})}
>
</Button>
</View>
)}
{/* 使用指南弹窗 */}
<CouponGuide
visible={showGuide}
onClose={() => setShowGuide(false)}
/>
{/* 筛选弹窗 */}
<CouponFilter
visible={showFilter}
filters={filters}
onFiltersChange={handleFiltersChange}
onClose={() => setShowFilter(false)}
/>
{/* 到期提醒弹窗 */}
<CouponExpireNotice
visible={showExpireNotice}
expiringSoonCoupons={expiringSoonCoupons}
onClose={() => setShowExpireNotice(false)}
onUseCoupon={handleUseExpiringSoonCoupon}
/>
</ConfigProvider>
);
};
export default CouponManage;

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '领取优惠券',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

248
src/user/coupon/receive.tsx Normal file
View File

@@ -0,0 +1,248 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/taro'
import {Button, Empty, ConfigProvider, SearchBar, InfiniteLoading, Loading, PullToRefresh} from '@nutui/nutui-react-taro'
import {Gift, Search} from '@nutui/icons-react-taro'
import {View} from '@tarojs/components'
import {ShopCoupon} from "@/api/shop/shopCoupon/model";
import {pageShopCoupon} from "@/api/shop/shopCoupon";
import CouponList from "@/components/CouponList";
import {CouponCardProps} from "@/components/CouponCard";
const CouponReceive = () => {
const [list, setList] = useState<ShopCoupon[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [searchValue, setSearchValue] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const reload = async (isRefresh = false) => {
if (isRefresh) {
setPage(1)
setList([])
setHasMore(true)
}
setLoading(true)
try {
const currentPage = isRefresh ? 1 : page
// 获取可领取的优惠券(启用状态且未过期)
const res = await pageShopCoupon({
page: currentPage,
limit: 10,
keywords: searchValue,
enabled: '1', // 启用状态
isExpire: 0 // 未过期
})
if (res && res.list) {
const newList = isRefresh ? res.list : [...list, ...res.list]
setList(newList)
setTotal(res.count || 0)
setHasMore(res.list.length === 10)
if (!isRefresh) {
setPage(currentPage + 1)
} else {
setPage(2)
}
} else {
setHasMore(false)
setTotal(0)
}
} catch (error) {
console.error('获取优惠券失败:', error)
Taro.showToast({
title: '获取优惠券失败',
icon: 'error'
});
} finally {
setLoading(false)
}
}
// 搜索功能
const handleSearch = (value: string) => {
setSearchValue(value)
reload(true)
}
// 下拉刷新
const handleRefresh = async () => {
await reload(true)
}
// 转换优惠券数据为CouponCard组件所需格式
const transformCouponData = (coupon: ShopCoupon): CouponCardProps => {
let amount = 0
let type: 1 | 2 | 3 = 1
if (coupon.type === 10) { // 满减券
type = 1
amount = parseFloat(coupon.reducePrice || '0')
} else if (coupon.type === 20) { // 折扣券
type = 2
amount = coupon.discount || 0
} else if (coupon.type === 30) { // 免费券
type = 3
amount = 0
}
return {
amount,
type,
status: 0, // 可领取状态
minAmount: parseFloat(coupon.minPrice || '0'),
title: coupon.name || '优惠券',
startTime: coupon.startTime,
endTime: coupon.endTime,
showReceiveBtn: true, // 显示领取按钮
onReceive: () => handleReceiveCoupon(coupon),
theme: getThemeByType(coupon.type)
}
}
// 根据优惠券类型获取主题色
const getThemeByType = (type?: number): 'red' | 'orange' | 'blue' | 'purple' | 'green' => {
switch (type) {
case 10: return 'red' // 满减券
case 20: return 'orange' // 折扣券
case 30: return 'green' // 免费券
default: return 'blue'
}
}
// 领取优惠券
const handleReceiveCoupon = async (coupon: ShopCoupon) => {
try {
// 这里应该调用领取优惠券的API
// await receiveCoupon(coupon.id)
Taro.showToast({
title: '领取成功',
icon: 'success'
})
// 刷新列表
reload(true)
} catch (error) {
console.error('领取优惠券失败:', error)
Taro.showToast({
title: '领取失败',
icon: 'error'
})
}
}
// 优惠券点击事件
const handleCouponClick = (coupon: CouponCardProps, index: number) => {
const originalCoupon = list[index]
if (originalCoupon) {
// 显示优惠券详情
showCouponDetail(originalCoupon)
}
}
// 显示优惠券详情
const showCouponDetail = (coupon: ShopCoupon) => {
// 跳转到优惠券详情页
Taro.navigateTo({
url: `/user/coupon/detail?id=${coupon.id}`
})
}
// 加载更多
const loadMore = async () => {
if (!loading && hasMore) {
await reload(false)
}
}
useDidShow(() => {
reload(true).then()
});
return (
<ConfigProvider>
{/* 搜索栏 */}
<View className="bg-white px-4 py-3">
<SearchBar
placeholder="搜索优惠券名称"
value={searchValue}
onChange={setSearchValue}
onSearch={handleSearch}
leftIcon={<Search />}
/>
</View>
{/* 统计信息 */}
{total > 0 && (
<View className="px-4 py-2 text-sm text-gray-500 bg-gray-50">
{total}
</View>
)}
{/* 优惠券列表 */}
<PullToRefresh
onRefresh={handleRefresh}
headHeight={60}
>
<View style={{ height: 'calc(100vh - 160px)', overflowY: 'auto' }} id="coupon-scroll">
{list.length === 0 && !loading ? (
<View className="flex flex-col justify-center items-center" style={{height: 'calc(100vh - 250px)'}}>
<Empty
description="暂无可领取优惠券"
style={{backgroundColor: 'transparent'}}
/>
<Button
type="primary"
size="small"
className="mt-4"
onClick={() => Taro.navigateTo({url: '/pages/index/index'})}
>
</Button>
</View>
) : (
<InfiniteLoading
target="coupon-scroll"
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? "暂无数据" : "没有更多了"}
</View>
}
>
<CouponList
coupons={list.map(transformCouponData)}
onCouponClick={handleCouponClick}
showEmpty={false}
/>
</InfiniteLoading>
)}
</View>
</PullToRefresh>
{/* 底部提示 */}
{list.length === 0 && !loading && (
<View className="text-center py-8">
<View className="text-gray-400 mb-4">
<Gift size="48" />
</View>
<View className="text-gray-500 mb-2"></View>
<View className="text-gray-400 text-sm"></View>
</View>
)}
</ConfigProvider>
);
};
export default CouponReceive;

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '新增收货地址',
navigationBarTextStyle: 'black'
})

323
src/user/gift/add.tsx Normal file
View File

@@ -0,0 +1,323 @@
import {useEffect, useState, useRef} from "react";
import {useRouter} from '@tarojs/taro'
import {Button, Loading, CellGroup, Input, TextArea, Form, Switch, InputNumber, Radio, Image} from '@nutui/nutui-react-taro'
import {Edit, Upload as UploadIcon} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {View} from '@tarojs/components'
import {ShopArticle} from "@/api/shop/shopArticle/model";
import {getShopArticle, addShopArticle, updateShopArticle} from "@/api/shop/shopArticle";
import FixedButton from "@/components/FixedButton";
const AddShopArticle = () => {
const {params} = useRouter();
const [loading, setLoading] = useState<boolean>(true)
const [formData, setFormData] = useState<ShopArticle>({
type: 0, // 默认常规文章
status: 0, // 默认已发布
permission: 0, // 默认所有人可见
recommend: 0, // 默认不推荐
showType: 10, // 默认小图展示
virtualViews: 0, // 默认虚拟阅读量
actualViews: 0, // 默认实际阅读量
sortNumber: 0 // 默认排序
})
const formRef = useRef<any>(null)
// 判断是编辑还是新增模式
const isEditMode = !!params.id
const articleId = params.id ? Number(params.id) : undefined
// 文章类型选项
const typeOptions = [
{ text: '常规文章', value: 0 },
{ text: '视频文章', value: 1 }
]
// 状态选项
const statusOptions = [
{ text: '已发布', value: 0 },
{ text: '待审核', value: 1 },
{ text: '已驳回', value: 2 },
{ text: '违规内容', value: 3 }
]
// 可见性选项
const permissionOptions = [
{ text: '所有人可见', value: 0 },
{ text: '登录可见', value: 1 },
{ text: '密码可见', value: 2 }
]
// 显示方式选项
const showTypeOptions = [
{ text: '小图展示', value: 10 },
{ text: '大图展示', value: 20 }
]
const reload = async () => {
// 如果是编辑模式,加载文章数据
if (isEditMode && articleId) {
try {
const article = await getShopArticle(articleId)
setFormData(article)
// 更新表单值
if (formRef.current) {
formRef.current.setFieldsValue(article)
}
} catch (error) {
console.error('加载文章失败:', error)
Taro.showToast({
title: '加载文章失败',
icon: 'error'
});
}
}
}
// 图片上传处理
const handleImageUpload = async () => {
try {
const res = await Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera']
});
if (res.tempFilePaths && res.tempFilePaths.length > 0) {
// 这里应该调用上传接口,暂时使用本地路径
const imagePath = res.tempFilePaths[0];
setFormData({
...formData,
image: imagePath
});
Taro.showToast({
title: '图片选择成功',
icon: 'success'
});
}
} catch (error) {
Taro.showToast({
title: '图片选择失败',
icon: 'error'
});
}
};
// 提交表单
const submitSucceed = async (values: any) => {
try {
// 准备提交的数据
const submitData = {
...formData,
...values,
};
// 如果是编辑模式添加id
if (isEditMode && articleId) {
submitData.articleId = articleId;
}
// 执行新增或更新操作
if (isEditMode) {
await updateShopArticle(submitData);
} else {
await addShopArticle(submitData);
}
Taro.showToast({
title: `${isEditMode ? '更新' : '保存'}成功`,
icon: 'success'
});
setTimeout(() => {
Taro.navigateBack();
}, 1000);
} catch (error) {
console.error('保存失败:', error);
Taro.showToast({
title: `${isEditMode ? '更新' : '保存'}失败`,
icon: 'error'
});
}
}
const submitFailed = (error: any) => {
console.log(error, 'err...')
}
useEffect(() => {
// 动态设置页面标题
Taro.setNavigationBarTitle({
title: isEditMode ? '编辑文章' : '新增文章'
});
reload().then(() => {
setLoading(false)
})
}, [isEditMode]);
if (loading) {
return <Loading className={'px-2'}></Loading>
}
return (
<>
<Form
ref={formRef}
divider
initialValues={formData}
labelPosition="left"
onFinish={(values) => submitSucceed(values)}
onFinishFailed={(errors) => submitFailed(errors)}
>
{/* 基本信息 */}
<CellGroup title="基本信息">
<Form.Item
name="title"
label="文章标题"
required
rules={[{ required: true, message: '请输入文章标题' }]}
initialValue={formData.title}
>
<Input placeholder="请输入文章标题" maxLength={100}/>
</Form.Item>
<Form.Item name="overview" label="文章概述" initialValue={formData.overview}>
<TextArea placeholder="请输入文章概述,用于列表展示" maxLength={200} rows={3}/>
</Form.Item>
<Form.Item
name="detail"
label="文章内容"
required
rules={[{ required: true, message: '请输入文章内容' }]}
initialValue={formData.detail}
>
<TextArea placeholder="请输入文章内容" maxLength={10000} rows={8}/>
</Form.Item>
<Form.Item name="author" label="作者" initialValue={formData.author}>
<Input placeholder="请输入作者名称" maxLength={50}/>
</Form.Item>
<Form.Item name="source" label="来源" initialValue={formData.source}>
<Input placeholder="请输入文章来源" maxLength={100}/>
</Form.Item>
</CellGroup>
{/* 文章设置 */}
<CellGroup title="文章设置">
<Form.Item name="type" label="文章类型" initialValue={formData.type}>
<Radio.Group direction="horizontal" value={formData.type}>
{typeOptions.map(option => (
<Radio key={option.value} value={option.value}>
{option.text}
</Radio>
))}
</Radio.Group>
</Form.Item>
<Form.Item name="status" label="发布状态" initialValue={formData.status}>
<Radio.Group direction="horizontal" value={formData.status}>
{statusOptions.map(option => (
<Radio key={option.value} value={option.value}>
{option.text}
</Radio>
))}
</Radio.Group>
</Form.Item>
<Form.Item name="permission" label="可见性" initialValue={formData.permission}>
<Radio.Group direction="horizontal" value={formData.permission}>
{permissionOptions.map(option => (
<Radio key={option.value} value={option.value}>
{option.text}
</Radio>
))}
</Radio.Group>
</Form.Item>
<Form.Item name="showType" label="显示方式" initialValue={formData.showType}>
<Radio.Group direction="horizontal" value={formData.showType}>
{showTypeOptions.map(option => (
<Radio key={option.value} value={option.value}>
{option.text}
</Radio>
))}
</Radio.Group>
</Form.Item>
</CellGroup>
{/* 高级设置 */}
<CellGroup title="高级设置">
<Form.Item name="recommend" label="推荐文章" initialValue={formData.recommend}>
<Switch
checked={formData.recommend === 1}
onChange={(checked) =>
setFormData({...formData, recommend: checked ? 1 : 0})
}
/>
</Form.Item>
<Form.Item name="price" label="付费金额" initialValue={formData.price}>
<Input placeholder="0.00" type="number"/>
<View className="text-xs text-gray-500 mt-1"></View>
</Form.Item>
<Form.Item name="virtualViews" label="虚拟阅读量" initialValue={formData.virtualViews}>
<InputNumber min={0} defaultValue={formData.virtualViews || 0}/>
</Form.Item>
<Form.Item name="actualViews" label="实际阅读量" initialValue={formData.actualViews}>
<InputNumber min={0} defaultValue={formData.actualViews || 0}/>
</Form.Item>
<Form.Item name="sortNumber" label="排序" initialValue={formData.sortNumber}>
<InputNumber min={0} defaultValue={formData.sortNumber || 0}/>
<View className="text-xs text-gray-500 mt-1"></View>
</Form.Item>
<Form.Item name="tags" label="标签" initialValue={formData.tags}>
<Input placeholder="请输入标签,多个标签用逗号分隔" maxLength={200}/>
</Form.Item>
<Form.Item name="topic" label="话题" initialValue={formData.topic}>
<Input placeholder="请输入话题" maxLength={100}/>
</Form.Item>
</CellGroup>
{/* 图片上传 */}
<CellGroup title="文章图片">
<Form.Item name="image" label="封面图片" initialValue={formData.image}>
<View className="flex items-center gap-3">
{formData.image && (
<Image
src={formData.image}
width="80"
height="80"
radius="8"
/>
)}
<Button
size="small"
type="primary"
fill="outline"
icon={<UploadIcon />}
onClick={handleImageUpload}
>
{formData.image ? '更换图片' : '上传图片'}
</Button>
</View>
</Form.Item>
</CellGroup>
{/* 提交按钮 */}
<FixedButton text={isEditMode ? '更新文章' : '发布文章'} onClick={submitSucceed} icon={<Edit />} />
</Form>
</>
);
};
export default AddShopArticle;

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '礼品卡详情',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff',
navigationStyle: 'custom'
})

345
src/user/gift/detail.tsx Normal file
View File

@@ -0,0 +1,345 @@
import {useState, useEffect} from "react";
import {useRouter} from '@tarojs/taro'
import {Button, ConfigProvider, Tag, Divider, Image} from '@nutui/nutui-react-taro'
import {ArrowLeft, Gift, Clock, Location, Phone, Share, Copy} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {View, Text} from '@tarojs/components'
import {ShopGift} from "@/api/shop/shopGift/model";
import {getShopGift} from "@/api/shop/shopGift";
import GiftCardShare from "@/components/GiftCardShare";
import dayjs from "dayjs";
const GiftCardDetail = () => {
const router = useRouter()
const [gift, setGift] = useState<ShopGift | null>(null)
const [loading, setLoading] = useState(true)
const [showShare, setShowShare] = useState(false)
const giftId = router.params.id
useEffect(() => {
if (giftId) {
loadGiftDetail()
}
}, [giftId])
const loadGiftDetail = async () => {
try {
setLoading(true)
const data = await getShopGift(Number(giftId))
setGift(data)
} catch (error) {
console.error('获取礼品卡详情失败:', error)
Taro.showToast({
title: '获取礼品卡详情失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}
// 获取礼品卡类型文本
const getGiftTypeText = (type?: number) => {
switch (type) {
case 10: return '实物礼品卡'
case 20: return '虚拟礼品卡'
case 30: return '服务礼品卡'
default: return '礼品卡'
}
}
// 获取礼品卡面值显示
const getGiftValueDisplay = () => {
if (!gift || !gift.faceValue) return ''
return `¥${gift.faceValue}`
}
// 获取使用条件文本
const getUsageText = () => {
if (!gift) return ''
if (gift.instructions) {
return gift.instructions
}
switch (gift.type) {
case 10: return '请到指定门店使用此礼品卡'
case 20: return '可在线上平台直接使用'
case 30: return '请联系客服预约服务时间'
default: return '请按照使用说明进行操作'
}
}
// 获取有效期文本
const getValidityText = () => {
if (!gift) return ''
if (gift.validDays) {
return `有效期${gift.validDays}`
} else if (gift.expireTime) {
return `有效期至 ${dayjs(gift.expireTime).format('YYYY年MM月DD日')}`
} else {
return '长期有效'
}
}
// 获取礼品卡状态
const getGiftStatus = () => {
if (!gift) return { status: 0, text: '未知', color: 'default' }
switch (gift.useStatus) {
case 0:
return { status: 0, text: '可使用', color: 'success' }
case 1:
return { status: 1, text: '已使用', color: 'warning' }
case 2:
return { status: 2, text: '已过期', color: 'danger' }
default:
return { status: 0, text: '未知', color: 'default' }
}
}
// 使用礼品卡
const handleUseGift = () => {
if (!gift) return
Taro.showModal({
title: '使用礼品卡',
content: `确定要使用"${gift.name}"吗?`,
success: (res) => {
if (res.confirm) {
// 跳转到礼品卡使用页面
Taro.navigateTo({
url: `/user/gift/use?id=${gift.id}`
})
}
}
})
}
// 复制兑换码
const handleCopyCode = () => {
if (!gift?.code) return
Taro.setClipboardData({
data: gift.code,
success: () => {
Taro.showToast({
title: '兑换码已复制',
icon: 'success'
})
},
fail: () => {
Taro.showToast({
title: '复制失败',
icon: 'error'
})
}
})
}
// 返回上一页
const handleBack = () => {
Taro.navigateBack()
}
if (loading) {
return (
<ConfigProvider>
<View className="flex justify-center items-center h-screen">
<Text>...</Text>
</View>
</ConfigProvider>
)
}
if (!gift) {
return (
<ConfigProvider>
<View className="flex flex-col justify-center items-center h-screen">
<Text className="text-gray-500 mb-4"></Text>
<Button onClick={handleBack}></Button>
</View>
</ConfigProvider>
)
}
const statusInfo = getGiftStatus()
return (
<ConfigProvider>
{/* 自定义导航栏 */}
<View className="flex items-center justify-between p-4 bg-white border-b border-gray-100">
<View className="flex items-center" onClick={handleBack}>
<ArrowLeft size="20" />
<Text className="ml-2 text-lg"></Text>
</View>
<View className="flex items-center gap-3">
<View onClick={() => setShowShare(true)}>
<Share size="20" className="text-gray-600" />
</View>
<Tag type={statusInfo.color as any}>{statusInfo.text}</Tag>
</View>
</View>
{/* 礼品卡卡片 */}
<View className="m-4 p-6 rounded-2xl text-white" style={{backgroundColor: '#fbbf24'}}>
<View className="flex items-center justify-between mb-4">
<View className="flex-1">
<Text className="text-4xl font-bold">{getGiftValueDisplay()}</Text>
<Text className="text-lg opacity-90 mt-1">{getGiftTypeText(gift.type)}</Text>
</View>
{gift.goodsImage ? (
<Image
src={gift.goodsImage}
className="w-16 h-16 rounded-lg"
mode="aspectFill"
/>
) : (
<Gift size="40" />
)}
</View>
<Text className="text-xl font-semibold mb-2">{gift.name}</Text>
<Text className="text-base opacity-90">{gift.description || getUsageText()}</Text>
{/* 兑换码 */}
{gift.code && (
<View className="mt-4 p-3 bg-white bg-opacity-20 rounded-lg">
<View className="flex items-center justify-between">
<View>
<Text className="text-sm opacity-80"></Text>
<Text className="text-lg font-mono font-bold">{gift.code}</Text>
</View>
<Button
size="small"
fill="outline"
icon={<Copy />}
onClick={handleCopyCode}
className="border-white text-white"
>
</Button>
</View>
</View>
)}
</View>
{/* 详细信息 */}
<View className="bg-white mx-4 rounded-xl p-4">
<Text className="text-lg font-semibold mb-4">使</Text>
<View>
<View className="flex items-center mb-3">
<Clock size="16" className="text-gray-400 mr-3" />
<View>
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-gray-900">{getValidityText()}</Text>
</View>
</View>
<Divider />
<View className="flex items-center mb-3">
<Gift size="16" className="text-gray-400 mr-3" />
<View>
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-gray-900">{getGiftTypeText(gift.type)}</Text>
</View>
</View>
{gift.useLocation && (
<>
<Divider />
<View className="flex items-center">
<Location size="16" className="text-gray-400 mr-3" />
<View>
<Text className="text-gray-600 text-sm">使</Text>
<Text className="text-gray-900">{gift.useLocation}</Text>
</View>
</View>
</>
)}
{gift.contactInfo && (
<>
<Divider />
<View className="flex items-center">
<Phone size="16" className="text-gray-400 mr-3" />
<View>
<Text className="text-gray-600 text-sm"></Text>
<Text className="text-gray-900">{gift.contactInfo}</Text>
</View>
</View>
</>
)}
{gift.instructions && (
<>
<Divider />
<View>
<Text className="text-gray-600 text-sm mb-2">使</Text>
<Text className="text-gray-900 leading-relaxed">{gift.instructions}</Text>
</View>
</>
)}
{gift.useTime && (
<>
<Divider />
<View>
<Text className="text-gray-600 text-sm mb-2">使</Text>
<Text className="text-gray-900">使{dayjs(gift.useTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
</>
)}
</View>
</View>
{/* 底部操作按钮 */}
{statusInfo.status === 0 && (
<View className="fixed bottom-0 left-0 right-0 p-4 bg-white border-t border-gray-100">
<View className="flex gap-3">
{gift.code && (
<Button
fill="outline"
size="large"
className="flex-1"
icon={<Copy />}
onClick={handleCopyCode}
>
</Button>
)}
<Button
type="primary"
size="large"
className="flex-1"
onClick={handleUseGift}
>
使
</Button>
</View>
</View>
)}
{/* 分享弹窗 */}
{gift && (
<GiftCardShare
visible={showShare}
giftCard={{
id: gift.id || 0,
name: gift.name || '',
type: gift.type || 10,
faceValue: gift.faceValue || '0',
code: gift.code,
description: gift.description
}}
onClose={() => setShowShare(false)}
/>
)}
</ConfigProvider>
);
};
export default GiftCardDetail;

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '我的礼品卡',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

403
src/user/gift/index.tsx Normal file
View File

@@ -0,0 +1,403 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/taro'
import {Button, Empty, ConfigProvider, InfiniteLoading, Loading, PullToRefresh, Tabs, TabPane} from '@nutui/nutui-react-taro'
import {Gift, Plus, Board, QrCode} from '@nutui/icons-react-taro'
import {View} from '@tarojs/components'
import {ShopGift} from "@/api/shop/shopGift/model";
import {getUserGifts} from "@/api/shop/shopGift";
import GiftCardList from "@/components/GiftCardList";
import GiftCardStats from "@/components/GiftCardStats";
import GiftCardGuide from "@/components/GiftCardGuide";
import {GiftCardProps} from "@/components/GiftCard";
const GiftCardManage = () => {
const [list, setList] = useState<ShopGift[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
// const [searchValue, setSearchValue] = useState('')
const [page, setPage] = useState(1)
// const [total, setTotal] = useState(0)
const [activeTab, setActiveTab] = useState('0') // 0-可用 1-已使用 2-已过期
const [stats, setStats] = useState({
available: 0,
used: 0,
expired: 0,
totalValue: 0
})
const [showGuide, setShowGuide] = useState(false)
// const [showRedeemModal, setShowRedeemModal] = useState(false)
// const [filters, setFilters] = useState({
// type: [] as number[],
// sortBy: 'createTime' as 'createTime' | 'expireTime' | 'faceValue' | 'useTime',
// sortOrder: 'desc' as 'asc' | 'desc'
// })
// 获取礼品卡状态过滤条件
const getStatusFilter = () => {
switch (activeTab) {
case '0': // 可用
return { useStatus: 0 }
case '1': // 已使用
return { useStatus: 1 }
case '2': // 已过期
return { useStatus: 2 }
default:
return {}
}
}
const reload = async (isRefresh = false) => {
if (isRefresh) {
setPage(1)
setList([])
setHasMore(true)
}
setLoading(true)
try {
const currentPage = isRefresh ? 1 : page
const statusFilter = getStatusFilter()
const res = await getUserGifts({
page: currentPage,
limit: 10,
// keywords: searchValue,
...statusFilter,
// 应用筛选条件
// ...(filters.type.length > 0 && { type: filters.type[0] }),
// sortBy: filters.sortBy,
// sortOrder: filters.sortOrder
})
if (res && res.list) {
const newList = isRefresh ? res.list : [...list, ...res.list]
setList(newList)
// setTotal(res.count || 0)
// 判断是否还有更多数据
setHasMore(res.list.length === 10) // 如果返回的数据等于limit说明可能还有更多
if (!isRefresh) {
setPage(currentPage + 1)
} else {
setPage(2) // 刷新后下一页是第2页
}
} else {
setHasMore(false)
// setTotal(0)
}
} catch (error) {
console.error('获取礼品卡失败:', error)
Taro.showToast({
title: '获取礼品卡失败',
icon: 'error'
});
} finally {
setLoading(false)
}
}
// 搜索功能
// const handleSearch = (value: string) => {
// setSearchValue(value)
// reload(true)
// }
// 下拉刷新
const handleRefresh = async () => {
await reload(true)
}
// Tab切换
const handleTabChange = (value: string) => {
setActiveTab(value)
setPage(1)
setList([])
setHasMore(true)
// 延迟执行reload确保状态更新完成
setTimeout(() => {
reload(true)
}, 100)
}
// 转换礼品卡数据为GiftCard组件所需格式
const transformGiftData = (gift: ShopGift): GiftCardProps => {
return {
id: gift.id || 0,
name: gift.name || '礼品卡',
description: gift.description,
code: gift.code,
goodsImage: gift.goodsImage,
faceValue: gift.faceValue,
type: gift.type,
useStatus: gift.useStatus,
expireTime: gift.expireTime,
useTime: gift.useTime,
useLocation: gift.useLocation,
contactInfo: gift.contactInfo,
showCode: gift.useStatus === 0, // 只有可用状态显示兑换码
showUseBtn: gift.useStatus === 0, // 只有可用状态显示使用按钮
showDetailBtn: true,
theme: getThemeByType(gift.type),
onUse: () => handleUseGift(gift),
onDetail: () => handleGiftDetail(gift)
}
}
// 根据礼品卡类型获取主题色
const getThemeByType = (type?: number): 'gold' | 'silver' | 'bronze' | 'blue' | 'green' | 'purple' => {
switch (type) {
case 10: return 'gold' // 实物礼品卡
case 20: return 'blue' // 虚拟礼品卡
case 30: return 'green' // 服务礼品卡
default: return 'silver'
}
}
// 使用礼品卡
const handleUseGift = (gift: ShopGift) => {
Taro.showModal({
title: '使用礼品卡',
content: `确定要使用"${gift.name}"吗?`,
success: (res) => {
if (res.confirm) {
// 跳转到礼品卡使用页面
Taro.navigateTo({
url: `/user/gift/use?id=${gift.id}`
})
}
}
})
}
// 礼品卡点击事件
const handleGiftClick = (gift: GiftCardProps, index: number) => {
console.log(gift.code)
const originalGift = list[index]
if (originalGift) {
// 显示礼品卡详情
handleGiftDetail(originalGift)
}
}
// 显示礼品卡详情
const handleGiftDetail = (gift: ShopGift) => {
// 跳转到礼品卡详情页
Taro.navigateTo({
url: `/user/gift/detail?id=${gift.id}`
})
}
// 加载礼品卡统计数据
const loadGiftStats = async () => {
try {
// 并行获取各状态的礼品卡数量
const [availableRes, usedRes, expiredRes] = await Promise.all([
getUserGifts({ page: 1, limit: 1, useStatus: 0 }),
getUserGifts({ page: 1, limit: 1, useStatus: 1 }),
getUserGifts({ page: 1, limit: 1, useStatus: 2 })
])
// 计算总价值(仅可用礼品卡)
const availableGifts = await getUserGifts({ page: 1, limit: 100, useStatus: 0 })
const totalValue = availableGifts?.list?.reduce((sum, gift) => {
return sum + parseFloat(gift.faceValue || '0')
}, 0) || 0
setStats({
available: availableRes?.count || 0,
used: usedRes?.count || 0,
expired: expiredRes?.count || 0,
totalValue
})
} catch (error) {
console.error('获取礼品卡统计失败:', error)
}
}
// 统计卡片点击事件
const handleStatsClick = (type: 'available' | 'used' | 'expired' | 'total') => {
const tabMap = {
available: '0',
used: '1',
expired: '2',
total: '0' // 总价值点击跳转到可用礼品卡
}
if (tabMap[type]) {
handleTabChange(tabMap[type])
}
}
// 兑换礼品卡
const handleRedeemGift = () => {
Taro.navigateTo({
url: '/user/gift/redeem'
})
}
// 扫码兑换礼品卡
const handleScanRedeem = () => {
Taro.scanCode({
success: (res) => {
// 处理扫码结果
const code = res.result
if (code) {
Taro.navigateTo({
url: `/user/gift/redeem?code=${encodeURIComponent(code)}`
})
}
},
fail: () => {
Taro.showToast({
title: '扫码失败',
icon: 'error'
})
}
})
}
// 加载更多
const loadMore = async () => {
if (!loading && hasMore) {
await reload(false) // 不刷新,追加数据
}
}
useDidShow(() => {
reload(true).then()
loadGiftStats().then()
});
return (
<ConfigProvider>
{/* 搜索栏和功能入口 */}
<View className="bg-white px-4 py-3">
<View className="flex items-center justify-between gap-3">
<Button
size="small"
type="primary"
icon={<Plus />}
onClick={handleRedeemGift}
>
</Button>
<Button
size="small"
fill="outline"
icon={<QrCode />}
onClick={handleScanRedeem}
>
</Button>
<Button
size="small"
fill="outline"
icon={<Board />}
onClick={() => setShowGuide(true)}
>
</Button>
</View>
</View>
{/* 礼品卡统计 */}
<GiftCardStats
availableCount={stats.available}
usedCount={stats.used}
expiredCount={stats.expired}
totalValue={stats.totalValue}
onStatsClick={handleStatsClick}
/>
{/* Tab切换 */}
<View className="bg-white">
<Tabs value={activeTab} onChange={handleTabChange}>
<TabPane title="可用" value="0">
</TabPane>
<TabPane title="已使用" value="1">
</TabPane>
<TabPane title="已过期" value="2">
</TabPane>
</Tabs>
</View>
{/* 礼品卡列表 */}
<PullToRefresh
onRefresh={handleRefresh}
headHeight={60}
>
<View style={{ height: '600px', overflowY: 'auto' }} id="gift-scroll">
{list.length === 0 && !loading ? (
<View className="flex flex-col justify-center items-center" style={{height: '500px'}}>
<Empty
description={
activeTab === '0' ? "暂无可用礼品卡" :
activeTab === '1' ? "暂无已使用礼品卡" :
"暂无已过期礼品卡"
}
style={{backgroundColor: 'transparent'}}
/>
</View>
) : (
<InfiniteLoading
target="gift-scroll"
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? "暂无数据" : "没有更多了"}
</View>
}
>
<GiftCardList
gifts={list.map(transformGiftData)}
onGiftClick={handleGiftClick}
showEmpty={false}
/>
</InfiniteLoading>
)}
</View>
</PullToRefresh>
{/* 底部提示 */}
{activeTab === '0' && list.length === 0 && !loading && (
<View className="text-center py-8">
<View className="text-gray-400 mb-4">
<Gift size="48" />
</View>
<View className="text-gray-500 mb-2"></View>
<View className="flex gap-2 justify-center">
<Button
size="small"
type="primary"
onClick={handleRedeemGift}
>
</Button>
<Button
size="small"
fill="outline"
onClick={handleScanRedeem}
>
</Button>
</View>
</View>
)}
{/* 使用指南弹窗 */}
<GiftCardGuide
visible={showGuide}
onClose={() => setShowGuide(false)}
/>
</ConfigProvider>
);
};
export default GiftCardManage;

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '领取优惠券',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

248
src/user/gift/receive.tsx Normal file
View File

@@ -0,0 +1,248 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/taro'
import {Button, Empty, ConfigProvider, SearchBar, InfiniteLoading, Loading, PullToRefresh} from '@nutui/nutui-react-taro'
import {Gift, Search} from '@nutui/icons-react-taro'
import {View} from '@tarojs/components'
import {ShopCoupon} from "@/api/shop/shopCoupon/model";
import {pageShopCoupon} from "@/api/shop/shopCoupon";
import CouponList from "@/components/CouponList";
import {CouponCardProps} from "@/components/CouponCard";
const CouponReceive = () => {
const [list, setList] = useState<ShopCoupon[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [searchValue, setSearchValue] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const reload = async (isRefresh = false) => {
if (isRefresh) {
setPage(1)
setList([])
setHasMore(true)
}
setLoading(true)
try {
const currentPage = isRefresh ? 1 : page
// 获取可领取的优惠券(启用状态且未过期)
const res = await pageShopCoupon({
page: currentPage,
limit: 10,
keywords: searchValue,
enabled: '1', // 启用状态
isExpire: 0 // 未过期
})
if (res && res.list) {
const newList = isRefresh ? res.list : [...list, ...res.list]
setList(newList)
setTotal(res.count || 0)
setHasMore(res.list.length === 10)
if (!isRefresh) {
setPage(currentPage + 1)
} else {
setPage(2)
}
} else {
setHasMore(false)
setTotal(0)
}
} catch (error) {
console.error('获取优惠券失败:', error)
Taro.showToast({
title: '获取优惠券失败',
icon: 'error'
});
} finally {
setLoading(false)
}
}
// 搜索功能
const handleSearch = (value: string) => {
setSearchValue(value)
reload(true)
}
// 下拉刷新
const handleRefresh = async () => {
await reload(true)
}
// 转换优惠券数据为CouponCard组件所需格式
const transformCouponData = (coupon: ShopCoupon): CouponCardProps => {
let amount = 0
let type: 1 | 2 | 3 = 1
if (coupon.type === 10) { // 满减券
type = 1
amount = parseFloat(coupon.reducePrice || '0')
} else if (coupon.type === 20) { // 折扣券
type = 2
amount = coupon.discount || 0
} else if (coupon.type === 30) { // 免费券
type = 3
amount = 0
}
return {
amount,
type,
status: 0, // 可领取状态
minAmount: parseFloat(coupon.minPrice || '0'),
title: coupon.name || '优惠券',
startTime: coupon.startTime,
endTime: coupon.endTime,
showReceiveBtn: true, // 显示领取按钮
onReceive: () => handleReceiveCoupon(coupon),
theme: getThemeByType(coupon.type)
}
}
// 根据优惠券类型获取主题色
const getThemeByType = (type?: number): 'red' | 'orange' | 'blue' | 'purple' | 'green' => {
switch (type) {
case 10: return 'red' // 满减券
case 20: return 'orange' // 折扣券
case 30: return 'green' // 免费券
default: return 'blue'
}
}
// 领取优惠券
const handleReceiveCoupon = async (coupon: ShopCoupon) => {
try {
// 这里应该调用领取优惠券的API
// await receiveCoupon(coupon.id)
Taro.showToast({
title: '领取成功',
icon: 'success'
})
// 刷新列表
reload(true)
} catch (error) {
console.error('领取优惠券失败:', error)
Taro.showToast({
title: '领取失败',
icon: 'error'
})
}
}
// 优惠券点击事件
const handleCouponClick = (coupon: CouponCardProps, index: number) => {
const originalCoupon = list[index]
if (originalCoupon) {
// 显示优惠券详情
showCouponDetail(originalCoupon)
}
}
// 显示优惠券详情
const showCouponDetail = (coupon: ShopCoupon) => {
// 跳转到优惠券详情页
Taro.navigateTo({
url: `/user/coupon/detail?id=${coupon.id}`
})
}
// 加载更多
const loadMore = async () => {
if (!loading && hasMore) {
await reload(false)
}
}
useDidShow(() => {
reload(true).then()
});
return (
<ConfigProvider>
{/* 搜索栏 */}
<View className="bg-white px-4 py-3">
<SearchBar
placeholder="搜索优惠券名称"
value={searchValue}
onChange={setSearchValue}
onSearch={handleSearch}
leftIcon={<Search />}
/>
</View>
{/* 统计信息 */}
{total > 0 && (
<View className="px-4 py-2 text-sm text-gray-500 bg-gray-50">
{total}
</View>
)}
{/* 优惠券列表 */}
<PullToRefresh
onRefresh={handleRefresh}
headHeight={60}
>
<View style={{ height: 'calc(100vh - 160px)', overflowY: 'auto' }} id="coupon-scroll">
{list.length === 0 && !loading ? (
<View className="flex flex-col justify-center items-center" style={{height: 'calc(100vh - 250px)'}}>
<Empty
description="暂无可领取优惠券"
style={{backgroundColor: 'transparent'}}
/>
<Button
type="primary"
size="small"
className="mt-4"
onClick={() => Taro.navigateTo({url: '/pages/index/index'})}
>
</Button>
</View>
) : (
<InfiniteLoading
target="coupon-scroll"
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? "暂无数据" : "没有更多了"}
</View>
}
>
<CouponList
coupons={list.map(transformCouponData)}
onCouponClick={handleCouponClick}
showEmpty={false}
/>
</InfiniteLoading>
)}
</View>
</PullToRefresh>
{/* 底部提示 */}
{list.length === 0 && !loading && (
<View className="text-center py-8">
<View className="text-gray-400 mb-4">
<Gift size="48" />
</View>
<View className="text-gray-500 mb-2"></View>
<View className="text-gray-400 text-sm"></View>
</View>
)}
</ConfigProvider>
);
};
export default CouponReceive;

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '兑换礼品卡',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff',
navigationStyle: 'custom'
})

266
src/user/gift/redeem.tsx Normal file
View File

@@ -0,0 +1,266 @@
import {useState, useEffect} from "react";
import {useRouter} from '@tarojs/taro'
import {Button, ConfigProvider, Input, Divider} from '@nutui/nutui-react-taro'
import {ArrowLeft, QrCode, Gift, Voucher} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {View, Text} from '@tarojs/components'
import {ShopGift} from "@/api/shop/shopGift/model";
import {validateGiftCode, redeemGift} from "@/api/shop/shopGift";
import GiftCard from "@/components/GiftCard";
const GiftCardRedeem = () => {
const router = useRouter()
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [validating, setValidating] = useState(false)
const [validGift, setValidGift] = useState<ShopGift | null>(null)
const [redeemSuccess, setRedeemSuccess] = useState(false)
// 从路由参数获取扫码结果
useEffect(() => {
if (router.params.code) {
const scannedCode = decodeURIComponent(router.params.code)
setCode(scannedCode)
handleValidateCode(scannedCode)
}
}, [router.params.code])
// 验证兑换码
const handleValidateCode = async (inputCode?: string) => {
const codeToValidate = inputCode || code
if (!codeToValidate.trim()) {
Taro.showToast({
title: '请输入兑换码',
icon: 'none'
})
return
}
setValidating(true)
try {
const gift = await validateGiftCode(codeToValidate.trim())
setValidGift(gift)
Taro.showToast({
title: '验证成功',
icon: 'success'
})
} catch (error) {
console.error('验证兑换码失败:', error)
setValidGift(null)
Taro.showToast({
title: '兑换码无效或已使用',
icon: 'error'
})
} finally {
setValidating(false)
}
}
// 兑换礼品卡
const handleRedeem = async () => {
if (!validGift || !code.trim()) return
setLoading(true)
try {
await redeemGift({
code: code.trim()
})
setRedeemSuccess(true)
Taro.showToast({
title: '兑换成功',
icon: 'success'
})
} catch (error) {
console.error('兑换礼品卡失败:', error)
Taro.showToast({
title: '兑换失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}
// 扫码兑换
const handleScanCode = () => {
Taro.scanCode({
success: (res) => {
const scannedCode = res.result
if (scannedCode) {
setCode(scannedCode)
handleValidateCode(scannedCode)
}
},
fail: () => {
Taro.showToast({
title: '扫码失败',
icon: 'error'
})
}
})
}
// 返回上一页
const handleBack = () => {
Taro.navigateBack()
}
// 查看我的礼品卡
const handleViewMyGifts = () => {
Taro.navigateTo({
url: '/user/gift/index'
})
}
// 转换礼品卡数据
const transformGiftData = (gift: ShopGift) => {
return {
id: gift.id || 0,
name: gift.name || '礼品卡',
description: gift.description,
code: gift.code,
goodsImage: gift.goodsImage,
faceValue: gift.faceValue,
type: gift.type,
useStatus: gift.useStatus,
expireTime: gift.expireTime,
useTime: gift.useTime,
useLocation: gift.useLocation,
contactInfo: gift.contactInfo,
showCode: false, // 兑换页面不显示兑换码
showUseBtn: false, // 兑换页面不显示使用按钮
showDetailBtn: false, // 兑换页面不显示详情按钮
theme: 'gold' as const
}
}
return (
<ConfigProvider>
{/* 自定义导航栏 */}
<View className="flex items-center justify-between p-4 bg-white border-b border-gray-100">
<View className="flex items-center" onClick={handleBack}>
<ArrowLeft size="20" />
<Text className="ml-2 text-lg"></Text>
</View>
</View>
{!redeemSuccess ? (
<>
{/* 兑换说明 */}
<View className="bg-blue-50 mx-4 mt-4 p-4 rounded-xl border border-blue-200">
<View className="flex items-center mb-2">
<Gift size="20" className="text-blue-600 mr-2" />
<Text className="font-semibold text-blue-800"></Text>
</View>
<Text className="text-blue-700 text-sm leading-relaxed">
使
</Text>
</View>
{/* 兑换码输入 */}
<View className="bg-white mx-4 mt-4 p-4 rounded-xl">
<Text className="font-semibold mb-3 text-gray-800"></Text>
<View className="mb-4">
<Input
placeholder="请输入礼品卡兑换码"
value={code}
onChange={setCode}
clearable
className="border border-gray-200 rounded-lg"
/>
</View>
<View className="flex gap-3">
<Button
fill="outline"
size="large"
className="flex-1"
icon={<QrCode />}
onClick={handleScanCode}
>
</Button>
<Button
type="primary"
size="large"
className="flex-1"
loading={validating}
onClick={() => handleValidateCode()}
>
</Button>
</View>
</View>
{/* 验证结果 */}
{validGift && (
<>
<Divider className="my-4"></Divider>
<View className="mx-4">
<GiftCard {...transformGiftData(validGift)} />
</View>
<View className="mx-4 mt-4">
<Button
type="primary"
size="large"
block
loading={loading}
onClick={handleRedeem}
>
</Button>
</View>
</>
)}
</>
) : (
/* 兑换成功页面 */
<View className="flex flex-col items-center justify-center px-4" style={{height: '600px'}}>
<View className="text-center">
<View className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Voucher size="40" className="text-green-600" />
</View>
<Text className="text-2xl font-bold text-gray-900 mb-2"></Text>
<Text className="text-gray-600 mb-6"></Text>
{validGift && (
<View className="mb-6">
<GiftCard {...transformGiftData(validGift)} />
</View>
)}
<View className="flex flex-col gap-3 w-full max-w-xs">
<Button
type="primary"
size="large"
block
onClick={handleViewMyGifts}
>
</Button>
<Button
fill="outline"
size="large"
block
onClick={() => {
setCode('')
setValidGift(null)
setRedeemSuccess(false)
}}
>
</Button>
</View>
</View>
</View>
)}
</ConfigProvider>
);
};
export default GiftCardRedeem;

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '使用礼品卡',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff',
navigationStyle: 'custom'
})

291
src/user/gift/use.tsx Normal file
View File

@@ -0,0 +1,291 @@
import {useState, useEffect} from "react";
import {useRouter} from '@tarojs/taro'
import {Button, ConfigProvider, Input, TextArea} from '@nutui/nutui-react-taro'
import {ArrowLeft, Location} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import {View, Text} from '@tarojs/components'
import {ShopGift} from "@/api/shop/shopGift/model";
import {getShopGift, useGift} from "@/api/shop/shopGift";
import GiftCard from "@/components/GiftCard";
const GiftCardUse = () => {
const router = useRouter()
const [gift, setGift] = useState<ShopGift | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [useLocation, setUseLocation] = useState('')
const [useNote, setUseNote] = useState('')
const [useSuccess, setUseSuccess] = useState(false)
const giftId = router.params.id
useEffect(() => {
if (giftId) {
loadGiftDetail()
}
}, [giftId])
// 加载礼品卡详情
const loadGiftDetail = async () => {
try {
setLoading(true)
const data = await getShopGift(Number(giftId))
setGift(data)
// 如果礼品卡有预设使用地址,自动填入
if (data.useLocation) {
setUseLocation(data.useLocation)
}
} catch (error) {
console.error('获取礼品卡详情失败:', error)
Taro.showToast({
title: '获取礼品卡详情失败',
icon: 'error'
})
} finally {
setLoading(false)
}
}
// 使用礼品卡
const handleUseGift = async () => {
if (!gift) return
// 根据礼品卡类型进行不同的验证
if (gift.type === 10 && !useLocation.trim()) { // 实物礼品卡需要地址
Taro.showToast({
title: '请填写使用地址',
icon: 'none'
})
return
}
setSubmitting(true)
try {
await useGift({
giftId: gift.id!,
useLocation: useLocation.trim(),
useNote: useNote.trim()
})
setUseSuccess(true)
Taro.showToast({
title: '使用成功',
icon: 'success'
})
} catch (error) {
console.error('使用礼品卡失败:', error)
Taro.showToast({
title: '使用失败',
icon: 'error'
})
} finally {
setSubmitting(false)
}
}
// 获取当前位置
const handleGetLocation = () => {
Taro.getLocation({
type: 'gcj02',
success: (res) => {
// 这里可以调用地理编码API将坐标转换为地址
// 暂时使用坐标信息
setUseLocation(`经度:${res.longitude}, 纬度:${res.latitude}`)
Taro.showToast({
title: '位置获取成功',
icon: 'success'
})
},
fail: () => {
Taro.showToast({
title: '位置获取失败',
icon: 'error'
})
}
})
}
// 返回上一页
const handleBack = () => {
Taro.navigateBack()
}
// 查看我的礼品卡
const handleViewMyGifts = () => {
Taro.navigateTo({
url: '/user/gift/index'
})
}
// 转换礼品卡数据
const transformGiftData = (gift: ShopGift) => {
return {
id: gift.id || 0,
name: gift.name || '礼品卡',
description: gift.description,
code: gift.code,
goodsImage: gift.goodsImage,
faceValue: gift.faceValue,
type: gift.type,
useStatus: gift.useStatus,
expireTime: gift.expireTime,
useTime: gift.useTime,
useLocation: gift.useLocation,
contactInfo: gift.contactInfo,
showCode: false,
showUseBtn: false,
showDetailBtn: false,
theme: 'gold' as const
}
}
if (loading) {
return (
<ConfigProvider>
<View className="flex justify-center items-center h-screen">
<Text>...</Text>
</View>
</ConfigProvider>
)
}
if (!gift) {
return (
<ConfigProvider>
<View className="flex flex-col justify-center items-center h-screen">
<Text className="text-gray-500 mb-4"></Text>
<Button onClick={handleBack}></Button>
</View>
</ConfigProvider>
)
}
return (
<ConfigProvider>
{/* 自定义导航栏 */}
<View className="flex items-center justify-between p-4 bg-white border-b border-gray-100">
<View className="flex items-center" onClick={handleBack}>
<ArrowLeft size="20" />
<Text className="ml-2 text-lg">使</Text>
</View>
</View>
{!useSuccess ? (
<>
{/* 礼品卡信息 */}
<View className="mx-4 mt-4">
<GiftCard {...transformGiftData(gift)} />
</View>
{/* 使用表单 */}
<View className="bg-white mx-4 mt-4 p-4 rounded-xl">
<Text className="font-semibold mb-4 text-gray-800">使</Text>
{/* 使用地址(实物礼品卡必填) */}
{gift.type === 10 && (
<View className="mb-4">
<Text className="text-gray-700 mb-2">使 *</Text>
<View className="flex gap-2">
<Input
placeholder="请输入使用地址"
value={useLocation}
onChange={setUseLocation}
className="flex-1 border border-gray-200 rounded-lg"
/>
<Button
size="small"
fill="outline"
icon={<Location />}
onClick={handleGetLocation}
>
</Button>
</View>
</View>
)}
{/* 虚拟礼品卡和服务礼品卡的地址选填 */}
{gift.type !== 10 && (
<View className="mb-4">
<Text className="text-gray-700 mb-2">使</Text>
<Input
placeholder="请输入使用地址"
value={useLocation}
onChange={setUseLocation}
className="border border-gray-200 rounded-lg"
/>
</View>
)}
{/* 使用备注 */}
<View className="mb-4">
<Text className="text-gray-700 mb-2">使</Text>
<TextArea
placeholder="请输入使用备注"
value={useNote}
onChange={setUseNote}
rows={3}
className="border border-gray-200 rounded-lg"
/>
</View>
{/* 使用说明 */}
<View className="bg-yellow-50 p-3 rounded-lg border border-yellow-200 mb-4">
<Text className="text-yellow-800 text-sm">
{gift.type === 10 && '💡 实物礼品卡使用后请到指定地址领取商品'}
{gift.type === 20 && '💡 虚拟礼品卡使用后将自动发放到您的账户'}
{gift.type === 30 && '💡 服务礼品卡使用后请联系客服预约服务时间'}
</Text>
</View>
<Button
type="primary"
size="large"
block
loading={submitting}
onClick={handleUseGift}
>
使
</Button>
</View>
</>
) : (
/* 使用成功页面 */
<View className="flex flex-col items-center justify-center px-4" style={{height: '600px'}}>
<View className="text-center">
<View className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
{/*<Voucher size="40" className="text-green-600" />*/}
</View>
<Text className="text-2xl font-bold text-gray-900 mb-2">使</Text>
<Text className="text-gray-600 mb-6">
{gift.type === 10 && '请到指定地址领取您的商品'}
{gift.type === 20 && '虚拟商品已发放到您的账户'}
{gift.type === 30 && '请联系客服预约服务时间'}
</Text>
{gift.contactInfo && (
<View className="bg-blue-50 p-4 rounded-lg mb-6 border border-blue-200">
<Text className="text-blue-800 font-semibold mb-1"></Text>
<Text className="text-blue-700">{gift.contactInfo}</Text>
</View>
)}
<View className="flex flex-col gap-3 w-full max-w-xs">
<Button
type="primary"
size="large"
block
onClick={handleViewMyGifts}
>
</Button>
</View>
</View>
</View>
)}
</ConfigProvider>
);
};
export default GiftCardUse;