feat(mp+health): 小程序分包迁移 + 积分商城后台列表 API
- 小程序页面迁移到 pkg-health/pkg-mall/pkg-profile 分包目录 - 删除旧 pages/health/input、pages/mall/detail 等旧路径 - 导航路径更新为分包路径(/pages/pkg-mall/exchange/index 等) - TrendChart 组件优化 - 后台添加 admin_list_products API(支持查看已下架商品) - config/index.ts 添加 defineConstants 环境变量 - mp e2e check-readiness 路径修正
This commit is contained in:
@@ -27,6 +27,9 @@ export default defineConfig(async (merge) => {
|
||||
mini: {
|
||||
compile: {
|
||||
exclude: [],
|
||||
include: [
|
||||
require.resolve('zod').replace(/[\\/]index\.cjs$/, ''),
|
||||
],
|
||||
},
|
||||
postcss: {
|
||||
pxtransform: { enable: true, config: {} },
|
||||
|
||||
@@ -13,6 +13,6 @@ async function check(url: string, label: string) {
|
||||
|
||||
export default async function setup() {
|
||||
const apiBase = process.env.E2E_API_URL || 'http://localhost:3000';
|
||||
await check(`${apiBase}/health/live`, '后端 API');
|
||||
await check(`${apiBase}/api/v1/health`, '后端 API');
|
||||
console.log('✅ 小程序 E2E 环境就绪');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import EcCanvas from '../EcCanvas';
|
||||
import type { EcCanvasRef } from '../EcCanvas';
|
||||
import { Canvas, View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import './index.scss';
|
||||
|
||||
interface TrendChartProps {
|
||||
@@ -12,6 +11,24 @@ interface TrendChartProps {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const DPR = Taro.getSystemInfoSync().pixelRatio || 2;
|
||||
|
||||
function drawLine(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
points: { x: number; y: number }[],
|
||||
) {
|
||||
if (points.length < 2) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
const cpx = (prev.x + curr.x) / 2;
|
||||
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
export default React.memo(function TrendChart({
|
||||
data,
|
||||
referenceMin,
|
||||
@@ -19,97 +36,132 @@ export default React.memo(function TrendChart({
|
||||
unit = '',
|
||||
height = 500,
|
||||
}: TrendChartProps) {
|
||||
const chartRef = useRef<EcCanvasRef>(null);
|
||||
const canvasRef = useRef<any>(null);
|
||||
|
||||
const getOption = useCallback(() => {
|
||||
if (!data || data.length === 0) return null;
|
||||
const draw = useCallback(() => {
|
||||
const node = canvasRef.current;
|
||||
if (!node || !data || data.length === 0) return;
|
||||
|
||||
const series: any[] = [];
|
||||
const markArea: any = {};
|
||||
const w = node.width / DPR;
|
||||
const h = node.height / DPR;
|
||||
const ctx = node.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, node.width, node.height);
|
||||
ctx.save();
|
||||
ctx.scale(DPR, DPR);
|
||||
|
||||
const pad = { left: 45, right: 15, top: 20, bottom: 30 };
|
||||
const cw = w - pad.left - pad.right;
|
||||
const ch = h - pad.top - pad.bottom;
|
||||
|
||||
const values = data.map((d) => d.value);
|
||||
let yMin = Math.min(...values);
|
||||
let yMax = Math.max(...values);
|
||||
if (referenceMin != null) yMin = Math.min(yMin, referenceMin);
|
||||
if (referenceMax != null) yMax = Math.max(yMax, referenceMax);
|
||||
const yRange = yMax - yMin || 1;
|
||||
const yPad = yRange * 0.1;
|
||||
yMin -= yPad;
|
||||
yMax += yPad;
|
||||
const yTotal = yMax - yMin;
|
||||
|
||||
const toX = (i: number) => pad.left + (i / Math.max(data.length - 1, 1)) * cw;
|
||||
const toY = (v: number) => pad.top + ch - ((v - yMin) / yTotal) * ch;
|
||||
|
||||
// Reference band
|
||||
if (referenceMin != null && referenceMax != null) {
|
||||
markArea.data = [
|
||||
[
|
||||
{
|
||||
yAxis: referenceMin,
|
||||
itemStyle: { color: 'rgba(5,150,105,0.08)' },
|
||||
},
|
||||
{ yAxis: referenceMax },
|
||||
],
|
||||
];
|
||||
const ry1 = toY(referenceMax);
|
||||
const ry2 = toY(referenceMin);
|
||||
ctx.fillStyle = 'rgba(5,150,105,0.08)';
|
||||
ctx.fillRect(pad.left, ry1, cw, ry2 - ry1);
|
||||
}
|
||||
|
||||
series.push({
|
||||
type: 'line',
|
||||
data: data.map((d) => d.value),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: '#0891B2', width: 2 },
|
||||
itemStyle: { color: '#0891B2' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(8,145,178,0.15)' },
|
||||
{ offset: 1, color: 'rgba(8,145,178,0.01)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
markArea: markArea.data
|
||||
? { silent: true, data: markArea.data }
|
||||
: undefined,
|
||||
markPoint:
|
||||
referenceMin != null && referenceMax != null
|
||||
? {
|
||||
data: data
|
||||
.filter((d) => d.value < referenceMin || d.value > referenceMax)
|
||||
.map((d) => ({
|
||||
coord: [data.indexOf(d), d.value],
|
||||
itemStyle: { color: '#DC2626' },
|
||||
symbolSize: 12,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#F3F4F6';
|
||||
ctx.lineWidth = 1;
|
||||
const gridLines = 4;
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const gy = pad.top + (ch / gridLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, gy);
|
||||
ctx.lineTo(pad.left + cw, gy);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
return {
|
||||
grid: { left: 45, right: 15, top: 20, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.date.slice(5)),
|
||||
axisLabel: { fontSize: 10, color: '#94A3B8' },
|
||||
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { fontSize: 10, color: '#94A3B8' },
|
||||
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const p = params[0];
|
||||
const idx = p.dataIndex;
|
||||
return `${data[idx]?.date || ''}\n${p.value}${unit ? ' ' + unit : ''}`;
|
||||
},
|
||||
},
|
||||
series,
|
||||
};
|
||||
}, [data, referenceMin, referenceMax, unit]);
|
||||
// Y-axis labels
|
||||
ctx.fillStyle = '#94A3B8';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const val = yMax - (yTotal / gridLines) * i;
|
||||
const gy = pad.top + (ch / gridLines) * i;
|
||||
ctx.fillText(val.toFixed(1), pad.left - 6, gy + 3);
|
||||
}
|
||||
|
||||
// X-axis labels
|
||||
ctx.textAlign = 'center';
|
||||
const step = Math.max(1, Math.floor(data.length / 5));
|
||||
for (let i = 0; i < data.length; i += step) {
|
||||
const lx = toX(i);
|
||||
ctx.fillText(data[i].date.slice(5), lx, h - 8);
|
||||
}
|
||||
|
||||
// Area fill
|
||||
const chartPoints = data.map((d, i) => ({ x: toX(i), y: toY(d.value) }));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartPoints[0].x, toY(yMin));
|
||||
ctx.lineTo(chartPoints[0].x, chartPoints[0].y);
|
||||
for (let i = 1; i < chartPoints.length; i++) {
|
||||
const prev = chartPoints[i - 1];
|
||||
const curr = chartPoints[i];
|
||||
const cpx = (prev.x + curr.x) / 2;
|
||||
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
|
||||
}
|
||||
ctx.lineTo(chartPoints[chartPoints.length - 1].x, toY(yMin));
|
||||
ctx.closePath();
|
||||
const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + ch);
|
||||
grad.addColorStop(0, 'rgba(8,145,178,0.15)');
|
||||
grad.addColorStop(1, 'rgba(8,145,178,0.01)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// Line
|
||||
ctx.strokeStyle = '#0891B2';
|
||||
ctx.lineWidth = 2;
|
||||
drawLine(ctx, chartPoints);
|
||||
|
||||
// Data points
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const d = data[i];
|
||||
const isAbnormal =
|
||||
(referenceMin != null && d.value < referenceMin) ||
|
||||
(referenceMax != null && d.value > referenceMax);
|
||||
ctx.beginPath();
|
||||
ctx.arc(chartPoints[i].x, chartPoints[i].y, isAbnormal ? 5 : 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isAbnormal ? '#DC2626' : '#0891B2';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}, [data, referenceMin, referenceMax]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartRef.current && data && data.length > 0) {
|
||||
const option = getOption();
|
||||
if (option) {
|
||||
chartRef.current.setOption(option);
|
||||
}
|
||||
}
|
||||
}, [data, getOption]);
|
||||
const query = Taro.createSelectorQuery();
|
||||
query
|
||||
.select('#trend-chart-canvas')
|
||||
.node()
|
||||
.exec((res) => {
|
||||
const node = res[0]?.node;
|
||||
if (!node) return;
|
||||
canvasRef.current = node;
|
||||
const sysInfo = Taro.getSystemInfoSync();
|
||||
const canvasW = (sysInfo.windowWidth * 750) / sysInfo.windowWidth;
|
||||
node.width = sysInfo.windowWidth * DPR;
|
||||
node.height = ((height / 750) * sysInfo.windowWidth) * DPR;
|
||||
draw();
|
||||
});
|
||||
}, [draw, height]);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
@@ -121,7 +173,11 @@ export default React.memo(function TrendChart({
|
||||
|
||||
return (
|
||||
<View className='trend-chart' style={{ height: `${height}rpx` }}>
|
||||
<EcCanvas canvasId='trend-chart-canvas' ref={chartRef} height={height} />
|
||||
<Canvas
|
||||
type='2d'
|
||||
id='trend-chart-canvas'
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -73,15 +73,15 @@ export default function Health() {
|
||||
};
|
||||
|
||||
const goToInput = () => {
|
||||
Taro.navigateTo({ url: '/pages/health/input/index' });
|
||||
Taro.navigateTo({ url: '/pages/pkg-health/input/index' });
|
||||
};
|
||||
|
||||
const goToDailyMonitoring = () => {
|
||||
Taro.navigateTo({ url: '/pages/health/daily-monitoring/index' });
|
||||
Taro.navigateTo({ url: '/pages/pkg-health/daily-monitoring/index' });
|
||||
};
|
||||
|
||||
const goToTrend = (indicator: string) => {
|
||||
Taro.navigateTo({ url: `/pages/health/trend/index?indicator=${indicator}` });
|
||||
Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${indicator}` });
|
||||
};
|
||||
|
||||
const goToMall = () => {
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.input-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 0 0 60px;
|
||||
}
|
||||
|
||||
/* ── hero ── */
|
||||
.input-hero {
|
||||
padding: 48px 32px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-hero-icon {
|
||||
@include flex-center;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: $r-lg;
|
||||
background: $pri-l;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-hero-icon-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.input-hero-title {
|
||||
@include section-title;
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-hero-sub {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
/* ── card ── */
|
||||
.input-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: $shadow-md;
|
||||
padding: 28px;
|
||||
margin: 0 24px 20px;
|
||||
}
|
||||
|
||||
.input-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-card-indicator {
|
||||
@include flex-center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: $r-sm;
|
||||
background: $acc-l;
|
||||
}
|
||||
|
||||
.input-card-indicator-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
.input-card-label {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
/* ── picker ── */
|
||||
.input-picker-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.input-picker-value {
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
@include serif-number;
|
||||
}
|
||||
|
||||
.input-picker-arrow {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
transform: rotate(180deg);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── section title ── */
|
||||
.input-section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── blood pressure group ── */
|
||||
.input-bp-group {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-bp-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-field-label {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-bp-divider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-bottom: 20px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-bp-line {
|
||||
width: 16px;
|
||||
height: 1px;
|
||||
background: $bd;
|
||||
}
|
||||
|
||||
.input-bp-slash {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 36px;
|
||||
color: $tx3;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* ── input field ── */
|
||||
.input-field-box {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 20px 24px;
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
@include serif-number;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-field-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-field-unit {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── submit ── */
|
||||
.input-submit {
|
||||
background: $pri;
|
||||
border-radius: $r;
|
||||
padding: 26px;
|
||||
text-align: center;
|
||||
margin: 48px 24px 0;
|
||||
box-shadow: $shadow-md;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.input-submit-disabled {
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.input-submit-text {
|
||||
font-size: 32px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Input, Picker } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { z } from 'zod';
|
||||
import { inputVitalSign } from '../../../services/health';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import { useHealthStore } from '@/stores/health';
|
||||
import { usePointsStore } from '@/stores/points';
|
||||
import { clearRequestCache } from '@/services/request';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
import './index.scss';
|
||||
|
||||
const INDICATORS = [
|
||||
{ value: 'blood_pressure', label: '血压 (mmHg)' },
|
||||
{ value: 'heart_rate', label: '心率 (bpm)' },
|
||||
{ value: 'blood_sugar_fasting', label: '空腹血糖 (mmol/L)' },
|
||||
{ value: 'blood_sugar_postprandial', label: '餐后血糖 (mmol/L)' },
|
||||
{ value: 'weight', label: '体重 (kg)' },
|
||||
{ value: 'temperature', label: '体温 (℃)' },
|
||||
];
|
||||
|
||||
const vitalSignSchema = z.object({
|
||||
indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar_fasting', 'blood_sugar_postprandial', 'weight', 'temperature']),
|
||||
value: z.number().positive({ message: '请输入有效数值' }),
|
||||
extra: z.object({
|
||||
systolic: z.number().min(60, '收缩压过低').max(250, '收缩压过高,请及时就医').optional(),
|
||||
diastolic: z.number().min(40, '舒张压过低').max(150, '舒张压过高,请及时就医').optional(),
|
||||
}).optional(),
|
||||
note: z.string().max(200, '备注不能超过200字').optional(),
|
||||
});
|
||||
|
||||
const WARN_THRESHOLDS: Record<string, { max?: number; min?: number; warning: string }> = {
|
||||
blood_pressure: { max: 180, warning: '收缩压偏高,建议及时就医' },
|
||||
heart_rate: { max: 120, min: 50, warning: '心率异常,请注意休息' },
|
||||
blood_sugar_fasting: { max: 11.0, warning: '血糖偏高,建议就医检查' },
|
||||
};
|
||||
|
||||
export default function HealthInput() {
|
||||
const [indicatorIdx, setIndicatorIdx] = useState(0);
|
||||
const [value, setValue] = useState('');
|
||||
const [systolic, setSystolic] = useState('');
|
||||
const [diastolic, setDiastolic] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { currentPatient } = useAuthStore();
|
||||
const { clearCache } = useHealthStore();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentPatient) {
|
||||
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndicator = INDICATORS[indicatorIdx].value;
|
||||
|
||||
if (currentIndicator === 'blood_pressure') {
|
||||
if (!systolic || !diastolic) {
|
||||
Taro.showToast({ title: '请填写收缩压和舒张压', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!value) {
|
||||
Taro.showToast({ title: '请输入数值', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const input = currentIndicator === 'blood_pressure'
|
||||
? { indicator_type: 'blood_pressure' as const, value: parseFloat(systolic), extra: { systolic: parseFloat(systolic), diastolic: parseFloat(diastolic) } }
|
||||
: { indicator_type: currentIndicator as any, value: parseFloat(value) };
|
||||
|
||||
const result = vitalSignSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
Taro.showToast({ title: result.error.issues[0].message, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const threshold = WARN_THRESHOLDS[currentIndicator];
|
||||
if (threshold) {
|
||||
const val = input.value;
|
||||
if ((threshold.max && val > threshold.max) || (threshold.min && val < threshold.min)) {
|
||||
await Taro.showModal({ title: '健康提示', content: threshold.warning, showCancel: false });
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await inputVitalSign(currentPatient.id, {
|
||||
...input,
|
||||
note: note || undefined,
|
||||
});
|
||||
clearCache();
|
||||
clearRequestCache('/health/');
|
||||
usePointsStore.getState().invalidate();
|
||||
Taro.showToast({ title: '录入成功', icon: 'success' });
|
||||
trackEvent('health_data_input', { type: currentIndicator });
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '录入失败';
|
||||
Taro.showToast({ title: msg, icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const indicatorInitial = INDICATORS[indicatorIdx].label.charAt(0);
|
||||
|
||||
return (
|
||||
<View className='input-page'>
|
||||
{/* 页面标题 */}
|
||||
<View className='input-hero'>
|
||||
<View className='input-hero-icon'>
|
||||
<Text className='input-hero-icon-text'>录</Text>
|
||||
</View>
|
||||
<Text className='input-hero-title'>体征录入</Text>
|
||||
<Text className='input-hero-sub'>记录今日健康数据</Text>
|
||||
</View>
|
||||
|
||||
{/* 指标类型选择 */}
|
||||
<View className='input-card'>
|
||||
<View className='input-card-header'>
|
||||
<View className='input-card-indicator'>
|
||||
<Text className='input-card-indicator-char'>{indicatorInitial}</Text>
|
||||
</View>
|
||||
<Text className='input-card-label'>指标类型</Text>
|
||||
</View>
|
||||
<Picker
|
||||
mode='selector'
|
||||
range={INDICATORS.map((i) => i.label)}
|
||||
value={indicatorIdx}
|
||||
onChange={(e) => setIndicatorIdx(Number(e.detail.value))}
|
||||
>
|
||||
<View className='input-picker-row'>
|
||||
<Text className='input-picker-value'>{INDICATORS[indicatorIdx].label}</Text>
|
||||
<Text className='input-picker-arrow'>V</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
{/* 数值输入 */}
|
||||
{INDICATORS[indicatorIdx].value === 'blood_pressure' ? (
|
||||
<View className='input-card'>
|
||||
<Text className='input-section-title'>血压数值</Text>
|
||||
<View className='input-bp-group'>
|
||||
<View className='input-bp-field'>
|
||||
<Text className='input-field-label'>收缩压</Text>
|
||||
<Input
|
||||
type='digit'
|
||||
className='input-field-box'
|
||||
placeholder='如 120'
|
||||
value={systolic}
|
||||
onInput={(e) => setSystolic(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='input-bp-divider'>
|
||||
<View className='input-bp-line' />
|
||||
<Text className='input-bp-slash'>/</Text>
|
||||
<View className='input-bp-line' />
|
||||
</View>
|
||||
<View className='input-bp-field'>
|
||||
<Text className='input-field-label'>舒张压</Text>
|
||||
<Input
|
||||
type='digit'
|
||||
className='input-field-box'
|
||||
placeholder='如 80'
|
||||
value={diastolic}
|
||||
onInput={(e) => setDiastolic(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='input-field-unit'>mmHg</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='input-card'>
|
||||
<Text className='input-section-title'>检测数值</Text>
|
||||
<Input
|
||||
type='digit'
|
||||
className='input-field-box input-field-full'
|
||||
placeholder='请输入数值'
|
||||
value={value}
|
||||
onInput={(e) => setValue(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-field-unit'>
|
||||
{INDICATORS[indicatorIdx].label.match(/\((.+)\)/)?.[1] || ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 备注 */}
|
||||
<View className='input-card'>
|
||||
<Text className='input-section-title'>备注</Text>
|
||||
<Input
|
||||
className='input-field-box input-field-full'
|
||||
placeholder='如:饭后2小时(可选)'
|
||||
value={note}
|
||||
onInput={(e) => setNote(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 提交 */}
|
||||
<View
|
||||
className={`input-submit ${submitting ? 'input-submit-disabled' : ''}`}
|
||||
onClick={submitting ? undefined : handleSubmit}
|
||||
>
|
||||
<Text className='input-submit-text'>{submitting ? '提交中...' : '提交录入'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export default function Mall() {
|
||||
Taro.showToast({ title: '已兑完', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({ url: `/pages/mall/exchange/index?product_id=${item.id}` });
|
||||
Taro.navigateTo({ url: `/pages/pkg-mall/exchange/index?product_id=${item.id}` });
|
||||
};
|
||||
|
||||
const balance = account?.balance ?? 0;
|
||||
@@ -134,7 +134,7 @@ export default function Mall() {
|
||||
</View>
|
||||
<Text className='empty-title'>请先完善个人档案</Text>
|
||||
<Text className='empty-hint'>建档后即可使用积分商城、签到等功能</Text>
|
||||
<View className='empty-action' onClick={() => Taro.navigateTo({ url: '/pages/profile/family-add/index' })}>
|
||||
<View className='empty-action' onClick={() => Taro.navigateTo({ url: '/pages/pkg-profile/family-add/index' })}>
|
||||
<Text className='empty-action-text'>去建档</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function ExchangeConfirm() {
|
||||
confirmText: '查看订单',
|
||||
success: () => {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/mall/orders/index`,
|
||||
url: `/pages/pkg-mall/orders/index`,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -39,12 +39,12 @@ export default function FamilyList() {
|
||||
};
|
||||
|
||||
const goToAdd = () => {
|
||||
Taro.navigateTo({ url: '/pages/profile/family-add/index' });
|
||||
Taro.navigateTo({ url: '/pages/pkg-profile/family-add/index' });
|
||||
};
|
||||
|
||||
const goToEdit = (patient: Patient) => {
|
||||
Taro.setStorageSync('edit_patient', patient);
|
||||
Taro.navigateTo({ url: `/pages/profile/family-add/index?id=${patient.id}` });
|
||||
Taro.navigateTo({ url: `/pages/pkg-profile/family-add/index?id=${patient.id}` });
|
||||
};
|
||||
|
||||
const genderText = (g?: string) => {
|
||||
@@ -6,13 +6,13 @@ import { usePointsStore } from '../../stores/points';
|
||||
import './index.scss';
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{ label: '我的订单', char: '单', path: '/pages/mall/orders/index' },
|
||||
{ label: '积分明细', char: '明', path: '/pages/mall/detail/index' },
|
||||
{ label: '就诊人管理', char: '人', path: '/pages/profile/family/index' },
|
||||
{ label: '我的报告', char: '报', path: '/pages/profile/reports/index' },
|
||||
{ label: '我的随访', char: '随', path: '/pages/profile/followups/index' },
|
||||
{ label: '用药提醒', char: '药', path: '/pages/profile/medication/index' },
|
||||
{ label: '设置', char: '设', path: '/pages/profile/settings/index' },
|
||||
{ label: '我的订单', char: '单', path: '/pages/pkg-mall/orders/index' },
|
||||
{ label: '积分明细', char: '明', path: '/pages/pkg-mall/detail/index' },
|
||||
{ label: '就诊人管理', char: '人', path: '/pages/pkg-profile/family/index' },
|
||||
{ label: '我的报告', char: '报', path: '/pages/pkg-profile/reports/index' },
|
||||
{ label: '我的随访', char: '随', path: '/pages/pkg-profile/followups/index' },
|
||||
{ label: '用药提醒', char: '药', path: '/pages/pkg-profile/medication/index' },
|
||||
{ label: '设置', char: '设', path: '/pages/pkg-profile/settings/index' },
|
||||
];
|
||||
|
||||
export default function Profile() {
|
||||
@@ -53,7 +53,7 @@ export default function Profile() {
|
||||
{/* 积分余额 */}
|
||||
<View
|
||||
className='profile-stats'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/mall/detail/index' })}
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/pkg-mall/detail/index' })}
|
||||
>
|
||||
<View className='stat-item'>
|
||||
<Text className='stat-value'>{(pointsAccount?.balance ?? 0).toLocaleString()}</Text>
|
||||
|
||||
@@ -23,6 +23,11 @@ pub struct ProductTypeParam {
|
||||
pub product_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct AdminProductFilter {
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 患者端:积分账户 + 打卡
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -259,6 +264,24 @@ where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
pub async fn admin_list_products<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ProductTypeParam>,
|
||||
Query(page): Query<PaginationParams>,
|
||||
Query(filter): Query<AdminProductFilter>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsProductResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let p = page.page.unwrap_or(1);
|
||||
let ps = page.page_size.unwrap_or(20);
|
||||
let result = points_service::admin_list_products(
|
||||
&state, ctx.tenant_id, params.product_type, filter.is_active, p, ps,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn admin_create_product<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
|
||||
@@ -589,6 +589,50 @@ pub async fn list_products(
|
||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||
}
|
||||
|
||||
/// 管理端商品列表 — 不过滤 is_active,显示全部商品
|
||||
pub async fn admin_list_products(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
product_type: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<PaginatedResponse<PointsProductResp>> {
|
||||
let limit = page_size.min(100);
|
||||
let offset = page.saturating_sub(1) * limit;
|
||||
|
||||
let mut query = points_product::Entity::find()
|
||||
.filter(points_product::Column::TenantId.eq(tenant_id))
|
||||
.filter(points_product::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref pt) = product_type {
|
||||
query = query.filter(points_product::Column::ProductType.eq(pt.as_str()));
|
||||
}
|
||||
if let Some(active) = is_active {
|
||||
query = query.filter(points_product::Column::IsActive.eq(active));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&state.db).await?;
|
||||
let models = query
|
||||
.order_by_asc(points_product::Column::SortOrder)
|
||||
.order_by_desc(points_product::Column::CreatedAt)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let total_pages = total.div_ceil(limit.max(1));
|
||||
let data = models.into_iter().map(|m| PointsProductResp {
|
||||
id: m.id, name: m.name, product_type: m.product_type,
|
||||
points_cost: m.points_cost, stock: m.stock,
|
||||
image_url: m.image_url, description: m.description,
|
||||
is_active: m.is_active, sort_order: m.sort_order,
|
||||
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||||
}).collect();
|
||||
|
||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||
}
|
||||
|
||||
pub async fn get_product(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
|
||||
Reference in New Issue
Block a user