feat(ticket): 添加水票功能模块

- 新增水票相关API接口,包括水票模板、用户水票、消费日志和水票释放功能
- 添加水票管理页面,实现水票的增删改查和详情展示功能
- 实现水票的分页查询和列表展示界面
- 替换原有的礼品卡功能为水票功能,在首页导航中更新路由链接
- 添加水票详情页面,支持二维码展示和兑换码复制功能
- 实现水票的状态管理和使用流程控制
This commit is contained in:
2026-02-04 10:02:26 +08:00
parent cb17e48b03
commit a3c952d092
23 changed files with 2393 additions and 5 deletions

251
src/user/ticket/index.tsx Normal file
View File

@@ -0,0 +1,251 @@
import { useState } from 'react';
import Taro, { useDidShow } from '@tarojs/taro';
import {
ConfigProvider,
Empty,
InfiniteLoading,
Loading,
PullToRefresh,
SearchBar,
Tabs,
TabPane,
Tag
} from '@nutui/nutui-react-taro';
import { View, Text } from '@tarojs/components';
import { pageGltUserTicket } from '@/api/glt/gltUserTicket';
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model';
const PAGE_SIZE = 10;
const UserTicketList = () => {
const [list, setList] = useState<GltUserTicket[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [searchValue, setSearchValue] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
// 0-正常 1-冻结
const [activeTab, setActiveTab] = useState<string>('0');
const getUserId = () => {
const raw = Taro.getStorageSync('UserId');
const id = Number(raw);
return Number.isFinite(id) && id > 0 ? id : undefined;
};
const showTicketDetail = (ticket: GltUserTicket) => {
const lines: string[] = [];
if (ticket.templateName) lines.push(`水票:${ticket.templateName}`);
lines.push(`可用:${ticket.availableQty ?? 0}`);
lines.push(`总量:${ticket.totalQty ?? 0}`);
lines.push(`已用:${ticket.usedQty ?? 0}`);
lines.push(`冻结:${ticket.frozenQty ?? 0}`);
lines.push(`已释放:${ticket.releasedQty ?? 0}`);
if (ticket.orderNo) lines.push(`订单号:${ticket.orderNo}`);
Taro.showModal({
title: '水票详情',
content: lines.join('\n'),
showCancel: false
});
};
const reloadWithTab = async (tab: string, isRefresh = true, keywords?: string) => {
if (loading) return;
const userId = getUserId();
if (!userId) {
setList([]);
setTotal(0);
setHasMore(false);
return;
}
if (isRefresh) {
setPage(1);
setList([]);
setHasMore(true);
}
setLoading(true);
try {
const currentPage = isRefresh ? 1 : page;
const status = Number(tab); // 0正常1冻结
const res = await pageGltUserTicket({
page: currentPage,
limit: PAGE_SIZE,
userId,
status,
keywords: (keywords ?? searchValue) || undefined
});
const nextList = isRefresh ? res.list : [...list, ...res.list];
setList(nextList);
const count = typeof res.count === 'number' ? res.count : nextList.length;
setTotal(count);
setHasMore(nextList.length < count);
if (res.list.length > 0) {
setPage(currentPage + 1);
} else {
setHasMore(false);
}
} catch (error) {
console.error('获取水票列表失败:', error);
Taro.showToast({ title: '获取水票失败', icon: 'error' });
setHasMore(false);
} finally {
setLoading(false);
}
};
const reload = async (isRefresh = true) => reloadWithTab(activeTab, isRefresh);
const handleSearch = (value: string) => {
setSearchValue(value);
reloadWithTab(activeTab, true, value);
};
const handleRefresh = async () => {
await reload(true);
};
const handleTabChange = (value: string | number) => {
const tab = String(value);
setActiveTab(tab);
setPage(1);
setList([]);
setHasMore(true);
reloadWithTab(tab, true);
};
const loadMore = async () => {
if (!loading && hasMore) {
await reload(false);
}
};
useDidShow(() => {
reloadWithTab(activeTab, true).then();
});
return (
<ConfigProvider>
{/* 搜索栏 */}
<View className="bg-white px-4 py-3 hidden">
<SearchBar
placeholder="搜索水票"
value={searchValue}
onChange={setSearchValue}
onSearch={handleSearch}
/>
</View>
{/* Tab切换 */}
<View className="bg-white">
<Tabs value={activeTab} onChange={handleTabChange}>
<TabPane title="正常" value="0"></TabPane>
<TabPane title="冻结" value="1"></TabPane>
</Tabs>
</View>
{total > 0 && (
<View className="px-4 py-2 text-sm text-gray-500 bg-gray-50 hidden">
{total}
</View>
)}
{/* 列表 */}
<PullToRefresh onRefresh={handleRefresh} headHeight={60}>
<View style={{ height: 'calc(100vh - 200px)', overflowY: 'auto' }} id="ticket-scroll">
{list.length === 0 && !loading ? (
<View
className="flex flex-col justify-center items-center"
style={{ height: 'calc(100vh - 260px)' }}
>
<Empty
description={activeTab === '0' ? '暂无正常水票' : '暂无冻结水票'}
style={{ backgroundColor: 'transparent' }}
/>
</View>
) : (
<InfiniteLoading
target="ticket-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>
}
>
<View className="px-4 py-3">
{list.map((item, index) => (
<View
key={String(item.id ?? `${item.templateId ?? 't'}-${index}`)}
className="bg-white rounded-xl p-4 mb-3"
onClick={() => showTicketDetail(item)}
>
<View className="flex items-start justify-between">
<View className="flex-1 pr-3">
<Text className="text-base font-semibold text-gray-900">
{item.templateName || '水票'}
</Text>
{item.orderNo && (
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.orderNo}</Text>
</View>
)}
{item.createTime && (
<View className="mt-1">
<Text className="text-xs text-gray-400">{item.createTime}</Text>
</View>
)}
</View>
<Tag type={item.status === 1 ? 'danger' : 'success'}>
{item.status === 1 ? '冻结' : '正常'}
</Tag>
</View>
<View className="mt-3 flex justify-between">
<View className="flex flex-col">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-lg font-bold text-blue-600">{item.availableQty ?? 0}</Text>
</View>
<View className="flex flex-col items-center">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.totalQty ?? 0}</Text>
</View>
<View className="flex flex-col items-end">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.usedQty ?? 0}</Text>
</View>
</View>
<View className="mt-2 flex justify-between">
<View className="flex flex-col">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.frozenQty ?? 0}</Text>
</View>
<View className="flex flex-col items-end">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.releasedQty ?? 0}</Text>
</View>
</View>
</View>
))}
</View>
</InfiniteLoading>
)}
</View>
</PullToRefresh>
</ConfigProvider>
);
};
export default UserTicketList;