fix(mp): 商品详情页加载超时 + 患者兑换权限
- 移除 getProduct 失败后的 listProducts 慢 fallback(拉 100 条),直接报错 - 处理 productId 为空时 loading 永不结束的卡死问题 - 添加 8s 加载超时保护,超时自动显示错误状态+重试按钮 - 新增迁移 000161:患者角色添加 health.points.manage 兑换权限
This commit is contained in:
@@ -11,11 +11,26 @@
|
|||||||
|
|
||||||
&__loading, &__empty {
|
&__loading, &__empty {
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-md;
|
||||||
padding: $sp-2xl 0;
|
padding: $sp-2xl 0;
|
||||||
font-size: var(--tk-font-body);
|
font-size: var(--tk-font-body);
|
||||||
color: $tx3;
|
color: $tx3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__retry-btn {
|
||||||
|
padding: 10px 28px;
|
||||||
|
border-radius: $r-pill;
|
||||||
|
background: $pri;
|
||||||
|
@include touch-target;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__retry-text {
|
||||||
|
font-size: var(--tk-font-body-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
&__scroll {
|
&__scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import { usePageData } from '@/hooks/usePageData';
|
|
||||||
import { getProduct } from '../../../services/points';
|
import { getProduct } from '../../../services/points';
|
||||||
import type { PointsProduct } from '../../../services/points';
|
import type { PointsProduct } from '../../../services/points';
|
||||||
import { usePointsStore } from '../../../stores/points';
|
import { usePointsStore } from '../../../stores/points';
|
||||||
import { useElderClass } from '../../../hooks/useElderClass';
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
|
import { usePageData } from '@/hooks/usePageData';
|
||||||
import PageShell from '@/components/ui/PageShell';
|
import PageShell from '@/components/ui/PageShell';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
@@ -21,29 +21,33 @@ const TYPE_LABEL: Record<string, string> = {
|
|||||||
privilege: '权益',
|
privilege: '权益',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LOAD_TIMEOUT = 8000;
|
||||||
|
|
||||||
export default function ProductDetail() {
|
export default function ProductDetail() {
|
||||||
const modeClass = useElderClass();
|
const modeClass = useElderClass();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const productId = router.params.product_id || '';
|
const productId = router.params.product_id || '';
|
||||||
const account = usePointsStore((s) => s.account);
|
const account = usePointsStore((s) => s.account);
|
||||||
|
const refreshPoints = usePointsStore((s) => s.refresh);
|
||||||
const [product, setProduct] = useState<PointsProduct | null>(null);
|
const [product, setProduct] = useState<PointsProduct | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const fetchProduct = useCallback(async () => {
|
const fetchProduct = useCallback(async () => {
|
||||||
if (!productId) return;
|
if (!productId) {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadError(false);
|
||||||
try {
|
try {
|
||||||
const data = await getProduct(productId);
|
const data = await getProduct(productId);
|
||||||
setProduct(data);
|
setProduct(data);
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
setLoadError(true);
|
||||||
const { listProducts } = await import('../../../services/points');
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
const res = await listProducts({ page: 1, page_size: 100 });
|
|
||||||
const found = res.data.find((p) => p.id === productId);
|
|
||||||
if (found) setProduct(found);
|
|
||||||
} catch {
|
|
||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -51,6 +55,23 @@ export default function ProductDetail() {
|
|||||||
|
|
||||||
usePageData(fetchProduct, { throttleMs: 30000 });
|
usePageData(fetchProduct, { throttleMs: 30000 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshPoints();
|
||||||
|
}, [refreshPoints]);
|
||||||
|
|
||||||
|
// 加载超时保护
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadError(true);
|
||||||
|
}, LOAD_TIMEOUT);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<PageShell className={modeClass}>
|
<PageShell className={modeClass}>
|
||||||
@@ -61,11 +82,14 @@ export default function ProductDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product) {
|
if (loadError || !product) {
|
||||||
return (
|
return (
|
||||||
<PageShell className={modeClass}>
|
<PageShell className={modeClass}>
|
||||||
<View className='product-detail__empty'>
|
<View className='product-detail__empty'>
|
||||||
<Text>商品不存在</Text>
|
<Text>{!productId ? '参数错误' : '商品不存在或加载失败'}</Text>
|
||||||
|
<View className='product-detail__retry-btn' onClick={fetchProduct}>
|
||||||
|
<Text className='product-detail__retry-text'>重试</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
@@ -93,12 +117,10 @@ export default function ProductDetail() {
|
|||||||
return (
|
return (
|
||||||
<PageShell padding="none" safeBottom={false} scroll={false} className={`product-detail ${modeClass}`}>
|
<PageShell padding="none" safeBottom={false} scroll={false} className={`product-detail ${modeClass}`}>
|
||||||
<View className='product-detail__scroll'>
|
<View className='product-detail__scroll'>
|
||||||
{/* 商品大图 */}
|
|
||||||
<View className={`product-detail__hero ${TYPE_BG[type] || ''}`}>
|
<View className={`product-detail__hero ${TYPE_BG[type] || ''}`}>
|
||||||
<Text className='product-detail__hero-type'>{TYPE_LABEL[type]}</Text>
|
<Text className='product-detail__hero-type'>{TYPE_LABEL[type]}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 商品信息卡 */}
|
|
||||||
<View className='product-detail__info-card'>
|
<View className='product-detail__info-card'>
|
||||||
<View className='product-detail__tags'>
|
<View className='product-detail__tags'>
|
||||||
<Text className='product-detail__type-badge'>{TYPE_LABEL[type]}</Text>
|
<Text className='product-detail__type-badge'>{TYPE_LABEL[type]}</Text>
|
||||||
@@ -113,7 +135,6 @@ export default function ProductDetail() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 库存/余额卡 */}
|
|
||||||
<View className='product-detail__status-card'>
|
<View className='product-detail__status-card'>
|
||||||
<View className='product-detail__status-row'>
|
<View className='product-detail__status-row'>
|
||||||
<Text className='product-detail__status-label'>库存状态</Text>
|
<Text className='product-detail__status-label'>库存状态</Text>
|
||||||
@@ -133,7 +154,6 @@ export default function ProductDetail() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 温馨提示 */}
|
|
||||||
<View className='product-detail__notice'>
|
<View className='product-detail__notice'>
|
||||||
<Text className='product-detail__notice-text'>
|
<Text className='product-detail__notice-text'>
|
||||||
{isService
|
{isService
|
||||||
@@ -143,7 +163,6 @@ export default function ProductDetail() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 底部操作栏 */}
|
|
||||||
<View className='product-detail__footer'>
|
<View className='product-detail__footer'>
|
||||||
<View className='product-detail__footer-left'>
|
<View className='product-detail__footer-left'>
|
||||||
<Text className='product-detail__footer-hint'>需要</Text>
|
<Text className='product-detail__footer-hint'>需要</Text>
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ mod m20260521_000161_consultation_media_id_and_suggestion_references;
|
|||||||
mod m20260521_000162_consultation_session_rating_feedback;
|
mod m20260521_000162_consultation_session_rating_feedback;
|
||||||
mod m20260521_000163_reorganize_menus_by_business_flow;
|
mod m20260521_000163_reorganize_menus_by_business_flow;
|
||||||
mod m20260521_000164_reorganize_menus_scheme_b;
|
mod m20260521_000164_reorganize_menus_scheme_b;
|
||||||
|
mod m20260522_000160_article_add_is_public;
|
||||||
|
mod m20260522_000161_patient_points_manage_perm;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -339,6 +341,8 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260521_000162_consultation_session_rating_feedback::Migration),
|
Box::new(m20260521_000162_consultation_session_rating_feedback::Migration),
|
||||||
Box::new(m20260521_000163_reorganize_menus_by_business_flow::Migration),
|
Box::new(m20260521_000163_reorganize_menus_by_business_flow::Migration),
|
||||||
Box::new(m20260521_000164_reorganize_menus_scheme_b::Migration),
|
Box::new(m20260521_000164_reorganize_menus_scheme_b::Migration),
|
||||||
|
Box::new(m20260522_000160_article_add_is_public::Migration),
|
||||||
|
Box::new(m20260522_000161_patient_points_manage_perm::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = manager.get_connection();
|
||||||
|
|
||||||
|
// 患者角色需要 health.points.manage 权限才能兑换商品
|
||||||
|
db.execute_unprepared(
|
||||||
|
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||||
|
SELECT r.id, p.id, r.tenant_id, 'self', NOW(), NOW(), r.id, r.id, NULL, 1 \
|
||||||
|
FROM roles r \
|
||||||
|
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = 'health.points.manage' AND p.deleted_at IS NULL \
|
||||||
|
WHERE r.code = 'patient' AND r.deleted_at IS NULL \
|
||||||
|
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
|
||||||
|
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = manager.get_connection();
|
||||||
|
|
||||||
|
db.execute_unprepared(
|
||||||
|
"DELETE FROM role_permissions \
|
||||||
|
WHERE role_id IN (SELECT id FROM roles WHERE code = 'patient') \
|
||||||
|
AND permission_id IN (SELECT id FROM permissions WHERE code = 'health.points.manage')",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user