This commit is contained in:
Your Name
2026-06-03 14:42:48 +08:00
parent ec4e0b9f29
commit 37df208706
6 changed files with 917 additions and 15 deletions
@@ -0,0 +1,203 @@
import { ref, computed } from 'vue'
import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js'
const emptyBloodCarePlan = () => ({
summary: '',
blood_advice: '',
tcm_plan: '',
watch_points: [],
next_steps: '',
control_level: '',
control_label: '',
disclaimer: '',
analysis: [],
source: '',
date: ''
})
/** AI 血糖照护建议(累计 7 天有记录后) */
export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) {
const bloodCarePlan = ref(emptyBloodCarePlan())
const bloodCareLoading = ref(false)
const bloodCareStreaming = ref(false)
const bloodCareStreamField = ref('')
let careStreamBuffer = ''
/** 用户点击后才展开照护建议卡片 */
const bloodCarePanelOpen = ref(false)
const bloodCareEligible = computed(() => {
return !!diagnosisId.value && !chartUseMockData.value
})
const hasBloodCareContent = computed(() => {
const p = bloodCarePlan.value
return !!(p.summary || p.blood_advice || p.tcm_plan)
})
const bloodCareAnalysis = computed(() => {
const list = bloodCarePlan.value?.analysis
return Array.isArray(list) ? list : []
})
const bloodCareControlLabel = computed(() => {
const label = String(bloodCarePlan.value?.control_label || '').trim()
return label
})
const bloodCareDisclaimer = computed(() => {
return String(bloodCarePlan.value?.disclaimer || '').trim()
})
function detectCareStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[:]\s*([^\n]*)$/)
if (!tail) return 'summary'
const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' }
return map[tail[1]] || 'summary'
}
function mergePartialCareFromStream() {
const partial = parsePartialBloodCarePlainText(careStreamBuffer)
const next = { ...bloodCarePlan.value }
for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points
bloodCarePlan.value = next
bloodCareStreamField.value = detectCareStreamField(careStreamBuffer)
}
function applyBloodCarePayload(payload) {
if (!payload) return
const prevAnalysis = bloodCarePlan.value.analysis
bloodCarePlan.value = {
...emptyBloodCarePlan(),
...payload,
watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [],
analysis: Array.isArray(payload.analysis) && payload.analysis.length
? payload.analysis
: (Array.isArray(prevAnalysis) ? prevAnalysis : [])
}
}
function onBloodCareStreamEvent(event, payload) {
if (event === 'analysis') {
if (Array.isArray(payload?.analysis)) {
bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis }
}
} else if (event === 'delta') {
bloodCareStreaming.value = true
if (payload.buffer != null) {
careStreamBuffer = payload.buffer
} else {
careStreamBuffer += payload.text || ''
}
mergePartialCareFromStream()
} else if (event === 'done') {
applyBloodCarePayload(payload)
careStreamBuffer = ''
bloodCareStreamField.value = ''
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '照护建议加载失败'))
}
}
async function fetchBloodCareAiFallback(refresh) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyBloodCareAiRecommend',
method: 'GET',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
}
}, false)
if (res?.code === 1 && res.data) {
applyBloodCarePayload(res.data)
} else if (res?.msg) {
showUserToast(res.msg)
}
}
function closeBloodCarePanel() {
bloodCarePanelOpen.value = false
}
/** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */
async function toggleBloodCareFromChart() {
if (!bloodCareEligible.value || bloodCareLoading.value) return
if (bloodCarePanelOpen.value) {
closeBloodCarePanel()
return
}
bloodCarePanelOpen.value = true
if (!hasBloodCareContent.value) {
await fetchBloodCareAi(false)
}
}
async function fetchBloodCareAi(refresh = false) {
if (!bloodCareEligible.value || bloodCareLoading.value) return
bloodCareLoading.value = true
bloodCareStreaming.value = false
careStreamBuffer = ''
bloodCareStreamField.value = ''
if (refresh) {
applyBloodCarePayload(emptyBloodCarePlan())
}
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyBloodCareAiRecommendStream',
method: 'GET',
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
},
onEvent: onBloodCareStreamEvent,
fallback: () => fetchBloodCareAiFallback(refresh)
})
} catch (e) {
try {
await fetchBloodCareAiFallback(refresh)
} catch (e2) {
showUserToast('照护建议加载失败')
}
} finally {
bloodCareLoading.value = false
bloodCareStreaming.value = false
bloodCareStreamField.value = ''
}
}
function refreshBloodCareAi() {
if (bloodCareLoading.value) return
fetchBloodCareAi(true)
}
function resetBloodCarePlan() {
applyBloodCarePayload(emptyBloodCarePlan())
careStreamBuffer = ''
bloodCareStreamField.value = ''
bloodCarePanelOpen.value = false
}
return {
bloodCarePlan,
bloodCareLoading,
bloodCareStreaming,
bloodCareStreamField,
bloodCareEligible,
bloodCarePanelOpen,
hasBloodCareContent,
bloodCareAnalysis,
bloodCareControlLabel,
bloodCareDisclaimer,
fetchBloodCareAi,
toggleBloodCareFromChart,
closeBloodCarePanel,
refreshBloodCareAi,
resetBloodCarePlan
}
}
+1 -1
View File
@@ -482,7 +482,7 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
}
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态,不含退款 */
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款」 */
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
}
@@ -2099,7 +2099,9 @@
</div>
</template>
</el-alert>
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态选择已完成实付将按已关联的支付单金额汇总</p>
<p class="text-sm text-gray-600 mb-3">
请选择本单结案状态选择已完成实付将按已关联的支付单金额汇总选择退款时将关联收款单标记为已退款并清零本单已付
</p>
<el-select
v-model="completeFulfillmentStatus"
class="w-full"
@@ -2112,11 +2114,76 @@
:value="opt.value"
/>
</el-select>
<template v-if="completeFulfillmentStatus === 10">
<el-alert
type="warning"
:closable="false"
show-icon
class="mt-3 !items-start"
title="退款将处理本单全部关联收款单,已付总额清零,且不可撤销。"
/>
<div v-loading="completeOrderPayLoading" class="mt-3">
<div
v-if="completeOrderLinkedPays.length"
class="rounded-lg border border-amber-200/80 bg-amber-50/50 p-3"
>
<div class="text-xs font-semibold text-amber-900 mb-2">
将退款的关联收款{{ completeOrderLinkedPays.length }} · 合计 ¥{{ formatMoney(completeOrderRefundTotal) }}
</div>
<el-table :data="completeOrderLinkedPays" size="small" border stripe max-height="200">
<el-table-column prop="order_no" label="单号" min-width="120" show-overflow-tooltip />
<el-table-column label="类型" width="88">
<template #default="{ row }">
{{ row.order_type_desc || feeTypeText(row.order_type) }}
</template>
</el-table-column>
<el-table-column label="金额" width="88" align="right">
<template #default="{ row }">
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="92" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<p v-else-if="!completeOrderPayLoading" class="text-xs text-gray-500 mt-2">
当前无已关联的收款单提交后仍将本单标记为退款并清零已付
</p>
</div>
<el-form
ref="completeRefundFormRef"
:model="completeRefundForm"
:rules="completeRefundRules"
label-width="88px"
class="mt-3"
>
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="completeRefundForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写退款原因(必填)"
/>
</el-form-item>
</el-form>
</template>
</div>
<template #footer>
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
确定
<el-button
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
:loading="completeOrderSubmitting"
@click="submitCompleteOrder"
>
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
</el-button>
</template>
</el-dialog>
@@ -3740,7 +3807,9 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
}
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
return canCompleteRow(row)
// (5) / (6) / (3)(1) 退
const fs = Number(row.fulfillment_status)
return (fs === 5 || fs === 6 || fs === 3) && Number(row.payment_slip_audit_status) === 1
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
@@ -5251,6 +5320,7 @@ const completeOrderStatusOptions = [
{ value: 7, label: '进行中' },
{ value: 8, label: '暂不制药' },
{ value: 9, label: '拒收' },
{ value: 10, label: '退款' },
{ value: 11, label: '保留药方' },
{ value: 12, label: '制药缓发' }
] as const
@@ -5259,15 +5329,54 @@ const completeOrderDialogVisible = ref(false)
const completeOrderId = ref(0)
const completeFulfillmentStatus = ref<number>(3)
const completeOrderSubmitting = ref(false)
const completeOrderPayLoading = ref(false)
const completeOrderLinkedPays = ref<any[]>([])
const completeRefundFormRef = ref<FormInstance>()
const completeRefundForm = reactive({ reason: '' })
const completeRefundRules: FormRules = {
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}
const completeOrderRefundTotal = computed(() => {
let sum = 0
for (const o of completeOrderLinkedPays.value) {
const st = Number(o?.status)
if (st === 2 || st === 5) {
sum += Number(o?.amount) || 0
}
}
return Math.round(sum * 100) / 100
})
let completeOrderLogisticsRequestSeq = 0
function onCompleteOrderDialogClosed() {
completeOrderId.value = 0
completeOrderLinkedPays.value = []
completeOrderPayLoading.value = false
completeRefundForm.reason = ''
completeRefundFormRef.value?.clearValidate()
completeOrderLogisticsRequestSeq += 1
completeOrderLogisticsPayload.value = null
completeOrderLogisticsLoading.value = false
}
async function loadCompleteOrderPayContext(orderId: number) {
if (!orderId) {
completeOrderLinkedPays.value = []
return
}
completeOrderPayLoading.value = true
try {
const r: any = await prescriptionOrderDetail({ id: orderId })
const d = r?.data ?? r ?? null
const arr = d?.linked_pay_orders
completeOrderLinkedPays.value = Array.isArray(arr) ? arr : []
} catch {
completeOrderLinkedPays.value = []
} finally {
completeOrderPayLoading.value = false
}
}
async function fetchCompleteOrderLogisticsForDialog(row: {
id: number
tracking_number?: unknown
@@ -5306,6 +5415,32 @@ async function fetchCompleteOrderLogisticsForDialog(row: {
async function submitCompleteOrder() {
const id = completeOrderId.value
if (!id) return
if (completeFulfillmentStatus.value === 10) {
const valid = await completeRefundFormRef.value?.validate().catch(() => false)
if (!valid) return
completeOrderSubmitting.value = true
try {
await prescriptionOrderRefund({ id, reason: completeRefundForm.reason.trim() })
feedback.msgSuccess('退款成功')
completeOrderDialogVisible.value = false
getLists()
if (detailVisible.value && detailData.value?.id === id) {
try {
const r: any = await prescriptionOrderDetail({ id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(id)
} catch { /* 静默 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
completeOrderSubmitting.value = false
}
return
}
completeOrderSubmitting.value = true
try {
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
@@ -5540,8 +5675,11 @@ function confirmComplete(row: {
}) {
completeOrderId.value = row.id
completeFulfillmentStatus.value = 3
completeRefundForm.reason = ''
completeOrderLinkedPays.value = []
completeOrderLogisticsPayload.value = null
completeOrderDialogVisible.value = true
void loadCompleteOrderPayContext(row.id)
if (String(row.tracking_number || '').trim()) {
void fetchCompleteOrderLogisticsForDialog(row)
}
@@ -1845,9 +1845,11 @@
:close-on-click-modal="false"
destroy-on-close
class="po-h5-dialog"
@closed="completeOrderId = 0"
@closed="onCompleteOrderDialogClosed"
>
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态选择已完成实付将按已关联的支付单金额汇总</p>
<p class="text-sm text-gray-600 mb-3">
选择已完成按关联收款汇总实付选择退款将关联收款标记已退款并清零已付
</p>
<el-select
v-model="completeFulfillmentStatus"
class="w-full"
@@ -1860,10 +1862,43 @@
:value="opt.value"
/>
</el-select>
<template v-if="completeFulfillmentStatus === 10">
<el-alert
type="warning"
:closable="false"
show-icon
class="mt-3"
title="关联收款将标记已退款,本单已付清零,不可撤销。"
/>
<p v-if="completeOrderLinkedPays.length" class="text-xs text-amber-800 mt-2">
将退款 {{ completeOrderLinkedPays.length }} 笔关联收款合计 ¥{{ formatMoney(completeOrderRefundTotal) }}
</p>
<el-form
ref="completeRefundFormRef"
:model="completeRefundForm"
:rules="completeRefundRules"
label-width="80px"
class="mt-2"
>
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="completeRefundForm.reason"
type="textarea"
:rows="3"
maxlength="500"
placeholder="必填"
/>
</el-form-item>
</el-form>
</template>
<template #footer>
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
确定
<el-button
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
:loading="completeOrderSubmitting"
@click="submitCompleteOrder"
>
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
</el-button>
</template>
</el-dialog>
@@ -3288,7 +3323,8 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
}
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
return canCompleteRow(row)
const fs = Number(row.fulfillment_status)
return (fs === 5 || fs === 6 || fs === 3) && Number(row.payment_slip_audit_status) === 1
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
@@ -4609,6 +4645,7 @@ const completeOrderStatusOptions = [
{ value: 7, label: '进行中' },
{ value: 8, label: '暂不制药' },
{ value: 9, label: '拒收' },
{ value: 10, label: '退款' },
{ value: 11, label: '保留药方' },
{ value: 12, label: '制药缓发' }
] as const
@@ -4617,10 +4654,71 @@ const completeOrderDialogVisible = ref(false)
const completeOrderId = ref(0)
const completeFulfillmentStatus = ref<number>(3)
const completeOrderSubmitting = ref(false)
const completeOrderLinkedPays = ref<any[]>([])
const completeRefundFormRef = ref<FormInstance>()
const completeRefundForm = reactive({ reason: '' })
const completeRefundRules: FormRules = {
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}
const completeOrderRefundTotal = computed(() => {
let sum = 0
for (const o of completeOrderLinkedPays.value) {
const st = Number(o?.status)
if (st === 2 || st === 5) sum += Number(o?.amount) || 0
}
return Math.round(sum * 100) / 100
})
function onCompleteOrderDialogClosed() {
completeOrderId.value = 0
completeOrderLinkedPays.value = []
completeRefundForm.reason = ''
completeRefundFormRef.value?.clearValidate()
}
async function loadCompleteOrderPayContext(orderId: number) {
if (!orderId) {
completeOrderLinkedPays.value = []
return
}
try {
const r: any = await prescriptionOrderDetail({ id: orderId })
const d = r?.data ?? r ?? null
completeOrderLinkedPays.value = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
} catch {
completeOrderLinkedPays.value = []
}
}
async function submitCompleteOrder() {
const id = completeOrderId.value
if (!id) return
if (completeFulfillmentStatus.value === 10) {
const valid = await completeRefundFormRef.value?.validate().catch(() => false)
if (!valid) return
completeOrderSubmitting.value = true
try {
await prescriptionOrderRefund({ id, reason: completeRefundForm.reason.trim() })
feedback.msgSuccess('退款成功')
completeOrderDialogVisible.value = false
getLists()
if (detailVisible.value && detailData.value?.id === id) {
try {
const r: any = await prescriptionOrderDetail({ id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(id)
} catch { /* 静默 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
completeOrderSubmitting.value = false
}
return
}
completeOrderSubmitting.value = true
try {
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
@@ -4830,7 +4928,10 @@ async function submitAddPayOrder() {
function confirmComplete(row: { id: number }) {
completeOrderId.value = row.id
completeFulfillmentStatus.value = 3
completeRefundForm.reason = ''
completeOrderLinkedPays.value = []
completeOrderDialogVisible.value = true
void loadCompleteOrderPayContext(row.id)
}
//
@@ -2652,8 +2652,8 @@ class PrescriptionOrderLogic
return false;
}
$curFs = (int) $order->fulfillment_status;
if ($curFs !== 5 && $curFs !== 6) {
self::$error = '仅「已发货」「已签收」状态可退款';
if (!in_array($curFs, [3, 5, 6], true)) {
self::$error = '仅「已发货」「已签收」或「已完成」状态可退款';
return false;
}
@@ -2665,12 +2665,25 @@ class PrescriptionOrderLogic
$linkedPayOrderIds = self::linkedPayOrderIdList($id);
$refundedPayCount = 0;
$refundedAmount = 0.0;
try {
if ($linkedPayOrderIds !== []) {
$refundedPayCount = Order::whereIn('id', $linkedPayOrderIds)
$toRefundRows = Order::whereIn('id', $linkedPayOrderIds)
->whereNull('delete_time')
->update(['status' => 4]);
->whereIn('status', [2, 5])
->field(['id', 'amount'])
->select()
->toArray();
foreach ($toRefundRows as $pr) {
$refundedAmount += (float) ($pr['amount'] ?? 0);
}
$refundedAmount = round($refundedAmount, 2);
if ($toRefundRows !== []) {
$refundedPayCount = Order::whereIn('id', array_column($toRefundRows, 'id'))
->whereNull('delete_time')
->update(['status' => 4]);
}
}
$order->fulfillment_status = 10;
$order->paid = 0;
@@ -2684,7 +2697,12 @@ class PrescriptionOrderLogic
$unassignSummary = self::clearDiagnosisAssistantOnComplete((int) $order->diagnosis_id);
$logLine = '订单退款,状态变更为「退款」;原因:' . $reason;
if ($refundedPayCount > 0) {
$logLine .= ';关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
$logLine .= ';关联收款单 ' . $refundedPayCount . ' 笔已标记为已退款';
if ($refundedAmount > 0) {
$logLine .= ',合计 ¥' . $refundedAmount;
}
} elseif ($linkedPayOrderIds !== []) {
$logLine .= ';关联收款单无待退款的已支付/待审核记录';
}
$logLine .= ';已付总额清零' . $unassignSummary;
@@ -0,0 +1,442 @@
<?php
namespace app\api\logic\tcm;
use app\common\model\tcm\Diagnosis;
use app\common\service\AiChatService;
use think\facade\Cache;
/**
* 日常护理 - 血糖照护 AI(累计 7 天有记录后:血糖建议 + 中医调理方向)
*/
class DailyBloodCareAiLogic
{
private const CACHE_TTL = 86400;
private const MIN_RECORD_DAYS = 7;
private const AI_TIMEOUT_SEC = 14;
private const AI_STREAM_TIMEOUT_SEC = 22;
/**
* @return array{ok:bool,error?:string,data?:array}
*/
public static function getDailyCarePlan(int $diagnosisId, bool $refresh = false): array
{
if ($diagnosisId <= 0) {
return ['ok' => false, 'error' => '缺少就诊卡'];
}
$context = self::buildCareContext($diagnosisId);
if (!$context['ok']) {
return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败'];
}
$gate = self::ensureEnoughBloodDays($context['data']);
if (!$gate['ok']) {
return ['ok' => false, 'error' => $gate['error']];
}
$analysis = self::buildAnalysisPayload($context['data']);
$cacheKey = self::careCacheKey($diagnosisId);
if (!$refresh) {
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['summary'])) {
$cached['cached'] = true;
$cached['analysis'] = $analysis;
return ['ok' => true, 'data' => $cached];
}
}
$data = null;
if (AiChatService::isEnabled()) {
$raw = self::aiDailyCarePlan($context['data'], $refresh);
if (is_array($raw) && !empty($raw['summary'])) {
$data = self::finalizeCarePlan($raw);
}
}
if (!$data) {
$data = GiKnowledge::fallbackBloodCarePlan(
$context['data']['stats_7d'] ?? [],
$context['data']['stats_30d'] ?? [],
$context['data']
);
}
$data['date'] = date('Y-m-d');
$data['cached'] = false;
$data['analysis'] = $analysis;
Cache::set($cacheKey, $data, self::CACHE_TTL);
return ['ok' => true, 'data' => $data];
}
/**
* @param callable(string, array<string, mixed>):void $emit
*/
public static function streamDailyCarePlan(int $diagnosisId, bool $refresh, callable $emit): void
{
if ($diagnosisId <= 0) {
$emit('error', ['message' => '缺少就诊卡']);
return;
}
$context = self::buildCareContext($diagnosisId);
if (!$context['ok']) {
$emit('error', ['message' => $context['error'] ?? '获取档案失败']);
return;
}
$gate = self::ensureEnoughBloodDays($context['data']);
if (!$gate['ok']) {
$emit('error', ['message' => $gate['error']]);
return;
}
$analysis = self::buildAnalysisPayload($context['data']);
$emit('analysis', ['analysis' => $analysis]);
$cacheKey = self::careCacheKey($diagnosisId);
if (!$refresh) {
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['summary'])) {
$cached['cached'] = true;
$cached['analysis'] = $analysis;
$emit('done', $cached);
return;
}
}
$data = null;
$fullText = '';
if (AiChatService::isEnabled()) {
[$system, $user] = self::buildCareStreamPrompts($context['data'], $refresh);
$streamRes = AiChatService::streamChat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
], static function (string $delta) use (&$fullText, $emit): void {
$fullText .= $delta;
$emit('delta', ['text' => $delta, 'buffer' => $fullText]);
}, [
'temperature' => $refresh ? 0.75 : 0.4,
'max_tokens' => 720,
'timeout' => self::AI_STREAM_TIMEOUT_SEC,
]);
if ($streamRes['ok'] && trim($fullText) !== '') {
$parsed = self::parseCarePlainText(trim($fullText));
if ($parsed && !empty($parsed['summary'])) {
$data = self::finalizeCarePlan($parsed);
}
}
}
if (!$data) {
$data = GiKnowledge::fallbackBloodCarePlan(
$context['data']['stats_7d'] ?? [],
$context['data']['stats_30d'] ?? [],
$context['data']
);
}
$data['date'] = date('Y-m-d');
$data['cached'] = false;
$data['analysis'] = $analysis;
Cache::set($cacheKey, $data, self::CACHE_TTL);
$emit('done', $data);
}
private static function careCacheKey(int $diagnosisId): string
{
return 'daily_blood_care_ai_v1:' . $diagnosisId . ':' . date('Y-m-d');
}
/**
* @return array{ok:bool,error?:string,data?:array}
*/
private static function buildCareContext(int $diagnosisId): array
{
$base = DailyDietAiLogic::getPatientContext($diagnosisId);
if (!$base['ok']) {
return $base;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if ($diagnosis) {
$row = $diagnosis->toArray();
$base['data']['syndrome_type'] = trim((string) ($row['syndrome_type'] ?? ''));
$base['data']['symptoms'] = trim((string) ($row['symptoms'] ?? ''));
$base['data']['remark'] = trim((string) ($row['remark'] ?? ''));
}
return $base;
}
/**
* @param array<string,mixed> $context
* @return array{ok:bool,error?:string}
*/
private static function ensureEnoughBloodDays(array $context): array
{
$days30 = (int) (($context['stats_30d']['record_days'] ?? 0));
$days7 = (int) (($context['stats_7d']['record_days'] ?? 0));
$total = max($days30, $days7);
if ($total < self::MIN_RECORD_DAYS) {
return ['ok' => false, 'error' => '累计记录满7天后可生成照护建议'];
}
return ['ok' => true];
}
/**
* @param array<string,mixed> $context
* @return array<int,array<string,mixed>>
*/
private static function buildAnalysisPayload(array $context): array
{
$out = [];
foreach (['stats_7d', 'stats_30d'] as $key) {
$s = $context[$key] ?? null;
if (is_array($s)) {
$out[] = $s;
}
}
return $out;
}
/**
* @param array<string,mixed> $context
* @return array<string,mixed>|null
*/
private static function aiDailyCarePlan(array $context, bool $refresh): ?array
{
[$system, $user] = self::buildCareJsonPrompts($context, $refresh);
$res = AiChatService::chat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
], [
'temperature' => $refresh ? 0.7 : 0.35,
'max_tokens' => 650,
'timeout' => self::AI_TIMEOUT_SEC,
'response_format' => ['type' => 'json_object'],
]);
if (empty($res['ok']) || empty($res['content'])) {
return null;
}
$json = self::parseJsonObject((string) $res['content']);
return is_array($json) ? $json : null;
}
/**
* @param array<string,mixed> $context
* @return array{0:string,1:string}
*/
private static function buildCareJsonPrompts(array $context, bool $refresh): array
{
$bloodLine = self::bloodLine($context);
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
$syndrome = trim((string) ($context['syndrome_type'] ?? ''));
$symptoms = trim((string) ($context['symptoms'] ?? ''));
$system = <<<SYS
你是基层中医糖尿病照护助手,面向农村中老年患者,用通俗中文给「血糖管理 + 中医调理方向」建议。
硬性要求:
1. 不得开具具体中药处方名、剂量、加减;不得指导自行增减西药/胰岛素。
2. 中医部分写调理思路、食疗方向、穴位艾灸注意事项,强调需面诊脉诊后由医师定方。
3. 结合提供的血糖统计与逐日记录,给出可执行的监测与生活方式建议。
4. 只输出 JSON
{"summary":"一句话总评","blood_advice":"血糖监测与饮食运动","tcm_plan":"中医辨证与调理方案","watch_points":["留意1","留意2"],"next_steps":"下一步复诊或就医提醒"}
SYS;
$user = sprintf(
"患者:%s,%s,%s岁。证型登记:%s。症状摘要:%s。\n日期:%s。\n近7日血糖:%s。\n统计:%s%s。\n请根据控制好坏给出个性化 JSON。",
$context['patient_name'] ?: '患者',
$context['gender_text'] ?? '',
$context['age'] ?? 0,
$syndrome !== '' ? $syndrome : '未登记',
$symptoms !== '' ? mb_substr($symptoms, 0, 120) : '无',
$context['today'] ?? date('Y-m-d'),
$bloodLine,
$stat7Line,
$stat30Line
);
if ($refresh) {
$user .= "\n\n用户点了「换一换」,请换一套不同表述但同样严谨的建议,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
}
return [$system, $user];
}
/**
* @param array<string,mixed> $context
* @return array{0:string,1:string}
*/
private static function buildCareStreamPrompts(array $context, bool $refresh): array
{
$bloodLine = self::bloodLine($context);
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
$system = <<<SYS
你是基层中医糖尿病照护助手,给农村中老年患者写血糖与中医调理建议。
硬性要求:
1. 不得写具体中药方名剂量、不得指导自行调药。
2. 中医写辨证思路与调理方向,强调面诊后由医师定方。
3. 严格按下面 5 行输出,不要 JSON、不要 markdown
总评:(一句话)
血糖:(监测、饮食、运动,2-4句)
中医:(辨证思路、食疗艾灸方向,2-4句)
留意:(用顿号隔开 2-4 条)
复诊:(下一步)
SYS;
$user = sprintf(
"患者:%s,%s,%s岁。证型:%s。症状:%s。\n近7日血糖:%s。\n%s%s。",
$context['patient_name'] ?: '患者',
$context['gender_text'] ?? '',
$context['age'] ?? 0,
($context['syndrome_type'] ?? '') ?: '未登记',
mb_substr((string) ($context['symptoms'] ?? ''), 0, 80) ?: '无',
$bloodLine,
$stat7Line,
$stat30Line
);
if ($refresh) {
$user .= "\n\n换一换,重新写一套,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
}
return [$system, $user];
}
/**
* @param array<string,mixed> $context
*/
private static function bloodLine(array $context): string
{
$recent = $context['blood_recent'] ?? [];
if (!is_array($recent) || empty($recent)) {
return '近7日无逐日明细';
}
return implode('', $recent);
}
/**
* @param array<string,mixed> $stats
*/
private static function statsLine(array $stats): string
{
$label = (string) ($stats['label'] ?? '统计');
if (($stats['record_days'] ?? 0) <= 0) {
return $label . '无记录';
}
$parts = [
$label . "有记录{$stats['record_days']}",
"偏高{$stats['high_days']}",
"达标率{$stats['compliance']}%",
];
if ($stats['fasting_avg'] !== null) {
$parts[] = "空腹均值{$stats['fasting_avg']}";
}
if ($stats['postprandial_avg'] !== null) {
$parts[] = "餐后均值{$stats['postprandial_avg']}";
}
return implode('', $parts);
}
/**
* @return array<string,mixed>|null
*/
private static function parseCarePlainText(string $text): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
if ($text[0] === '{') {
$json = self::parseJsonObject($text);
return is_array($json) ? $json : null;
}
$map = [];
if (preg_match('/总评[:]\s*(.+)$/mu', $text, $m)) {
$map['summary'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/血糖[:]\s*(.+)$/mu', $text, $m)) {
$map['blood_advice'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/中医[:]\s*(.+)$/mu', $text, $m)) {
$map['tcm_plan'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/留意[:]\s*(.+)$/mu', $text, $m)) {
$line = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
$parts = preg_split('/[、,;\s]+/u', $line) ?: [];
$map['watch_points'] = array_values(array_filter(array_map('trim', $parts)));
}
if (preg_match('/复诊[:]\s*(.+)$/mu', $text, $m)) {
$map['next_steps'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (empty($map['summary'])) {
return null;
}
return $map;
}
/**
* @param array<string,mixed> $raw
* @return array<string,mixed>
*/
private static function finalizeCarePlan(array $raw): array
{
$watch = $raw['watch_points'] ?? [];
if (!is_array($watch)) {
$watch = preg_split('/[、,;\n]+/u', (string) $watch) ?: [];
}
$watch = array_values(array_filter(array_map('trim', $watch)));
return [
'summary' => trim((string) ($raw['summary'] ?? '')),
'blood_advice' => trim((string) ($raw['blood_advice'] ?? '')),
'tcm_plan' => trim((string) ($raw['tcm_plan'] ?? '')),
'watch_points' => $watch,
'next_steps' => trim((string) ($raw['next_steps'] ?? '')),
'control_level' => (string) ($raw['control_level'] ?? ''),
'control_label' => (string) ($raw['control_label'] ?? ''),
'disclaimer' => trim((string) ($raw['disclaimer'] ?? 'AI 建议供参考,不能替代医师面诊、开方与调药。')),
'source' => (string) ($raw['source'] ?? 'ai'),
];
}
/**
* @return array<string,mixed>|null
*/
private static function parseJsonObject(string $text): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
if (preg_match('/\{[\s\S]*\}/u', $text, $m)) {
$text = $m[0];
}
$data = json_decode($text, true);
return is_array($data) ? $data : null;
}
}