feat(ticket): 添加水票释放计划功能

- 在应用配置中注册新的释放计划页面路由 ticket/release/index
- 简化 API 请求参数结构,移除不必要的包装对象
- 在用户票券列表中添加释放计划详情入口和跳转逻辑
- 显示票券套票名称信息增强用户体验
- 在配送时间选择中添加日期验证防止选择过去日期
- 新增完整的释放计划详情页面实现列表展示、下拉刷新、上拉加载等功能
- 添加释放计划状态显示和数量统计信息展示
This commit is contained in:
2026-03-10 15:27:33 +08:00
parent 1cdb6404ad
commit ac194b93eb
6 changed files with 276 additions and 10 deletions

View File

@@ -8,9 +8,7 @@ import type { GltUserTicketRelease, GltUserTicketReleaseParam } from './model';
export async function pageGltUserTicketRelease(params: GltUserTicketReleaseParam) {
const res = await request.get<ApiResult<PageResult<GltUserTicketRelease>>>(
'/glt/glt-user-ticket-release/page',
{
params
}
params
);
if (res.code === 0) {
return res.data;
@@ -24,9 +22,7 @@ export async function pageGltUserTicketRelease(params: GltUserTicketReleaseParam
export async function listGltUserTicketRelease(params?: GltUserTicketReleaseParam) {
const res = await request.get<ApiResult<GltUserTicketRelease[]>>(
'/glt/glt-user-ticket-release',
{
params
}
params
);
if (res.code === 0 && res.data) {
return res.data;

View File

@@ -56,6 +56,7 @@ export default {
"points/points",
"ticket/index",
"ticket/use",
"ticket/release/index",
"ticket/orders/index",
// "gift/index",
// "gift/redeem",

View File

@@ -116,6 +116,20 @@ const UserTicketList = () => {
await Taro.navigateTo({ url });
};
const goReleasePlanDetail = async (ticket: GltUserTicket) => {
if (!ticket?.id) {
Taro.showToast({ title: '水票信息不完整', icon: 'none' });
return;
}
const url = `/user/ticket/release/index?userTicketId=${encodeURIComponent(String(ticket.id))}&templateName=${encodeURIComponent(
String(ticket.templateName ?? '')
)}&frozenQty=${encodeURIComponent(String(ticket.frozenQty ?? 0))}&releasedQty=${encodeURIComponent(
String(ticket.releasedQty ?? 0)
)}`;
if (!ensureLoggedIn(url)) return;
await Taro.navigateTo({ url });
};
const showTicketDetail = (ticket: GltUserTicket) => {
const lines: string[] = [];
if (ticket.templateName) lines.push(`水票:${ticket.templateName}`);
@@ -566,6 +580,9 @@ const UserTicketList = () => {
<Text className="text-base font-semibold text-gray-900">
{item.id}
</Text>
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.templateName}</Text>
</View>
{item.orderNo && (
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.orderNo}</Text>
@@ -617,7 +634,14 @@ const UserTicketList = () => {
<Text className="text-lg text-gray-900 text-center">{item.usedQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="flex flex-col items-center">
<View
className="flex flex-col items-center"
hoverClass="opacity-70"
onClick={(e) => {
e.stopPropagation();
void goReleasePlanDetail(item);
}}
>
<Text className="text-lg text-gray-900 text-center">{item.frozenQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '释放计划',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

View File

@@ -0,0 +1,221 @@
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { ConfigProvider, Empty, InfiniteLoading, Loading, PullToRefresh, Tag } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { pageGltUserTicketRelease } from '@/api/glt/gltUserTicketRelease'
import type { GltUserTicketRelease } from '@/api/glt/gltUserTicketRelease/model'
import { ensureLoggedIn } from '@/utils/auth'
const PAGE_SIZE = 10
export default function TicketReleasePlanPage() {
const [list, setList] = useState<GltUserTicketRelease[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
const [total, setTotal] = useState<number | undefined>(undefined)
const router = Taro.getCurrentInstance().router
const userTicketId = String(router?.params?.userTicketId || '').trim()
const templateName = (() => {
const raw = String(router?.params?.templateName || '')
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})()
const frozenQtyText = router?.params?.frozenQty !== undefined ? String(router?.params?.frozenQty) : undefined
const releasedQtyText = router?.params?.releasedQty !== undefined ? String(router?.params?.releasedQty) : undefined
const getUserId = () => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
}
const getStatusMeta = (item: GltUserTicketRelease) => {
const status = Number(item.status)
if (status === 1) return { text: '已释放', type: 'success' as const }
if (status === 0) return { text: '待释放', type: 'warning' as const }
return { text: `状态${Number.isFinite(status) ? status : '-'}`, type: 'primary' as const }
}
const formatDateTime = (v?: string) => {
if (!v) return '-'
const d = dayjs(v)
return d.isValid() ? d.format('YYYY年MM月DD日 HH:mm:ss') : v
}
const reload = async (isRefresh = true) => {
if (loading) return
const uid = getUserId()
if (!uid) {
setList([])
setHasMore(false)
setTotal(0)
return
}
if (!userTicketId) {
setList([])
setHasMore(false)
setTotal(0)
return
}
if (isRefresh) {
setPage(1)
setList([])
setHasMore(true)
}
setLoading(true)
try {
const currentPage = isRefresh ? 1 : page
const res = await pageGltUserTicketRelease({
page: currentPage,
limit: PAGE_SIZE,
userId: uid,
userTicketId: userTicketId as any
} as any)
const incoming = Array.isArray(res?.list) ? res.list : []
const safe = incoming
.filter(r => Number((r as any)?.deleted) !== 1)
.filter(r => !userTicketId || String(r.userTicketId || '') === userTicketId)
.sort((a, b) => {
const at = dayjs(a.releaseTime || a.createTime || 0).valueOf()
const bt = dayjs(b.releaseTime || b.createTime || 0).valueOf()
return bt - at
})
const nextList = isRefresh ? safe : list.concat(safe)
setList(nextList)
const serverCount = typeof (res as any)?.count === 'number' ? Number((res as any).count) : undefined
const nextTotal = typeof serverCount === 'number' ? serverCount : nextList.length
setTotal(nextTotal)
setHasMore(typeof serverCount === 'number' ? nextList.length < serverCount : incoming.length >= PAGE_SIZE)
if (incoming.length > 0) setPage(currentPage + 1)
else setHasMore(false)
} catch (e) {
console.error('加载释放计划失败:', e)
Taro.showToast({ title: '加载失败', icon: 'none' })
setHasMore(false)
} finally {
setLoading(false)
}
}
useDidShow(() => {
const redirect = userTicketId
? `/user/ticket/release/index?userTicketId=${encodeURIComponent(userTicketId)}`
: '/user/ticket/index'
if (!ensureLoggedIn(redirect)) return
void reload(true)
})
const handleRefresh = async () => {
await reload(true)
}
const loadMore = async () => {
if (!loading && hasMore) await reload(false)
}
return (
<ConfigProvider>
<PullToRefresh onRefresh={handleRefresh} headHeight={60}>
<View style={{ height: 'calc(100vh)', overflowY: 'auto' }} id="ticket-release-scroll">
<View className="px-4 py-3">
<View className="bg-white rounded-xl p-4 mb-3">
<View className="flex items-center justify-between">
<Text className="text-base font-semibold text-gray-900"></Text>
{typeof total === 'number' ? (
<Text className="text-xs text-gray-400"> {total} </Text>
) : null}
</View>
<View className="mt-2 text-xs text-gray-500">
<Text>{userTicketId || '-'}</Text>
</View>
{templateName ? (
<View className="mt-1 text-xs text-gray-500">
<Text>{templateName}</Text>
</View>
) : null}
{frozenQtyText !== undefined || releasedQtyText !== undefined ? (
<View className="mt-2 flex gap-4">
{frozenQtyText !== undefined ? (
<View>
<Text className="text-xs text-gray-500">{frozenQtyText}</Text>
</View>
) : null}
{releasedQtyText !== undefined ? (
<View>
<Text className="text-xs text-gray-500">{releasedQtyText}</Text>
</View>
) : null}
</View>
) : null}
</View>
{list.length === 0 && !loading ? (
<View className="flex flex-col justify-center items-center" style={{ height: 'calc(100vh - 220px)' }}>
<Empty description="暂无释放计划" style={{ backgroundColor: 'transparent' }} />
</View>
) : (
<InfiniteLoading
target="ticket-release-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>
{list.map((item, index) => {
const meta = getStatusMeta(item)
return (
<View
key={String(item.id ?? `${item.userTicketId ?? 't'}-${index}`)}
className="bg-white rounded-xl p-4 mb-3"
>
<View className="flex items-start justify-between">
<View className="flex-1 pr-3">
<Text className="text-sm font-semibold text-gray-900">
{item.periodNo ?? '-'}
</Text>
<View className="mt-1 text-xs text-gray-500">
<Text>{item.releaseQty ?? 0}</Text>
</View>
<View className="mt-1 text-xs text-gray-500">
<Text>{formatDateTime(item.releaseTime)}</Text>
</View>
</View>
<Tag type={meta.type}>{meta.text}</Tag>
</View>
</View>
)
})}
</View>
</InfiniteLoading>
)}
</View>
</View>
</PullToRefresh>
</ConfigProvider>
)
}

View File

@@ -13,7 +13,7 @@ import {
Space
} from '@nutui/nutui-react-taro'
import { ArrowRight, Location, Ticket } from '@nutui/icons-react-taro'
import dayjs from 'dayjs'
import dayjs, { type Dayjs } from 'dayjs'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import { getShopGoods } from '@/api/shop/shopGoods'
import { getShopUserAddress, listShopUserAddress } from '@/api/shop/shopUserAddress'
@@ -136,6 +136,12 @@ const OrderConfirm = () => {
return d.isValid() ? d : null
}
const clampSendDateToToday = (d: Dayjs) => {
const today = dayjs().startOf('day')
if (!d.isValid()) return today
return d.isBefore(today, 'day') ? today : d.startOf('day')
}
const getOrderTime = (o?: Partial<GltTicketOrder> | null) => {
return parseTime(o?.createTime) || parseTime(o?.updateTime)
}
@@ -728,6 +734,11 @@ const OrderConfirm = () => {
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
return
}
if (dayjs(sendTime).isBefore(dayjs().startOf('day'), 'day')) {
Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' })
setSendTime(dayjs().startOf('day').toDate())
return
}
// 配送范围校验(电子围栏)
const ok = await ensureInDeliveryRange()
@@ -869,7 +880,7 @@ const OrderConfirm = () => {
setQuantity(Number.isFinite(initQty) && initQty > 0 ? initQty : minStartQty)
setOrderRemark(String(editingOrderRes.buyerRemarks || ''))
const st = parseTime(editingOrderRes.sendTime)
if (st) setSendTime(st.startOf('day').toDate())
if (st) setSendTime(clampSendDateToToday(st).toDate())
const addrId = Number(editingOrderRes.addressId)
const addrIdSafe = Number.isFinite(addrId) && addrId > 0 ? addrId : undefined
@@ -1162,11 +1173,18 @@ const OrderConfirm = () => {
extra={(
<Picker
mode="date"
start={dayjs().format('YYYY-MM-DD')}
value={dayjs(sendTime).format('YYYY-MM-DD')}
onChange={(e) => {
const v = (e as any)?.detail?.value
const d = dayjs(v)
if (d.isValid()) setSendTime(d.startOf('day').toDate())
if (!d.isValid()) return
if (d.isBefore(dayjs().startOf('day'), 'day')) {
Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' })
setSendTime(dayjs().startOf('day').toDate())
return
}
setSendTime(d.startOf('day').toDate())
}}
>
<View className={'flex items-center gap-2'}>