更新
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
|
||||
|
||||
class ConversionController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.conversion/overview';
|
||||
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看综合数据转化');
|
||||
}
|
||||
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(FirstVisitConversionLogic::overview(
|
||||
$this->request->get(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
));
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\FirstVisitDoctorDashboardLogic;
|
||||
|
||||
class DoctorDashboardController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.doctorDashboard/overview';
|
||||
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看医生看板');
|
||||
}
|
||||
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(FirstVisitDoctorDashboardLogic::overview(
|
||||
$this->request->get(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
));
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\firstvisit\MyPatientLists;
|
||||
use app\adminapi\lists\firstvisit\MyPatientOrderLists;
|
||||
use app\adminapi\lists\firstvisit\MyPatientProgressLists;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\adminapi\validate\doctor\AppointmentValidate;
|
||||
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
|
||||
class MyPatientController extends BaseAdminController
|
||||
{
|
||||
private const LIST_PERMISSION = 'firstvisit.myPatient/lists';
|
||||
|
||||
private string $orderGuardError = '订单不存在或无权操作';
|
||||
|
||||
public function lists()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法访问我的患者');
|
||||
}
|
||||
|
||||
return $this->dataLists(new MyPatientLists());
|
||||
}
|
||||
|
||||
/** 当前角色/部门患者范围内的处方业务订单。 */
|
||||
public function orders()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看患者订单');
|
||||
}
|
||||
|
||||
return $this->dataLists(new MyPatientOrderLists());
|
||||
}
|
||||
|
||||
/** 当前角色/部门患者范围内的挂号面诊进度。 */
|
||||
public function progress()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看面诊进度');
|
||||
}
|
||||
|
||||
return $this->dataLists(new MyPatientProgressLists());
|
||||
}
|
||||
|
||||
/** 当前患者范围内的订单详情;仍要求原订单详情权限。 */
|
||||
public function orderDetail()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->get()->goCheck('detail');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/detail') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$detail = PrescriptionOrderLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($detail === null) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->data($detail);
|
||||
}
|
||||
|
||||
/** 编辑当前患者范围内的订单,参数只允许原编辑表单支持的字段。 */
|
||||
public function orderEdit()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('edit');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/edit') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
if ((float) ($params['amount'] ?? 0) < 0) {
|
||||
return $this->fail('订单金额不能为负数');
|
||||
}
|
||||
|
||||
$params = $this->onlyParams($params, [
|
||||
'id', 'recipient_name', 'recipient_phone', 'shipping_province', 'shipping_city',
|
||||
'shipping_district', 'shipping_address', 'is_follow_up', 'medication_days',
|
||||
'dose_unit', 'dose_count', 'prev_staff', 'service_channel', 'service_package',
|
||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra',
|
||||
'remark_assistant', 'pay_order_ids', 'internal_cost',
|
||||
]);
|
||||
$result = PrescriptionOrderLogic::edit($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('保存成功', $result);
|
||||
}
|
||||
|
||||
public function orderAuditPrescription()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPrescription') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::auditPrescription(
|
||||
(int) $params['id'],
|
||||
(string) $params['action'],
|
||||
(string) ($params['remark'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('操作成功', $result);
|
||||
}
|
||||
|
||||
public function orderRevokeRxAudit()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPrescription') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::revokeRxAudit((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('处方审核已撤回', $result);
|
||||
}
|
||||
|
||||
public function orderAuditPayment()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPayment');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPayment') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::auditPaymentSlip(
|
||||
(int) $params['id'],
|
||||
(string) $params['action'],
|
||||
(string) ($params['remark'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('操作成功', $result);
|
||||
}
|
||||
|
||||
public function orderRevokePayAudit()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPayment') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::revokePayAudit((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('支付单审核已撤回', $result);
|
||||
}
|
||||
|
||||
public function orderDdcode()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('ddcode');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/ddcode') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::ddcode(
|
||||
(int) $params['id'],
|
||||
(string) ($params['express_company'] ?? 'auto'),
|
||||
(string) $params['tracking_number'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('快递单号已保存', $result);
|
||||
}
|
||||
|
||||
public function orderShip()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('ship');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/ship') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::ship(
|
||||
(int) $params['id'],
|
||||
(string) ($params['express_company'] ?? 'auto'),
|
||||
(string) ($params['tracking_number'] ?? ''),
|
||||
(string) ($params['ship_mode'] ?? 'gancao'),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('发货成功', $result);
|
||||
}
|
||||
|
||||
public function orderAddPayOrder()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('addPayOrder');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/addPayOrder') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$params = $this->onlyParams($params, [
|
||||
'id', 'order_type', 'pay_amount', 'pay_remark', 'completion_request', 'pay_create_type',
|
||||
]);
|
||||
$result = PrescriptionOrderLogic::addPayOrder($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('新增支付单成功', $result);
|
||||
}
|
||||
|
||||
public function orderComplete()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('complete');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/complete') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::complete(
|
||||
(int) $params['id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
(int) $params['fulfillment_status']
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('操作成功', $result);
|
||||
}
|
||||
|
||||
public function orderRefund()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('refund');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/refund') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$rawRefundAmount = $params['refund_amount'] ?? null;
|
||||
$refundAmount = ($rawRefundAmount === null || $rawRefundAmount === '')
|
||||
? null
|
||||
: round((float) $rawRefundAmount, 2);
|
||||
$result = PrescriptionOrderLogic::refund(
|
||||
(int) $params['id'],
|
||||
(string) ($params['reason'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$refundAmount
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('退款成功', $result);
|
||||
}
|
||||
|
||||
public function orderWithdraw()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('withdraw');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/withdraw') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::withdraw((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('已撤回', $result);
|
||||
}
|
||||
|
||||
public function orderUploadToPharmacy()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('uploadToPharmacy');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/uploadToPharmacy') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('药方上传成功', $result);
|
||||
}
|
||||
|
||||
/** 从“我的患者”页面创建挂号,写操作复用原逻辑但先做患者行级校验。 */
|
||||
public function createAppointment()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法创建挂号');
|
||||
}
|
||||
|
||||
$params = (new AppointmentValidate())->post()->goCheck('create');
|
||||
$diagnosisId = (int) ($params['patient_id'] ?? 0);
|
||||
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $this->adminId, $this->adminInfo)) {
|
||||
return $this->fail('患者不存在或无权操作');
|
||||
}
|
||||
|
||||
$params['assistant_id'] = $this->adminId;
|
||||
$result = AppointmentLogic::create($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(AppointmentLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('挂号成功', $result);
|
||||
}
|
||||
|
||||
/** 从“我的患者”页面取消挂号,按挂号所属诊单再次校验数据范围。 */
|
||||
public function cancelAppointment()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法取消挂号');
|
||||
}
|
||||
|
||||
$params = (new AppointmentValidate())->post()->goCheck('cancel');
|
||||
$appointment = Appointment::findOrEmpty((int) ($params['id'] ?? 0));
|
||||
if ($appointment->isEmpty()) {
|
||||
return $this->fail('挂号记录不存在');
|
||||
}
|
||||
if (!MyPatientLogic::canAccessDiagnosis((int) $appointment->patient_id, $this->adminId, $this->adminInfo)) {
|
||||
return $this->fail('患者不存在或无权操作');
|
||||
}
|
||||
|
||||
$result = AppointmentLogic::cancel($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(AppointmentLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('取消挂号成功');
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::LIST_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
|
||||
private function hasOriginalPermission(string $permission): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($permission, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
|
||||
private function guardOrder(int $orderId, string $permission): ?PrescriptionOrder
|
||||
{
|
||||
$this->orderGuardError = '订单不存在或无权操作';
|
||||
if (!$this->hasPagePermission() || !$this->hasOriginalPermission($permission) || $orderId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find();
|
||||
if ($order === null) {
|
||||
return null;
|
||||
}
|
||||
if (!MyPatientLogic::canAccessDiagnosis((int) $order->diagnosis_id, $this->adminId, $this->adminInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $params @param array<int,string> $keys */
|
||||
private function onlyParams(array $params, array $keys): array
|
||||
{
|
||||
return array_intersect_key($params, array_flip($keys));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\FirstVisitRegistrationStatsLogic;
|
||||
|
||||
class RegistrationStatsController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.registrationStats/overview';
|
||||
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看挂号统计');
|
||||
}
|
||||
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(FirstVisitRegistrationStatsLogic::overview(
|
||||
$this->request->get(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
));
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
|
||||
|
||||
class WecomPromotionController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
|
||||
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法访问企业微信推广助手');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->data(WecomPromotionLogic::overview(
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$this->request->domain()
|
||||
)));
|
||||
}
|
||||
|
||||
public function authorizationUrl()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->data(WecomPromotionLogic::authorizationUrl(
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$this->request->domain()
|
||||
)));
|
||||
}
|
||||
|
||||
public function verifyAccount()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
|
||||
return $this->run(fn () => $this->success('凭证验证成功', WecomPromotionLogic::verifyAccount(
|
||||
$id,
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function savePool()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('分流方案已保存', WecomPromotionLogic::savePool(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function deletePool()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
|
||||
return $this->run(function () use ($id) {
|
||||
WecomPromotionLogic::deletePool($id, $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success('分流方案已删除');
|
||||
});
|
||||
}
|
||||
|
||||
public function saveLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('推广链接已保存', WecomPromotionLogic::saveLink(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function toggleLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
$status = (int) $this->request->post('status', 0);
|
||||
|
||||
return $this->run(function () use ($id, $status) {
|
||||
WecomPromotionLogic::toggleLink($id, $status, $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success('状态已更新');
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
|
||||
return $this->run(function () use ($id) {
|
||||
WecomPromotionLogic::deleteLink($id, $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success('推广链接已删除');
|
||||
});
|
||||
}
|
||||
|
||||
private function run(callable $callback)
|
||||
{
|
||||
try {
|
||||
return $callback();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\stats;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\stats\PerformanceDashboardLogic;
|
||||
|
||||
/**
|
||||
* 角色数据驾驶舱。
|
||||
*
|
||||
* 所有数据在服务端按当前管理员的数据范围聚合,前端不参与权限裁剪。
|
||||
*/
|
||||
class PerformanceDashboardController extends BaseAdminController
|
||||
{
|
||||
public function overview()
|
||||
{
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(PerformanceDashboardLogic::overview($this->adminId, $this->adminInfo));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\firstvisit;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||
{
|
||||
private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4];
|
||||
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$query = $this->buildQuery(true, true);
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$today = date('Y-m-d');
|
||||
$upcomingSql = "SELECT MIN(CONCAT(sort_apt.appointment_date, ' ', IFNULL(NULLIF(TRIM(sort_apt.appointment_time), ''), '00:00:00')))"
|
||||
. " FROM {$appointmentTable} sort_apt"
|
||||
. ' WHERE sort_apt.patient_id = d.id'
|
||||
. ' AND sort_apt.status IN (1,4)'
|
||||
. " AND sort_apt.appointment_date >= '{$today}'";
|
||||
|
||||
$rows = $query
|
||||
->field([
|
||||
'd.id', 'd.patient_id', 'd.patient_name', 'd.phone', 'd.gender', 'd.age',
|
||||
'd.diagnosis_date', 'd.diagnosis_type', 'd.syndrome_type', 'd.assistant_id',
|
||||
'd.assign_read_at', 'd.create_time',
|
||||
])
|
||||
->orderRaw("CASE WHEN ({$upcomingSql}) IS NULL THEN 1 ELSE 0 END ASC")
|
||||
->orderRaw("IFNULL(({$upcomingSql}), '9999-12-31 23:59:59') ASC")
|
||||
->order('d.id', 'desc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return $this->appendRelations($rows);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->buildQuery(true, true)->count('d.id');
|
||||
}
|
||||
|
||||
public function extend(): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$tomorrow = date('Y-m-d', strtotime('+1 day'));
|
||||
$dayAfter = date('Y-m-d', strtotime('+2 days'));
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'today' => $this->countByAppointmentDate($today),
|
||||
'tomorrow' => $this->countByAppointmentDate($tomorrow),
|
||||
'day_after' => $this->countByAppointmentDate($dayAfter),
|
||||
],
|
||||
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
|
||||
'dates' => [
|
||||
'today' => $today,
|
||||
'tomorrow' => $tomorrow,
|
||||
'day_after' => $dayAfter,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildQuery(bool $applyStatusFilter, bool $applyDateFilter): Query
|
||||
{
|
||||
$diagnosisTable = (new Diagnosis())->getTable();
|
||||
$query = Db::table($diagnosisTable)
|
||||
->alias('d')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1);
|
||||
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
$this->applyKeyword($query);
|
||||
|
||||
$statusFilter = $applyStatusFilter ? trim((string) ($this->params['status_filter'] ?? '')) : '';
|
||||
if ($statusFilter === 'unconfirmed') {
|
||||
$viewTable = (new DiagnosisViewRecord())->getTable();
|
||||
$query->whereNotExists(
|
||||
"SELECT 1 FROM {$viewTable} confirm_row"
|
||||
. ' WHERE confirm_row.diagnosis_id = d.id'
|
||||
. ' AND confirm_row.is_confirmed = 1'
|
||||
. ' AND confirm_row.delete_time IS NULL'
|
||||
);
|
||||
}
|
||||
|
||||
$appointmentStatuses = self::EFFECTIVE_APPOINTMENT_STATUSES;
|
||||
if ($statusFilter === 'booked') {
|
||||
$appointmentStatuses = [1];
|
||||
} elseif ($statusFilter === 'completed') {
|
||||
$appointmentStatuses = [3];
|
||||
} elseif ($statusFilter === 'missed') {
|
||||
$appointmentStatuses = [4];
|
||||
}
|
||||
|
||||
$needsAppointmentFilter = in_array($statusFilter, ['booked', 'completed', 'missed'], true);
|
||||
[$startDate, $endDate] = $applyDateFilter ? $this->dateRange() : ['', ''];
|
||||
if ($startDate !== '' || $endDate !== '') {
|
||||
$needsAppointmentFilter = true;
|
||||
}
|
||||
|
||||
if ($needsAppointmentFilter) {
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$conditions = [
|
||||
'filter_apt.patient_id = d.id',
|
||||
'filter_apt.status IN (' . implode(',', $appointmentStatuses) . ')',
|
||||
];
|
||||
if ($startDate !== '') {
|
||||
$conditions[] = "filter_apt.appointment_date >= '{$startDate}'";
|
||||
}
|
||||
if ($endDate !== '') {
|
||||
$conditions[] = "filter_apt.appointment_date <= '{$endDate}'";
|
||||
}
|
||||
$query->whereExists(
|
||||
"SELECT 1 FROM {$appointmentTable} filter_apt WHERE " . implode(' AND ', $conditions)
|
||||
);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyKeyword(Query $query): void
|
||||
{
|
||||
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||
if ($keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$needle = addslashes($keyword);
|
||||
$adminTable = (new Admin())->getTable();
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$query->whereRaw(
|
||||
"(d.patient_name LIKE '%{$needle}%'"
|
||||
. " OR d.phone LIKE '%{$needle}%'"
|
||||
. " OR EXISTS (SELECT 1 FROM {$adminTable} assistant_admin"
|
||||
. ' WHERE assistant_admin.id = CAST(d.assistant_id AS UNSIGNED)'
|
||||
. ' AND assistant_admin.delete_time IS NULL'
|
||||
. " AND assistant_admin.name LIKE '%{$needle}%')"
|
||||
. " OR EXISTS (SELECT 1 FROM {$appointmentTable} keyword_apt"
|
||||
. " INNER JOIN {$adminTable} doctor_admin ON doctor_admin.id = keyword_apt.doctor_id"
|
||||
. ' AND doctor_admin.delete_time IS NULL'
|
||||
. ' WHERE keyword_apt.patient_id = d.id'
|
||||
. ' AND keyword_apt.status IN (1,3,4)'
|
||||
. " AND doctor_admin.name LIKE '%{$needle}%'))"
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function dateRange(): array
|
||||
{
|
||||
$startDate = $this->normalizeDate($this->params['start_date'] ?? '');
|
||||
$endDate = $this->normalizeDate($this->params['end_date'] ?? '');
|
||||
if ($startDate === '' && $endDate !== '') {
|
||||
$startDate = $endDate;
|
||||
}
|
||||
if ($endDate === '' && $startDate !== '') {
|
||||
$endDate = $startDate;
|
||||
}
|
||||
if ($startDate !== '' && $endDate !== '' && $startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
|
||||
return [$startDate, $endDate];
|
||||
}
|
||||
|
||||
private function normalizeDate($value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
private function countByAppointmentDate(string $date): int
|
||||
{
|
||||
$query = $this->buildQuery(false, false);
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$query->whereExists(
|
||||
"SELECT 1 FROM {$appointmentTable} summary_apt"
|
||||
. ' WHERE summary_apt.patient_id = d.id'
|
||||
. " AND summary_apt.appointment_date = '{$date}'"
|
||||
. ' AND summary_apt.status IN (1,3,4)'
|
||||
);
|
||||
|
||||
return (int) $query->count('d.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function appendRelations(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$diagnosisIds = array_values(array_unique(array_map('intval', array_column($rows, 'id'))));
|
||||
$assistantIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'assistant_id')))));
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$appointments = Db::table($appointmentTable)
|
||||
->whereIn('patient_id', $diagnosisIds)
|
||||
->whereIn('status', self::EFFECTIVE_APPOINTMENT_STATUSES)
|
||||
->field(['id', 'patient_id', 'doctor_id', 'appointment_date', 'appointment_time', 'status'])
|
||||
->order('appointment_date', 'asc')
|
||||
->order('appointment_time', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$doctorIds = array_values(array_unique(array_filter(array_map('intval', array_column($appointments, 'doctor_id')))));
|
||||
$adminIds = array_values(array_unique(array_merge($assistantIds, $doctorIds)));
|
||||
$adminNames = $adminIds === [] ? [] : Admin::whereIn('id', $adminIds)->whereNull('delete_time')->column('name', 'id');
|
||||
|
||||
$appointmentMap = [];
|
||||
foreach ($appointments as $appointment) {
|
||||
$diagnosisId = (int) ($appointment['patient_id'] ?? 0);
|
||||
if ($diagnosisId > 0) {
|
||||
$appointmentMap[$diagnosisId][] = $appointment;
|
||||
}
|
||||
}
|
||||
|
||||
$viewTable = (new DiagnosisViewRecord())->getTable();
|
||||
$confirmedIds = Db::table($viewTable)
|
||||
->whereIn('diagnosis_id', $diagnosisIds)
|
||||
->where('is_confirmed', 1)
|
||||
->whereNull('delete_time')
|
||||
->column('diagnosis_id');
|
||||
$confirmedSet = array_fill_keys(array_map('intval', $confirmedIds), true);
|
||||
[$rangeStart, $rangeEnd] = $this->dateRange();
|
||||
$today = date('Y-m-d');
|
||||
$statusFilter = trim((string) ($this->params['status_filter'] ?? ''));
|
||||
$preferredStatuses = [
|
||||
'booked' => [1],
|
||||
'completed' => [3],
|
||||
'missed' => [4],
|
||||
][$statusFilter] ?? [];
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$diagnosisId = (int) $row['id'];
|
||||
$rowAppointments = $appointmentMap[$diagnosisId] ?? [];
|
||||
$primary = $this->pickPrimaryAppointment(
|
||||
$rowAppointments,
|
||||
$rangeStart,
|
||||
$rangeEnd,
|
||||
$today,
|
||||
$preferredStatuses
|
||||
);
|
||||
$completedCount = count(array_filter($rowAppointments, static function (array $appointment): bool {
|
||||
return (int) ($appointment['status'] ?? 0) === 3;
|
||||
}));
|
||||
$assistantId = (int) ($row['assistant_id'] ?? 0);
|
||||
|
||||
$row['diagnosis_id'] = $diagnosisId;
|
||||
$row['source_patient_id'] = (int) ($row['patient_id'] ?? 0);
|
||||
$row['phone_masked'] = $this->maskPhone((string) ($row['phone'] ?? ''));
|
||||
unset($row['phone']);
|
||||
$row['gender_desc'] = (int) ($row['gender'] ?? 0) === 1 ? '男' : '女';
|
||||
$row['diagnosis_date_text'] = $this->formatDiagnosisDate($row['diagnosis_date'] ?? '');
|
||||
$row['assistant_name'] = (string) ($adminNames[$assistantId] ?? '未分配');
|
||||
$row['confirmed'] = isset($confirmedSet[$diagnosisId]) ? 1 : 0;
|
||||
$row['confirmation_text'] = $row['confirmed'] ? '已确认' : '待确认';
|
||||
$row['visit_count'] = $completedCount;
|
||||
$row['revisit_count'] = max(0, $completedCount - 1);
|
||||
$row['appointment_id'] = $primary ? (int) $primary['id'] : 0;
|
||||
$row['appointment_status'] = $primary ? (int) $primary['status'] : 0;
|
||||
$row['appointment_status_text'] = $this->appointmentStatusText((int) ($primary['status'] ?? 0));
|
||||
$row['appointment_doctor_id'] = $primary ? (int) $primary['doctor_id'] : 0;
|
||||
$row['appointment_doctor_name'] = $primary
|
||||
? (string) ($adminNames[(int) $primary['doctor_id']] ?? '未知医生')
|
||||
: '未预约';
|
||||
$row['appointment_time_text'] = $primary ? $this->appointmentTimeText($primary) : '';
|
||||
$row['has_appointment'] = $primary !== null ? 1 : 0;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $appointments
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function pickPrimaryAppointment(
|
||||
array $appointments,
|
||||
string $rangeStart,
|
||||
string $rangeEnd,
|
||||
string $today,
|
||||
array $preferredStatuses = []
|
||||
): ?array
|
||||
{
|
||||
if ($appointments === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidates = $appointments;
|
||||
if ($preferredStatuses !== []) {
|
||||
$candidates = array_values(array_filter($candidates, static function (array $appointment) use ($preferredStatuses): bool {
|
||||
return in_array((int) ($appointment['status'] ?? 0), $preferredStatuses, true);
|
||||
}));
|
||||
}
|
||||
if ($rangeStart !== '' || $rangeEnd !== '') {
|
||||
$candidates = array_values(array_filter($candidates, static function (array $appointment) use ($rangeStart, $rangeEnd): bool {
|
||||
$date = (string) ($appointment['appointment_date'] ?? '');
|
||||
|
||||
return ($rangeStart === '' || $date >= $rangeStart) && ($rangeEnd === '' || $date <= $rangeEnd);
|
||||
}));
|
||||
}
|
||||
if ($candidates === []) {
|
||||
$candidates = $appointments;
|
||||
}
|
||||
|
||||
foreach ($candidates as $appointment) {
|
||||
$status = (int) ($appointment['status'] ?? 0);
|
||||
$date = (string) ($appointment['appointment_date'] ?? '');
|
||||
if (in_array($status, [1, 4], true) && $date >= $today) {
|
||||
return $appointment;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[count($candidates) - 1] ?? null;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
return preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $phone) ?: $phone;
|
||||
}
|
||||
|
||||
private function formatDiagnosisDate($value): string
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value > 0 ? date('Y-m-d', (int) $value) : '';
|
||||
}
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? '' : substr($text, 0, 10);
|
||||
}
|
||||
|
||||
private function appointmentTimeText(array $appointment): string
|
||||
{
|
||||
$time = trim((string) ($appointment['appointment_time'] ?? ''));
|
||||
if (strlen($time) > 5) {
|
||||
$time = substr($time, 0, 5);
|
||||
}
|
||||
|
||||
return trim((string) ($appointment['appointment_date'] ?? '') . ' ' . $time);
|
||||
}
|
||||
|
||||
private function appointmentStatusText(int $status): string
|
||||
{
|
||||
return [1 => '已挂号', 3 => '已完成', 4 => '已过号'][$status] ?? '未挂号';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\firstvisit;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\Order;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* “我的患者”内嵌订单列表。
|
||||
*
|
||||
* 订单可见性始终锚定 diagnosis 别名 d,并复用 MyPatientLogic;订单创建人仅用于展示,
|
||||
* 不能作为患者归属或数据范围条件。
|
||||
*/
|
||||
class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||
{
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->buildQuery()
|
||||
->field([
|
||||
'po.id', 'po.order_no', 'po.prescription_id', 'po.diagnosis_id', 'po.creator_id',
|
||||
'po.recipient_name', 'po.recipient_phone', 'po.fee_type', 'po.amount',
|
||||
'po.prescription_audit_status', 'po.payment_slip_audit_status',
|
||||
'po.fulfillment_status', 'po.express_company', 'po.tracking_number', 'po.ship_mode',
|
||||
'po.gancao_reciperl_order_no', 'po.ej_pharmacy_order_no',
|
||||
'po.gancao_submit_time', 'po.ej_pharmacy_submit_time',
|
||||
'po.ej_pharmacy_status', 'po.ej_pharmacy_review_status', 'po.refund_amount',
|
||||
'po.create_time',
|
||||
'd.patient_name', 'd.phone AS patient_phone', 'd.assistant_id',
|
||||
])
|
||||
->order('po.id', 'desc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return $this->appendRelations($rows);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->buildQuery()->count('po.id');
|
||||
}
|
||||
|
||||
public function extend(): array
|
||||
{
|
||||
$query = $this->buildQuery();
|
||||
$pendingQuery = clone $query;
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'orders' => (int) (clone $query)->count('po.id'),
|
||||
'amount' => round((float) (clone $query)->sum('po.amount'), 2),
|
||||
'pending' => (int) $pendingQuery
|
||||
->where(function ($q) {
|
||||
$q->where('po.prescription_audit_status', 0)
|
||||
->whereOr('po.payment_slip_audit_status', 0);
|
||||
})
|
||||
->count('po.id'),
|
||||
'completed' => (int) (clone $query)->whereIn('po.fulfillment_status', [3, 6])->count('po.id'),
|
||||
],
|
||||
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildQuery(): Query
|
||||
{
|
||||
$query = PrescriptionOrder::alias('po')
|
||||
->join('tcm_diagnosis d', 'po.diagnosis_id = d.id')
|
||||
->whereNull('po.delete_time')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1);
|
||||
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
$this->applyKeyword($query);
|
||||
$this->applyStatusFilters($query);
|
||||
$this->applyDateFilter($query);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyKeyword(Query $query): void
|
||||
{
|
||||
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||
if ($keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q->whereLike('po.order_no', $like)
|
||||
->whereOr('d.patient_name', 'like', $like)
|
||||
->whereOr('d.phone', 'like', $like)
|
||||
->whereOr('po.recipient_name', 'like', $like)
|
||||
->whereOr('po.recipient_phone', 'like', $like);
|
||||
if (preg_match('/^\d+$/', $keyword)) {
|
||||
$id = (int) $keyword;
|
||||
if ($id > 0) {
|
||||
$q->whereOr('po.id', $id)
|
||||
->whereOr('po.prescription_id', $id)
|
||||
->whereOr('po.diagnosis_id', $id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function applyStatusFilters(Query $query): void
|
||||
{
|
||||
foreach (['prescription_audit_status', 'payment_slip_audit_status', 'fulfillment_status'] as $field) {
|
||||
$raw = $this->params[$field] ?? '';
|
||||
if ($raw === '' || $raw === null) {
|
||||
continue;
|
||||
}
|
||||
$query->where('po.' . $field, (int) $raw);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyDateFilter(Query $query): void
|
||||
{
|
||||
[$startDate, $endDate] = $this->dateRange();
|
||||
if ($startDate !== '') {
|
||||
$query->where('po.create_time', '>=', strtotime($startDate . ' 00:00:00'));
|
||||
}
|
||||
if ($endDate !== '') {
|
||||
$query->where('po.create_time', '<=', strtotime($endDate . ' 23:59:59'));
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function dateRange(): array
|
||||
{
|
||||
$startDate = $this->normalizeDate($this->params['start_date'] ?? '');
|
||||
$endDate = $this->normalizeDate($this->params['end_date'] ?? '');
|
||||
if ($startDate === '' && $endDate !== '') {
|
||||
$startDate = $endDate;
|
||||
}
|
||||
if ($endDate === '' && $startDate !== '') {
|
||||
$endDate = $startDate;
|
||||
}
|
||||
if ($startDate !== '' && $endDate !== '' && $startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
|
||||
return [$startDate, $endDate];
|
||||
}
|
||||
|
||||
private function normalizeDate($value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function appendRelations(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$orderIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'id')))));
|
||||
$prescriptionIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'prescription_id')))));
|
||||
$creatorIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'creator_id')))));
|
||||
$assistantIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'assistant_id')))));
|
||||
|
||||
$prescriptionMap = [];
|
||||
$doctorIds = [];
|
||||
if ($prescriptionIds !== []) {
|
||||
$prescriptions = Prescription::whereIn('id', $prescriptionIds)
|
||||
->whereNull('delete_time')
|
||||
->field(['id', 'creator_id', 'doctor_name'])
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($prescriptions as $prescription) {
|
||||
$prescriptionId = (int) ($prescription['id'] ?? 0);
|
||||
$doctorId = (int) ($prescription['creator_id'] ?? 0);
|
||||
if ($prescriptionId > 0) {
|
||||
$prescriptionMap[$prescriptionId] = $prescription;
|
||||
}
|
||||
if ($doctorId > 0) {
|
||||
$doctorIds[] = $doctorId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$adminIds = array_values(array_unique(array_merge($creatorIds, $assistantIds, $doctorIds)));
|
||||
$adminNames = $adminIds === []
|
||||
? []
|
||||
: Admin::whereIn('id', $adminIds)->whereNull('delete_time')->column('name', 'id');
|
||||
|
||||
$linkCounts = [];
|
||||
$paidTotals = [];
|
||||
if ($orderIds !== []) {
|
||||
$linkRows = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $orderIds)
|
||||
->field(['prescription_order_id', 'pay_order_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
$payOrderIds = array_values(array_unique(array_filter(array_map('intval', array_column($linkRows, 'pay_order_id')))));
|
||||
$payOrders = $payOrderIds === []
|
||||
? []
|
||||
: Order::whereIn('id', $payOrderIds)
|
||||
->whereNull('delete_time')
|
||||
->field(['id', 'amount', 'status'])
|
||||
->select()
|
||||
->toArray();
|
||||
$payOrderMap = [];
|
||||
foreach ($payOrders as $payOrder) {
|
||||
$payOrderMap[(int) ($payOrder['id'] ?? 0)] = $payOrder;
|
||||
}
|
||||
foreach ($linkRows as $linkRow) {
|
||||
$orderId = (int) ($linkRow['prescription_order_id'] ?? 0);
|
||||
if ($orderId > 0) {
|
||||
$linkCounts[$orderId] = ($linkCounts[$orderId] ?? 0) + 1;
|
||||
}
|
||||
$payOrder = $payOrderMap[(int) ($linkRow['pay_order_id'] ?? 0)] ?? [];
|
||||
if ($orderId > 0 && in_array((int) ($payOrder['status'] ?? 0), [2, 5], true)) {
|
||||
$paidTotals[$orderId] = round(
|
||||
(float) ($paidTotals[$orderId] ?? 0) + (float) ($payOrder['amount'] ?? 0),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$assistantByDiagnosis = [];
|
||||
foreach ($rows as $row) {
|
||||
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
|
||||
if ($diagnosisId > 0) {
|
||||
$assistantByDiagnosis[$diagnosisId] = (int) ($row['assistant_id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$claimByOrder = [];
|
||||
if ($orderIds !== []) {
|
||||
$claimRows = Db::name('pharmacy_submission_claim')
|
||||
->whereIn('prescription_order_id', $orderIds)
|
||||
->field(['prescription_order_id', 'target', 'status', 'lease_expires_at'])
|
||||
->order('source_revision', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($claimRows as $claimRow) {
|
||||
$orderId = (int) ($claimRow['prescription_order_id'] ?? 0);
|
||||
if ($orderId > 0 && !isset($claimByOrder[$orderId])) {
|
||||
$claimByOrder[$orderId] = $claimRow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$prescription = $prescriptionMap[(int) ($row['prescription_id'] ?? 0)] ?? [];
|
||||
$doctorId = (int) ($prescription['creator_id'] ?? 0);
|
||||
$doctorName = trim((string) ($prescription['doctor_name'] ?? ''));
|
||||
$creatorId = (int) ($row['creator_id'] ?? 0);
|
||||
$assistantId = (int) ($row['assistant_id'] ?? 0);
|
||||
|
||||
$row['patient_phone_masked'] = $this->maskPhone((string) ($row['patient_phone'] ?? ''));
|
||||
$row['recipient_phone_masked'] = $this->maskPhone((string) ($row['recipient_phone'] ?? ''));
|
||||
unset($row['patient_phone'], $row['recipient_phone']);
|
||||
$row['creator_name'] = (string) ($adminNames[$creatorId] ?? '—');
|
||||
$row['assistant_name'] = (string) ($adminNames[$assistantId] ?? '未分配');
|
||||
$row['doctor_name'] = $doctorName !== '' ? $doctorName : (string) ($adminNames[$doctorId] ?? '—');
|
||||
$row['linked_pay_order_count'] = (int) ($linkCounts[(int) $row['id']] ?? 0);
|
||||
$row['linked_pay_paid_total'] = (float) ($paidTotals[(int) $row['id']] ?? 0);
|
||||
$claim = $claimByOrder[(int) $row['id']] ?? [];
|
||||
$row['pharmacy_claim_target'] = (string) ($claim['target'] ?? '');
|
||||
$row['pharmacy_claim_status'] = (string) ($claim['status'] ?? '');
|
||||
$row['pharmacy_claim_lease_expires_at'] = (int) ($claim['lease_expires_at'] ?? 0);
|
||||
$row['can_upload_pharmacy'] = PrescriptionOrderLogic::canUploadToPharmacy(
|
||||
$row,
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$assistantByDiagnosis
|
||||
);
|
||||
$row['create_time_text'] = $this->formatTimestamp($row['create_time'] ?? 0);
|
||||
$row['fee_type_text'] = $this->feeTypeText((int) ($row['fee_type'] ?? 0));
|
||||
$row['prescription_audit_text'] = $this->auditStatusText((int) ($row['prescription_audit_status'] ?? 0));
|
||||
$row['payment_slip_audit_text'] = $this->auditStatusText((int) ($row['payment_slip_audit_status'] ?? 0));
|
||||
$row['fulfillment_text'] = $this->fulfillmentStatusText((int) ($row['fulfillment_status'] ?? 0));
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
return preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $phone) ?: $phone;
|
||||
}
|
||||
|
||||
private function formatTimestamp($value): string
|
||||
{
|
||||
return is_numeric($value) && (int) $value > 0 ? date('Y-m-d H:i', (int) $value) : '';
|
||||
}
|
||||
|
||||
private function auditStatusText(int $status): string
|
||||
{
|
||||
return [0 => '待审核', 1 => '已通过', 2 => '已驳回'][$status] ?? '未知';
|
||||
}
|
||||
|
||||
private function feeTypeText(int $type): string
|
||||
{
|
||||
return [1 => '挂号', 2 => '问诊', 3 => '药品', 4 => '首付', 5 => '尾款', 6 => '其他', 7 => '全部'][$type] ?? '其他';
|
||||
}
|
||||
|
||||
private function fulfillmentStatusText(int $status): string
|
||||
{
|
||||
return [
|
||||
1 => '待双审通过', 2 => '待发货', 3 => '已完成', 4 => '已取消',
|
||||
5 => '已发货', 6 => '已签收', 7 => '进行中', 8 => '暂不制药',
|
||||
9 => '拒收', 10 => '退款', 11 => '保留药方', 12 => '制药缓发',
|
||||
][$status] ?? '未知';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\firstvisit;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\doctor\Roster;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\service\doctor\RosterSegmentService;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* “我的患者”内嵌面诊进度。
|
||||
*
|
||||
* 一条挂号一行;只返回脱敏患者信息,并严格复用 MyPatientLogic 的患者级范围。
|
||||
*/
|
||||
class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||
{
|
||||
private const EFFECTIVE_STATUSES = [1, 3, 4];
|
||||
private const AVG_MINUTES_PER_VISIT = 15;
|
||||
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->buildQuery(true)
|
||||
->field([
|
||||
'a.id', 'a.patient_id AS diagnosis_id', 'a.doctor_id', 'a.appointment_date',
|
||||
'a.appointment_time', 'a.appointment_type', 'a.status', 'a.create_time',
|
||||
'd.patient_id AS source_patient_id', 'd.patient_name', 'd.phone', 'd.gender', 'd.age',
|
||||
'd.assistant_id', 'doctor_admin.name AS doctor_name', 'assistant_admin.name AS assistant_name',
|
||||
])
|
||||
->order('a.appointment_date', 'asc')
|
||||
->order('a.appointment_time', 'asc')
|
||||
->order('a.id', 'asc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return $this->appendProgress($rows);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->buildQuery(true)->count('a.id');
|
||||
}
|
||||
|
||||
public function extend(): array
|
||||
{
|
||||
$query = $this->buildQuery(false);
|
||||
[$startDate, $endDate] = $this->dateRange();
|
||||
$summary = [
|
||||
'total' => (int) (clone $query)->count('a.id'),
|
||||
'booked' => (int) (clone $query)->where('a.status', 1)->count('a.id'),
|
||||
'completed' => (int) (clone $query)->where('a.status', 3)->count('a.id'),
|
||||
'missed' => (int) (clone $query)->where('a.status', 4)->count('a.id'),
|
||||
];
|
||||
$scheduleMode = $this->usesOwnershipSchedule() ? 'ownership' : 'roster';
|
||||
$weekSchedule = $scheduleMode === 'ownership' ? $this->ownershipWeekSchedule() : $this->weekSchedule();
|
||||
$todaySchedule = $weekSchedule[0] ?? $this->emptyScheduleDay(date('Y-m-d'));
|
||||
$todayOverview = $scheduleMode === 'ownership'
|
||||
? [
|
||||
'total_visits' => (int) ($todaySchedule['total_appointments'] ?? 0),
|
||||
'booked' => (int) ($todaySchedule['waiting_appointments'] ?? 0),
|
||||
'completed' => (int) ($todaySchedule['completed_appointments'] ?? 0),
|
||||
'missed' => (int) ($todaySchedule['missed_appointments'] ?? 0),
|
||||
'empty_slots' => 0,
|
||||
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
|
||||
]
|
||||
: [
|
||||
'total_visits' => (int) ($todaySchedule['total_slots'] ?? 0),
|
||||
'booked' => (int) ($todaySchedule['booked_slots'] ?? 0),
|
||||
'completed' => 0,
|
||||
'missed' => 0,
|
||||
'empty_slots' => (int) ($todaySchedule['empty_slots'] ?? 0),
|
||||
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
|
||||
];
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'schedule_mode' => $scheduleMode,
|
||||
'today_overview' => $todayOverview,
|
||||
'week_schedule' => $weekSchedule,
|
||||
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
|
||||
'dates' => ['start' => $startDate, 'end' => $endDate],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildQuery(bool $applyStatus): Query
|
||||
{
|
||||
$query = Appointment::alias('a')
|
||||
->join('tcm_diagnosis d', 'a.patient_id = d.id')
|
||||
->leftJoin('admin doctor_admin', 'a.doctor_id = doctor_admin.id')
|
||||
->leftJoin('admin assistant_admin', 'CAST(d.assistant_id AS UNSIGNED) = assistant_admin.id')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1);
|
||||
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
$this->applyKeyword($query);
|
||||
$this->applyDateFilter($query);
|
||||
|
||||
if ($applyStatus) {
|
||||
$status = $this->params['status'] ?? '';
|
||||
if ($status !== '' && $status !== null && in_array((int) $status, self::EFFECTIVE_STATUSES, true)) {
|
||||
$query->where('a.status', (int) $status);
|
||||
} else {
|
||||
$query->whereIn('a.status', self::EFFECTIVE_STATUSES);
|
||||
}
|
||||
} else {
|
||||
$query->whereIn('a.status', self::EFFECTIVE_STATUSES);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyKeyword(Query $query): void
|
||||
{
|
||||
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||
if ($keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q->whereLike('d.patient_name', $like)
|
||||
->whereOr('d.phone', 'like', $like)
|
||||
->whereOr('doctor_admin.name', 'like', $like)
|
||||
->whereOr('assistant_admin.name', 'like', $like);
|
||||
if (preg_match('/^\d+$/', $keyword)) {
|
||||
$id = (int) $keyword;
|
||||
if ($id > 0) {
|
||||
$q->whereOr('a.id', $id)->whereOr('d.id', $id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function applyDateFilter(Query $query): void
|
||||
{
|
||||
[$startDate, $endDate] = $this->dateRange();
|
||||
$query->whereBetween('a.appointment_date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function dateRange(): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$startDate = $this->normalizeDate($this->params['start_date'] ?? '') ?: $today;
|
||||
$endDate = $this->normalizeDate($this->params['end_date'] ?? '') ?: $startDate;
|
||||
if ($startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
|
||||
$startTs = strtotime($startDate);
|
||||
$endTs = strtotime($endDate);
|
||||
if ($startTs !== false && $endTs !== false && $endTs - $startTs > 31 * 86400) {
|
||||
$endDate = date('Y-m-d', $startTs + 31 * 86400);
|
||||
}
|
||||
|
||||
return [$startDate, $endDate];
|
||||
}
|
||||
|
||||
private function normalizeDate($value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function appendProgress(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$diagnosisIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'diagnosis_id')))));
|
||||
$appointmentIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'id')))));
|
||||
$queuePositionMap = $this->queuePositionMap($rows);
|
||||
|
||||
$confirmedSet = [];
|
||||
if ($diagnosisIds !== []) {
|
||||
$viewTable = (new DiagnosisViewRecord())->getTable();
|
||||
$confirmedIds = Db::table($viewTable)
|
||||
->whereIn('diagnosis_id', $diagnosisIds)
|
||||
->where('is_confirmed', 1)
|
||||
->whereNull('delete_time')
|
||||
->column('diagnosis_id');
|
||||
$confirmedSet = array_fill_keys(array_map('intval', $confirmedIds), true);
|
||||
}
|
||||
|
||||
$prescriptionMap = [];
|
||||
if ($appointmentIds !== []) {
|
||||
$prescriptions = Prescription::whereIn('appointment_id', $appointmentIds)
|
||||
->whereNull('delete_time')
|
||||
->where('void_status', 0)
|
||||
->field(['id', 'appointment_id', 'audit_status', 'is_system_auto'])
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($prescriptions as $prescription) {
|
||||
$appointmentId = (int) ($prescription['appointment_id'] ?? 0);
|
||||
if ($appointmentId > 0 && !isset($prescriptionMap[$appointmentId])) {
|
||||
$prescriptionMap[$appointmentId] = $prescription;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$appointmentId = (int) ($row['id'] ?? 0);
|
||||
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
|
||||
$status = (int) ($row['status'] ?? 0);
|
||||
$prescription = $prescriptionMap[$appointmentId] ?? [];
|
||||
$confirmed = isset($confirmedSet[$diagnosisId]);
|
||||
$prescribed = $prescription !== [];
|
||||
$aheadCount = $status === 1 ? (int) ($queuePositionMap[$appointmentId] ?? 0) : 0;
|
||||
|
||||
$row['phone_masked'] = $this->maskPhone((string) ($row['phone'] ?? ''));
|
||||
unset($row['phone']);
|
||||
$row['gender_desc'] = (int) ($row['gender'] ?? 0) === 1 ? '男' : '女';
|
||||
$row['assistant_name'] = trim((string) ($row['assistant_name'] ?? '')) ?: '未分配';
|
||||
$row['doctor_name'] = trim((string) ($row['doctor_name'] ?? '')) ?: '未知医生';
|
||||
$row['appointment_time_text'] = $this->appointmentTimeText($row);
|
||||
$row['status_text'] = $this->appointmentStatusText($status);
|
||||
$row['appointment_type_text'] = $this->appointmentTypeText((string) ($row['appointment_type'] ?? ''));
|
||||
$row['registered'] = 1;
|
||||
$row['diagnosis_confirmed'] = $confirmed ? 1 : 0;
|
||||
$row['visit_completed'] = $status === 3 ? 1 : 0;
|
||||
$row['has_prescription'] = $prescribed ? 1 : 0;
|
||||
$row['prescription_id'] = (int) ($prescription['id'] ?? 0);
|
||||
$row['prescription_audit_status'] = $prescribed ? (int) ($prescription['audit_status'] ?? 0) : -1;
|
||||
$row['progress_text'] = $this->progressText($confirmed, $status === 3, $prescribed, $status);
|
||||
$row['queue_no'] = $status === 1 ? $aheadCount + 1 : 0;
|
||||
$row['ahead_count'] = $aheadCount;
|
||||
$row['estimated_wait_minutes'] = $aheadCount * self::AVG_MINUTES_PER_VISIT;
|
||||
$row['queue_status'] = $this->queueStatus($status, $confirmed, $aheadCount);
|
||||
$row['queue_status_text'] = $this->queueStatusText((string) $row['queue_status']);
|
||||
$row['is_self_patient'] = (
|
||||
(int) ($row['assistant_id'] ?? 0) === $this->adminId
|
||||
|| (int) ($row['doctor_id'] ?? 0) === $this->adminId
|
||||
) ? 1 : 0;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候诊位次按 progress.vue 的真实规则计算:同医生、同日、待就诊,按预约时刻和挂号 ID 升序。
|
||||
* 队列计算读取完整医生队列,只向当前范围列表返回人数,不暴露范围外患者身份。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @return array<int,int>
|
||||
*/
|
||||
private function queuePositionMap(array $rows): array
|
||||
{
|
||||
$doctorIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'doctor_id')))));
|
||||
$dates = array_values(array_unique(array_filter(array_map('strval', array_column($rows, 'appointment_date')))));
|
||||
if ($doctorIds === [] || $dates === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$queueRows = Appointment::whereIn('doctor_id', $doctorIds)
|
||||
->whereIn('appointment_date', $dates)
|
||||
->where('status', 1)
|
||||
->field(['id', 'doctor_id', 'appointment_date', 'appointment_time'])
|
||||
->order('doctor_id', 'asc')
|
||||
->order('appointment_date', 'asc')
|
||||
->order('appointment_time', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$groupCounts = [];
|
||||
$positions = [];
|
||||
foreach ($queueRows as $queueRow) {
|
||||
$group = (int) ($queueRow['doctor_id'] ?? 0) . '|' . (string) ($queueRow['appointment_date'] ?? '');
|
||||
$positions[(int) ($queueRow['id'] ?? 0)] = (int) ($groupCounts[$group] ?? 0);
|
||||
$groupCounts[$group] = (int) ($groupCounts[$group] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未来七天号源:完全复用 paiban/availableSlots 的生成口径,按医生+日期+时刻去重。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function weekSchedule(): array
|
||||
{
|
||||
$startDate = date('Y-m-d');
|
||||
$endDate = date('Y-m-d', strtotime($startDate . ' +6 days'));
|
||||
$days = [];
|
||||
for ($offset = 0; $offset < 7; $offset++) {
|
||||
$date = date('Y-m-d', strtotime($startDate . " +{$offset} days"));
|
||||
$days[$date] = $this->emptyScheduleDay($date);
|
||||
}
|
||||
|
||||
$doctorIds = $this->visibleDoctorIds($startDate, $endDate);
|
||||
if ($doctorIds === []) {
|
||||
return array_values($days);
|
||||
}
|
||||
|
||||
$rosters = Roster::whereIn('doctor_id', $doctorIds)
|
||||
->whereBetween('date', [$startDate, $endDate])
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->field(['doctor_id', 'date', 'period', 'start_time', 'end_time', 'slot_minutes', 'quota'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$doctorNames = Admin::whereIn('id', $doctorIds)->column('name', 'id');
|
||||
|
||||
$doctorSlotSets = [];
|
||||
$doctorWindowSets = [];
|
||||
foreach ($rosters as $roster) {
|
||||
$date = (string) ($roster['date'] ?? '');
|
||||
$doctorId = (int) ($roster['doctor_id'] ?? 0);
|
||||
$window = RosterSegmentService::resolveWindow($roster);
|
||||
if (!isset($days[$date]) || $doctorId <= 0 || $window === null) {
|
||||
continue;
|
||||
}
|
||||
[$startTime, $endTime] = $window;
|
||||
$times = RosterSegmentService::generateSlotTimes(
|
||||
$startTime,
|
||||
$endTime,
|
||||
RosterSegmentService::normalizeSlotMinutes($roster['slot_minutes'] ?? 15)
|
||||
);
|
||||
$times = RosterSegmentService::applyQuotaCap($times, (int) ($roster['quota'] ?? 0));
|
||||
foreach ($times as $time) {
|
||||
$doctorSlotSets[$date][$doctorId][$time] = true;
|
||||
}
|
||||
$doctorWindowSets[$date][$doctorId][$startTime . '-' . $endTime] = true;
|
||||
}
|
||||
|
||||
$appointments = Appointment::whereIn('doctor_id', $doctorIds)
|
||||
->whereBetween('appointment_date', [$startDate, $endDate])
|
||||
->where('status', 1)
|
||||
->field(['doctor_id', 'appointment_date', 'appointment_time'])
|
||||
->select()
|
||||
->toArray();
|
||||
$doctorBookedSets = [];
|
||||
foreach ($appointments as $appointment) {
|
||||
$date = (string) ($appointment['appointment_date'] ?? '');
|
||||
$doctorId = (int) ($appointment['doctor_id'] ?? 0);
|
||||
$time = substr((string) ($appointment['appointment_time'] ?? ''), 0, 5);
|
||||
if (isset($doctorSlotSets[$date][$doctorId][$time])) {
|
||||
$doctorBookedSets[$date][$doctorId][$time] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($days as $date => &$day) {
|
||||
$doctorDetails = [];
|
||||
$total = 0;
|
||||
$booked = 0;
|
||||
foreach ($doctorSlotSets[$date] ?? [] as $doctorId => $slotSet) {
|
||||
$doctorTotal = count($slotSet);
|
||||
$doctorBooked = count($doctorBookedSets[$date][$doctorId] ?? []);
|
||||
$total += $doctorTotal;
|
||||
$booked += $doctorBooked;
|
||||
$scheduleWindows = array_values(array_keys($doctorWindowSets[$date][$doctorId] ?? []));
|
||||
sort($scheduleWindows, SORT_STRING);
|
||||
$doctorDetails[] = [
|
||||
'doctor_id' => (int) $doctorId,
|
||||
'doctor_name' => trim((string) ($doctorNames[$doctorId] ?? '')) ?: '未知医生',
|
||||
'schedule_windows' => $scheduleWindows,
|
||||
'total_slots' => $doctorTotal,
|
||||
'booked_slots' => $doctorBooked,
|
||||
'empty_slots' => max(0, $doctorTotal - $doctorBooked),
|
||||
];
|
||||
}
|
||||
usort($doctorDetails, static function (array $left, array $right): int {
|
||||
return $right['booked_slots'] <=> $left['booked_slots']
|
||||
?: $right['total_slots'] <=> $left['total_slots']
|
||||
?: strcmp((string) $left['doctor_name'], (string) $right['doctor_name']);
|
||||
});
|
||||
$day['total_slots'] = $total;
|
||||
$day['booked_slots'] = $booked;
|
||||
$day['empty_slots'] = max(0, $total - $booked);
|
||||
$day['doctor_count'] = count($doctorDetails);
|
||||
$day['doctors'] = $doctorDetails;
|
||||
}
|
||||
unset($day);
|
||||
|
||||
return array_values($days);
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助“本人归属”只统计其患者的真实挂号,不再把历史接诊医生的整周号源算到本人名下。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function ownershipWeekSchedule(): array
|
||||
{
|
||||
$startDate = date('Y-m-d');
|
||||
$endDate = date('Y-m-d', strtotime($startDate . ' +6 days'));
|
||||
$days = [];
|
||||
for ($offset = 0; $offset < 7; $offset++) {
|
||||
$date = date('Y-m-d', strtotime($startDate . " +{$offset} days"));
|
||||
$days[$date] = $this->emptyScheduleDay($date);
|
||||
}
|
||||
|
||||
$query = Appointment::alias('ownership_a')
|
||||
->join('tcm_diagnosis d', 'ownership_a.patient_id = d.id')
|
||||
->leftJoin('admin ownership_doctor', 'ownership_a.doctor_id = ownership_doctor.id')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1)
|
||||
->whereBetween('ownership_a.appointment_date', [$startDate, $endDate])
|
||||
->whereIn('ownership_a.status', self::EFFECTIVE_STATUSES);
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
|
||||
$appointments = $query
|
||||
->field([
|
||||
'ownership_a.id', 'ownership_a.doctor_id', 'ownership_a.appointment_date',
|
||||
'ownership_a.appointment_time', 'ownership_a.status',
|
||||
'ownership_doctor.name AS doctor_name',
|
||||
])
|
||||
->order('ownership_a.appointment_date', 'asc')
|
||||
->order('ownership_a.appointment_time', 'asc')
|
||||
->order('ownership_a.id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$doctorDetails = [];
|
||||
foreach ($appointments as $appointment) {
|
||||
$date = (string) ($appointment['appointment_date'] ?? '');
|
||||
$doctorId = (int) ($appointment['doctor_id'] ?? 0);
|
||||
if (!isset($days[$date]) || $doctorId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($doctorDetails[$date][$doctorId])) {
|
||||
$doctorDetails[$date][$doctorId] = [
|
||||
'doctor_id' => $doctorId,
|
||||
'doctor_name' => trim((string) ($appointment['doctor_name'] ?? '')) ?: '未知医生',
|
||||
'appointment_time_set' => [],
|
||||
'total_appointments' => 0,
|
||||
'waiting_appointments' => 0,
|
||||
'completed_appointments' => 0,
|
||||
'missed_appointments' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$time = substr((string) ($appointment['appointment_time'] ?? ''), 0, 5);
|
||||
if ($time !== '') {
|
||||
$doctorDetails[$date][$doctorId]['appointment_time_set'][$time] = true;
|
||||
}
|
||||
$status = (int) ($appointment['status'] ?? 0);
|
||||
$doctorDetails[$date][$doctorId]['total_appointments']++;
|
||||
if ($status === 1) {
|
||||
$doctorDetails[$date][$doctorId]['waiting_appointments']++;
|
||||
} elseif ($status === 3) {
|
||||
$doctorDetails[$date][$doctorId]['completed_appointments']++;
|
||||
} elseif ($status === 4) {
|
||||
$doctorDetails[$date][$doctorId]['missed_appointments']++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($days as $date => &$day) {
|
||||
$rows = [];
|
||||
foreach ($doctorDetails[$date] ?? [] as $doctor) {
|
||||
$times = array_values(array_keys($doctor['appointment_time_set'] ?? []));
|
||||
sort($times, SORT_STRING);
|
||||
unset($doctor['appointment_time_set']);
|
||||
$doctor['appointment_times'] = $times;
|
||||
$rows[] = $doctor;
|
||||
}
|
||||
usort($rows, static function (array $left, array $right): int {
|
||||
return $right['waiting_appointments'] <=> $left['waiting_appointments']
|
||||
?: $right['total_appointments'] <=> $left['total_appointments']
|
||||
?: strcmp((string) $left['doctor_name'], (string) $right['doctor_name']);
|
||||
});
|
||||
|
||||
$day['total_appointments'] = array_sum(array_column($rows, 'total_appointments'));
|
||||
$day['waiting_appointments'] = array_sum(array_column($rows, 'waiting_appointments'));
|
||||
$day['completed_appointments'] = array_sum(array_column($rows, 'completed_appointments'));
|
||||
$day['missed_appointments'] = array_sum(array_column($rows, 'missed_appointments'));
|
||||
$day['doctor_count'] = count($rows);
|
||||
$day['doctors'] = $rows;
|
||||
}
|
||||
unset($day);
|
||||
|
||||
return array_values($days);
|
||||
}
|
||||
|
||||
private function usesOwnershipSchedule(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roleIds = $this->currentRoleIds();
|
||||
|
||||
return in_array(2, $roleIds, true) && array_intersect($roleIds, [3, 7, 8]) === [];
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function visibleDoctorIds(string $startDate, string $endDate): array
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
$doctorIds = array_values(array_unique(array_map('intval', Roster::whereBetween('date', [$startDate, $endDate])
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->column('doctor_id'))));
|
||||
|
||||
return $this->activeDoctorIds($doctorIds);
|
||||
}
|
||||
|
||||
$roleIds = $this->currentRoleIds();
|
||||
$isTeamRole = array_intersect($roleIds, [3, 7, 8]) !== [];
|
||||
$isDoctor = in_array(1, $roleIds, true);
|
||||
$isAssistant = in_array(2, $roleIds, true);
|
||||
|
||||
// 纯医生账号的概览只统计本人排班,避免同一患者曾由其他医生接诊时放大到其他医生。
|
||||
if (!$isTeamRole && $isDoctor && !$isAssistant) {
|
||||
return $this->activeDoctorIds([$this->adminId]);
|
||||
}
|
||||
|
||||
$query = Appointment::alias('scope_a')
|
||||
->join('tcm_diagnosis d', 'scope_a.patient_id = d.id')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1)
|
||||
->whereIn('scope_a.status', self::EFFECTIVE_STATUSES)
|
||||
->where('scope_a.doctor_id', '>', 0);
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
|
||||
$doctorIds = array_values(array_unique(array_filter(array_map('intval', $query->distinct(true)->column('scope_a.doctor_id')))));
|
||||
if (!$isTeamRole && $isDoctor) {
|
||||
$doctorIds[] = $this->adminId;
|
||||
}
|
||||
|
||||
return $this->activeDoctorIds(array_values(array_unique($doctorIds)));
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function currentRoleIds(): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', AdminRole::where('admin_id', $this->adminId)->column('role_id')))));
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @return int[] */
|
||||
private function activeDoctorIds(array $doctorIds): array
|
||||
{
|
||||
$doctorIds = array_values(array_unique(array_filter(array_map('intval', $doctorIds))));
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$roleDoctorIds = array_values(array_unique(array_map('intval', AdminRole::whereIn('admin_id', $doctorIds)
|
||||
->where('role_id', 1)
|
||||
->column('admin_id'))));
|
||||
if ($roleDoctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$activeSet = array_fill_keys(array_map('intval', Admin::whereIn('id', $roleDoctorIds)
|
||||
->where('disable', 0)
|
||||
->column('id')), true);
|
||||
|
||||
return array_values(array_filter($doctorIds, static function (int $doctorId) use ($activeSet): bool {
|
||||
return isset($activeSet[$doctorId]);
|
||||
}));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function emptyScheduleDay(string $date): array
|
||||
{
|
||||
$weekdayLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
$timestamp = strtotime($date) ?: time();
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'date_text' => date('m-d', $timestamp),
|
||||
'weekday' => $weekdayLabels[(int) date('w', $timestamp)],
|
||||
'total_slots' => 0,
|
||||
'booked_slots' => 0,
|
||||
'empty_slots' => 0,
|
||||
'doctor_count' => 0,
|
||||
'doctors' => [],
|
||||
'total_appointments' => 0,
|
||||
'waiting_appointments' => 0,
|
||||
'completed_appointments' => 0,
|
||||
'missed_appointments' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function queueStatus(int $status, bool $confirmed, int $aheadCount): string
|
||||
{
|
||||
if ($status === 3) {
|
||||
return 'completed';
|
||||
}
|
||||
if ($status === 4) {
|
||||
return 'missed';
|
||||
}
|
||||
if ($confirmed) {
|
||||
return 'consulting';
|
||||
}
|
||||
|
||||
return $aheadCount === 0 ? 'next' : 'waiting';
|
||||
}
|
||||
|
||||
private function queueStatusText(string $status): string
|
||||
{
|
||||
return [
|
||||
'completed' => '已完成',
|
||||
'missed' => '已过号',
|
||||
'consulting' => '就诊中',
|
||||
'next' => '待确认',
|
||||
'waiting' => '等待中',
|
||||
][$status] ?? '等待中';
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
return preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $phone) ?: $phone;
|
||||
}
|
||||
|
||||
private function appointmentTimeText(array $row): string
|
||||
{
|
||||
$time = trim((string) ($row['appointment_time'] ?? ''));
|
||||
if (strlen($time) > 5) {
|
||||
$time = substr($time, 0, 5);
|
||||
}
|
||||
|
||||
return trim((string) ($row['appointment_date'] ?? '') . ' ' . $time);
|
||||
}
|
||||
|
||||
private function appointmentStatusText(int $status): string
|
||||
{
|
||||
return [1 => '已挂号', 3 => '已完成', 4 => '已过号'][$status] ?? '未知';
|
||||
}
|
||||
|
||||
private function appointmentTypeText(string $type): string
|
||||
{
|
||||
return ['video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊'][$type] ?? '面诊';
|
||||
}
|
||||
|
||||
private function progressText(bool $confirmed, bool $completed, bool $prescribed, int $status): string
|
||||
{
|
||||
if ($status === 4) {
|
||||
return '已过号';
|
||||
}
|
||||
if (!$confirmed) {
|
||||
return '待确认诊单';
|
||||
}
|
||||
if (!$completed) {
|
||||
return '待完诊';
|
||||
}
|
||||
|
||||
return $prescribed ? '已开方' : '待开方';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\ConversionLogic;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\stats\PersonalYeji;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 一诊「综合数据转化」。
|
||||
*
|
||||
* 自动指标复用 ConversionLogic;开口数来自个人业绩录入。所有筛选先与 DataScope
|
||||
* 可见管理员集合取交集,HTTP 参数不能扩大当前账号的数据范围。
|
||||
*/
|
||||
class FirstVisitConversionLogic
|
||||
{
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
[$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange((string) ($params['time_type'] ?? 'today'));
|
||||
$baseVisibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
|
||||
|
||||
$deptSelectionValid = $selectedDeptId <= 0
|
||||
|| $allowedDeptSet === null
|
||||
|| isset($allowedDeptSet[$selectedDeptId]);
|
||||
$selectedDeptIds = [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$selectedDeptIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
DeptLogic::getSelfAndDescendantIds($selectedDeptId)
|
||||
), static fn (int $id): bool => $id > 0)));
|
||||
if ($allowedDeptSet !== null) {
|
||||
$selectedDeptIds = array_values(array_filter(
|
||||
$selectedDeptIds,
|
||||
static fn (int $id): bool => isset($allowedDeptSet[$id])
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveAdminIds = $deptSelectionValid ? $baseVisibleAdminIds : [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$deptAdminIds = $selectedDeptIds === []
|
||||
? []
|
||||
: self::normalizeIds(AdminDept::whereIn('dept_id', $selectedDeptIds)->column('admin_id'));
|
||||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||||
}
|
||||
|
||||
if ($selectedAssistantId > 0) {
|
||||
$assistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $assistantValid ? [$selectedAssistantId] : [];
|
||||
}
|
||||
$costAllocationAdminIds = self::costAllocationAdminIds(
|
||||
$effectiveAdminIds,
|
||||
$scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0
|
||||
);
|
||||
|
||||
$conversionParams = [
|
||||
'dimension' => 'dept',
|
||||
'time_type' => 'custom',
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'include_filters' => 0,
|
||||
'include_members' => 0,
|
||||
'page_no' => 1,
|
||||
'page_size' => 100,
|
||||
];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$conversionParams['dept_id'] = $selectedDeptId;
|
||||
}
|
||||
|
||||
$conversion = ConversionLogic::overview(
|
||||
$conversionParams,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$effectiveAdminIds,
|
||||
$costAllocationAdminIds
|
||||
);
|
||||
$rows = is_array($conversion['lists'] ?? null) ? $conversion['lists'] : [];
|
||||
$rowAllowedDeptIds = self::visibleRowDeptIds($effectiveAdminIds);
|
||||
if ($rowAllowedDeptIds !== null) {
|
||||
$rows = self::filterDeptRows($rows, array_fill_keys($rowAllowedDeptIds, true));
|
||||
}
|
||||
|
||||
$rowDeptIdSet = [];
|
||||
self::collectRowDeptIds($rows, $rowDeptIdSet);
|
||||
$openDirect = self::loadOpenCountByDept(
|
||||
$startDate,
|
||||
$endDate,
|
||||
$effectiveAdminIds,
|
||||
array_fill_keys(array_keys($rowDeptIdSet), true)
|
||||
);
|
||||
self::applyOpenCounts($rows, $openDirect);
|
||||
|
||||
$summary = is_array($conversion['summary'] ?? null) ? $conversion['summary'] : [];
|
||||
$summary['total_open_count'] = array_sum($openDirect);
|
||||
$summary['open_receive_rate'] = self::percent(
|
||||
(int) ($summary['completed_order_count'] ?? 0),
|
||||
(int) $summary['total_open_count']
|
||||
);
|
||||
|
||||
$rankingRows = self::rankingRows($rows);
|
||||
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
|
||||
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
|
||||
? []
|
||||
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
|
||||
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = $selectedAssistantId > 0
|
||||
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'time_type' => $timeType,
|
||||
'time_label' => $timeLabel,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_assistant_name' => $selectedAssistantName,
|
||||
'open_count_source' => '个人业绩录入',
|
||||
],
|
||||
'filters' => [
|
||||
'departments' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||||
'assistants' => self::assistantOptions($baseVisibleAdminIds, $selectedDeptIds, $selectedDeptId),
|
||||
],
|
||||
'summary' => $summary,
|
||||
'rankings' => [
|
||||
'orders' => self::topRows($rankingRows, 'completed_order_count'),
|
||||
'amounts' => self::topRows($rankingRows, 'completed_order_amount'),
|
||||
],
|
||||
'rows' => $rows,
|
||||
'target' => $target,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string,2:string,3:string} */
|
||||
private static function resolveTimeRange(string $timeType): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$timeType = in_array($timeType, ['today', 'week', 'month', 'quarter', 'year'], true)
|
||||
? $timeType
|
||||
: 'today';
|
||||
|
||||
if ($timeType === 'week') {
|
||||
return [date('Y-m-d', strtotime('monday this week')), $today, $timeType, '本周'];
|
||||
}
|
||||
if ($timeType === 'month') {
|
||||
return [date('Y-m-01'), $today, $timeType, '本月'];
|
||||
}
|
||||
if ($timeType === 'quarter') {
|
||||
$quarterMonth = ((int) floor(((int) date('n') - 1) / 3) * 3) + 1;
|
||||
|
||||
return [date('Y-' . str_pad((string) $quarterMonth, 2, '0', STR_PAD_LEFT) . '-01'), $today, $timeType, '本季度'];
|
||||
}
|
||||
if ($timeType === 'year') {
|
||||
return [date('Y-01-01'), $today, $timeType, '本年'];
|
||||
}
|
||||
|
||||
return [$today, $today, 'today', '今日'];
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleIds @param int[] $candidateIds @return int[]|null */
|
||||
private static function intersectVisibleIds(?array $visibleIds, array $candidateIds): ?array
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return $candidateIds;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($visibleIds, $candidateIds));
|
||||
}
|
||||
|
||||
private static function isActiveAssistant(int $adminId): bool
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Db::name('admin')
|
||||
->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('a.id', $adminId)
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time')
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleAdminIds @return int[]|null */
|
||||
private static function visibleRowDeptIds(?array $visibleAdminIds): ?array
|
||||
{
|
||||
if ($visibleAdminIds === null) {
|
||||
return null;
|
||||
}
|
||||
if ($visibleAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizeIds(AdminDept::whereIn('admin_id', $visibleAdminIds)->column('dept_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人指标仍只查本人;成本按本人所在部门全员的加粉占比分摊。
|
||||
*
|
||||
* @param int[]|null $effectiveAdminIds
|
||||
* @return int[]|null null 表示使用默认分摊范围
|
||||
*/
|
||||
private static function costAllocationAdminIds(?array $effectiveAdminIds, bool $personalScope): ?array
|
||||
{
|
||||
if (!$personalScope) {
|
||||
return null;
|
||||
}
|
||||
if ($effectiveAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
$deptIds = self::visibleRowDeptIds($effectiveAdminIds);
|
||||
if ($deptIds === null || $deptIds === []) {
|
||||
return $effectiveAdminIds ?? [];
|
||||
}
|
||||
|
||||
$ids = self::normalizeIds(AdminDept::whereIn('dept_id', $deptIds)->column('admin_id'));
|
||||
|
||||
return $ids !== [] ? $ids : ($effectiveAdminIds ?? []);
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $allowedSet @return array<int,array<string,mixed>> */
|
||||
private static function filterDeptRows(array $rows, array $allowedSet): array
|
||||
{
|
||||
if ($allowedSet === []) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$children = self::filterDeptRows(is_array($row['children'] ?? null) ? $row['children'] : [], $allowedSet);
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if (isset($allowedSet[$id])) {
|
||||
$row['children'] = $children;
|
||||
if ($children === []) {
|
||||
unset($row['children']);
|
||||
}
|
||||
$out[] = $row;
|
||||
continue;
|
||||
}
|
||||
foreach ($children as $child) {
|
||||
$out[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $set */
|
||||
private static function collectRowDeptIds(array $rows, array &$set): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id !== 0) {
|
||||
$set[$id] = true;
|
||||
}
|
||||
self::collectRowDeptIds(is_array($row['children'] ?? null) ? $row['children'] : [], $set);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param int[]|null $effectiveAdminIds @param array<int,true> $rowDeptSet @return array<int,int> */
|
||||
private static function loadOpenCountByDept(string $startDate, string $endDate, ?array $effectiveAdminIds, array $rowDeptSet): array
|
||||
{
|
||||
if ($effectiveAdminIds === [] || $rowDeptSet === []) {
|
||||
return [];
|
||||
}
|
||||
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
|
||||
if ($effectiveAdminIds !== null) {
|
||||
$query->whereIn('creator_id', $effectiveAdminIds);
|
||||
}
|
||||
$rows = $query
|
||||
->fieldRaw('creator_id, SUM(total_open_count) AS open_count')
|
||||
->group('creator_id')
|
||||
->select()
|
||||
->toArray();
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$creatorIds = self::normalizeIds(array_column($rows, 'creator_id'));
|
||||
$deptRows = $creatorIds === [] ? [] : AdminDept::whereIn('admin_id', $creatorIds)
|
||||
->field('admin_id, dept_id')
|
||||
->order('admin_id', 'asc')
|
||||
->order('dept_id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$adminDeptMap = [];
|
||||
foreach ($deptRows as $deptRow) {
|
||||
$adminDeptMap[(int) $deptRow['admin_id']][] = (int) $deptRow['dept_id'];
|
||||
}
|
||||
|
||||
$direct = [];
|
||||
foreach ($rows as $row) {
|
||||
$adminId = (int) ($row['creator_id'] ?? 0);
|
||||
$targetDeptId = 0;
|
||||
foreach ($adminDeptMap[$adminId] ?? [] as $deptId) {
|
||||
if (isset($rowDeptSet[$deptId])) {
|
||||
$targetDeptId = $deptId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($targetDeptId === 0 && isset($rowDeptSet[-2])) {
|
||||
$targetDeptId = -2;
|
||||
}
|
||||
if ($targetDeptId !== 0) {
|
||||
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + (int) ($row['open_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $direct;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @param array<int,int> $direct */
|
||||
private static function applyOpenCounts(array &$rows, array $direct): int
|
||||
{
|
||||
$sum = 0;
|
||||
foreach ($rows as &$row) {
|
||||
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
|
||||
$childTotal = self::applyOpenCounts($children, $direct);
|
||||
if ($children !== []) {
|
||||
$row['children'] = $children;
|
||||
}
|
||||
$count = (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
|
||||
$row['total_open_count'] = $count;
|
||||
$row['open_receive_rate'] = self::percent((int) ($row['completed_order_count'] ?? 0), $count);
|
||||
$sum += (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $sum;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function rankingRows(array $rows): array
|
||||
{
|
||||
if (count($rows) === 1 && is_array($rows[0]['children'] ?? null) && $rows[0]['children'] !== []) {
|
||||
return $rows[0]['children'];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function topRows(array $rows, string $metric): array
|
||||
{
|
||||
$rows = array_values(array_filter($rows, static fn (array $row): bool => (int) ($row['id'] ?? 0) > 0));
|
||||
usort($rows, static function (array $left, array $right) use ($metric): int {
|
||||
return (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
|
||||
});
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => round((float) ($row[$metric] ?? 0), 2),
|
||||
], array_slice($rows, 0, 6));
|
||||
}
|
||||
|
||||
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
|
||||
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
|
||||
{
|
||||
$query = Db::name('admin')
|
||||
->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time');
|
||||
if ($baseVisibleAdminIds !== null) {
|
||||
if ($baseVisibleAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->whereIn('a.id', $baseVisibleAdminIds);
|
||||
}
|
||||
if ($selectedDeptId > 0) {
|
||||
if ($selectedDeptIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->join('admin_dept ad', 'ad.admin_id = a.id')->whereIn('ad.dept_id', $selectedDeptIds);
|
||||
}
|
||||
|
||||
return $query->field('a.id, a.name')->distinct(true)->order('a.name', 'asc')->select()->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,true>|null $allowedDeptSet @param int[] $selectedDeptIds @return int[]|null */
|
||||
private static function resolveTargetDeptIds(?array $allowedDeptSet, array $selectedDeptIds, int $selectedDeptId): ?array
|
||||
{
|
||||
if ($selectedDeptId > 0) {
|
||||
return $selectedDeptIds;
|
||||
}
|
||||
if ($allowedDeptSet === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_map('intval', array_keys($allowedDeptSet));
|
||||
}
|
||||
|
||||
/** @param int[]|null $effectiveAdminIds @param int[]|null $targetDeptIds @return array<string,mixed> */
|
||||
private static function buildTargetProgress(int $year, ?array $effectiveAdminIds, ?array $targetDeptIds): array
|
||||
{
|
||||
$targetQuery = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%');
|
||||
if ($targetDeptIds !== null) {
|
||||
if ($targetDeptIds === []) {
|
||||
$targetRows = [];
|
||||
} else {
|
||||
$targetRows = $targetQuery->whereIn('dept_id', $targetDeptIds)
|
||||
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
|
||||
->group('`year_month`')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
} else {
|
||||
$targetRows = $targetQuery
|
||||
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
|
||||
->group('`year_month`')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$actualRows = [];
|
||||
if ($effectiveAdminIds !== []) {
|
||||
$actualQuery = Db::name('tcm_prescription_order')
|
||||
->alias('po')
|
||||
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.prescription_audit_status', 1)
|
||||
->where('po.payment_slip_audit_status', 1)
|
||||
->where('po.create_time', 'between', [
|
||||
strtotime($year . '-01-01 00:00:00'),
|
||||
strtotime($year . '-12-31 23:59:59'),
|
||||
]);
|
||||
if ($effectiveAdminIds !== null) {
|
||||
$actualQuery->whereIn('rx.assistant_id', $effectiveAdminIds);
|
||||
}
|
||||
$actualRows = $actualQuery
|
||||
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
|
||||
->group('month_no')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$targets = array_fill(1, 12, 0.0);
|
||||
$actuals = array_fill(1, 12, 0.0);
|
||||
$deptCountSet = [];
|
||||
foreach ($targetRows as $row) {
|
||||
$month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$targets[$month] = round((float) ($row['target_amount'] ?? 0), 2);
|
||||
$deptCountSet[$month] = (int) ($row['dept_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
foreach ($actualRows as $row) {
|
||||
$month = (int) ($row['month_no'] ?? 0);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$actuals[$month] = round((float) ($row['actual_amount'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
|
||||
$targetCumulative = [];
|
||||
$actualCumulative = [];
|
||||
$targetRunning = 0.0;
|
||||
$actualRunning = 0.0;
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$targetRunning = round($targetRunning + $targets[$month], 2);
|
||||
$actualRunning = round($actualRunning + $actuals[$month], 2);
|
||||
$targetCumulative[] = $targetRunning;
|
||||
$actualCumulative[] = $actualRunning;
|
||||
}
|
||||
$currentMonth = (int) date('n');
|
||||
|
||||
return [
|
||||
'year' => $year,
|
||||
'target_amount' => $targetRunning,
|
||||
'actual_amount' => $actualRunning,
|
||||
'completion_rate' => $targetRunning > 0 ? round($actualRunning / $targetRunning * 100, 2) : null,
|
||||
'current_month_target' => $targets[$currentMonth],
|
||||
'current_month_actual' => $actuals[$currentMonth],
|
||||
'current_month_rate' => $targets[$currentMonth] > 0
|
||||
? round($actuals[$currentMonth] / $targets[$currentMonth] * 100, 2)
|
||||
: null,
|
||||
'department_count' => max($deptCountSet ?: [0]),
|
||||
'months' => array_map(static fn (int $month): string => str_pad((string) $month, 2, '0', STR_PAD_LEFT) . '月', range(1, 12)),
|
||||
'target_cumulative' => $targetCumulative,
|
||||
'actual_cumulative' => $actualCumulative,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int|string,mixed> $ids @return int[] */
|
||||
private static function normalizeIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
|
||||
}
|
||||
|
||||
private static function percent(int $numerator, int $denominator): float
|
||||
{
|
||||
return $denominator > 0 ? round($numerator / $denominator * 100, 2) : 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\DoctorDailyStatsLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 一诊「医生看板」。
|
||||
*
|
||||
* 医生是最终展示维度;部门权限通过实际经手医助下推到挂号、诊单与业绩:
|
||||
* - 医生 SELF:只看本人医生数据,不限制经手医助;
|
||||
* - 医助 SELF:只看本人经手患者关联的医生数据;
|
||||
* - 组长/经理:只看数据范围内医助经手患者关联的医生数据;
|
||||
* - 管理员/ALL:全部医生,可再选择部门收窄。
|
||||
*/
|
||||
class FirstVisitDoctorDashboardLogic
|
||||
{
|
||||
private const DOCTOR_ROLE_ID = 1;
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
private const TREND_DAYS = 30;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$range = self::resolveRange((string) ($params['time_type'] ?? 'month'));
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$roleIds = self::normalizeIds(Db::name('admin_role')->where('admin_id', $adminId)->column('role_id'));
|
||||
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
|
||||
$doctorSelf = !$isRoot
|
||||
&& $scopeValue === DataScopeService::SCOPE_SELF
|
||||
&& in_array(self::DOCTOR_ROLE_ID, $roleIds, true);
|
||||
$activeOnly = (int) ($params['active_only'] ?? 1) !== 0;
|
||||
$selectedDeptId = $doctorSelf ? 0 : max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedDoctorId = max(0, (int) ($params['doctor_id'] ?? 0));
|
||||
$threshold = min(100.0, max(1.0, (float) ($params['alert_threshold'] ?? 15)));
|
||||
|
||||
$allDoctorOptions = self::doctorOptions($activeOnly, $doctorSelf ? $adminId : 0);
|
||||
$doctorIds = self::normalizeIds(array_column($allDoctorOptions, 'id'));
|
||||
if ($selectedDoctorId > 0) {
|
||||
$doctorIds = in_array($selectedDoctorId, $doctorIds, true) ? [$selectedDoctorId] : [];
|
||||
}
|
||||
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
[$selectedDeptIds, $deptSelectionValid] = self::resolveSelectedDeptIds(
|
||||
$selectedDeptId,
|
||||
$allowedDeptSet
|
||||
);
|
||||
$assistantIds = self::resolveAssistantScope(
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$doctorSelf,
|
||||
$selectedDeptId,
|
||||
$selectedDeptIds,
|
||||
$deptSelectionValid
|
||||
);
|
||||
|
||||
$stats = DoctorDailyStatsLogic::overview(
|
||||
[
|
||||
'start_date' => $range['start'],
|
||||
'end_date' => $range['end'],
|
||||
],
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$doctorIds,
|
||||
$assistantIds
|
||||
);
|
||||
|
||||
$doctorDeptNames = self::doctorDepartmentNames($doctorIds);
|
||||
$doctorStatus = self::doctorStatusMap($doctorIds);
|
||||
$rows = self::enrichRows(
|
||||
is_array($stats['rows'] ?? null) ? $stats['rows'] : [],
|
||||
$doctorDeptNames,
|
||||
$doctorStatus
|
||||
);
|
||||
$summary = self::buildSummary($rows);
|
||||
$trend = self::buildAmountTrend($doctorIds, $assistantIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedDoctorName = '';
|
||||
if ($selectedDoctorId > 0) {
|
||||
foreach ($allDoctorOptions as $doctor) {
|
||||
if ((int) ($doctor['id'] ?? 0) === $selectedDoctorId) {
|
||||
$selectedDoctorName = (string) ($doctor['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'time_type' => $range['type'],
|
||||
'time_label' => $range['label'],
|
||||
'start_date' => $range['start'],
|
||||
'end_date' => $range['end'],
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => $doctorSelf ? '医生本人' : DataScopeService::scopeLabel($scopeValue),
|
||||
'scope_kind' => $doctorSelf ? 'doctor_self' : ($assistantIds === null ? 'all' : 'assistant_scope'),
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_doctor_name' => $selectedDoctorName,
|
||||
'doctor_count' => count($rows),
|
||||
'appointment_rule' => '总挂号包含已预约、已取消、已完成和已过号;面诊取状态为已完成的挂号',
|
||||
'performance_rule' => '诊单按订单创建时间统计,排除履约已取消、拒收和退款,金额归属处方开方医生',
|
||||
],
|
||||
'filters' => [
|
||||
'departments' => $doctorSelf ? [] : DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||||
'doctors' => $allDoctorOptions,
|
||||
'can_filter_department' => !$doctorSelf,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'rankings' => [
|
||||
'amounts' => self::ranking($rows, 'deal_amount', 8),
|
||||
'conversion' => self::ranking($rows, 'receive_conversion_rate', 8),
|
||||
],
|
||||
'funnel' => [
|
||||
['key' => 'appointment', 'label' => '挂号', 'value' => (int) $summary['appointment_total']],
|
||||
['key' => 'interview', 'label' => '面诊', 'value' => (int) $summary['interview_count']],
|
||||
['key' => 'receive', 'label' => '接诊', 'value' => (int) $summary['order_count']],
|
||||
['key' => 'deal', 'label' => '成交', 'value' => (int) $summary['order_count']],
|
||||
],
|
||||
'trend' => $trend,
|
||||
'alerts' => self::alertRows($rows, $threshold),
|
||||
'alert_threshold' => $threshold,
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private static function resolveRange(string $type): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
if ($type === 'today') {
|
||||
return ['type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today];
|
||||
}
|
||||
if ($type === 'week') {
|
||||
return [
|
||||
'type' => 'week', 'label' => '本周',
|
||||
'start' => date('Y-m-d', strtotime('monday this week')), 'end' => $today,
|
||||
];
|
||||
}
|
||||
|
||||
return ['type' => 'month', 'label' => '本月', 'start' => date('Y-m-01'), 'end' => $today];
|
||||
}
|
||||
|
||||
/** @return array<int,array{id:int,name:string,disable:int}> */
|
||||
private static function doctorOptions(bool $activeOnly, int $selfDoctorId = 0): array
|
||||
{
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('ar.role_id', self::DOCTOR_ROLE_ID)
|
||||
->whereNull('a.delete_time');
|
||||
if ($activeOnly) {
|
||||
$query->where('a.disable', 0);
|
||||
}
|
||||
if ($selfDoctorId > 0) {
|
||||
$query->where('a.id', $selfDoctorId);
|
||||
}
|
||||
|
||||
return $query->field('a.id, a.name, a.disable')
|
||||
->distinct(true)
|
||||
->order('a.disable', 'asc')
|
||||
->order('a.name', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,true>|null $allowedSet @return array{0:array<int>,1:bool} */
|
||||
private static function resolveSelectedDeptIds(int $selectedDeptId, ?array $allowedSet): array
|
||||
{
|
||||
if ($selectedDeptId <= 0) {
|
||||
return [[], true];
|
||||
}
|
||||
$ids = self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId));
|
||||
if ($allowedSet !== null) {
|
||||
$ids = array_values(array_filter($ids, static fn (int $id): bool => isset($allowedSet[$id])));
|
||||
}
|
||||
|
||||
return [$ids, $ids !== []];
|
||||
}
|
||||
|
||||
/**
|
||||
* null 表示医生本人或 ALL,不附加医助过滤;数组表示必须按这些医助经手的数据收窄。
|
||||
*
|
||||
* @param int[] $selectedDeptIds
|
||||
* @return int[]|null
|
||||
*/
|
||||
private static function resolveAssistantScope(
|
||||
int $adminId,
|
||||
array $adminInfo,
|
||||
bool $doctorSelf,
|
||||
int $selectedDeptId,
|
||||
array $selectedDeptIds,
|
||||
bool $deptSelectionValid
|
||||
): ?array {
|
||||
if ($doctorSelf) {
|
||||
return null;
|
||||
}
|
||||
if (!$deptSelectionValid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$assistantIds = self::activeAssistantIds($visibleIds);
|
||||
if ($selectedDeptId <= 0) {
|
||||
return $visibleIds === null ? null : $assistantIds;
|
||||
}
|
||||
|
||||
$deptAssistantIds = self::activeAssistantIdsByDepartment($selectedDeptIds);
|
||||
if ($visibleIds === null) {
|
||||
return $deptAssistantIds;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($assistantIds, $deptAssistantIds));
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleIds @return int[] */
|
||||
private static function activeAssistantIds(?array $visibleIds): array
|
||||
{
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time');
|
||||
if ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
}
|
||||
|
||||
return self::normalizeIds($query->column('a.id'));
|
||||
}
|
||||
|
||||
/** @param int[] $deptIds @return int[] */
|
||||
private static function activeAssistantIdsByDepartment(array $deptIds): array
|
||||
{
|
||||
if ($deptIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizeIds(Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->join('admin_dept ad', 'ad.admin_id = a.id')
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->whereIn('ad.dept_id', $deptIds)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time')
|
||||
->distinct(true)
|
||||
->column('a.id'));
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @return array<int,string> */
|
||||
private static function doctorDepartmentNames(array $doctorIds): array
|
||||
{
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = AdminDept::alias('ad')
|
||||
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL')
|
||||
->whereIn('ad.admin_id', $doctorIds)
|
||||
->field('ad.admin_id, d.name')
|
||||
->order('d.sort', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['admin_id'] ?? 0);
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($id > 0 && $name !== '' && !isset($out[$id])) {
|
||||
$out[$id] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @return array<int,int> */
|
||||
private static function doctorStatusMap(array $doctorIds): array
|
||||
{
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('admin')->whereIn('id', $doctorIds)->field('id, disable')->select()->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[(int) $row['id']] = (int) ($row['disable'] ?? 0);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function enrichRows(array $rows, array $deptNames, array $statusMap): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['admin_id'] ?? 0);
|
||||
$appointmentTotal = (int) ($row['appointment_total'] ?? 0);
|
||||
$interviewCount = (int) ($row['appointment_completed'] ?? 0);
|
||||
$orderCount = (int) ($row['deal_order_count'] ?? 0);
|
||||
$out[] = array_merge($row, [
|
||||
'doctor_id' => $id,
|
||||
'department_name' => (string) ($deptNames[$id] ?? '未分配部门'),
|
||||
'interview_count' => $interviewCount,
|
||||
'order_count' => $orderCount,
|
||||
'appointment_completion_rate' => $appointmentTotal > 0
|
||||
? round($interviewCount / $appointmentTotal * 100, 2)
|
||||
: null,
|
||||
'receive_conversion_rate' => $interviewCount > 0
|
||||
? round($orderCount / $interviewCount * 100, 2)
|
||||
: null,
|
||||
'status' => (int) ($statusMap[$id] ?? 0) === 0 ? 'active' : 'disabled',
|
||||
]);
|
||||
}
|
||||
usort($out, static fn (array $a, array $b): int => (($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<string,mixed> */
|
||||
private static function buildSummary(array $rows): array
|
||||
{
|
||||
$appointmentTotal = 0;
|
||||
$interviewCount = 0;
|
||||
$orderCount = 0;
|
||||
$dealAmount = 0.0;
|
||||
$missed = 0;
|
||||
$cancelled = 0;
|
||||
foreach ($rows as $row) {
|
||||
$appointmentTotal += (int) ($row['appointment_total'] ?? 0);
|
||||
$interviewCount += (int) ($row['interview_count'] ?? 0);
|
||||
$orderCount += (int) ($row['order_count'] ?? 0);
|
||||
$dealAmount += (float) ($row['deal_amount'] ?? 0);
|
||||
$missed += (int) ($row['appointment_missed'] ?? 0);
|
||||
$cancelled += (int) ($row['appointment_cancelled'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'appointment_total' => $appointmentTotal,
|
||||
'interview_count' => $interviewCount,
|
||||
'order_count' => $orderCount,
|
||||
'deal_amount' => round($dealAmount, 2),
|
||||
'avg_order_amount' => $orderCount > 0 ? round($dealAmount / $orderCount, 2) : null,
|
||||
'appointment_completion_rate' => $appointmentTotal > 0
|
||||
? round($interviewCount / $appointmentTotal * 100, 2)
|
||||
: null,
|
||||
'receive_conversion_rate' => $interviewCount > 0
|
||||
? round($orderCount / $interviewCount * 100, 2)
|
||||
: null,
|
||||
'missed_count' => $missed,
|
||||
'cancelled_count' => $cancelled,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function ranking(array $rows, string $field, int $limit): array
|
||||
{
|
||||
$ranked = $rows;
|
||||
usort($ranked, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
|
||||
$out = [];
|
||||
foreach (array_slice($ranked, 0, $limit) as $row) {
|
||||
$out[] = [
|
||||
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
|
||||
'name' => (string) ($row['doctor_name'] ?? ''),
|
||||
'value' => round((float) ($row[$field] ?? 0), 2),
|
||||
'interview_count' => (int) ($row['interview_count'] ?? 0),
|
||||
'order_count' => (int) ($row['order_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @param int[]|null $assistantIds @return array<string,mixed> */
|
||||
private static function buildAmountTrend(array $doctorIds, ?array $assistantIds): array
|
||||
{
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
|
||||
$amountByDate = [];
|
||||
if ($doctorIds !== [] && $assistantIds !== []) {
|
||||
$query = Db::name('tcm_prescription_order')->alias('o')
|
||||
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
|
||||
->whereNull('o.delete_time')
|
||||
->whereIn('rx.creator_id', $doctorIds)
|
||||
->where('o.diagnosis_id', '>', 0)
|
||||
->where('o.create_time', 'between', [
|
||||
strtotime($startDate . ' 00:00:00'),
|
||||
strtotime($endDate . ' 23:59:59'),
|
||||
]);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'o');
|
||||
if ($assistantIds !== null) {
|
||||
$query->whereIn('o.creator_id', $assistantIds);
|
||||
}
|
||||
$rows = $query
|
||||
->fieldRaw("FROM_UNIXTIME(o.create_time, '%Y-%m-%d') AS date_label, SUM(o.amount) AS amount_sum")
|
||||
->group('date_label')
|
||||
->order('date_label', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($date !== '') {
|
||||
$amountByDate[$date] = round((float) ($row['amount_sum'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
$dates = [];
|
||||
$labels = [];
|
||||
$amounts = [];
|
||||
for ($offset = 0; $offset < self::TREND_DAYS; $offset++) {
|
||||
$date = date('Y-m-d', strtotime($startDate . ' +' . $offset . ' days'));
|
||||
$dates[] = $date;
|
||||
$labels[] = date('m-d', strtotime($date));
|
||||
$amounts[] = (float) ($amountByDate[$date] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'dates' => $dates,
|
||||
'labels' => $labels,
|
||||
'amounts' => $amounts,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function alertRows(array $rows, float $threshold): array
|
||||
{
|
||||
$alerts = array_values(array_filter($rows, static function (array $row) use ($threshold): bool {
|
||||
$interviews = (int) ($row['interview_count'] ?? 0);
|
||||
$rate = $row['receive_conversion_rate'] ?? null;
|
||||
|
||||
return $interviews > 0 && ($rate === null || (float) $rate < $threshold);
|
||||
}));
|
||||
usort($alerts, static fn (array $a, array $b): int => (($a['receive_conversion_rate'] ?? -1) <=> ($b['receive_conversion_rate'] ?? -1)) ?: (($b['interview_count'] ?? 0) <=> ($a['interview_count'] ?? 0)));
|
||||
|
||||
return array_map(static function (array $row) use ($threshold): array {
|
||||
$rate = (float) ($row['receive_conversion_rate'] ?? 0);
|
||||
return [
|
||||
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
|
||||
'doctor_name' => (string) ($row['doctor_name'] ?? ''),
|
||||
'department_name' => (string) ($row['department_name'] ?? ''),
|
||||
'interview_count' => (int) ($row['interview_count'] ?? 0),
|
||||
'order_count' => (int) ($row['order_count'] ?? 0),
|
||||
'rate' => round($rate, 2),
|
||||
'severity' => $rate < $threshold / 2 ? 'high' : 'medium',
|
||||
'suggestion' => (int) ($row['order_count'] ?? 0) === 0
|
||||
? '当前有面诊但无接诊诊单,建议核对诊单及跟进记录'
|
||||
: '接诊转化低于预警线,建议复盘患者需求与沟通记录',
|
||||
];
|
||||
}, $alerts);
|
||||
}
|
||||
|
||||
/** @param array<int|string,mixed> $ids @return int[] */
|
||||
private static function normalizeIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 一诊「挂号统计」。
|
||||
*
|
||||
* 统计口径:
|
||||
* - 挂号:doctor_appointment.appointment_date,状态 1/3/4,排除已取消 2;
|
||||
* 归属优先挂号医助 assistant_id,再回退诊单医助 assistant_id。
|
||||
* - 诊单:tcm_prescription_order.create_time,归属订单 creator_id,排除履约 4/9/10。
|
||||
* - 所有部门和员工筛选都只能收窄 DataScope,不允许 HTTP 参数扩大当前账号范围。
|
||||
*/
|
||||
class FirstVisitRegistrationStatsLogic
|
||||
{
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$range = self::resolveRange((string) ($params['time_type'] ?? 'today'));
|
||||
$baseVisibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
|
||||
|
||||
[$selectedDeptIds, $deptSelectionValid] = self::resolveSelectedDeptIds(
|
||||
$selectedDeptId,
|
||||
$allowedDeptSet
|
||||
);
|
||||
$assistants = $deptSelectionValid
|
||||
? self::assistantOptions($baseVisibleIds, $selectedDeptIds, $selectedDeptId)
|
||||
: [];
|
||||
$assistantIds = self::normalizeIds(array_column($assistants, 'id'));
|
||||
if ($selectedAssistantId > 0) {
|
||||
$assistantIds = in_array($selectedAssistantId, $assistantIds, true)
|
||||
? [$selectedAssistantId]
|
||||
: [];
|
||||
}
|
||||
|
||||
$departmentTree = DeptLogic::getAllDataScoped($adminId, $adminInfo);
|
||||
$departmentIndex = [];
|
||||
self::flattenDepartmentTree($departmentTree, $departmentIndex, 0);
|
||||
$assignment = self::buildAssistantDepartmentMap(
|
||||
$assistantIds,
|
||||
$departmentIndex,
|
||||
$selectedDeptIds,
|
||||
$selectedDeptId
|
||||
);
|
||||
|
||||
$appointmentDaily = self::loadAppointmentDaily(
|
||||
min($range['compare_start'], $range['start']),
|
||||
$range['day_after_tomorrow'],
|
||||
$assistantIds
|
||||
);
|
||||
$orderDaily = self::loadOrderDaily(
|
||||
min($range['compare_start'], $range['start']),
|
||||
$range['end'],
|
||||
$assistantIds
|
||||
);
|
||||
|
||||
$members = self::buildMemberRows(
|
||||
$assistants,
|
||||
$assistantIds,
|
||||
$assignment,
|
||||
$appointmentDaily,
|
||||
$orderDaily,
|
||||
$range
|
||||
);
|
||||
$groups = self::buildDepartmentGroups($members, $departmentIndex);
|
||||
$summary = self::buildSummary($members, $range);
|
||||
$targetDeptIds = self::resolveTargetDeptIds(
|
||||
$adminId,
|
||||
$scopeValue,
|
||||
$selectedAssistantId,
|
||||
$selectedDeptIds,
|
||||
$selectedDeptId
|
||||
);
|
||||
$target = self::buildTarget((int) date('Y'), $assistantIds, $targetDeptIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) ($departmentIndex[$selectedDeptId]['name'] ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = '';
|
||||
if ($selectedAssistantId > 0) {
|
||||
foreach ($assistants as $assistant) {
|
||||
if ((int) ($assistant['id'] ?? 0) === $selectedAssistantId) {
|
||||
$selectedAssistantName = (string) ($assistant['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'time_type' => $range['type'],
|
||||
'time_label' => $range['label'],
|
||||
'start_date' => $range['start'],
|
||||
'end_date' => $range['end'],
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_assistant_name' => $selectedAssistantName,
|
||||
'member_count' => count($assistantIds),
|
||||
'appointment_rule' => '预约日期在统计区间,状态为已预约、已完成或已过号,排除已取消',
|
||||
'performance_rule' => '按订单创建时间和创建人统计,排除已取消、拒收和退款',
|
||||
],
|
||||
'filters' => [
|
||||
'departments' => $departmentTree,
|
||||
'assistants' => $assistants,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'employee_rows' => $groups,
|
||||
'rankings' => [
|
||||
'performance' => self::rankMembers($members, 'order_amount', 10),
|
||||
'appointments' => self::rankMembers($members, 'appointment_count', 10),
|
||||
],
|
||||
'departments' => self::departmentSummaryRows($groups),
|
||||
'target' => $target,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private static function resolveRange(string $type): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$tomorrow = date('Y-m-d', strtotime('+1 day'));
|
||||
$dayAfterTomorrow = date('Y-m-d', strtotime('+2 days'));
|
||||
if ($type === 'week') {
|
||||
$start = date('Y-m-d', strtotime('monday this week'));
|
||||
|
||||
return [
|
||||
'type' => 'week', 'label' => '本周', 'start' => $start, 'end' => $today,
|
||||
'compare_start' => date('Y-m-d', strtotime($start . ' -7 days')),
|
||||
'compare_end' => date('Y-m-d', strtotime($today . ' -7 days')),
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
if ($type === 'month') {
|
||||
$start = date('Y-m-01');
|
||||
$previousStart = date('Y-m-01', strtotime('first day of previous month'));
|
||||
$previousLastDay = (int) date('t', strtotime($previousStart));
|
||||
$day = min((int) date('j'), $previousLastDay);
|
||||
|
||||
return [
|
||||
'type' => 'month', 'label' => '本月', 'start' => $start, 'end' => $today,
|
||||
'compare_start' => $previousStart,
|
||||
'compare_end' => date('Y-m-d', strtotime($previousStart . ' +' . max(0, $day - 1) . ' days')),
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today,
|
||||
'compare_start' => date('Y-m-d', strtotime('-1 day')),
|
||||
'compare_end' => date('Y-m-d', strtotime('-1 day')),
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,true>|null $allowedSet @return array{0:array<int>,1:bool} */
|
||||
private static function resolveSelectedDeptIds(int $selectedDeptId, ?array $allowedSet): array
|
||||
{
|
||||
if ($selectedDeptId <= 0) {
|
||||
return [[], true];
|
||||
}
|
||||
$ids = self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId));
|
||||
if ($allowedSet !== null) {
|
||||
$ids = array_values(array_filter($ids, static fn (int $id): bool => isset($allowedSet[$id])));
|
||||
}
|
||||
|
||||
return [$ids, $ids !== []];
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
|
||||
private static function assistantOptions(?array $visibleIds, array $selectedDeptIds, int $selectedDeptId): array
|
||||
{
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time');
|
||||
if ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
}
|
||||
if ($selectedDeptId > 0) {
|
||||
if ($selectedDeptIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->join('admin_dept ad', 'ad.admin_id = a.id')
|
||||
->whereIn('ad.dept_id', $selectedDeptIds);
|
||||
}
|
||||
|
||||
return $query->field('a.id, a.name')->distinct(true)->order('a.name', 'asc')->select()->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $nodes @param array<int,array<string,mixed>> $index */
|
||||
private static function flattenDepartmentTree(array $nodes, array &$index, int $depth): void
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
$id = (int) ($node['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$index[$id] = [
|
||||
'id' => $id,
|
||||
'pid' => (int) ($node['pid'] ?? 0),
|
||||
'name' => (string) ($node['name'] ?? '未命名部门'),
|
||||
'sort' => (int) ($node['sort'] ?? 0),
|
||||
'depth' => $depth,
|
||||
];
|
||||
self::flattenDepartmentTree(
|
||||
is_array($node['children'] ?? null) ? $node['children'] : [],
|
||||
$index,
|
||||
$depth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @param array<int,array<string,mixed>> $deptIndex @param int[] $selectedDeptIds @return array<int,int> */
|
||||
private static function buildAssistantDepartmentMap(
|
||||
array $assistantIds,
|
||||
array $deptIndex,
|
||||
array $selectedDeptIds,
|
||||
int $selectedDeptId
|
||||
): array {
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$allowed = $selectedDeptId > 0 ? array_fill_keys($selectedDeptIds, true) : null;
|
||||
$rows = AdminDept::whereIn('admin_id', $assistantIds)
|
||||
->field('admin_id, dept_id')
|
||||
->select()
|
||||
->toArray();
|
||||
$candidates = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['admin_id'] ?? 0);
|
||||
$deptId = (int) ($row['dept_id'] ?? 0);
|
||||
if (!isset($deptIndex[$deptId]) || ($allowed !== null && !isset($allowed[$deptId]))) {
|
||||
continue;
|
||||
}
|
||||
$candidates[$aid][] = $deptId;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($assistantIds as $aid) {
|
||||
$ids = $candidates[$aid] ?? [];
|
||||
usort($ids, static function (int $left, int $right) use ($deptIndex): int {
|
||||
$depthCompare = (int) ($deptIndex[$right]['depth'] ?? 0) <=> (int) ($deptIndex[$left]['depth'] ?? 0);
|
||||
if ($depthCompare !== 0) {
|
||||
return $depthCompare;
|
||||
}
|
||||
|
||||
return (int) ($deptIndex[$right]['sort'] ?? 0) <=> (int) ($deptIndex[$left]['sort'] ?? 0);
|
||||
});
|
||||
$out[$aid] = (int) ($ids[0] ?? 0);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @return array<int,array<string,array{count:int}>> */
|
||||
private static function loadAppointmentDaily(string $startDate, string $endDate, array $assistantIds): array
|
||||
{
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$effective = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||||
$query = Db::name('doctor_appointment')->alias('a')
|
||||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||
->where('a.appointment_date', 'between', [$startDate, $endDate])
|
||||
->whereIn('a.status', [1, 3, 4])
|
||||
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)')
|
||||
->whereRaw("({$effective}) IN (" . implode(',', $assistantIds) . ')');
|
||||
$rows = $query
|
||||
->field([
|
||||
'a.appointment_date AS date_label',
|
||||
Db::raw("({$effective}) AS assistant_id"),
|
||||
Db::raw('COUNT(*) AS item_count'),
|
||||
])
|
||||
->group(['a.appointment_date', $effective])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($aid > 0 && $date !== '') {
|
||||
$out[$aid][$date] = ['count' => (int) ($row['item_count'] ?? 0)];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @return array<int,array<string,array{count:int,amount:float}>> */
|
||||
private static function loadOrderDaily(string $startDate, string $endDate, array $assistantIds): array
|
||||
{
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('tcm_prescription_order')->alias('po')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.create_time', 'between', [
|
||||
strtotime($startDate . ' 00:00:00'),
|
||||
strtotime($endDate . ' 23:59:59'),
|
||||
])
|
||||
->whereIn('po.creator_id', $assistantIds);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||||
$rows = $query
|
||||
->fieldRaw("po.creator_id AS assistant_id, FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count, SUM(po.amount) AS amount_sum")
|
||||
->group(['po.creator_id', 'date_label'])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($aid > 0 && $date !== '') {
|
||||
$out[$aid][$date] = [
|
||||
'count' => (int) ($row['item_count'] ?? 0),
|
||||
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private static function buildMemberRows(
|
||||
array $assistants,
|
||||
array $assistantIds,
|
||||
array $assignment,
|
||||
array $appointmentDaily,
|
||||
array $orderDaily,
|
||||
array $range
|
||||
): array {
|
||||
$assistantIndex = [];
|
||||
foreach ($assistants as $assistant) {
|
||||
$assistantIndex[(int) ($assistant['id'] ?? 0)] = (string) ($assistant['name'] ?? '未命名员工');
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($assistantIds as $aid) {
|
||||
$appointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
|
||||
$compareAppointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
|
||||
$orderCount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
|
||||
$orderAmount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'amount');
|
||||
$rows[] = [
|
||||
'id' => 'admin-' . $aid,
|
||||
'admin_id' => $aid,
|
||||
'dept_id' => (int) ($assignment[$aid] ?? 0),
|
||||
'name' => (string) ($assistantIndex[$aid] ?? '未命名员工'),
|
||||
'row_type' => 'employee',
|
||||
'appointment_count' => (int) $appointmentCount,
|
||||
'compare_appointment_count' => (int) $compareAppointmentCount,
|
||||
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
|
||||
'tomorrow_count' => (int) ($appointmentDaily[$aid][$range['tomorrow']]['count'] ?? 0),
|
||||
'day_after_count' => (int) ($appointmentDaily[$aid][$range['day_after_tomorrow']]['count'] ?? 0),
|
||||
'order_count' => (int) $orderCount,
|
||||
'order_amount' => round((float) $orderAmount, 2),
|
||||
'status' => 'normal',
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int => ($b['appointment_count'] <=> $a['appointment_count']) ?: ($b['order_amount'] <=> $a['order_amount']));
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $members @param array<int,array<string,mixed>> $deptIndex @return array<int,array<string,mixed>> */
|
||||
private static function buildDepartmentGroups(array $members, array $deptIndex): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($members as $member) {
|
||||
$deptId = (int) ($member['dept_id'] ?? 0);
|
||||
$key = $deptId > 0 ? $deptId : -2;
|
||||
if (!isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'id' => 'dept-' . $key,
|
||||
'dept_id' => $key,
|
||||
'name' => $key > 0 ? (string) ($deptIndex[$key]['name'] ?? '未命名部门') : '未分配部门',
|
||||
'row_type' => 'department',
|
||||
'member_count' => 0,
|
||||
'appointment_count' => 0,
|
||||
'compare_appointment_count' => 0,
|
||||
'tomorrow_count' => 0,
|
||||
'day_after_count' => 0,
|
||||
'order_count' => 0,
|
||||
'order_amount' => 0.0,
|
||||
'children' => [],
|
||||
'_sort' => $key > 0 ? (int) ($deptIndex[$key]['sort'] ?? 0) : -1,
|
||||
];
|
||||
}
|
||||
$groups[$key]['children'][] = $member;
|
||||
$groups[$key]['member_count']++;
|
||||
foreach (['appointment_count', 'compare_appointment_count', 'tomorrow_count', 'day_after_count', 'order_count'] as $field) {
|
||||
$groups[$key][$field] += (int) ($member[$field] ?? 0);
|
||||
}
|
||||
$groups[$key]['order_amount'] += (float) ($member['order_amount'] ?? 0);
|
||||
}
|
||||
foreach ($groups as &$group) {
|
||||
$group['order_amount'] = round((float) $group['order_amount'], 2);
|
||||
$group['appointment_compare_rate'] = self::relativeChange(
|
||||
(float) $group['appointment_count'],
|
||||
(float) $group['compare_appointment_count']
|
||||
);
|
||||
$group['status'] = 'normal';
|
||||
}
|
||||
unset($group);
|
||||
$out = array_values($groups);
|
||||
usort($out, static fn (array $a, array $b): int => ($b['_sort'] <=> $a['_sort']) ?: strcmp((string) $a['name'], (string) $b['name']));
|
||||
foreach ($out as &$row) {
|
||||
unset($row['_sort']);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $members @return array<string,mixed> */
|
||||
private static function buildSummary(array $members, array $range): array
|
||||
{
|
||||
$appointmentCount = 0;
|
||||
$compareAppointmentCount = 0;
|
||||
$orderCount = 0;
|
||||
$orderAmount = 0.0;
|
||||
foreach ($members as $member) {
|
||||
$appointmentCount += (int) ($member['appointment_count'] ?? 0);
|
||||
$compareAppointmentCount += (int) ($member['compare_appointment_count'] ?? 0);
|
||||
$orderCount += (int) ($member['order_count'] ?? 0);
|
||||
$orderAmount += (float) ($member['order_amount'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'appointment_count' => $appointmentCount,
|
||||
'appointment_compare_count' => $compareAppointmentCount,
|
||||
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
|
||||
'order_count' => $orderCount,
|
||||
'order_amount' => round($orderAmount, 2),
|
||||
'range_label' => $range['label'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $members @return array<int,array<string,mixed>> */
|
||||
private static function rankMembers(array $members, string $field, int $limit): array
|
||||
{
|
||||
$rows = $members;
|
||||
usort($rows, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['name'], (string) $b['name']));
|
||||
$out = [];
|
||||
foreach (array_slice($rows, 0, $limit) as $row) {
|
||||
$out[] = [
|
||||
'admin_id' => (int) ($row['admin_id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => $field === 'order_amount'
|
||||
? round((float) ($row[$field] ?? 0), 2)
|
||||
: (int) ($row[$field] ?? 0),
|
||||
'count' => $field === 'order_amount' ? (int) ($row['order_count'] ?? 0) : (int) ($row['appointment_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $groups @return array<int,array<string,mixed>> */
|
||||
private static function departmentSummaryRows(array $groups): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($groups as $group) {
|
||||
$copy = $group;
|
||||
unset($copy['children']);
|
||||
$rows[] = $copy;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param int[] $selectedDeptIds @return int[]|null */
|
||||
private static function resolveTargetDeptIds(
|
||||
int $adminId,
|
||||
int $scopeValue,
|
||||
int $selectedAssistantId,
|
||||
array $selectedDeptIds,
|
||||
int $selectedDeptId
|
||||
): ?array {
|
||||
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$scopeDeptIds = null;
|
||||
if ($scopeValue !== DataScopeService::SCOPE_ALL) {
|
||||
$ownDeptIds = self::normalizeIds(AdminDept::where('admin_id', $adminId)->column('dept_id'));
|
||||
if ($scopeValue === DataScopeService::SCOPE_DEPT) {
|
||||
$scopeDeptIds = $ownDeptIds;
|
||||
} else {
|
||||
$set = [];
|
||||
foreach ($ownDeptIds as $deptId) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$set[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$scopeDeptIds = array_map('intval', array_keys($set));
|
||||
}
|
||||
}
|
||||
|
||||
if ($selectedDeptId <= 0) {
|
||||
return $scopeDeptIds;
|
||||
}
|
||||
if ($scopeDeptIds === null) {
|
||||
return $selectedDeptIds;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($scopeDeptIds, $selectedDeptIds));
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @param int[]|null $targetDeptIds @return array<string,mixed> */
|
||||
private static function buildTarget(int $year, array $assistantIds, ?array $targetDeptIds): array
|
||||
{
|
||||
$targetRows = [];
|
||||
if ($targetDeptIds !== []) {
|
||||
$query = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%');
|
||||
if ($targetDeptIds !== null) {
|
||||
$query->whereIn('dept_id', $targetDeptIds);
|
||||
}
|
||||
$targetRows = $query
|
||||
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
|
||||
->group('`year_month`')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$actualRows = [];
|
||||
if ($assistantIds !== []) {
|
||||
$query = Db::name('tcm_prescription_order')->alias('po')
|
||||
->whereNull('po.delete_time')
|
||||
->whereIn('po.creator_id', $assistantIds)
|
||||
->where('po.create_time', 'between', [
|
||||
strtotime($year . '-01-01 00:00:00'),
|
||||
strtotime($year . '-12-31 23:59:59'),
|
||||
]);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||||
$actualRows = $query
|
||||
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
|
||||
->group('month_no')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$monthlyTarget = array_fill(1, 12, 0.0);
|
||||
$monthlyActual = array_fill(1, 12, 0.0);
|
||||
$deptCount = 0;
|
||||
foreach ($targetRows as $row) {
|
||||
$month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$monthlyTarget[$month] = round((float) ($row['target_amount'] ?? 0), 2);
|
||||
$deptCount = max($deptCount, (int) ($row['dept_count'] ?? 0));
|
||||
}
|
||||
}
|
||||
foreach ($actualRows as $row) {
|
||||
$month = (int) ($row['month_no'] ?? 0);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$monthlyActual[$month] = round((float) ($row['actual_amount'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
$targetCumulative = [];
|
||||
$actualCumulative = [];
|
||||
$targetTotal = 0.0;
|
||||
$actualTotal = 0.0;
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$targetTotal = round($targetTotal + $monthlyTarget[$month], 2);
|
||||
$actualTotal = round($actualTotal + $monthlyActual[$month], 2);
|
||||
$targetCumulative[] = $targetTotal;
|
||||
$actualCumulative[] = $actualTotal;
|
||||
}
|
||||
|
||||
return [
|
||||
'year' => $year,
|
||||
'target_amount' => $targetTotal,
|
||||
'actual_amount' => $actualTotal,
|
||||
'completion_rate' => $targetTotal > 0 ? round($actualTotal / $targetTotal * 100, 2) : null,
|
||||
'department_count' => $deptCount,
|
||||
'scope_note' => $targetDeptIds === [] ? '当前为本人或单个员工范围,未设置个人目标' : '按当前可见部门汇总',
|
||||
'months' => array_map(static fn (int $month): string => str_pad((string) $month, 2, '0', STR_PAD_LEFT) . '月', range(1, 12)),
|
||||
'target_cumulative' => $targetCumulative,
|
||||
'actual_cumulative' => $actualCumulative,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,array<string,int|float>> $daily */
|
||||
private static function sumDaily(array $daily, string $start, string $end, string $field): float
|
||||
{
|
||||
$sum = 0.0;
|
||||
foreach ($daily as $date => $values) {
|
||||
if ($date >= $start && $date <= $end) {
|
||||
$sum += (float) ($values[$field] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $sum;
|
||||
}
|
||||
|
||||
private static function relativeChange(float $current, float $previous): ?float
|
||||
{
|
||||
if (abs($previous) < 0.00001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(($current - $previous) / $previous * 100, 2);
|
||||
}
|
||||
|
||||
/** @param array<int|string,mixed> $ids @return int[] */
|
||||
private static function normalizeIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* “我的患者”统一数据范围。
|
||||
*
|
||||
* 角色语义:医生只看本人接诊患者,医助只看本人归属患者;经理、
|
||||
* 诊室组长和管理员按系统 DataScope 查看团队患者;root 查看全部。
|
||||
*/
|
||||
class MyPatientLogic
|
||||
{
|
||||
private const DOCTOR_ROLE_ID = 1;
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
private const TEAM_ROLE_IDS = [3, 7, 8];
|
||||
private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4];
|
||||
|
||||
/**
|
||||
* @param Query $query 以 d 作为 zyt_tcm_diagnosis 别名的查询
|
||||
*/
|
||||
public static function applyScope(Query $query, int $adminId, array $adminInfo): void
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roleIds = self::roleIds($adminId);
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$statusList = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
|
||||
|
||||
// 管理角色按系统的数据范围查看“范围内医助归属或医生接诊”的患者。
|
||||
if (array_intersect($roleIds, self::TEAM_ROLE_IDS) !== []) {
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleAdminIds === null) {
|
||||
return;
|
||||
}
|
||||
$visibleAdminIds = self::normalizeIds($visibleAdminIds);
|
||||
if ($visibleAdminIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
$ids = implode(',', $visibleAdminIds);
|
||||
$query->whereRaw(
|
||||
"(CAST(d.assistant_id AS UNSIGNED) IN ({$ids})"
|
||||
. " OR EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
|
||||
. ' WHERE scope_apt.patient_id = d.id'
|
||||
. " AND scope_apt.status IN ({$statusList})"
|
||||
. " AND scope_apt.doctor_id IN ({$ids})))"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 一线角色始终只取“本人关系”,不受数据库中医生角色 ALL 配置影响。
|
||||
$conditions = [];
|
||||
if (in_array(self::ASSISTANT_ROLE_ID, $roleIds, true)) {
|
||||
$conditions[] = 'CAST(d.assistant_id AS UNSIGNED) = ' . $adminId;
|
||||
}
|
||||
if (in_array(self::DOCTOR_ROLE_ID, $roleIds, true)) {
|
||||
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
|
||||
. ' WHERE scope_apt.patient_id = d.id'
|
||||
. " AND scope_apt.status IN ({$statusList})"
|
||||
. " AND scope_apt.doctor_id = {$adminId})";
|
||||
}
|
||||
|
||||
// 未知/异常角色按本人医助或本人医生关系收窄,拒绝意外放大全库。
|
||||
if ($conditions === []) {
|
||||
$conditions[] = 'CAST(d.assistant_id AS UNSIGNED) = ' . $adminId;
|
||||
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
|
||||
. ' WHERE scope_apt.patient_id = d.id'
|
||||
. " AND scope_apt.status IN ({$statusList})"
|
||||
. " AND scope_apt.doctor_id = {$adminId})";
|
||||
}
|
||||
|
||||
$query->whereRaw('(' . implode(' OR ', $conditions) . ')');
|
||||
}
|
||||
|
||||
public static function canAccessDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if ($diagnosisId <= 0 || $adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$diagnosisTable = (new Diagnosis())->getTable();
|
||||
$query = Db::table($diagnosisTable)
|
||||
->alias('d')
|
||||
->where('d.id', $diagnosisId)
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1);
|
||||
self::applyScope($query, $adminId, $adminInfo);
|
||||
|
||||
return (int) $query->count() > 0;
|
||||
}
|
||||
|
||||
/** @return array{mode:string,label:string} */
|
||||
public static function scopeMeta(int $adminId, array $adminInfo): array
|
||||
{
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return ['mode' => 'all', 'label' => '全部数据'];
|
||||
}
|
||||
|
||||
$roleIds = self::roleIds($adminId);
|
||||
if (array_intersect($roleIds, self::TEAM_ROLE_IDS) !== []) {
|
||||
$scope = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$labels = [
|
||||
DataScopeService::SCOPE_ALL => '全部数据',
|
||||
DataScopeService::SCOPE_DEPT_AND_CHILD => '本部门及下级',
|
||||
DataScopeService::SCOPE_DEPT => '本部门',
|
||||
DataScopeService::SCOPE_SELF => '仅本人',
|
||||
];
|
||||
|
||||
return [
|
||||
'mode' => $scope === DataScopeService::SCOPE_ALL ? 'all' : 'team',
|
||||
'label' => $labels[$scope] ?? '仅本人',
|
||||
];
|
||||
}
|
||||
|
||||
$isDoctor = in_array(self::DOCTOR_ROLE_ID, $roleIds, true);
|
||||
$isAssistant = in_array(self::ASSISTANT_ROLE_ID, $roleIds, true);
|
||||
if ($isDoctor && $isAssistant) {
|
||||
return ['mode' => 'self', 'label' => '本人归属及接诊'];
|
||||
}
|
||||
if ($isDoctor) {
|
||||
return ['mode' => 'self', 'label' => '本人接诊'];
|
||||
}
|
||||
|
||||
return ['mode' => 'self', 'label' => '本人归属'];
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private static function roleIds(int $adminId): array
|
||||
{
|
||||
return self::normalizeIds(AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||
}
|
||||
|
||||
/** @param array<int|string, mixed> $ids @return int[] */
|
||||
private static function normalizeIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $id): bool {
|
||||
return $id > 0;
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxPromotionOpenWorkService;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 一诊 / 企业微信推广助手管理逻辑。 */
|
||||
class WecomPromotionLogic
|
||||
{
|
||||
public static function overview(int $adminId, array $adminInfo, string $domain): array
|
||||
{
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$accountsQuery = Db::name('qywx_promotion_account')->alias('a')
|
||||
->leftJoin('admin u', 'u.id = a.owner_admin_id')
|
||||
->leftJoin('dept d', 'd.id = a.dept_id')
|
||||
->whereNull('a.delete_time');
|
||||
$accounts = $accountsQuery
|
||||
->field('a.id,a.corp_id,a.corp_name,a.agent_id,a.auth_status,a.owner_admin_id,a.dept_id,a.authorized_at,a.last_refresh_at,a.create_time,u.name as owner_name,d.name as dept_name')
|
||||
->order('a.auth_status', 'desc')
|
||||
->order('a.id', 'desc')
|
||||
->select()->toArray();
|
||||
foreach ($accounts as &$account) {
|
||||
$account['corp_id_masked'] = self::mask((string) ($account['corp_id'] ?? ''));
|
||||
unset($account['corp_id']);
|
||||
}
|
||||
unset($account);
|
||||
|
||||
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
|
||||
->leftJoin('admin u', 'u.id = p.owner_admin_id')
|
||||
->leftJoin('dept d', 'd.id = p.dept_id')
|
||||
->whereNull('p.delete_time');
|
||||
self::applyOwnerScope($poolsQuery, 'p', $visibleIds);
|
||||
$pools = $poolsQuery
|
||||
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
|
||||
->order('p.id', 'desc')
|
||||
->select()->toArray();
|
||||
|
||||
$poolIds = array_values(array_filter(array_map('intval', array_column($pools, 'id'))));
|
||||
$links = [];
|
||||
if ($poolIds !== []) {
|
||||
$links = Db::name('qywx_promotion_link')->alias('l')
|
||||
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
|
||||
->whereNull('l.delete_time')
|
||||
->whereIn('l.pool_id', $poolIds)
|
||||
->field('l.id,l.pool_id,l.account_id,l.name,l.group_name,l.wecom_url,l.weight,l.status,l.daily_limit,l.today_count,l.today_date,l.active_start,l.active_end,l.click_count,l.last_click_time,l.remark,l.create_time,l.update_time,a.corp_name,a.auth_status')
|
||||
->order('l.status', 'desc')
|
||||
->order('l.weight', 'desc')
|
||||
->order('l.id', 'desc')
|
||||
->select()->toArray();
|
||||
}
|
||||
|
||||
$domain = rtrim($domain, '/');
|
||||
foreach ($pools as &$pool) {
|
||||
$key = (string) $pool['public_key'];
|
||||
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
|
||||
$goUrl = $domain . '/api/qywx-promotion/go/' . $key;
|
||||
$pool['script_url'] = $scriptUrl;
|
||||
$pool['go_url'] = $goUrl;
|
||||
$pool['install_code'] = '<script src="' . $scriptUrl . '" defer></script>';
|
||||
$pool['trigger_code'] = '<a href="#" data-wecom-promotion="' . $key . '">添加企业微信</a>';
|
||||
}
|
||||
unset($pool);
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$todayClicks = 0;
|
||||
$onlineLinks = 0;
|
||||
foreach ($links as $link) {
|
||||
if ((int) ($link['status'] ?? 0) === 1) {
|
||||
$onlineLinks++;
|
||||
}
|
||||
if ((string) ($link['today_date'] ?? '') === $today) {
|
||||
$todayClicks += (int) ($link['today_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$config = QywxPromotionOpenWorkService::configurationStatus();
|
||||
$config['provider_callback_url'] = $domain . '/api/qywx-promotion/provider/callback';
|
||||
$config['auth_callback_url'] = QywxPromotionOpenWorkService::configuredRedirectUri(
|
||||
$domain . '/api/qywx-promotion/auth/callback'
|
||||
);
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
|
||||
'can_authorize' => self::canAuthorize($adminId, $adminInfo),
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
'config' => $config,
|
||||
'summary' => [
|
||||
'authorized_accounts' => count(array_filter($accounts, static fn (array $row): bool => (int) ($row['auth_status'] ?? 0) === 1)),
|
||||
'pool_count' => count($pools),
|
||||
'online_links' => $onlineLinks,
|
||||
'today_clicks' => $todayClicks,
|
||||
],
|
||||
'accounts' => $accounts,
|
||||
'pools' => $pools,
|
||||
'links' => $links,
|
||||
'allowed_link_hosts' => array_values((array) config('qywx_promotion.allowed_link_hosts', [])),
|
||||
];
|
||||
}
|
||||
|
||||
public static function authorizationUrl(int $adminId, array $adminInfo, string $domain): array
|
||||
{
|
||||
if (!self::canAuthorize($adminId, $adminInfo)) {
|
||||
throw new RuntimeException('只有系统管理员可以发起企业微信应用授权');
|
||||
}
|
||||
$redirectUri = rtrim($domain, '/') . '/api/qywx-promotion/auth/callback';
|
||||
|
||||
return ['url' => QywxPromotionOpenWorkService::authorizationUrl($adminId, $redirectUri)];
|
||||
}
|
||||
|
||||
public static function verifyAccount(int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
if (!self::canAuthorize($adminId, $adminInfo)) {
|
||||
throw new RuntimeException('只有系统管理员可以验证企业微信授权凭证');
|
||||
}
|
||||
self::assertAuthorizedAccount($id, false);
|
||||
|
||||
return QywxPromotionOpenWorkService::verifyAccount($id);
|
||||
}
|
||||
|
||||
public static function savePool(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 60) {
|
||||
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
|
||||
}
|
||||
$fallback = trim((string) ($params['fallback_url'] ?? ''));
|
||||
if (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true)) {
|
||||
throw new RuntimeException('兜底链接必须是已允许的 HTTPS 企业微信链接');
|
||||
}
|
||||
$now = time();
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
|
||||
'fallback_url' => $fallback,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($id > 0) {
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
'public_key' => bin2hex(random_bytes(16)),
|
||||
'owner_admin_id' => $adminId,
|
||||
'dept_id' => self::primaryDeptId($adminId),
|
||||
'click_count' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($data);
|
||||
}
|
||||
|
||||
return ['id' => $id];
|
||||
}
|
||||
|
||||
public static function deletePool(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
$now = time();
|
||||
Db::transaction(function () use ($id, $now): void {
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update(['delete_time' => $now, 'update_time' => $now]);
|
||||
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update(['delete_time' => $now, 'update_time' => $now]);
|
||||
});
|
||||
}
|
||||
|
||||
public static function saveLink(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
|
||||
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 80) {
|
||||
throw new RuntimeException('请输入 1-80 个字符的推广链接名称');
|
||||
}
|
||||
$url = trim((string) ($params['wecom_url'] ?? ''));
|
||||
if (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($url)) {
|
||||
throw new RuntimeException('推广链接必须是已允许的 HTTPS 企业微信链接');
|
||||
}
|
||||
$accountId = max(0, (int) ($params['account_id'] ?? 0));
|
||||
if ($accountId > 0) {
|
||||
self::assertAuthorizedAccount($accountId, true);
|
||||
}
|
||||
$startAt = self::parseTime($params['active_start'] ?? null);
|
||||
$endAt = self::parseTime($params['active_end'] ?? null);
|
||||
if ($startAt > 0 && $endAt > 0 && $startAt >= $endAt) {
|
||||
throw new RuntimeException('生效结束时间必须晚于开始时间');
|
||||
}
|
||||
$now = time();
|
||||
$data = [
|
||||
'pool_id' => $poolId,
|
||||
'account_id' => $accountId,
|
||||
'name' => $name,
|
||||
'group_name' => mb_substr(trim((string) ($params['group_name'] ?? '默认分组')), 0, 60),
|
||||
'wecom_url' => $url,
|
||||
'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
|
||||
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
|
||||
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
|
||||
'active_start' => $startAt,
|
||||
'active_end' => $endAt,
|
||||
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($id > 0) {
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
|
||||
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
|
||||
'click_count' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => null,
|
||||
'last_click_time' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
|
||||
}
|
||||
|
||||
return ['id' => $id];
|
||||
}
|
||||
|
||||
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'status' => $status === 1 ? 1 : 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function deleteLink(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'delete_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function assertScopedRow(string $table, int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('数据不存在');
|
||||
}
|
||||
$query = Db::name($table)->where('id', $id)->whereNull('delete_time');
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds !== null) {
|
||||
if ($visibleIds === []) {
|
||||
throw new RuntimeException('无权访问该数据');
|
||||
}
|
||||
$query->whereIn('owner_admin_id', $visibleIds);
|
||||
}
|
||||
$row = $query->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException('数据不存在或超出当前权限范围');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function assertAuthorizedAccount(int $id, bool $requireActive): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('授权企业不存在');
|
||||
}
|
||||
$query = Db::name('qywx_promotion_account')->where('id', $id)->whereNull('delete_time');
|
||||
if ($requireActive) {
|
||||
$query->where('auth_status', 1);
|
||||
}
|
||||
$row = $query->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException($requireActive ? '授权企业无效或已取消授权' : '授权企业不存在');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function applyOwnerScope($query, string $alias, ?array $visibleIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereIn($alias . '.owner_admin_id', $visibleIds);
|
||||
}
|
||||
|
||||
private static function canAuthorize(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Db::name('admin_role')->alias('ar')
|
||||
->join('system_role r', 'r.id = ar.role_id AND r.delete_time IS NULL')
|
||||
->where('ar.admin_id', $adminId)
|
||||
->where('r.name', '管理员')
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
private static function primaryDeptId(int $adminId): int
|
||||
{
|
||||
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
|
||||
}
|
||||
|
||||
private static function parseTime(mixed $value): int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
$time = strtotime((string) $value);
|
||||
|
||||
return $time === false ? 0 : $time;
|
||||
}
|
||||
|
||||
private static function mask(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
if ($length <= 8) {
|
||||
return $value === '' ? '' : str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ class ConversionLogic
|
||||
* @param array $params
|
||||
* @param int $adminId 当前操作 admin(来自 BaseAdminController)
|
||||
* @param array $adminInfo 当前 admin 完整信息(含 root / role_id 数组等)
|
||||
* @param int[]|null $trustedVisibleAdminIdsOverride 仅供服务端内部可信调用覆盖本次可见管理员;不从 HTTP 参数读取
|
||||
* @param int[]|null $trustedCostAllocationAdminIdsOverride 仅用于成本按加粉占比分摊的分母,不会放大任何业务指标
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* 数据权限:通过 DataScopeService::getVisibleAdminIds 拿到当前用户的"可见 admin id 集合"。
|
||||
@@ -29,7 +31,13 @@ class ConversionLogic
|
||||
* - []:可见为空(SCOPE_SELF 且无绑定且关闭 fallback),返回空数据
|
||||
* - 其他:用 visibleAdminIds 收窄 entities 加载、hydrate 数据归属、虚拟桶可见性、filters 选项
|
||||
*/
|
||||
public static function overview(array $params = [], int $adminId = 0, ?array $adminInfo = null): array
|
||||
public static function overview(
|
||||
array $params = [],
|
||||
int $adminId = 0,
|
||||
?array $adminInfo = null,
|
||||
?array $trustedVisibleAdminIdsOverride = null,
|
||||
?array $trustedCostAllocationAdminIdsOverride = null
|
||||
): array
|
||||
{
|
||||
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
|
||||
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
|
||||
@@ -38,11 +46,29 @@ class ConversionLogic
|
||||
$filterEmptyEntities = $mediaChannel !== null;
|
||||
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
|
||||
$pageNo = max(1, (int)($params['page_no'] ?? 1));
|
||||
$pageSize = max(1, min(100, (int)($params['page_size'] ?? 15)));
|
||||
if ($trustedVisibleAdminIdsOverride !== null) {
|
||||
$trustedVisibleAdminIdsOverride = array_values(array_unique(array_filter(
|
||||
array_map('intval', $trustedVisibleAdminIdsOverride),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
if ($trustedCostAllocationAdminIdsOverride !== null) {
|
||||
$trustedCostAllocationAdminIdsOverride = array_values(array_unique(array_filter(
|
||||
array_map('intval', $trustedCostAllocationAdminIdsOverride),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
$pageSizeLimit = $trustedVisibleAdminIdsOverride !== null
|
||||
? max(100, count($trustedVisibleAdminIdsOverride))
|
||||
: 100;
|
||||
$pageSize = max(1, min($pageSizeLimit, (int)($params['page_size'] ?? 15)));
|
||||
|
||||
$visibleAdminIds = ($adminInfo !== null && $adminId > 0)
|
||||
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
|
||||
: null;
|
||||
$visibleAdminIds = $trustedVisibleAdminIdsOverride;
|
||||
if ($trustedVisibleAdminIdsOverride === null) {
|
||||
$visibleAdminIds = ($adminInfo !== null && $adminId > 0)
|
||||
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
|
||||
: null;
|
||||
}
|
||||
// 严格隔离:可见 admin 集合为空时直接返回空骨架,避免下游误以为是"全部"。
|
||||
if ($visibleAdminIds === []) {
|
||||
$emptyResult = [
|
||||
@@ -98,7 +124,23 @@ class ConversionLogic
|
||||
$adminToDeptIds = self::loadAdminDeptMap();
|
||||
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
|
||||
$allocationEntities = $entities;
|
||||
$allocationEntityIds = $entityIds;
|
||||
if ($trustedCostAllocationAdminIdsOverride !== null) {
|
||||
// 个人口径下,仍以同部门全员加粉作为成本分摊分母,避免将整个部门成本全部计到一个人。
|
||||
$allocationEntities = self::loadEntities($dimension, $params, $trustedCostAllocationAdminIdsOverride);
|
||||
// 分摊只能使用实际响应中已允许的部门,防止同事的多部门绑定扩大成本范围。
|
||||
$allocationEntities = array_intersect_key($allocationEntities, $entities);
|
||||
$allocationEntityIds = array_keys($allocationEntities);
|
||||
self::hydrateFanStats(
|
||||
$allocationEntities,
|
||||
$dimension,
|
||||
$allocationEntityIds,
|
||||
$adminToDeptIds,
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$mediaChannel,
|
||||
$trustedCostAllocationAdminIdsOverride
|
||||
);
|
||||
}
|
||||
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
|
||||
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
|
||||
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
|
||||
|
||||
@@ -39,7 +39,13 @@ class DoctorDailyStatsLogic
|
||||
*
|
||||
* @return array{start_date:string,end_date:string,rows:array,total:array<string,mixed>}
|
||||
*/
|
||||
public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
|
||||
public static function overview(
|
||||
array $params,
|
||||
int $viewerAdminId = 0,
|
||||
array $viewerAdminInfo = [],
|
||||
?array $trustedDoctorIds = null,
|
||||
?array $trustedAssistantIds = null
|
||||
): array
|
||||
{
|
||||
// dept_ids 透传至共享上下文:未传时仍按默认「中心」树解析(仅用于挂号率默认 0 等兜底);
|
||||
// 显式传入时由下方 $deptScopedAdminIds 分支用 adminToPrimary 取出医助集合,并下推到三类聚合作为「经手医助」筛选。
|
||||
@@ -57,7 +63,9 @@ class DoctorDailyStatsLogic
|
||||
$filterDoctorId = (int) ($params['doctor_id'] ?? 0);
|
||||
$deptFilterActive = self::hasExplicitDeptIds($params['dept_ids'] ?? null);
|
||||
|
||||
$doctorIds = self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo);
|
||||
$doctorIds = $trustedDoctorIds === null
|
||||
? self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo)
|
||||
: self::resolveTrustedDoctorIds($trustedDoctorIds);
|
||||
|
||||
if ($filterDoctorId > 0) {
|
||||
$doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : [];
|
||||
@@ -65,8 +73,10 @@ class DoctorDailyStatsLogic
|
||||
|
||||
// 部门下医助集合(含全部子级展开后的 admin_dept 命中者);未显式选部门时不参与筛选 → null。
|
||||
// 显式选部门但集合为空 ⇒ 该部门下无可见医助,直接返回空结果。
|
||||
$deptScopedAdminIds = null;
|
||||
if ($deptFilterActive) {
|
||||
$deptScopedAdminIds = $trustedAssistantIds === null
|
||||
? null
|
||||
: self::normalizePositiveIds($trustedAssistantIds);
|
||||
if ($trustedAssistantIds === null && $deptFilterActive) {
|
||||
$deptScopedAdminIds = array_values(array_unique(array_map(
|
||||
'intval',
|
||||
array_keys($ctx['adminToPrimary'] ?? [])
|
||||
@@ -159,7 +169,7 @@ class DoctorDailyStatsLogic
|
||||
}
|
||||
|
||||
// 显式部门筛选时隐藏「该部门无任何关联」的医生,避免列出大量全 0 行。
|
||||
if ($deptFilterActive) {
|
||||
if ($deptFilterActive || $trustedAssistantIds !== null) {
|
||||
$rows = array_values(array_filter($rows, static function (array $r): bool {
|
||||
return (int) ($r['system_prescription_count'] ?? 0) > 0
|
||||
|| (int) ($r['manual_prescription_count'] ?? 0) > 0
|
||||
@@ -243,6 +253,37 @@ class DoctorDailyStatsLogic
|
||||
return $doctorIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供服务端内部聚合页传入已经过权限计算的医生集合;仍再次校验医生角色与软删除状态。
|
||||
* HTTP 参数不会进入此分支。
|
||||
*
|
||||
* @param array<int|string,mixed> $trustedDoctorIds
|
||||
* @return int[]
|
||||
*/
|
||||
private static function resolveTrustedDoctorIds(array $trustedDoctorIds): array
|
||||
{
|
||||
$ids = self::normalizePositiveIds($trustedDoctorIds);
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizePositiveIds(Db::name('admin_role')->alias('ar')
|
||||
->join('admin a', 'a.id = ar.admin_id')
|
||||
->where('ar.role_id', 1)
|
||||
->whereIn('ar.admin_id', $ids)
|
||||
->whereNull('a.delete_time')
|
||||
->column('ar.admin_id'));
|
||||
}
|
||||
|
||||
/** @param array<int|string,mixed> $ids @return int[] */
|
||||
private static function normalizePositiveIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
$ids
|
||||
), static fn (int $id): bool => $id > 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, float|int|null>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\auth\SystemRole;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 数据驾驶舱聚合逻辑。
|
||||
*
|
||||
* 数据口径:
|
||||
* - 所有“业绩/接诊诊单”与业绩统计、业务订单列表保持一致:按业务订单创建时间,
|
||||
* 排除履约已取消/拒收/退款(4/9/10),金额取业务订单 amount,归属人取订单 creator_id。
|
||||
* - 今日加粉、挂号、面诊和转化率继续沿用 ConversionLogic;它们不是业绩指标。
|
||||
* - 趋势使用同一业绩条件的轻量按日 SQL,固定补齐最近 7 个自然日。
|
||||
* - 所有查询都使用 DataScopeService 返回的可见管理员集合收窄。
|
||||
*/
|
||||
class PerformanceDashboardLogic
|
||||
{
|
||||
private const TREND_DAYS = 7;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function overview(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||||
$dayBeforeYesterday = date('Y-m-d', strtotime('-2 days'));
|
||||
$monthStart = date('Y-m-01');
|
||||
$previousMonthStart = date('Y-m-01', strtotime('first day of previous month'));
|
||||
$previousMonthLastDay = (int) date('t', strtotime($previousMonthStart));
|
||||
$comparisonDay = min((int) date('j'), $previousMonthLastDay);
|
||||
$previousMonthComparableEnd = date(
|
||||
'Y-m-d',
|
||||
strtotime($previousMonthStart . ' +' . max(0, $comparisonDay - 1) . ' days')
|
||||
);
|
||||
$trendStart = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
|
||||
|
||||
$scope = self::buildScopeContext($adminId, $adminInfo);
|
||||
/** @var array<int>|null $visibleAdminIds */
|
||||
$visibleAdminIds = $scope['_visible_admin_ids'];
|
||||
unset($scope['_visible_admin_ids']);
|
||||
|
||||
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $visibleAdminIds);
|
||||
$personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]);
|
||||
|
||||
$monthAmount = self::sumDailyMetric($orderDaily, $monthStart, $today, 'amount');
|
||||
$previousMonthAmount = self::sumDailyMetric(
|
||||
$orderDaily,
|
||||
$previousMonthStart,
|
||||
$previousMonthComparableEnd,
|
||||
'amount'
|
||||
);
|
||||
$yesterdayAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
|
||||
$dayBeforeAmount = self::dailyMetric($orderDaily, $dayBeforeYesterday, 'amount');
|
||||
$personalMonthAmount = self::sumDailyMetric($personalOrderDaily, $monthStart, $today, 'amount');
|
||||
|
||||
$todayOverview = ConversionLogic::overview([
|
||||
'dimension' => 'dept',
|
||||
'time_type' => 'today',
|
||||
'include_members' => 0,
|
||||
'include_filters' => 0,
|
||||
'page_no' => 1,
|
||||
'page_size' => 100,
|
||||
], $adminId, $adminInfo);
|
||||
$todaySummary = is_array($todayOverview['summary'] ?? null) ? $todayOverview['summary'] : [];
|
||||
|
||||
// 业绩指标必须直接复用业绩页的权威聚合,不能使用 ConversionLogic 的“双审完成单”。
|
||||
$todayPerformanceOverview = YejiStatsLogic::overview([
|
||||
'start_date' => $today,
|
||||
'end_date' => $today,
|
||||
], $adminId, $adminInfo);
|
||||
$appointmentRanking = self::buildAppointmentRanking($adminId, $adminInfo, $scope);
|
||||
$performanceRanking = self::buildPerformanceRanking(
|
||||
is_array($todayPerformanceOverview['rows'] ?? null) ? $todayPerformanceOverview['rows'] : []
|
||||
);
|
||||
$trendContext = YejiStatsLogic::resolveSharedYejiFilterContext([
|
||||
'start_date' => $trendStart,
|
||||
'end_date' => $today,
|
||||
], $adminId, $adminInfo);
|
||||
$trend = self::buildTrend($trendStart, $today, $visibleAdminIds, $orderDaily, $trendContext);
|
||||
$todayTrendIndex = max(0, count($trend['dates'] ?? []) - 1);
|
||||
$target = self::buildTargetProgress(
|
||||
$adminId,
|
||||
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF),
|
||||
date('Y-m'),
|
||||
$monthAmount,
|
||||
$personalMonthAmount
|
||||
);
|
||||
|
||||
return [
|
||||
'scope' => $scope,
|
||||
'performance' => [
|
||||
'month_amount' => round($monthAmount, 2),
|
||||
'month_compare_rate' => self::relativeChange($monthAmount, $previousMonthAmount),
|
||||
'month_compare_label' => '较上月同期',
|
||||
'yesterday_amount' => round($yesterdayAmount, 2),
|
||||
'yesterday_compare_rate' => self::relativeChange($yesterdayAmount, $dayBeforeAmount),
|
||||
'yesterday_compare_label' => '较前一日',
|
||||
'personal_month_amount' => round($personalMonthAmount, 2),
|
||||
],
|
||||
'today' => [
|
||||
'add_fans_count' => (int) ($trend['leads'][$todayTrendIndex] ?? 0),
|
||||
'appointment_total_count' => (int) ($trend['appointments'][$todayTrendIndex] ?? 0),
|
||||
'interview_count' => (int) ($todaySummary['interview_count'] ?? 0),
|
||||
// 保留原响应字段名以兼容已发布前端,数值含义已统一为“计入业绩的业务订单”。
|
||||
'completed_order_count' => (int) self::dailyMetric($orderDaily, $today, 'count'),
|
||||
'completed_order_amount' => self::dailyMetric($orderDaily, $today, 'amount'),
|
||||
'paid_appointment_rate' => round((float) ($todaySummary['paid_appointment_rate'] ?? 0), 2),
|
||||
'interview_receive_rate' => round((float) ($todaySummary['interview_receive_rate'] ?? 0), 2),
|
||||
],
|
||||
'rankings' => [
|
||||
'appointments' => $appointmentRanking,
|
||||
'performance' => [
|
||||
'title' => '今日部门业绩排行',
|
||||
'scope_label' => (string) ($scope['label'] ?? ''),
|
||||
'items' => $performanceRanking,
|
||||
],
|
||||
],
|
||||
'trend' => $trend,
|
||||
'target' => $target,
|
||||
'meta' => [
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'timezone' => date_default_timezone_get(),
|
||||
'commission_note' => '本人业绩按当前账号创建的业务订单统计,排除已取消、拒收和退款订单。',
|
||||
'rate_note' => '近 7 天趋势与业绩统计一致:挂号排除已取消记录,进线仅统计当前范围内可归属业绩中心的新增客户事件,诊单按订单创建时间统计并排除履约 4/9/10。',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildScopeContext(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
|
||||
$roleIds = array_values(array_unique(array_filter(array_map('intval', $roleIds), static fn (int $id): bool => $id > 0)));
|
||||
|
||||
$roleNames = [];
|
||||
if ($roleIds !== []) {
|
||||
$roleNames = SystemRole::whereIn('id', $roleIds)
|
||||
->whereNull('delete_time')
|
||||
->order('sort', 'desc')
|
||||
->column('name');
|
||||
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
|
||||
}
|
||||
|
||||
$deptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
|
||||
$deptIds = array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $id): bool => $id > 0)));
|
||||
$deptNames = [];
|
||||
if ($deptIds !== []) {
|
||||
$deptNames = Dept::whereIn('id', $deptIds)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->column('name');
|
||||
$deptNames = array_values(array_filter(array_map('strval', $deptNames)));
|
||||
}
|
||||
|
||||
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
|
||||
$scopeKey = $isRoot ? 'root' : [
|
||||
DataScopeService::SCOPE_ALL => 'all',
|
||||
DataScopeService::SCOPE_DEPT_AND_CHILD => 'dept_children',
|
||||
DataScopeService::SCOPE_DEPT => 'dept',
|
||||
DataScopeService::SCOPE_SELF => 'self',
|
||||
][$scopeValue] ?? 'self';
|
||||
$scopeLabel = $isRoot ? '全部数据' : DataScopeService::scopeLabel($scopeValue);
|
||||
|
||||
if ($visibleAdminIds === null) {
|
||||
$visibleMemberCount = (int) Db::name('admin')->whereNull('delete_time')->count();
|
||||
} else {
|
||||
$visibleMemberCount = count($visibleAdminIds);
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $scopeKey,
|
||||
'scope_value' => $scopeValue,
|
||||
'label' => $scopeLabel,
|
||||
'is_limited' => $visibleAdminIds !== null,
|
||||
'viewer_name' => (string) ($adminInfo['name'] ?? $adminInfo['account'] ?? ''),
|
||||
'role_ids' => $roleIds,
|
||||
'role_names' => $roleNames,
|
||||
'department_names' => $deptNames,
|
||||
'visible_member_count' => $visibleMemberCount,
|
||||
'_visible_admin_ids' => $visibleAdminIds,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $visibleAdminIds
|
||||
* @return array<string, array{amount: float, count: int}>
|
||||
*/
|
||||
private static function loadPerformanceOrderDaily(string $startDate, string $endDate, ?array $visibleAdminIds): array
|
||||
{
|
||||
if ($visibleAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$startTs = (int) strtotime($startDate . ' 00:00:00');
|
||||
$endTs = (int) strtotime($endDate . ' 23:59:59');
|
||||
$query = Db::name('tcm_prescription_order')
|
||||
->alias('po')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.create_time', 'between', [$startTs, $endTs]);
|
||||
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||||
|
||||
if ($visibleAdminIds !== null) {
|
||||
$query->whereIn('po.creator_id', $visibleAdminIds);
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->fieldRaw("FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, SUM(po.amount) AS amount_sum, COUNT(*) AS order_count")
|
||||
->group('date_label')
|
||||
->order('date_label', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($date === '') {
|
||||
continue;
|
||||
}
|
||||
$out[$date] = [
|
||||
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
|
||||
'count' => (int) ($row['order_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{amount: float, count: int}> $daily
|
||||
*/
|
||||
private static function sumDailyMetric(array $daily, string $startDate, string $endDate, string $metric): float
|
||||
{
|
||||
$sum = 0.0;
|
||||
foreach ($daily as $date => $values) {
|
||||
if ($date < $startDate || $date > $endDate) {
|
||||
continue;
|
||||
}
|
||||
$sum += (float) ($values[$metric] ?? 0);
|
||||
}
|
||||
|
||||
return round($sum, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{amount: float, count: int}> $daily
|
||||
*/
|
||||
private static function dailyMetric(array $daily, string $date, string $metric): float
|
||||
{
|
||||
return round((float) ($daily[$date][$metric] ?? 0), 2);
|
||||
}
|
||||
|
||||
private static function relativeChange(float $current, float $previous): ?float
|
||||
{
|
||||
if (abs($previous) < 0.00001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round((($current - $previous) / $previous) * 100, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scope
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildAppointmentRanking(int $adminId, array $adminInfo, array $scope): array
|
||||
{
|
||||
$roleIds = array_map('intval', $scope['role_ids'] ?? []);
|
||||
$isDoctorSelf = ($scope['key'] ?? '') === 'self'
|
||||
&& in_array(1, $roleIds, true)
|
||||
&& !in_array(2, $roleIds, true);
|
||||
|
||||
if ($isDoctorSelf) {
|
||||
$doctorStats = DoctorDailyStatsLogic::overview([
|
||||
'start_date' => date('Y-m-d'),
|
||||
'end_date' => date('Y-m-d'),
|
||||
], $adminId, $adminInfo);
|
||||
$items = [];
|
||||
foreach (array_slice($doctorStats['rows'] ?? [], 0, 5) as $row) {
|
||||
$items[] = [
|
||||
'id' => (int) ($row['admin_id'] ?? 0),
|
||||
'name' => (string) ($row['doctor_name'] ?? ''),
|
||||
'count' => (int) ($row['appointment_total'] ?? 0),
|
||||
'amount' => round((float) ($row['deal_amount'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => '实时挂号排行',
|
||||
'kind' => 'doctor',
|
||||
'scope_label' => (string) ($scope['label'] ?? ''),
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
$rankingVisibleAdminIds = null;
|
||||
$rankingScopeLabel = (string) ($scope['label'] ?? '');
|
||||
if (
|
||||
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF) === DataScopeService::SCOPE_SELF
|
||||
&& in_array(2, $roleIds, true)
|
||||
) {
|
||||
$departmentAssistantIds = self::directDepartmentAssistantIds($adminId);
|
||||
if ($departmentAssistantIds !== []) {
|
||||
$rankingVisibleAdminIds = $departmentAssistantIds;
|
||||
$rankingScopeLabel = '本人所属部门';
|
||||
}
|
||||
}
|
||||
|
||||
$assistantStats = ConversionLogic::overview([
|
||||
'dimension' => 'assistant',
|
||||
'time_type' => 'today',
|
||||
'include_filters' => 0,
|
||||
'page_no' => 1,
|
||||
'page_size' => $rankingVisibleAdminIds !== null ? max(1, count($rankingVisibleAdminIds)) : 100,
|
||||
], $adminId, $adminInfo, $rankingVisibleAdminIds);
|
||||
$rows = is_array($assistantStats['lists'] ?? null) ? $assistantStats['lists'] : [];
|
||||
usort($rows, static function (array $a, array $b): int {
|
||||
$byAppointment = (int) ($b['appointment_total_count'] ?? 0) <=> (int) ($a['appointment_total_count'] ?? 0);
|
||||
if ($byAppointment !== 0) {
|
||||
return $byAppointment;
|
||||
}
|
||||
|
||||
$byAmount = (float) ($b['completed_order_amount'] ?? 0) <=> (float) ($a['completed_order_amount'] ?? 0);
|
||||
if ($byAmount !== 0) {
|
||||
return $byAmount;
|
||||
}
|
||||
|
||||
return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0);
|
||||
});
|
||||
|
||||
$items = [];
|
||||
foreach (array_slice($rows, 0, 5) as $row) {
|
||||
$items[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'count' => (int) ($row['appointment_total_count'] ?? 0),
|
||||
'amount' => round((float) ($row['completed_order_amount'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => '实时挂号排行',
|
||||
'kind' => 'assistant',
|
||||
'scope_label' => $rankingScopeLabel,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SELF 医助排行的卡片级例外:只扩展到当前账号所有有效直接部门内的有效医助。
|
||||
* 不展开子部门,也不改变驾驶舱其它指标的数据范围。
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private static function directDepartmentAssistantIds(int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$activeAdmin = Db::name('admin')
|
||||
->where('id', $adminId)
|
||||
->whereNull('delete_time')
|
||||
->value('id');
|
||||
if ((int) $activeAdmin <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$deptIds = Db::name('admin_dept')
|
||||
->alias('ad')
|
||||
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
|
||||
->where('ad.admin_id', $adminId)
|
||||
->column('ad.dept_id');
|
||||
$deptIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', $deptIds),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($deptIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$assistantIds = Db::name('admin_dept')
|
||||
->alias('ad')
|
||||
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
|
||||
->join('admin a', 'a.id = ad.admin_id AND a.delete_time IS NULL', 'INNER')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id AND ar.role_id = 2', 'INNER')
|
||||
->join('system_role sr', 'sr.id = ar.role_id AND sr.delete_time IS NULL', 'INNER')
|
||||
->whereIn('ad.dept_id', $deptIds)
|
||||
->distinct(true)
|
||||
->column('a.id');
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
array_map('intval', $assistantIds),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function buildPerformanceRanking(array $rows): array
|
||||
{
|
||||
usort($rows, static function (array $a, array $b): int {
|
||||
$byAmount = (float) ($b['performance_amount'] ?? 0) <=> (float) ($a['performance_amount'] ?? 0);
|
||||
if ($byAmount !== 0) {
|
||||
return $byAmount;
|
||||
}
|
||||
|
||||
$byCount = (int) ($b['deal_order_count'] ?? 0) <=> (int) ($a['deal_order_count'] ?? 0);
|
||||
if ($byCount !== 0) {
|
||||
return $byCount;
|
||||
}
|
||||
|
||||
return (int) ($a['dept_id'] ?? 0) <=> (int) ($b['dept_id'] ?? 0);
|
||||
});
|
||||
|
||||
$items = [];
|
||||
foreach (array_slice($rows, 0, 5) as $row) {
|
||||
$items[] = [
|
||||
'id' => (int) ($row['dept_id'] ?? 0),
|
||||
'name' => (string) ($row['dept_name'] ?? '未归属中心'),
|
||||
'amount' => round((float) ($row['performance_amount'] ?? 0), 2),
|
||||
'count' => (int) ($row['deal_order_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $visibleAdminIds
|
||||
* @param array<string, array{amount: float, count: int}> $orderDaily
|
||||
* @param array<string, mixed> $trendContext
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildTrend(
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?array $visibleAdminIds,
|
||||
array $orderDaily,
|
||||
array $trendContext
|
||||
): array {
|
||||
$adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null)
|
||||
? $trendContext['adminToPrimary']
|
||||
: [];
|
||||
$tableRowDeptIds = is_array($trendContext['tableRowDeptIds'] ?? null)
|
||||
? array_values(array_map('intval', $trendContext['tableRowDeptIds']))
|
||||
: [];
|
||||
$leadDaily = self::loadLeadDaily($startDate, $endDate, $adminToPrimary, $tableRowDeptIds);
|
||||
$appointmentDaily = self::loadAppointmentDaily(
|
||||
$startDate,
|
||||
$endDate,
|
||||
$visibleAdminIds,
|
||||
$adminToPrimary,
|
||||
$tableRowDeptIds
|
||||
);
|
||||
$dates = [];
|
||||
$appointments = [];
|
||||
$leads = [];
|
||||
$orders = [];
|
||||
|
||||
$cursor = strtotime($startDate);
|
||||
$end = strtotime($endDate);
|
||||
while ($cursor <= $end) {
|
||||
$date = date('Y-m-d', $cursor);
|
||||
$dates[] = date('m-d', $cursor);
|
||||
$appointments[] = (int) ($appointmentDaily[$date] ?? 0);
|
||||
$leads[] = (int) ($leadDaily[$date] ?? 0);
|
||||
$orders[] = (int) ($orderDaily[$date]['count'] ?? 0);
|
||||
$cursor = strtotime('+1 day', $cursor);
|
||||
}
|
||||
|
||||
return [
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'dates' => $dates,
|
||||
'appointments' => $appointments,
|
||||
'leads' => $leads,
|
||||
'orders' => $orders,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 YejiStatsLogic 的“进线”一致:只有能映射到当前业绩中心展示行的管理员事件才计入。
|
||||
*
|
||||
* @param array<int, int> $adminToPrimary
|
||||
* @param int[] $tableRowDeptIds
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private static function loadLeadDaily(
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
array $adminToPrimary,
|
||||
array $tableRowDeptIds
|
||||
): array
|
||||
{
|
||||
$rowFlip = array_flip($tableRowDeptIds);
|
||||
$mappedAdminIds = [];
|
||||
foreach ($adminToPrimary as $adminId => $deptId) {
|
||||
$adminId = (int) $adminId;
|
||||
$deptId = (int) $deptId;
|
||||
if ($adminId > 0 && isset($rowFlip[$deptId])) {
|
||||
$mappedAdminIds[] = $adminId;
|
||||
}
|
||||
}
|
||||
$mappedAdminIds = array_values(array_unique($mappedAdminIds));
|
||||
if ($mappedAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL', 'INNER')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->whereIn('a.id', $mappedAdminIds)
|
||||
->where('e.event_time', 'between', [
|
||||
strtotime($startDate . ' 00:00:00'),
|
||||
strtotime($endDate . ' 23:59:59'),
|
||||
]);
|
||||
|
||||
$rows = $query
|
||||
->fieldRaw("FROM_UNIXTIME(e.event_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count")
|
||||
->group('date_label')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($date !== '') {
|
||||
$out[$date] = (int) ($row['item_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $visibleAdminIds
|
||||
* 与 YejiStatsLogic 的“预约诊单”一致:appointment_date,状态 1/3/4,
|
||||
* 归属优先挂号医助、再诊单医助,缺失时回退医生;受限账号只保留当前业绩中心展示行。
|
||||
*
|
||||
* @param array<int>|null $visibleAdminIds
|
||||
* @param array<int, int> $adminToPrimary
|
||||
* @param int[] $tableRowDeptIds
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private static function loadAppointmentDaily(
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?array $visibleAdminIds,
|
||||
array $adminToPrimary,
|
||||
array $tableRowDeptIds
|
||||
): array {
|
||||
if ($visibleAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$effectiveAssistantSql = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||||
$query = Db::name('doctor_appointment')
|
||||
->alias('a')
|
||||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||
->where('a.appointment_date', 'between', [$startDate, $endDate])
|
||||
->whereIn('a.status', [1, 3, 4])
|
||||
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
|
||||
|
||||
$rows = $query
|
||||
->field([
|
||||
'a.appointment_date AS date_label',
|
||||
Db::raw("({$effectiveAssistantSql}) AS effective_assistant_id"),
|
||||
'a.doctor_id',
|
||||
Db::raw('COUNT(*) AS appointment_count'),
|
||||
])
|
||||
->group(['a.appointment_date', $effectiveAssistantSql, 'a.doctor_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$visibleFlip = $visibleAdminIds !== null ? array_flip($visibleAdminIds) : null;
|
||||
$rowFlip = array_flip($tableRowDeptIds);
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($date === '') {
|
||||
continue;
|
||||
}
|
||||
$effectiveAssistantId = (int) ($row['effective_assistant_id'] ?? 0);
|
||||
$doctorId = (int) ($row['doctor_id'] ?? 0);
|
||||
if ($visibleFlip !== null) {
|
||||
if ($effectiveAssistantId > 0) {
|
||||
if (!isset($visibleFlip[$effectiveAssistantId])) {
|
||||
continue;
|
||||
}
|
||||
} elseif ($doctorId <= 0 || !isset($visibleFlip[$doctorId])) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$deptId = $effectiveAssistantId > 0
|
||||
? (int) ($adminToPrimary[$effectiveAssistantId] ?? 0)
|
||||
: 0;
|
||||
if ($deptId <= 0 && $doctorId > 0) {
|
||||
$deptId = (int) ($adminToPrimary[$doctorId] ?? 0);
|
||||
}
|
||||
if ($visibleFlip !== null && !isset($rowFlip[$deptId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[$date] = ($out[$date] ?? 0) + (int) ($row['appointment_count'] ?? 0);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildTargetProgress(
|
||||
int $adminId,
|
||||
int $scopeValue,
|
||||
string $yearMonth,
|
||||
float $completedAmount,
|
||||
float $personalAmount
|
||||
): array {
|
||||
$deptIds = self::targetDeptIds($adminId, $scopeValue);
|
||||
$query = Db::name('dept_performance_target')->where('year_month', $yearMonth);
|
||||
if ($deptIds !== null) {
|
||||
if ($deptIds === []) {
|
||||
return self::emptyTarget($yearMonth, $completedAmount, $personalAmount);
|
||||
}
|
||||
$query->whereIn('dept_id', $deptIds);
|
||||
}
|
||||
|
||||
$rows = $query->field('dept_id, dept_name, target_amount')->select()->toArray();
|
||||
$targetAmount = 0.0;
|
||||
foreach ($rows as $row) {
|
||||
$targetAmount += (float) ($row['target_amount'] ?? 0);
|
||||
}
|
||||
$targetAmount = round($targetAmount, 2);
|
||||
|
||||
return [
|
||||
'year_month' => $yearMonth,
|
||||
'target_amount' => $targetAmount,
|
||||
'completed_amount' => round($completedAmount, 2),
|
||||
'completion_rate' => $targetAmount > 0 ? round($completedAmount / $targetAmount * 100, 2) : null,
|
||||
'personal_amount' => round($personalAmount, 2),
|
||||
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
|
||||
'department_count' => count($rows),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>|null null 表示全部部门。
|
||||
*/
|
||||
private static function targetDeptIds(int $adminId, int $scopeValue): ?array
|
||||
{
|
||||
if ($scopeValue === DataScopeService::SCOPE_ALL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ownDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
|
||||
$ownDeptIds = array_values(array_unique(array_filter(array_map('intval', $ownDeptIds), static fn (int $id): bool => $id > 0)));
|
||||
if ($ownDeptIds === [] || $scopeValue !== DataScopeService::SCOPE_DEPT_AND_CHILD) {
|
||||
return $ownDeptIds;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($ownDeptIds as $deptId) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$out[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function emptyTarget(string $yearMonth, float $completedAmount, float $personalAmount): array
|
||||
{
|
||||
return [
|
||||
'year_month' => $yearMonth,
|
||||
'target_amount' => 0.0,
|
||||
'completed_amount' => round($completedAmount, 2),
|
||||
'completion_rate' => null,
|
||||
'personal_amount' => round($personalAmount, 2),
|
||||
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
|
||||
'department_count' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\qywx\QywxPromotionOpenWorkService;
|
||||
use app\common\service\qywx\QywxPromotionRedirectService;
|
||||
use think\facade\Log;
|
||||
|
||||
/** 企业微信推广公开端点:服务商回调、授权回跳、JS 与随机跳转。 */
|
||||
class QywxPromotionPublicController extends BaseController
|
||||
{
|
||||
public function script(string $key)
|
||||
{
|
||||
if (!QywxPromotionRedirectService::poolExists($key)) {
|
||||
return response('/* promotion pool not found */', 404, ['Content-Type' => 'application/javascript; charset=utf-8']);
|
||||
}
|
||||
$goUrl = rtrim($this->request->domain(), '/') . '/api/qywx-promotion/go/' . $key;
|
||||
$jsonKey = json_encode($key, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$jsonGo = json_encode($goUrl, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$javascript = <<<JS
|
||||
(function(w,d){
|
||||
'use strict';
|
||||
var key={$jsonKey}, go={$jsonGo};
|
||||
function openPromotion(){
|
||||
var source=w.location.href;
|
||||
w.location.assign(go+'?from='+encodeURIComponent(source));
|
||||
}
|
||||
d.addEventListener('click',function(event){
|
||||
var node=event.target&&event.target.closest?event.target.closest('[data-wecom-promotion="'+key+'"],.wecom-promotion-link[data-pool="'+key+'"]'):null;
|
||||
if(!node){return;}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openPromotion();
|
||||
},true);
|
||||
w.WecomPromotion=w.WecomPromotion||{};
|
||||
w.WecomPromotion[key]={open:openPromotion};
|
||||
})(window,document);
|
||||
JS;
|
||||
|
||||
return response($javascript, 200, [
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=60',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
public function redirect(string $key)
|
||||
{
|
||||
$picked = QywxPromotionRedirectService::pick($key, [
|
||||
'source_url' => (string) $this->request->get('from', ''),
|
||||
'referer' => (string) $this->request->header('referer', ''),
|
||||
'user_agent' => (string) $this->request->header('user-agent', ''),
|
||||
'ip' => (string) $this->request->ip(),
|
||||
]);
|
||||
if (!$picked) {
|
||||
return response('当前暂无可用的企业微信推广链接,请稍后再试。', 503, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect($picked['url'], 302)->header([
|
||||
'Cache-Control' => 'no-store',
|
||||
'Referrer-Policy' => 'no-referrer',
|
||||
]);
|
||||
}
|
||||
|
||||
public function providerCallback()
|
||||
{
|
||||
try {
|
||||
$psr = QywxPromotionOpenWorkService::serveProviderCallback();
|
||||
$body = $psr->getBody();
|
||||
$body->rewind();
|
||||
$headers = [];
|
||||
if ($psr->getHeaderLine('Content-Type') !== '') {
|
||||
$headers['Content-Type'] = $psr->getHeaderLine('Content-Type');
|
||||
}
|
||||
|
||||
return response($body->getContents(), $psr->getStatusCode(), $headers);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广服务商回调失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
|
||||
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
}
|
||||
|
||||
public function authCallback()
|
||||
{
|
||||
$fallback = rtrim($this->request->domain(), '/') . '/admin/first_visit/wecom_promotion';
|
||||
$returnUrl = QywxPromotionOpenWorkService::configuredAdminReturnUrl($fallback);
|
||||
try {
|
||||
$result = QywxPromotionOpenWorkService::consumeAuthorizationCallback(
|
||||
trim((string) $this->request->get('auth_code', '')),
|
||||
trim((string) $this->request->get('state', ''))
|
||||
);
|
||||
$query = ['wecom_auth' => 'success', 'account_id' => (int) $result['id']];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广授权回跳失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
$query = ['wecom_auth' => 'failed', 'message' => mb_substr($e->getMessage(), 0, 160)];
|
||||
}
|
||||
|
||||
return redirect($returnUrl . (str_contains($returnUrl, '?') ? '&' : '?') . http_build_query($query), 302);
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,9 @@ use think\facade\Route;
|
||||
// 企业微信「客户联系」事件回调:GET 验签(echostr)、POST 收事件
|
||||
Route::rule('qywx/external-contact/notify', 'QywxExternalContactCallback/notify', 'GET|POST');
|
||||
Route::post('ej-pharmacy/webhook', 'EjPharmacyCallback/webhook');
|
||||
|
||||
// 企业微信推广助手:服务商应用指令、授权回跳、公开 JS 与随机分流。
|
||||
Route::rule('qywx-promotion/provider/callback', 'QywxPromotionPublic/providerCallback', 'GET|POST');
|
||||
Route::get('qywx-promotion/auth/callback', 'QywxPromotionPublic/authCallback');
|
||||
Route::get('qywx-promotion/js/:key', 'QywxPromotionPublic/script');
|
||||
Route::get('qywx-promotion/go/:key', 'QywxPromotionPublic/redirect');
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 企业微信推广凭证加密器:密钥仅来自服务器配置,密文可安全落库。 */
|
||||
class QywxPromotionCredentialCipher
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
public static function encrypt(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt($plain, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($cipher === false) {
|
||||
throw new RuntimeException('企业微信授权凭证加密失败');
|
||||
}
|
||||
|
||||
return base64_encode(json_encode([
|
||||
'v' => 1,
|
||||
'iv' => base64_encode($iv),
|
||||
'tag' => base64_encode($tag),
|
||||
'data' => base64_encode($cipher),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public static function decrypt(string $payload): string
|
||||
{
|
||||
if ($payload === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$json = base64_decode($payload, true);
|
||||
$data = is_string($json) ? json_decode($json, true) : null;
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('企业微信授权凭证格式无效');
|
||||
}
|
||||
|
||||
$iv = base64_decode((string) ($data['iv'] ?? ''), true);
|
||||
$tag = base64_decode((string) ($data['tag'] ?? ''), true);
|
||||
$cipher = base64_decode((string) ($data['data'] ?? ''), true);
|
||||
if (!is_string($iv) || !is_string($tag) || !is_string($cipher)) {
|
||||
throw new RuntimeException('企业微信授权凭证格式无效');
|
||||
}
|
||||
|
||||
$plain = openssl_decrypt($cipher, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($plain === false) {
|
||||
throw new RuntimeException('企业微信授权凭证解密失败,请检查 CREDENTIAL_KEY 是否发生变更');
|
||||
}
|
||||
|
||||
return $plain;
|
||||
}
|
||||
|
||||
private static function key(): string
|
||||
{
|
||||
$material = trim((string) config('qywx_promotion.credential_key', ''));
|
||||
if ($material === '') {
|
||||
$material = trim((string) config('qywx_promotion.suite_secret', ''));
|
||||
}
|
||||
if ($material === '') {
|
||||
throw new RuntimeException('未配置企业微信推广凭证加密密钥');
|
||||
}
|
||||
|
||||
return hash('sha256', $material, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use EasyWeChat\OpenWork\Application;
|
||||
use EasyWeChat\OpenWork\Message;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/** 企业微信服务商授权流程及授权企业凭证管理。 */
|
||||
class QywxPromotionOpenWorkService
|
||||
{
|
||||
public static function configurationStatus(): array
|
||||
{
|
||||
$suiteId = self::configString('suite_id');
|
||||
$required = ['provider_corp_id', 'suite_id', 'suite_secret', 'token', 'aes_key'];
|
||||
$missing = [];
|
||||
foreach ($required as $key) {
|
||||
if (self::configString($key) === '') {
|
||||
$missing[] = $key;
|
||||
}
|
||||
}
|
||||
if (self::credentialMaterial() === '') {
|
||||
$missing[] = 'credential_key';
|
||||
}
|
||||
|
||||
$ticketAt = 0;
|
||||
if ($suiteId !== '' && self::tableExists('qywx_promotion_provider_state')) {
|
||||
$ticketAt = (int) (Db::name('qywx_promotion_provider_state')
|
||||
->where('suite_id', $suiteId)
|
||||
->value('ticket_received_at') ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => (bool) config('qywx_promotion.enabled', false),
|
||||
'configured' => $missing === [],
|
||||
'ready' => (bool) config('qywx_promotion.enabled', false) && $missing === [] && $ticketAt > 0,
|
||||
'missing' => $missing,
|
||||
'suite_id_masked' => self::mask($suiteId),
|
||||
'ticket_received_at' => $ticketAt,
|
||||
];
|
||||
}
|
||||
|
||||
public static function authorizationUrl(int $adminId, string $redirectUri): string
|
||||
{
|
||||
self::assertReady();
|
||||
$redirectUri = self::configuredRedirectUri($redirectUri);
|
||||
if ($redirectUri === '') {
|
||||
throw new RuntimeException('无法生成企业微信授权回调地址');
|
||||
}
|
||||
|
||||
$app = self::application();
|
||||
$suiteAccessToken = $app->getSuiteAccessToken()->getToken();
|
||||
$response = $app->getHttpClient()->request('GET', 'cgi-bin/service/get_pre_auth_code', [
|
||||
'query' => ['suite_access_token' => $suiteAccessToken],
|
||||
])->toArray(false);
|
||||
$preAuthCode = trim((string) ($response['pre_auth_code'] ?? ''));
|
||||
if ($preAuthCode === '') {
|
||||
throw new RuntimeException('获取企业微信预授权码失败:' . (string) ($response['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
|
||||
return 'https://open.work.weixin.qq.com/3rdapp/install?' . http_build_query([
|
||||
'suite_id' => self::configString('suite_id'),
|
||||
'pre_auth_code' => $preAuthCode,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'state' => self::makeState($adminId),
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
public static function consumeAuthorizationCallback(string $authCode, string $state): array
|
||||
{
|
||||
$adminId = self::verifyState($state);
|
||||
if ($authCode === '') {
|
||||
throw new RuntimeException('企业微信未返回临时授权码');
|
||||
}
|
||||
|
||||
return self::exchangePermanentCode($authCode, $adminId);
|
||||
}
|
||||
|
||||
public static function exchangePermanentCode(string $authCode, int $adminId = 0): array
|
||||
{
|
||||
self::assertConfigured();
|
||||
$app = self::application();
|
||||
$suiteAccessToken = $app->getSuiteAccessToken()->getToken();
|
||||
$response = $app->getHttpClient()->request('POST', 'cgi-bin/service/get_permanent_code', [
|
||||
'query' => ['suite_access_token' => $suiteAccessToken],
|
||||
'json' => ['auth_code' => $authCode],
|
||||
])->toArray(false);
|
||||
$permanentCode = trim((string) ($response['permanent_code'] ?? ''));
|
||||
$corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : [];
|
||||
$corpId = trim((string) ($corpInfo['corpid'] ?? ''));
|
||||
if ($permanentCode === '' || $corpId === '') {
|
||||
throw new RuntimeException('换取企业永久授权码失败:' . (string) ($response['errmsg'] ?? '返回信息不完整'));
|
||||
}
|
||||
|
||||
return self::saveAuthorization($corpId, $permanentCode, $response, $adminId);
|
||||
}
|
||||
|
||||
public static function verifyAccount(int $accountId): array
|
||||
{
|
||||
self::assertConfigured();
|
||||
$row = Db::name('qywx_promotion_account')->where('id', $accountId)->whereNull('delete_time')->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException('授权企业不存在');
|
||||
}
|
||||
|
||||
$permanentCode = QywxPromotionCredentialCipher::decrypt((string) ($row['permanent_code_cipher'] ?? ''));
|
||||
$authorization = self::application()->getAuthorization((string) $row['corp_id'], $permanentCode)->toArray();
|
||||
self::saveAuthorization((string) $row['corp_id'], $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0));
|
||||
|
||||
return ['id' => $accountId, 'verified_at' => time()];
|
||||
}
|
||||
|
||||
public static function serveProviderCallback()
|
||||
{
|
||||
self::assertConfigured();
|
||||
$app = self::application();
|
||||
$server = $app->getServer();
|
||||
|
||||
$server->handleAuthCreated(function (Message $message, \Closure $next) {
|
||||
$authCode = trim((string) ($message['AuthCode'] ?? ''));
|
||||
if ($authCode !== '') {
|
||||
try {
|
||||
self::exchangePermanentCode($authCode, 0);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广 create_auth 处理失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
$server->handleAuthChanged(function (Message $message, \Closure $next) {
|
||||
$corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? ''));
|
||||
if ($corpId !== '') {
|
||||
try {
|
||||
self::refreshByCorpId($corpId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广 change_auth 处理失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
$server->handleAuthCancelled(function (Message $message, \Closure $next) {
|
||||
$corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? ''));
|
||||
if ($corpId !== '') {
|
||||
Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->update([
|
||||
'auth_status' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
return $server->serve();
|
||||
}
|
||||
|
||||
public static function configuredRedirectUri(string $fallback): string
|
||||
{
|
||||
return self::configString('redirect_uri') ?: trim($fallback);
|
||||
}
|
||||
|
||||
public static function configuredAdminReturnUrl(string $fallback): string
|
||||
{
|
||||
return self::configString('admin_return_url') ?: trim($fallback);
|
||||
}
|
||||
|
||||
public static function isAllowedPromotionUrl(string $url, bool $allowEmpty = false): bool
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
return $allowEmpty;
|
||||
}
|
||||
$parts = parse_url($url);
|
||||
if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https') {
|
||||
return false;
|
||||
}
|
||||
$host = strtolower(trim((string) ($parts['host'] ?? '')));
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
foreach ((array) config('qywx_promotion.allowed_link_hosts', []) as $allowed) {
|
||||
$allowed = strtolower(trim((string) $allowed));
|
||||
if ($allowed !== '' && ($host === $allowed || str_ends_with($host, '.' . $allowed))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function application(): Application
|
||||
{
|
||||
$app = new Application([
|
||||
'corp_id' => self::configString('provider_corp_id'),
|
||||
'provider_secret' => '',
|
||||
'suite_id' => self::configString('suite_id'),
|
||||
'suite_secret' => self::configString('suite_secret'),
|
||||
'token' => self::configString('token'),
|
||||
'aes_key' => self::configString('aes_key'),
|
||||
]);
|
||||
$app->setSuiteTicket(new QywxPromotionSuiteTicket(self::configString('suite_id')));
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
private static function refreshByCorpId(string $corpId): void
|
||||
{
|
||||
$row = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->find();
|
||||
if (!$row) {
|
||||
return;
|
||||
}
|
||||
$permanentCode = QywxPromotionCredentialCipher::decrypt((string) $row['permanent_code_cipher']);
|
||||
$authorization = self::application()->getAuthorization($corpId, $permanentCode)->toArray();
|
||||
self::saveAuthorization($corpId, $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0));
|
||||
}
|
||||
|
||||
private static function saveAuthorization(string $corpId, string $permanentCode, array $response, int $adminId): array
|
||||
{
|
||||
$corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : [];
|
||||
$authInfo = is_array($response['auth_info'] ?? null) ? $response['auth_info'] : [];
|
||||
$agents = is_array($authInfo['agent'] ?? null) ? $authInfo['agent'] : [];
|
||||
$agent = is_array($agents[0] ?? null) ? $agents[0] : [];
|
||||
$now = time();
|
||||
$existing = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->find();
|
||||
$ownerId = $adminId > 0 ? $adminId : (int) ($existing['owner_admin_id'] ?? 0);
|
||||
$deptId = $ownerId > 0 ? self::primaryDeptId($ownerId) : (int) ($existing['dept_id'] ?? 0);
|
||||
$data = [
|
||||
'corp_name' => trim((string) ($corpInfo['corp_name'] ?? $existing['corp_name'] ?? $corpId)),
|
||||
'permanent_code_cipher' => QywxPromotionCredentialCipher::encrypt($permanentCode),
|
||||
'agent_id' => trim((string) ($agent['agentid'] ?? $existing['agent_id'] ?? '')),
|
||||
// 授权响应可能包含 permanent_code;数据库元数据中只保留脱敏后的授权信息。
|
||||
'auth_info_json' => json_encode(self::sanitizeAuthInfo($response), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'auth_status' => 1,
|
||||
'owner_admin_id' => $ownerId,
|
||||
'dept_id' => $deptId,
|
||||
'authorized_at' => (int) ($existing['authorized_at'] ?? 0) ?: $now,
|
||||
'last_refresh_at' => $now,
|
||||
'update_time' => $now,
|
||||
'delete_time' => null,
|
||||
];
|
||||
if ($existing) {
|
||||
Db::name('qywx_promotion_account')->where('id', (int) $existing['id'])->update($data);
|
||||
$id = (int) $existing['id'];
|
||||
} else {
|
||||
$data['corp_id'] = $corpId;
|
||||
$data['create_time'] = $now;
|
||||
$id = (int) Db::name('qywx_promotion_account')->insertGetId($data);
|
||||
}
|
||||
|
||||
return ['id' => $id, 'corp_id' => $corpId, 'corp_name' => $data['corp_name']];
|
||||
}
|
||||
|
||||
private static function makeState(int $adminId): string
|
||||
{
|
||||
$payload = self::base64UrlEncode(json_encode([
|
||||
'a' => $adminId,
|
||||
't' => time(),
|
||||
'n' => bin2hex(random_bytes(8)),
|
||||
], JSON_THROW_ON_ERROR));
|
||||
$signature = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true));
|
||||
|
||||
return $payload . '.' . $signature;
|
||||
}
|
||||
|
||||
private static function sanitizeAuthInfo(array $data): array
|
||||
{
|
||||
$sensitiveKeys = ['permanent_code', 'access_token', 'suite_ticket', 'suite_secret', 'provider_secret'];
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array(strtolower((string) $key), $sensitiveKeys, true)) {
|
||||
unset($data[$key]);
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$data[$key] = self::sanitizeAuthInfo($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function verifyState(string $state): int
|
||||
{
|
||||
$parts = explode('.', $state, 2);
|
||||
if (count($parts) !== 2) {
|
||||
throw new RuntimeException('企业微信授权 state 无效');
|
||||
}
|
||||
[$payload, $signature] = $parts;
|
||||
$expected = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true));
|
||||
if (!hash_equals($expected, $signature)) {
|
||||
throw new RuntimeException('企业微信授权 state 验证失败');
|
||||
}
|
||||
$data = json_decode(self::base64UrlDecode($payload), true);
|
||||
if (!is_array($data) || time() - (int) ($data['t'] ?? 0) > 1800) {
|
||||
throw new RuntimeException('企业微信授权请求已过期,请重新发起');
|
||||
}
|
||||
|
||||
return max(0, (int) ($data['a'] ?? 0));
|
||||
}
|
||||
|
||||
private static function assertReady(): void
|
||||
{
|
||||
$status = self::configurationStatus();
|
||||
if (!$status['enabled']) {
|
||||
throw new RuntimeException('企业微信推广授权尚未启用');
|
||||
}
|
||||
self::assertConfigured();
|
||||
if (!$status['ticket_received_at']) {
|
||||
throw new RuntimeException('尚未收到 suite_ticket,请先配置企业微信应用指令回调');
|
||||
}
|
||||
}
|
||||
|
||||
private static function assertConfigured(): void
|
||||
{
|
||||
$status = self::configurationStatus();
|
||||
if (!$status['configured']) {
|
||||
throw new RuntimeException('企业微信服务商配置不完整:' . implode(', ', $status['missing']));
|
||||
}
|
||||
}
|
||||
|
||||
private static function stateKey(): string
|
||||
{
|
||||
$key = self::credentialMaterial();
|
||||
if ($key === '') {
|
||||
throw new RuntimeException('未配置企业微信推广授权签名密钥');
|
||||
}
|
||||
|
||||
return hash('sha256', 'qywx-promotion-state|' . $key);
|
||||
}
|
||||
|
||||
private static function credentialMaterial(): string
|
||||
{
|
||||
return self::configString('credential_key') ?: self::configString('suite_secret');
|
||||
}
|
||||
|
||||
private static function configString(string $key): string
|
||||
{
|
||||
return trim((string) config('qywx_promotion.' . $key, ''));
|
||||
}
|
||||
|
||||
private static function primaryDeptId(int $adminId): int
|
||||
{
|
||||
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
|
||||
}
|
||||
|
||||
private static function mask(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
if ($length <= 8) {
|
||||
return $value === '' ? '' : str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
|
||||
}
|
||||
|
||||
private static function base64UrlEncode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private static function base64UrlDecode(string $value): string
|
||||
{
|
||||
$value = strtr($value, '-_', '+/');
|
||||
$padding = strlen($value) % 4;
|
||||
if ($padding > 0) {
|
||||
$value .= str_repeat('=', 4 - $padding);
|
||||
}
|
||||
|
||||
return (string) base64_decode($value, true);
|
||||
}
|
||||
|
||||
private static function tableExists(string $table): bool
|
||||
{
|
||||
try {
|
||||
return Db::query("SHOW TABLES LIKE '" . config('database.connections.mysql.prefix', '') . $table . "'") !== [];
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/** 公开推广链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
|
||||
class QywxPromotionRedirectService
|
||||
{
|
||||
/** @return array{url:string,link_id:int}|null */
|
||||
public static function pick(string $publicKey, array $context = []): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $publicKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Db::transaction(function () use ($publicKey, $context): ?array {
|
||||
$pool = Db::name('qywx_promotion_pool')
|
||||
->where('public_key', $publicKey)
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$pool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$today = date('Y-m-d', $now);
|
||||
$links = Db::name('qywx_promotion_link')->alias('l')
|
||||
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
|
||||
->where('l.pool_id', (int) $pool['id'])
|
||||
->where('l.status', 1)
|
||||
->whereNull('l.delete_time')
|
||||
->whereRaw('(l.account_id = 0 OR a.auth_status = 1)')
|
||||
->whereRaw('(l.active_start = 0 OR l.active_start <= ' . $now . ')')
|
||||
->whereRaw('(l.active_end = 0 OR l.active_end >= ' . $now . ')')
|
||||
->whereRaw("(l.daily_limit = 0 OR l.today_date IS NULL OR l.today_date <> '" . addslashes($today) . "' OR l.today_count < l.daily_limit)")
|
||||
->field('l.*')
|
||||
->lock(true)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$selected = self::weightedRandom($links);
|
||||
if (!$selected) {
|
||||
$fallback = trim((string) ($pool['fallback_url'] ?? ''));
|
||||
if (QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true) && $fallback !== '') {
|
||||
return ['url' => $fallback, 'link_id' => 0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$sameDay = (string) ($selected['today_date'] ?? '') === $today;
|
||||
Db::name('qywx_promotion_link')->where('id', (int) $selected['id'])->update([
|
||||
'click_count' => (int) ($selected['click_count'] ?? 0) + 1,
|
||||
'today_count' => $sameDay ? (int) ($selected['today_count'] ?? 0) + 1 : 1,
|
||||
'today_date' => $today,
|
||||
'last_click_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::name('qywx_promotion_pool')->where('id', (int) $pool['id'])->inc('click_count')->update([
|
||||
'update_time' => $now,
|
||||
]);
|
||||
self::recordClick((int) $pool['id'], (int) $selected['id'], $context, $now);
|
||||
|
||||
return ['url' => (string) $selected['wecom_url'], 'link_id' => (int) $selected['id']];
|
||||
});
|
||||
}
|
||||
|
||||
public static function poolExists(string $publicKey): bool
|
||||
{
|
||||
return preg_match('/^[a-f0-9]{32}$/', $publicKey) === 1
|
||||
&& Db::name('qywx_promotion_pool')->where('public_key', $publicKey)->whereNull('delete_time')->count() > 0;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $links */
|
||||
private static function weightedRandom(array $links): ?array
|
||||
{
|
||||
if ($links === []) {
|
||||
return null;
|
||||
}
|
||||
$total = array_sum(array_map(static fn (array $row): int => max(1, (int) ($row['weight'] ?? 1)), $links));
|
||||
$needle = random_int(1, max(1, $total));
|
||||
foreach ($links as $link) {
|
||||
$needle -= max(1, (int) ($link['weight'] ?? 1));
|
||||
if ($needle <= 0) {
|
||||
return $link;
|
||||
}
|
||||
}
|
||||
|
||||
return $links[array_key_last($links)];
|
||||
}
|
||||
|
||||
private static function recordClick(int $poolId, int $linkId, array $context, int $now): void
|
||||
{
|
||||
$source = self::safeSource((string) ($context['source_url'] ?? ''));
|
||||
$ip = trim((string) ($context['ip'] ?? ''));
|
||||
$salt = (string) config('qywx_promotion.credential_key', '') ?: (string) config('qywx_promotion.suite_secret', '');
|
||||
Db::name('qywx_promotion_click_log')->insert([
|
||||
'pool_id' => $poolId,
|
||||
'link_id' => $linkId,
|
||||
'source_url' => $source,
|
||||
'referer' => self::safeSource((string) ($context['referer'] ?? '')),
|
||||
'user_agent' => mb_substr((string) ($context['user_agent'] ?? ''), 0, 500),
|
||||
// 未配置服务端密钥时不落 IP,避免使用公开固定盐形成可枚举标识。
|
||||
'ip_hash' => $ip === '' || $salt === '' ? '' : hash_hmac('sha256', $ip, $salt),
|
||||
'click_date' => date('Y-m-d', $now),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function safeSource(string $url): string
|
||||
{
|
||||
$parts = parse_url(trim($url));
|
||||
if (!is_array($parts)) {
|
||||
return '';
|
||||
}
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
$host = strtolower((string) ($parts['host'] ?? ''));
|
||||
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_substr($scheme . '://' . $host . (string) ($parts['path'] ?? ''), 0, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\OpenWork\Contracts\SuiteTicket;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 将企业微信每十分钟推送的 suite_ticket 加密持久化,避免进程/缓存重启后丢失。 */
|
||||
class QywxPromotionSuiteTicket implements SuiteTicket
|
||||
{
|
||||
public function __construct(private readonly string $suiteId)
|
||||
{
|
||||
}
|
||||
|
||||
public function getTicket(): string
|
||||
{
|
||||
$cipher = (string) (Db::name('qywx_promotion_provider_state')
|
||||
->where('suite_id', $this->suiteId)
|
||||
->value('suite_ticket_cipher') ?? '');
|
||||
if ($cipher === '') {
|
||||
throw new RuntimeException('No suite_ticket found. 请先在企业微信服务商后台配置并验证应用指令回调。');
|
||||
}
|
||||
|
||||
return QywxPromotionCredentialCipher::decrypt($cipher);
|
||||
}
|
||||
|
||||
public function setTicket(string $ticket): static
|
||||
{
|
||||
$now = time();
|
||||
$cipher = QywxPromotionCredentialCipher::encrypt($ticket);
|
||||
$exists = Db::name('qywx_promotion_provider_state')->where('suite_id', $this->suiteId)->find();
|
||||
if ($exists) {
|
||||
Db::name('qywx_promotion_provider_state')->where('suite_id', $this->suiteId)->update([
|
||||
'suite_ticket_cipher' => $cipher,
|
||||
'ticket_received_at' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
} else {
|
||||
Db::name('qywx_promotion_provider_state')->insert([
|
||||
'suite_id' => $this->suiteId,
|
||||
'suite_ticket_cipher' => $cipher,
|
||||
'ticket_received_at' => $now,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
// 企业微信服务商第三方应用配置。所有敏感项仅从服务端环境变量读取,不返回管理端。
|
||||
'enabled' => (bool) env('qywx_promotion.enabled', false),
|
||||
'provider_corp_id' => trim((string) env('qywx_promotion.provider_corp_id', '')),
|
||||
'suite_id' => trim((string) env('qywx_promotion.suite_id', '')),
|
||||
'suite_secret' => trim((string) env('qywx_promotion.suite_secret', '')),
|
||||
'token' => trim((string) env('qywx_promotion.token', '')),
|
||||
'aes_key' => trim((string) env('qywx_promotion.aes_key', '')),
|
||||
|
||||
// 留空时按当前请求域名生成 /api/qywx-promotion/auth/callback。
|
||||
'redirect_uri' => trim((string) env('qywx_promotion.redirect_uri', '')),
|
||||
// 留空时授权完成后返回当前域名 /admin/first_visit/wecom_promotion。
|
||||
'admin_return_url' => trim((string) env('qywx_promotion.admin_return_url', '')),
|
||||
|
||||
// 用于加密永久授权码及 suite_ticket。生产环境建议使用独立的 32 字节以上随机字符串。
|
||||
'credential_key' => trim((string) env('qywx_promotion.credential_key', '')),
|
||||
|
||||
// 防止公开跳转接口被配置成任意外链。可通过逗号分隔的环境变量追加企业自有可信域名。
|
||||
'allowed_link_hosts' => array_values(array_unique(array_filter(array_merge(
|
||||
[
|
||||
'work.weixin.qq.com',
|
||||
'open.work.weixin.qq.com',
|
||||
'workweixin.qq.com',
|
||||
'qyapi.weixin.qq.com',
|
||||
'wework.qpic.cn',
|
||||
'wwcdn.weixin.qq.com',
|
||||
],
|
||||
array_map('trim', explode(',', (string) env('qywx_promotion.allowed_link_hosts', '')))
|
||||
)))),
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
# 企业微信推广助手配置
|
||||
|
||||
管理端菜单:`一诊 / 企业微信推广助手`
|
||||
|
||||
该功能使用企业微信“服务商第三方应用”授权模式。服务商参数只保存在服务器环境变量中,企业永久授权码会使用 AES-256-GCM 加密后存入数据库。
|
||||
|
||||
在 `server/.env` 中增加:
|
||||
|
||||
```ini
|
||||
[qywx_promotion]
|
||||
ENABLED = true
|
||||
PROVIDER_CORP_ID = "服务商企业 CorpID"
|
||||
SUITE_ID = "第三方应用 SuiteID"
|
||||
SUITE_SECRET = "第三方应用 SuiteSecret"
|
||||
TOKEN = "应用指令回调 Token"
|
||||
AES_KEY = "应用指令回调 EncodingAESKey"
|
||||
CREDENTIAL_KEY = "至少32字节的独立随机密钥"
|
||||
REDIRECT_URI = "https://你的域名/api/qywx-promotion/auth/callback"
|
||||
ADMIN_RETURN_URL = "https://你的域名/admin/first_visit/wecom_promotion"
|
||||
```
|
||||
|
||||
企业微信服务商后台需要配置:
|
||||
|
||||
- 应用指令回调 URL:`https://你的域名/api/qywx-promotion/provider/callback`
|
||||
- 授权完成回调域名:与你的 `REDIRECT_URI` 域名一致
|
||||
|
||||
保存配置并让企业微信成功推送一次 `suite_ticket` 后,管理端的“发起企业授权”按钮才会生成安装链接。
|
||||
|
||||
推广链接默认仅允许企业微信官方域名。如需跳转企业自有的可信中间页,可在环境变量中追加:
|
||||
|
||||
```ini
|
||||
ALLOWED_LINK_HOSTS = "promo.example.com,crm.example.com"
|
||||
```
|
||||
|
||||
公开 JS 示例:
|
||||
|
||||
```html
|
||||
<script src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
|
||||
<a href="#" data-wecom-promotion="分流方案KEY">添加企业微信</a>
|
||||
```
|
||||
|
||||
随机分流在服务端完成。候选链接必须同时满足:方案启用、链接上线、授权企业有效、处于有效时间段、未超过当日上限。权重越大,被选中的概率越高。
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import t from"./error-DnKmC0DZ.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-9dA1PEvN.js";import"./index-Cz9kp4CD.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
import t from"./error-sPdj3ytj.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-Bie2Sx_u.js";import"./index-sfLQNRIR.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import e from"./error-DnKmC0DZ.js";import{o,q as r,r as t,v as s}from"./.pnpm-9dA1PEvN.js";import"./index-Cz9kp4CD.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
import e from"./error-sPdj3ytj.js";import{o,q as r,r as t,v as s}from"./.pnpm-Bie2Sx_u.js";import"./index-sfLQNRIR.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-9dA1PEvN.js";import{a as V}from"./doctor-BWPdcasv.js";import{m as A,_ as M}from"./index-Cz9kp4CD.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-Bie2Sx_u.js";import{a as V}from"./doctor-PVjG7QBC.js";import{m as A,_ as M}from"./index-sfLQNRIR.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-9dA1PEvN.js";import{aa as V}from"./tcm-C41UnSAo.js";import{_ as q}from"./index-Cz9kp4CD.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-Bie2Sx_u.js";import{aa as V}from"./tcm-D30dxXYU.js";import{_ as q}from"./index-sfLQNRIR.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,cX as c}from"./.pnpm-9dA1PEvN.js";import{ab as Y}from"./tcm-C41UnSAo.js";import{_ as q}from"./index-Cz9kp4CD.js";const X={key:0,class:"watch-state"},z={key:1,class:"watch-state watch-error"},F=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(M){console.warn("[AssistantWatchCall] startRemoteVideo",M)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function b(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:b},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",X,"正在连接房间…")):i.value?(E(),_("div",z,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(F,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dd as c}from"./.pnpm-Bie2Sx_u.js";import{ab as Y}from"./tcm-D30dxXYU.js";import{_ as q}from"./index-sfLQNRIR.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(M){console.warn("[AssistantWatchCall] startRemoteVideo",M)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function b(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:b},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as N,cZ as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-9dA1PEvN.js";import j from"./RecordingPlaybackBlock-Bxbw4hv0.js";import{U as x}from"./index-C4QA4TrT.js";import{i as c,_ as q}from"./index-Cz9kp4CD.js";import{af as K,ag as k,ah as Z}from"./tcm-C41UnSAo.js";import"./RecordingVideoPlayer-DV3mcuzh.js";import"./file-CLzBc42F.js";const A={class:"call-record-panel"},F={key:0,class:"call-record-toolbar"},G={class:"call-record-empty"},H={class:"call-record-empty__desc"},J={key:0,class:"text-primary"},Q={key:1,class:"text-gray-400"},W=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await Z({diagnosis_id:r.diagnosisId});await k({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await k({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",A,[_.readOnly?h("",!0):(d(),f("div",F,[o(x,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",G,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",H,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",J,u(s.room_id),1)):(d(),f("span",Q,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(x,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(W,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
import{o as N,df as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-Bie2Sx_u.js";import j from"./RecordingPlaybackBlock-FkG7F3L6.js";import{U as x}from"./index-BGD0Q0pO.js";import{i as c,_ as q}from"./index-sfLQNRIR.js";import{af as K,ag as k,ah as A}from"./tcm-D30dxXYU.js";import"./RecordingVideoPlayer-eek8Mbd_.js";import"./file-CA_icqOC.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await k({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await k({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(x,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(x,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-9dA1PEvN.js";import{ai as q}from"./tcm-C41UnSAo.js";import{_ as H}from"./index-Cz9kp4CD.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-Bie2Sx_u.js";import{ai as q}from"./tcm-D30dxXYU.js";import{_ as H}from"./index-sfLQNRIR.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-zVZ0nOdI.js";import"./.pnpm-9dA1PEvN.js";import"./tcm-C41UnSAo.js";import"./index-Cz9kp4CD.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-8CEW8s7z.js";import"./.pnpm-Bie2Sx_u.js";import"./tcm-D30dxXYU.js";import"./index-sfLQNRIR.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-9dA1PEvN.js";import{p as j}from"./tcm-C41UnSAo.js";import{i as C}from"./index-Cz9kp4CD.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-Bie2Sx_u.js";import{p as j}from"./tcm-D30dxXYU.js";import{i as C}from"./index-sfLQNRIR.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,c_ as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as ae,a8 as se,E as C}from"./.pnpm-9dA1PEvN.js";import{d as te}from"./dayjs-BsOAYQJw.js";import{an as ne,ao as oe}from"./tcm-C41UnSAo.js";import{p as re}from"./im-business-message-parse-DmDISFGN.js";import{_ as le}from"./index-Cz9kp4CD.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=se([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const s=P(e);let l="";return s!=null&&s.tag?l=s.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:s,tag:l}}));function N(e){if(e==null||!e)return"—";const s=e>1e12?Math.floor(e/1e3):e;return te.unix(s).format("YYYY-MM-DD HH:mm:ss")}function P(e){const s=(e.text||"").trim();if(!s)return null;const l=s.startsWith("{")&&(/\bbusinessID\b/.test(s)||/\bcmd\b/.test(s));return e.msg_type==="custom"||l?re(s):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,s)=>{const l=G,k=H,I=j,T=Z,Y=ee,V=ae,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...s[0]||(s[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),s[1]||(s[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),s[2]||(s[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,a=>(t(),n("div",{key:a.raw.msg_id,class:X(["chat-row",a.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(a.raw)),1),o("span",me,r(N(a.raw.time)),1),a.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(a.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[a.raw.msg_type==="image"&&a.raw.image_url?(t(),f(Y,{key:0,src:a.raw.image_url,"preview-src-list":[a.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(a.raw.msg_type==="file"||a.raw.msg_type==="sound"||a.raw.msg_type==="video")&&a.raw.file_url?(t(),f(V,{key:1,href:a.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(a.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[a.friendly?(t(),n("div",ye,[o("div",ge,r(a.friendly.main),1),a.friendly.sub?(t(),n("div",ve,r(a.friendly.sub),1)):v("",!0)])):a.raw.msg_type==="text"&&a.raw.text?(t(),n("div",he,r(a.raw.text),1)):a.raw.text?(t(),n("div",we,r(a.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as ae,a8 as se,E as C}from"./.pnpm-Bie2Sx_u.js";import{d as te}from"./dayjs-B83f5yoE.js";import{an as ne,ao as oe}from"./tcm-D30dxXYU.js";import{p as re}from"./im-business-message-parse-Sf7Mozj-.js";import{_ as le}from"./index-sfLQNRIR.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=se([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const s=P(e);let l="";return s!=null&&s.tag?l=s.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:s,tag:l}}));function N(e){if(e==null||!e)return"—";const s=e>1e12?Math.floor(e/1e3):e;return te.unix(s).format("YYYY-MM-DD HH:mm:ss")}function P(e){const s=(e.text||"").trim();if(!s)return null;const l=s.startsWith("{")&&(/\bbusinessID\b/.test(s)||/\bcmd\b/.test(s));return e.msg_type==="custom"||l?re(s):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,s)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=ae,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...s[0]||(s[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),s[1]||(s[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),s[2]||(s[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,a=>(t(),n("div",{key:a.raw.msg_id,class:X(["chat-row",a.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(a.raw)),1),o("span",me,r(N(a.raw.time)),1),a.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(a.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[a.raw.msg_type==="image"&&a.raw.image_url?(t(),f(V,{key:0,src:a.raw.image_url,"preview-src-list":[a.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(a.raw.msg_type==="file"||a.raw.msg_type==="sound"||a.raw.msg_type==="video")&&a.raw.file_url?(t(),f(Y,{key:1,href:a.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(a.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[a.friendly?(t(),n("div",ye,[o("div",ge,r(a.friendly.main),1),a.friendly.sub?(t(),n("div",ve,r(a.friendly.sub),1)):v("",!0)])):a.raw.msg_type==="text"&&a.raw.text?(t(),n("div",he,r(a.raw.text),1)):a.raw.text?(t(),n("div",we,r(a.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-B8TP8hmm.js";import"./.pnpm-9dA1PEvN.js";export{m as default};
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-D_TsA72o.js";import"./.pnpm-Bie2Sx_u.js";export{m as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-9dA1PEvN.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-Bie2Sx_u.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-9dA1PEvN.js";import{t as j,_ as J}from"./index-Cz9kp4CD.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-Bie2Sx_u.js";import{t as j,_ as J}from"./index-sfLQNRIR.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,cO as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-9dA1PEvN.js";import{_ as fe}from"./picker-CbsMoheE.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-Cz9kp4CD.js";import{a as T,d as he}from"./patient-DgoBDS22.js";import{h as ke}from"./perm-CLjA3zNj.js";import"./index-B90a91_l.js";import"./index-Bs4lHLA-.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./index-DyHvFort.js";import"./index-C4QA4TrT.js";import"./file-CLzBc42F.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";import"./usePaging-Boz1Q6w3.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,O;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(G=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(O=s.report_files)!=null&&O.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(G=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(G=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d5 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-Bie2Sx_u.js";import{_ as fe}from"./picker-BpTqhQho.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-sfLQNRIR.js";import{a as T,d as he}from"./patient-BYqnkcb3.js";import{h as ke}from"./perm-JmcyQMs1.js";import"./index-DlUJ-5rv.js";import"./index-CQAtyLf8.js";import"./index.vue_vue_type_script_setup_true_lang-DGR6g81t.js";import"./index-8QX51AC-.js";import"./index-BGD0Q0pO.js";import"./file-CA_icqOC.js";import"./index.vue_vue_type_script_setup_true_lang-C-jbsa3S.js";import"./usePaging-DYszyN6E.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.form-grid[data-v-3c102918]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.form-tip[data-v-3c102918]{margin-left:10px;color:#8a95a6;font-size:12px}.danger-menu-item{color:var(--el-color-danger)!important}@media (max-width: 720px){.form-grid[data-v-3c102918]{grid-template-columns:1fr}}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.embedded-panel[data-v-b9324dce]{min-height:420px}.panel-toolbar[data-v-b9324dce]{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}.panel-toolbar h2[data-v-b9324dce]{margin:0;font-size:17px}.panel-toolbar p[data-v-b9324dce]{margin:4px 0 0;color:#8a95a6;font-size:12px}.toolbar-actions[data-v-b9324dce],.scope-chip[data-v-b9324dce]{display:flex;align-items:center;gap:8px}.scope-chip[data-v-b9324dce]{color:#0f766e;font-size:12px}.filter-panel[data-v-b9324dce]{display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfd}.filter-panel[data-v-b9324dce] .el-select{width:132px}.keyword-input[data-v-b9324dce]{width:min(340px,100%)}.date-range[data-v-b9324dce]{width:250px}.metric-grid[data-v-b9324dce]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0}.metric-card[data-v-b9324dce]{min-height:82px;padding:14px 16px;border:1px solid #e3e8ef;border-radius:10px;background:#fff}.metric-card span[data-v-b9324dce],.metric-card small[data-v-b9324dce]{color:#8a95a6;font-size:12px}.metric-card strong[data-v-b9324dce]{display:block;margin:7px 0 3px;color:#172033;font-size:22px;line-height:1}.metric-warning[data-v-b9324dce]{border-color:#f3d8aa;background:#fffcf5}.metric-success[data-v-b9324dce]{border-color:#b9e2dc;background:#f7fcfb}.embedded-table[data-v-b9324dce]{width:100%;border:1px solid #e7ebf0;border-radius:9px;overflow:hidden}.embedded-table[data-v-b9324dce] th.el-table__cell{height:44px;color:#5f6b7d;background:#f7f9fb;font-weight:600}.embedded-table[data-v-b9324dce] .order-row-risk>td.el-table__cell{background:#fff8f7}.embedded-table[data-v-b9324dce] .order-row-done>td.el-table__cell{background:#f8fcfb}.primary-cell[data-v-b9324dce],.id-stack[data-v-b9324dce]{display:flex;flex-direction:column;gap:3px}.primary-cell strong[data-v-b9324dce],.id-stack strong[data-v-b9324dce]{color:#202939;font-size:13px}.primary-cell span[data-v-b9324dce],.id-stack span[data-v-b9324dce]{color:#8b96a8;font-size:12px}.amount[data-v-b9324dce]{color:#d04f3f;font-variant-numeric:tabular-nums}.order-actions[data-v-b9324dce]{display:flex;align-items:center;gap:4px;white-space:nowrap}.order-actions[data-v-b9324dce] .el-button+.el-button{margin-left:0}.danger-menu-item{color:var(--el-color-danger)!important}.pagination-wrap[data-v-b9324dce]{display:flex;justify-content:flex-end;padding-top:16px}@media (max-width: 1080px){.metric-grid[data-v-b9324dce]{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width: 760px){.panel-toolbar[data-v-b9324dce]{align-items:flex-start;flex-direction:column}.metric-grid[data-v-b9324dce]{grid-template-columns:1fr}.filter-panel[data-v-b9324dce]>*,.filter-panel[data-v-b9324dce] .el-select,.date-range[data-v-b9324dce]{width:100%}}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-9dA1PEvN.js";import{_ as V}from"./index-Cz9kp4CD.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-Bie2Sx_u.js";import{_ as V}from"./index-sfLQNRIR.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
@charset "UTF-8";.po-detail-drawer[data-v-ca98ec61] .el-drawer__header{margin-bottom:0;padding:16px 24px;border-bottom:1px solid var(--el-border-color-lighter)}.stat-card[data-v-ca98ec61]{border-radius:8px}.stat-title[data-v-ca98ec61]{font-weight:500}.po-panel[data-v-ca98ec61]{border-radius:8px;transition:all .3s}.po-panel[data-v-ca98ec61] .el-card__header{padding:14px 16px;background-color:var(--el-bg-color-page);border-bottom:1px solid var(--el-border-color-lighter)}.po-panel[data-v-ca98ec61] .el-card__body{padding:16px}.po-desc[data-v-ca98ec61] .el-descriptions__label{width:120px;color:var(--el-text-color-regular)}.po-audit-remark[data-v-ca98ec61]{color:var(--el-color-danger);font-weight:600;white-space:pre-wrap;word-break:break-word}.audit-stamp[data-v-ca98ec61]{position:absolute;top:18px;right:-14px;width:72px;height:72px;border:3px solid currentColor;border-radius:50%;display:flex;align-items:center;justify-content:center;transform:rotate(20deg);opacity:.8;pointer-events:none;z-index:10;-webkit-user-select:none;-moz-user-select:none;user-select:none;font-weight:700;font-size:13px;letter-spacing:1px;box-shadow:inset 0 0 0 1px #ffffff80}.audit-stamp[data-v-ca98ec61]:after{content:"";position:absolute;top:4px;left:4px;right:4px;bottom:4px;border:1px double currentColor;border-radius:50%;opacity:.6}.audit-stamp .stamp-inner[data-v-ca98ec61]{text-align:center;line-height:1.1}.stamp-pass[data-v-ca98ec61]{color:var(--el-color-success)}.stamp-reject[data-v-ca98ec61]{color:var(--el-color-danger)}.po-diagnosis-creator-dept-breadcrumb[data-v-ca98ec61] .el-breadcrumb__item{display:inline-flex;float:none}.po-diagnosis-creator-dept-breadcrumb[data-v-ca98ec61] .el-breadcrumb__separator{margin:0 2px 0 4px}
|
||||
@@ -1 +0,0 @@
|
||||
@charset "UTF-8";.po-detail-drawer[data-v-a16e796e] .el-drawer__header{margin-bottom:0;padding:16px 24px;border-bottom:1px solid var(--el-border-color-lighter)}.stat-card[data-v-a16e796e]{border-radius:8px}.stat-title[data-v-a16e796e]{font-weight:500}.po-panel[data-v-a16e796e]{border-radius:8px;transition:all .3s}.po-panel[data-v-a16e796e] .el-card__header{padding:14px 16px;background-color:var(--el-bg-color-page);border-bottom:1px solid var(--el-border-color-lighter)}.po-panel[data-v-a16e796e] .el-card__body{padding:16px}.po-desc[data-v-a16e796e] .el-descriptions__label{width:120px;color:var(--el-text-color-regular)}.po-audit-remark[data-v-a16e796e]{color:var(--el-color-danger);font-weight:600;white-space:pre-wrap;word-break:break-word}.audit-stamp[data-v-a16e796e]{position:absolute;top:18px;right:-14px;width:72px;height:72px;border:3px solid currentColor;border-radius:50%;display:flex;align-items:center;justify-content:center;transform:rotate(20deg);opacity:.8;pointer-events:none;z-index:10;-webkit-user-select:none;-moz-user-select:none;user-select:none;font-weight:700;font-size:13px;letter-spacing:1px;box-shadow:inset 0 0 0 1px #ffffff80}.audit-stamp[data-v-a16e796e]:after{content:"";position:absolute;top:4px;left:4px;right:4px;bottom:4px;border:1px double currentColor;border-radius:50%;opacity:.6}.audit-stamp .stamp-inner[data-v-a16e796e]{text-align:center;line-height:1.1}.stamp-pass[data-v-a16e796e]{color:var(--el-color-success)}.stamp-reject[data-v-a16e796e]{color:var(--el-color-danger)}.po-diagnosis-creator-dept-breadcrumb[data-v-a16e796e] .el-breadcrumb__item{display:inline-flex;float:none}.po-diagnosis-creator-dept-breadcrumb[data-v-a16e796e] .el-breadcrumb__separator{margin:0 2px 0 4px}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-9dA1PEvN.js";import H from"./RecordingVideoPlayer-DV3mcuzh.js";import{e as I,_ as P}from"./index-Cz9kp4CD.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-Bie2Sx_u.js";import H from"./RecordingVideoPlayer-eek8Mbd_.js";import{e as I,_ as P}from"./index-sfLQNRIR.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-9dA1PEvN.js","assets/.pnpm-LiWq8Xu0.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-9dA1PEvN.js";import{e as ae,_ as ne}from"./index-Cz9kp4CD.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?U(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function U(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function C(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-9dA1PEvN.js").then(M=>M.dx),__vite__mapDeps([0,1])),T=o.default;if(T.isSupported()){d=new T({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function N(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function x(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{N()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:C},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:x,onKeydown:$(j(x,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-Bie2Sx_u.js","assets/.pnpm-BtiqMGM_.css"])))=>i.map(i=>d[i]);
|
||||
import{o as F,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-Bie2Sx_u.js";import{e as ae,_ as ne}from"./index-sfLQNRIR.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=F({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?U(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function U(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function C(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-Bie2Sx_u.js").then(M=>M.dK),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,K)=>{if(K.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function N(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{N()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:C},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-9dA1PEvN.js";import{$ as L}from"./tcm-C41UnSAo.js";import{i as M,_ as S}from"./index-Cz9kp4CD.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-Bie2Sx_u.js";import{$ as L}from"./tcm-D30dxXYU.js";import{i as M,_ as S}from"./index-sfLQNRIR.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),J=S(O,[["__scopeId","data-v-82b635bd"]]);export{J as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-Cu5sN1ss.js";import"./.pnpm-9dA1PEvN.js";import"./index-B90a91_l.js";import"./index-Cz9kp4CD.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-Crq_qBZ2.js";import"./.pnpm-Bie2Sx_u.js";import"./index-DlUJ-5rv.js";import"./index-sfLQNRIR.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-9dA1PEvN.js";import{_ as L}from"./index-B90a91_l.js";import{i as V}from"./index-Cz9kp4CD.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-Bie2Sx_u.js";import{_ as L}from"./index-DlUJ-5rv.js";import{i as V}from"./index-sfLQNRIR.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-C-GpfAui.js";import"./.pnpm-Bie2Sx_u.js";import"./index-8QX51AC-.js";import"./index-sfLQNRIR.js";import"./picker-qbBjoERH.js";import"./index-DlUJ-5rv.js";import"./index.vue_vue_type_script_setup_true_lang-DGR6g81t.js";import"./article-kz9P31OA.js";import"./usePaging-DYszyN6E.js";import"./picker-BpTqhQho.js";import"./index-CQAtyLf8.js";import"./index-BGD0Q0pO.js";import"./file-CA_icqOC.js";import"./index.vue_vue_type_script_setup_true_lang-C-jbsa3S.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-BdcpQCmR.js";import"./.pnpm-9dA1PEvN.js";import"./index-DyHvFort.js";import"./index-Cz9kp4CD.js";import"./picker-jTXO690S.js";import"./index-B90a91_l.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./article-BQ6iauIT.js";import"./usePaging-Boz1Q6w3.js";import"./picker-CbsMoheE.js";import"./index-Bs4lHLA-.js";import"./index-C4QA4TrT.js";import"./file-CLzBc42F.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as E,q as p,r as C,s as l,v as a,u as c,bQ as B,C as N,D as d,O as $,b7 as z,b6 as D,I,K as A,L,p as R}from"./.pnpm-9dA1PEvN.js";import{_ as q}from"./index-DyHvFort.js";import{_ as F}from"./picker-jTXO690S.js";import{_ as K}from"./picker-CbsMoheE.js";import{c as O,i as r}from"./index-Cz9kp4CD.js";const P={class:"bg-fill-light flex items-center w-full p-4 mb-4"},Q={class:"upload-btn w-[60px] h-[60px]"},S={class:"ml-3 flex-1"},T={class:"flex items-center"},j={class:"flex items-center mt-[18px]"},G={class:"flex-1 flex items-center"},H={class:"drag-move cursor-move ml-auto"},Z=E({__name:"add-nav",props:{modelValue:{type:Array,default:()=>[]},max:{type:Number,default:100},min:{type:Number,default:1}},emits:["update:modelValue"],setup(_,{emit:f}){const t=_,V=f,m=R({get(){return t.modelValue},set(s){V("update:modelValue",s)}}),x=()=>{var s;((s=t.modelValue)==null?void 0:s.length)<t.max?m.value.push({image:"",name:"导航名称",link:{},is_show:"1"}):r.msgError(`最多添加${t.max}个`)},v=s=>{var e;if(((e=t.modelValue)==null?void 0:e.length)<=t.min)return r.msgError(`最少保留${t.min}个`);m.value.splice(s,1)};return(s,e)=>{const u=O,g=K,b=z,h=F,k=I,w=D,y=q,U=A;return p(),C("div",null,[l("div",null,[a(c(B),{class:"draggable",modelValue:c(m),"onUpdate:modelValue":e[0]||(e[0]=o=>N(m)?m.value=o:null),animation:"300",handle:".drag-move","item-key":"index"},{item:d(({element:o,index:i})=>[(p(),$(y,{class:"w-[467px]",key:i,onClose:n=>v(i)},{default:d(()=>[l("div",P,[a(g,{modelValue:o.image,"onUpdate:modelValue":n=>o.image=n,"upload-class":"bg-body",size:"60px","exclude-domain":""},{upload:d(()=>[l("div",Q,[a(u,{name:"el-icon-Plus",size:20})])]),_:1},8,["modelValue","onUpdate:modelValue"]),l("div",S,[l("div",T,[e[1]||(e[1]=l("span",{class:"text-tx-regular flex-none mr-3"},"名称",-1)),a(b,{modelValue:o.name,"onUpdate:modelValue":n=>o.name=n,placeholder:"请输入名称"},null,8,["modelValue","onUpdate:modelValue"])]),l("div",j,[e[2]||(e[2]=l("span",{class:"text-tx-regular flex-none mr-3"},"链接",-1)),a(h,{modelValue:o.link,"onUpdate:modelValue":n=>o.link=n},null,8,["modelValue","onUpdate:modelValue"])]),a(w,{label:"是否显示",class:"mt-[18px]"},{default:d(()=>[l("div",G,[a(k,{modelValue:o.is_show,"onUpdate:modelValue":n=>o.is_show=n,"active-value":"1","inactive-value":"0"},null,8,["modelValue","onUpdate:modelValue"]),l("div",H,[a(u,{name:"el-icon-Rank",size:"18"})])])]),_:2},1024)])])]),_:2},1032,["onClose"]))]),_:1},8,["modelValue"])]),l("div",null,[a(U,{type:"primary",onClick:x},{default:d(()=>[...e[3]||(e[3]=[L("添加",-1)])]),_:1})])])}}});export{Z as _};
|
||||
import{o as E,q as p,r as C,s as l,v as a,u as c,bQ as B,C as N,D as d,O as $,b7 as z,b6 as D,I,K as A,L,p as R}from"./.pnpm-Bie2Sx_u.js";import{_ as q}from"./index-8QX51AC-.js";import{_ as F}from"./picker-qbBjoERH.js";import{_ as K}from"./picker-BpTqhQho.js";import{c as O,i as r}from"./index-sfLQNRIR.js";const P={class:"bg-fill-light flex items-center w-full p-4 mb-4"},Q={class:"upload-btn w-[60px] h-[60px]"},S={class:"ml-3 flex-1"},T={class:"flex items-center"},j={class:"flex items-center mt-[18px]"},G={class:"flex-1 flex items-center"},H={class:"drag-move cursor-move ml-auto"},Z=E({__name:"add-nav",props:{modelValue:{type:Array,default:()=>[]},max:{type:Number,default:100},min:{type:Number,default:1}},emits:["update:modelValue"],setup(_,{emit:f}){const t=_,V=f,m=R({get(){return t.modelValue},set(s){V("update:modelValue",s)}}),x=()=>{var s;((s=t.modelValue)==null?void 0:s.length)<t.max?m.value.push({image:"",name:"导航名称",link:{},is_show:"1"}):r.msgError(`最多添加${t.max}个`)},v=s=>{var e;if(((e=t.modelValue)==null?void 0:e.length)<=t.min)return r.msgError(`最少保留${t.min}个`);m.value.splice(s,1)};return(s,e)=>{const u=O,g=K,b=z,h=F,k=I,w=D,y=q,U=A;return p(),C("div",null,[l("div",null,[a(c(B),{class:"draggable",modelValue:c(m),"onUpdate:modelValue":e[0]||(e[0]=o=>N(m)?m.value=o:null),animation:"300",handle:".drag-move","item-key":"index"},{item:d(({element:o,index:i})=>[(p(),$(y,{class:"w-[467px]",key:i,onClose:n=>v(i)},{default:d(()=>[l("div",P,[a(g,{modelValue:o.image,"onUpdate:modelValue":n=>o.image=n,"upload-class":"bg-body",size:"60px","exclude-domain":""},{upload:d(()=>[l("div",Q,[a(u,{name:"el-icon-Plus",size:20})])]),_:1},8,["modelValue","onUpdate:modelValue"]),l("div",S,[l("div",T,[e[1]||(e[1]=l("span",{class:"text-tx-regular flex-none mr-3"},"名称",-1)),a(b,{modelValue:o.name,"onUpdate:modelValue":n=>o.name=n,placeholder:"请输入名称"},null,8,["modelValue","onUpdate:modelValue"])]),l("div",j,[e[2]||(e[2]=l("span",{class:"text-tx-regular flex-none mr-3"},"链接",-1)),a(h,{modelValue:o.link,"onUpdate:modelValue":n=>o.link=n},null,8,["modelValue","onUpdate:modelValue"])]),a(w,{label:"是否显示",class:"mt-[18px]"},{default:d(()=>[l("div",G,[a(k,{modelValue:o.is_show,"onUpdate:modelValue":n=>o.is_show=n,"active-value":"1","inactive-value":"0"},null,8,["modelValue","onUpdate:modelValue"]),l("div",H,[a(u,{name:"el-icon-Rank",size:"18"})])])]),_:2},1024)])])]),_:2},1032,["onClose"]))]),_:1},8,["modelValue"])]),l("div",null,[a(U,{type:"primary",onClick:x},{default:d(()=>[...e[3]||(e[3]=[L("添加",-1)])]),_:1})])])}}});export{Z as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{r as n}from"./index-Cz9kp4CD.js";function e(t){return n.get({url:"/auth.admin/lists",params:t},{ignoreCancelToken:!0})}function i(t){return n.post({url:"/auth.admin/add",params:t})}function r(t){return n.post({url:"/auth.admin/edit",params:t})}function u(t){return n.post({url:"/auth.admin/delete",params:t})}function d(t){return n.get({url:"/auth.admin/detail",params:t})}export{e as a,r as b,u as c,i as d,d as e};
|
||||
import{r as n}from"./index-sfLQNRIR.js";function e(t){return n.get({url:"/auth.admin/lists",params:t},{ignoreCancelToken:!0})}function i(t){return n.post({url:"/auth.admin/add",params:t})}function r(t){return n.post({url:"/auth.admin/edit",params:t})}function u(t){return n.post({url:"/auth.admin/delete",params:t})}function d(t){return n.get({url:"/auth.admin/detail",params:t})}export{e as a,r as b,u as c,i as d,d as e};
|
||||
@@ -1 +0,0 @@
|
||||
.today-block-alert[data-v-dbfff1e8]{margin-bottom:16px}.appointment-form[data-v-dbfff1e8] .el-form-item__label{font-weight:500}.appointment-form .channel-source-select[data-v-dbfff1e8]{width:100%;max-width:360px}.doctor-list[data-v-dbfff1e8]{display:flex;flex-wrap:wrap;gap:12px}.doctor-list .doctor-radio[data-v-dbfff1e8]{margin-right:0}.doctor-list .doctor-radio[data-v-dbfff1e8] .el-radio__label{display:flex;align-items:center}.appointment-time-container[data-v-dbfff1e8]{width:100%}.date-selector[data-v-dbfff1e8]{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:20px}.date-selector .date-button[data-v-dbfff1e8]{min-width:130px;height:40px;font-size:14px;border-radius:8px;transition:all .2s}.date-selector .date-button[data-v-dbfff1e8]:hover{transform:translateY(-2px);box-shadow:0 2px 8px #0000001a}.time-slots-container[data-v-dbfff1e8]{background-color:#f8f9fa;border-radius:8px;padding:16px}.time-slots-header[data-v-dbfff1e8]{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.time-slots-header .header-title[data-v-dbfff1e8]{font-size:15px;font-weight:600;color:#303133}.time-slots-grid[data-v-dbfff1e8]{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;max-height:450px;overflow-y:auto;padding:2px}.time-slots-grid[data-v-dbfff1e8]::-webkit-scrollbar{width:6px}.time-slots-grid[data-v-dbfff1e8]::-webkit-scrollbar-thumb{background-color:#dcdfe6;border-radius:3px}.time-slots-grid[data-v-dbfff1e8]::-webkit-scrollbar-thumb:hover{background-color:#c0c4cc}.time-slots-grid .time-slot-item[data-v-dbfff1e8]{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:0 8px;border:2px solid #e4e7ed;border-radius:8px;cursor:pointer;transition:all .2s;background-color:#fff;min-height:70px}.time-slots-grid .time-slot-item .slot-time[data-v-dbfff1e8]{font-size:15px;font-weight:600;color:#303133;margin-bottom:6px}.time-slots-grid .time-slot-item .slot-status[data-v-dbfff1e8]{font-size:12px;color:#909399;padding:0 8px;border-radius:4px;background-color:#f4f4f5}.time-slots-grid .time-slot-item .slot-status.status-available[data-v-dbfff1e8]{color:#67c23a;background-color:#f0f9ff}.time-slots-grid .time-slot-item.available[data-v-dbfff1e8]{border-color:#e4e7ed}.time-slots-grid .time-slot-item.available[data-v-dbfff1e8]:hover{border-color:#409eff;background-color:#ecf5ff;transform:translateY(-2px);box-shadow:0 4px 12px #409eff26}.time-slots-grid .time-slot-item.unavailable[data-v-dbfff1e8]{background-color:#f5f7fa;border-color:#e4e7ed;cursor:not-allowed;opacity:.6}.time-slots-grid .time-slot-item.unavailable .slot-time[data-v-dbfff1e8]{color:#c0c4cc}.time-slots-grid .time-slot-item.unavailable .slot-status[data-v-dbfff1e8]{color:#c0c4cc;background-color:#f5f7fa}.time-slots-grid .time-slot-item.unavailable[data-v-dbfff1e8]:hover{transform:none;box-shadow:none}.time-slots-grid .time-slot-item.selected[data-v-dbfff1e8]{border-color:#409eff;background:linear-gradient(135deg,#409eff,#66b1ff);box-shadow:0 4px 12px #409eff4d}.time-slots-grid .time-slot-item.selected .slot-time[data-v-dbfff1e8]{color:#fff}.time-slots-grid .time-slot-item.selected .slot-status[data-v-dbfff1e8]{color:#fff;background-color:#fff3}[data-v-dbfff1e8] .el-radio-group{display:flex;flex-wrap:wrap;gap:12px}[data-v-dbfff1e8] .el-radio{margin-right:0}.drawer-footer[data-v-dbfff1e8]{display:flex;justify-content:flex-end;gap:12px;padding:12px 0}
|
||||
@@ -0,0 +1 @@
|
||||
.today-block-alert[data-v-88624e05]{margin-bottom:16px}.appointment-form[data-v-88624e05] .el-form-item__label{font-weight:500}.appointment-form .channel-source-select[data-v-88624e05]{width:100%;max-width:360px}.doctor-list[data-v-88624e05]{display:flex;flex-wrap:wrap;gap:12px}.doctor-list .doctor-radio[data-v-88624e05]{margin-right:0}.doctor-list .doctor-radio[data-v-88624e05] .el-radio__label{display:flex;align-items:center}.appointment-time-container[data-v-88624e05]{width:100%}.date-selector[data-v-88624e05]{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:20px}.date-selector .date-button[data-v-88624e05]{min-width:130px;height:40px;font-size:14px;border-radius:8px;transition:all .2s}.date-selector .date-button[data-v-88624e05]:hover{transform:translateY(-2px);box-shadow:0 2px 8px #0000001a}.time-slots-container[data-v-88624e05]{background-color:#f8f9fa;border-radius:8px;padding:16px}.time-slots-header[data-v-88624e05]{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.time-slots-header .header-title[data-v-88624e05]{font-size:15px;font-weight:600;color:#303133}.time-slots-grid[data-v-88624e05]{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;max-height:450px;overflow-y:auto;padding:2px}.time-slots-grid[data-v-88624e05]::-webkit-scrollbar{width:6px}.time-slots-grid[data-v-88624e05]::-webkit-scrollbar-thumb{background-color:#dcdfe6;border-radius:3px}.time-slots-grid[data-v-88624e05]::-webkit-scrollbar-thumb:hover{background-color:#c0c4cc}.time-slots-grid .time-slot-item[data-v-88624e05]{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:0 8px;border:2px solid #e4e7ed;border-radius:8px;cursor:pointer;transition:all .2s;background-color:#fff;min-height:70px}.time-slots-grid .time-slot-item .slot-time[data-v-88624e05]{font-size:15px;font-weight:600;color:#303133;margin-bottom:6px}.time-slots-grid .time-slot-item .slot-status[data-v-88624e05]{font-size:12px;color:#909399;padding:0 8px;border-radius:4px;background-color:#f4f4f5}.time-slots-grid .time-slot-item .slot-status.status-available[data-v-88624e05]{color:#67c23a;background-color:#f0f9ff}.time-slots-grid .time-slot-item.available[data-v-88624e05]{border-color:#e4e7ed}.time-slots-grid .time-slot-item.available[data-v-88624e05]:hover{border-color:#409eff;background-color:#ecf5ff;transform:translateY(-2px);box-shadow:0 4px 12px #409eff26}.time-slots-grid .time-slot-item.unavailable[data-v-88624e05]{background-color:#f5f7fa;border-color:#e4e7ed;cursor:not-allowed;opacity:.6}.time-slots-grid .time-slot-item.unavailable .slot-time[data-v-88624e05]{color:#c0c4cc}.time-slots-grid .time-slot-item.unavailable .slot-status[data-v-88624e05]{color:#c0c4cc;background-color:#f5f7fa}.time-slots-grid .time-slot-item.unavailable[data-v-88624e05]:hover{transform:none;box-shadow:none}.time-slots-grid .time-slot-item.selected[data-v-88624e05]{border-color:#409eff;background:linear-gradient(135deg,#409eff,#66b1ff);box-shadow:0 4px 12px #409eff4d}.time-slots-grid .time-slot-item.selected .slot-time[data-v-88624e05]{color:#fff}.time-slots-grid .time-slot-item.selected .slot-status[data-v-88624e05]{color:#fff;background-color:#fff3}[data-v-88624e05] .el-radio-group{display:flex;flex-wrap:wrap;gap:12px}[data-v-88624e05] .el-radio{margin-right:0}.drawer-footer[data-v-88624e05]{display:flex;justify-content:flex-end;gap:12px;padding:12px 0}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./index-Cz9kp4CD.js";function a(t){return e.get({url:"/article.articleCate/lists",params:t})}function l(t){return e.get({url:"/article.articleCate/all",params:t})}function i(t){return e.post({url:"/article.articleCate/add",params:t})}function c(t){return e.post({url:"/article.articleCate/edit",params:t})}function u(t){return e.post({url:"/article.articleCate/delete",params:t})}function n(t){return e.get({url:"/article.articleCate/detail",params:t})}function s(t){return e.post({url:"/article.articleCate/updateStatus",params:t})}function o(t){return e.get({url:"/article.article/lists",params:t})}function d(t){return e.post({url:"/article.article/add",params:t})}function f(t){return e.post({url:"/article.article/edit",params:t})}function C(t){return e.post({url:"/article.article/delete",params:t})}function p(t){return e.get({url:"/article.article/detail",params:t})}function g(t){return e.post({url:"/article.article/updateStatus",params:t})}export{a,u as b,s as c,c as d,i as e,n as f,p as g,f as h,d as i,l as j,o as k,g as l,C as m};
|
||||
import{r as e}from"./index-sfLQNRIR.js";function a(t){return e.get({url:"/article.articleCate/lists",params:t})}function l(t){return e.get({url:"/article.articleCate/all",params:t})}function i(t){return e.post({url:"/article.articleCate/add",params:t})}function c(t){return e.post({url:"/article.articleCate/edit",params:t})}function u(t){return e.post({url:"/article.articleCate/delete",params:t})}function n(t){return e.get({url:"/article.articleCate/detail",params:t})}function s(t){return e.post({url:"/article.articleCate/updateStatus",params:t})}function o(t){return e.get({url:"/article.article/lists",params:t})}function d(t){return e.post({url:"/article.article/add",params:t})}function f(t){return e.post({url:"/article.article/edit",params:t})}function C(t){return e.post({url:"/article.article/delete",params:t})}function p(t){return e.get({url:"/article.article/detail",params:t})}function g(t){return e.post({url:"/article.article/updateStatus",params:t})}export{a,u as b,s as c,c as d,i as e,n as f,p as g,f as h,d as i,l as j,o as k,g as l,C as m};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./index-Cz9kp4CD.js";function r(s){return e.get({url:"/asset.AssetUser/lists",params:s})}function u(s){return e.post({url:"/asset.AssetUser/add",params:s})}function a(s){return e.post({url:"/asset.AssetUser/edit",params:s})}function i(s){return e.post({url:"/asset.AssetUser/delete",params:s})}function o(s){return e.get({url:"/asset.AssetResource/lists",params:s})}function n(s){return e.post({url:"/asset.AssetResource/add",params:s})}function A(s){return e.post({url:"/asset.AssetResource/edit",params:s})}function c(s){return e.post({url:"/asset.AssetResource/delete",params:s})}export{o as a,c as b,A as c,n as d,r as e,a as f,i as g,u as h};
|
||||
import{r as e}from"./index-sfLQNRIR.js";function r(s){return e.get({url:"/asset.AssetUser/lists",params:s})}function u(s){return e.post({url:"/asset.AssetUser/add",params:s})}function a(s){return e.post({url:"/asset.AssetUser/edit",params:s})}function i(s){return e.post({url:"/asset.AssetUser/delete",params:s})}function o(s){return e.get({url:"/asset.AssetResource/lists",params:s})}function n(s){return e.post({url:"/asset.AssetResource/add",params:s})}function A(s){return e.post({url:"/asset.AssetResource/edit",params:s})}function c(s){return e.post({url:"/asset.AssetResource/delete",params:s})}export{o as a,c as b,A as c,n as d,r as e,a as f,i as g,u as h};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-BRfbvJr-.js";import"./.pnpm-9dA1PEvN.js";import"./index-DyHvFort.js";import"./index-Cz9kp4CD.js";import"./picker-jTXO690S.js";import"./index-B90a91_l.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./article-BQ6iauIT.js";import"./usePaging-Boz1Q6w3.js";import"./picker-CbsMoheE.js";import"./index-Bs4lHLA-.js";import"./index-C4QA4TrT.js";import"./file-CLzBc42F.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";import"./index.vue_vue_type_script_setup_true_lang-DNrIEWkc.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-ZXKmGzzs.js";import"./.pnpm-Bie2Sx_u.js";import"./index-8QX51AC-.js";import"./index-sfLQNRIR.js";import"./picker-qbBjoERH.js";import"./index-DlUJ-5rv.js";import"./index.vue_vue_type_script_setup_true_lang-DGR6g81t.js";import"./article-kz9P31OA.js";import"./usePaging-DYszyN6E.js";import"./picker-BpTqhQho.js";import"./index-CQAtyLf8.js";import"./index-BGD0Q0pO.js";import"./file-CA_icqOC.js";import"./index.vue_vue_type_script_setup_true_lang-C-jbsa3S.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-Cf_ppg4c.js";import"./.pnpm-9dA1PEvN.js";export{m as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-CPgMFDSF.js";import"./.pnpm-9dA1PEvN.js";export{m as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-BqLy5oH1.js";import"./.pnpm-Bie2Sx_u.js";export{m as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-D9nrVJGr.js";import"./.pnpm-9dA1PEvN.js";import"./picker-CbsMoheE.js";import"./index-B90a91_l.js";import"./index-Cz9kp4CD.js";import"./index-Bs4lHLA-.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./index-DyHvFort.js";import"./index-C4QA4TrT.js";import"./file-CLzBc42F.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";import"./usePaging-Boz1Q6w3.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-C5M8ur8o.js";import"./.pnpm-9dA1PEvN.js";import"./add-nav.vue_vue_type_script_setup_true_lang-BdcpQCmR.js";import"./index-DyHvFort.js";import"./index-Cz9kp4CD.js";import"./picker-jTXO690S.js";import"./index-B90a91_l.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./article-BQ6iauIT.js";import"./usePaging-Boz1Q6w3.js";import"./picker-CbsMoheE.js";import"./index-Bs4lHLA-.js";import"./index-C4QA4TrT.js";import"./file-CLzBc42F.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-DAqFCc9A.js";import"./.pnpm-9dA1PEvN.js";import"./index-DyHvFort.js";import"./index-Cz9kp4CD.js";import"./picker-jTXO690S.js";import"./index-B90a91_l.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./article-BQ6iauIT.js";import"./usePaging-Boz1Q6w3.js";import"./picker-CbsMoheE.js";import"./index-Bs4lHLA-.js";import"./index-C4QA4TrT.js";import"./file-CLzBc42F.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as b,q as c,r as V,s as v,v as t,D as a,Y as x,X as E,u as l,b9 as g,F as k,p as w}from"./.pnpm-9dA1PEvN.js";import{_ as p}from"./menu-set.vue_vue_type_script_setup_true_lang-C1RY2qAf.js";import"./index-DyHvFort.js";import"./index-Cz9kp4CD.js";import"./picker-jTXO690S.js";import"./index-B90a91_l.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./article-BQ6iauIT.js";import"./usePaging-Boz1Q6w3.js";import"./picker-CbsMoheE.js";import"./index-Bs4lHLA-.js";import"./index-C4QA4TrT.js";import"./file-CLzBc42F.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";const $=b({__name:"attr",props:{modelValue:{type:Object,default:()=>({nav:[],menu:{}})}},emits:["update:modelValue"],setup(s,{emit:u}){const d=s,i=u,o=w({get(){return d.modelValue},set(n){i("update:modelValue",n)}});return(n,e)=>{const r=E,f=x,_=g;return c(),V(k,null,[e[2]||(e[2]=v("div",{class:"title flex items-center before:w-[3px] before:h-[14px] before:block before:bg-primary before:mr-2"}," pc导航设置 ",-1)),t(_,{class:"mt-4","label-width":"70px"},{default:a(()=>[t(f,{"model-value":"nav"},{default:a(()=>[t(r,{label:"主导航设置",name:"nav"},{default:a(()=>[t(p,{modelValue:l(o).nav,"onUpdate:modelValue":e[0]||(e[0]=m=>l(o).nav=m)},null,8,["modelValue"])]),_:1}),t(r,{label:"菜单设置",name:"menu"},{default:a(()=>[t(p,{modelValue:l(o).menu,"onUpdate:modelValue":e[1]||(e[1]=m=>l(o).menu=m)},null,8,["modelValue"])]),_:1})]),_:1})]),_:1})],64)}}});export{$ as default};
|
||||
import{o as b,q as c,r as V,s as v,v as t,D as a,Y as x,X as E,u as l,b9 as g,F as k,p as w}from"./.pnpm-Bie2Sx_u.js";import{_ as p}from"./menu-set.vue_vue_type_script_setup_true_lang-C334vyMD.js";import"./index-8QX51AC-.js";import"./index-sfLQNRIR.js";import"./picker-qbBjoERH.js";import"./index-DlUJ-5rv.js";import"./index.vue_vue_type_script_setup_true_lang-DGR6g81t.js";import"./article-kz9P31OA.js";import"./usePaging-DYszyN6E.js";import"./picker-BpTqhQho.js";import"./index-CQAtyLf8.js";import"./index-BGD0Q0pO.js";import"./file-CA_icqOC.js";import"./index.vue_vue_type_script_setup_true_lang-C-jbsa3S.js";const $=b({__name:"attr",props:{modelValue:{type:Object,default:()=>({nav:[],menu:{}})}},emits:["update:modelValue"],setup(s,{emit:u}){const d=s,i=u,o=w({get(){return d.modelValue},set(n){i("update:modelValue",n)}});return(n,e)=>{const r=E,f=x,_=g;return c(),V(k,null,[e[2]||(e[2]=v("div",{class:"title flex items-center before:w-[3px] before:h-[14px] before:block before:bg-primary before:mr-2"}," pc导航设置 ",-1)),t(_,{class:"mt-4","label-width":"70px"},{default:a(()=>[t(f,{"model-value":"nav"},{default:a(()=>[t(r,{label:"主导航设置",name:"nav"},{default:a(()=>[t(p,{modelValue:l(o).nav,"onUpdate:modelValue":e[0]||(e[0]=m=>l(o).nav=m)},null,8,["modelValue"])]),_:1}),t(r,{label:"菜单设置",name:"menu"},{default:a(()=>[t(p,{modelValue:l(o).menu,"onUpdate:modelValue":e[1]||(e[1]=m=>l(o).menu=m)},null,8,["modelValue"])]),_:1})]),_:1})]),_:1})],64)}}});export{$ as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-DAQn0si1.js";import"./.pnpm-Bie2Sx_u.js";import"./picker-BpTqhQho.js";import"./index-DlUJ-5rv.js";import"./index-sfLQNRIR.js";import"./index-CQAtyLf8.js";import"./index.vue_vue_type_script_setup_true_lang-DGR6g81t.js";import"./index-8QX51AC-.js";import"./index-BGD0Q0pO.js";import"./file-CA_icqOC.js";import"./index.vue_vue_type_script_setup_true_lang-C-jbsa3S.js";import"./usePaging-DYszyN6E.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-BjE8TqXK.js";import"./.pnpm-Bie2Sx_u.js";import"./index.vue_vue_type_script_setup_true_lang-Cn-owMPy.js";import"./picker-BpTqhQho.js";import"./index-DlUJ-5rv.js";import"./index-sfLQNRIR.js";import"./index-CQAtyLf8.js";import"./index.vue_vue_type_script_setup_true_lang-DGR6g81t.js";import"./index-8QX51AC-.js";import"./index-BGD0Q0pO.js";import"./file-CA_icqOC.js";import"./index.vue_vue_type_script_setup_true_lang-C-jbsa3S.js";import"./usePaging-DYszyN6E.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-CWq8pX6x.js";import"./.pnpm-Bie2Sx_u.js";export{m as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-DC1PlTCU.js";import"./.pnpm-Bie2Sx_u.js";import"./index-8QX51AC-.js";import"./index-sfLQNRIR.js";import"./picker-qbBjoERH.js";import"./index-DlUJ-5rv.js";import"./index.vue_vue_type_script_setup_true_lang-DGR6g81t.js";import"./article-kz9P31OA.js";import"./usePaging-DYszyN6E.js";import"./picker-BpTqhQho.js";import"./index-CQAtyLf8.js";import"./index-BGD0Q0pO.js";import"./file-CA_icqOC.js";import"./index.vue_vue_type_script_setup_true_lang-C-jbsa3S.js";import"./index.vue_vue_type_script_setup_true_lang-Cn-owMPy.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-BPYGM2bu.js";import"./.pnpm-Bie2Sx_u.js";import"./add-nav.vue_vue_type_script_setup_true_lang-C-GpfAui.js";import"./index-8QX51AC-.js";import"./index-sfLQNRIR.js";import"./picker-qbBjoERH.js";import"./index-DlUJ-5rv.js";import"./index.vue_vue_type_script_setup_true_lang-DGR6g81t.js";import"./article-kz9P31OA.js";import"./usePaging-DYszyN6E.js";import"./picker-BpTqhQho.js";import"./index-CQAtyLf8.js";import"./index-BGD0Q0pO.js";import"./file-CA_icqOC.js";import"./index.vue_vue_type_script_setup_true_lang-C-jbsa3S.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-BFHWFrw5.js";import"./.pnpm-Bie2Sx_u.js";import"./add-nav.vue_vue_type_script_setup_true_lang-C-GpfAui.js";import"./index-8QX51AC-.js";import"./index-sfLQNRIR.js";import"./picker-qbBjoERH.js";import"./index-DlUJ-5rv.js";import"./index.vue_vue_type_script_setup_true_lang-DGR6g81t.js";import"./article-kz9P31OA.js";import"./usePaging-DYszyN6E.js";import"./picker-BpTqhQho.js";import"./index-CQAtyLf8.js";import"./index-BGD0Q0pO.js";import"./file-CA_icqOC.js";import"./index.vue_vue_type_script_setup_true_lang-C-jbsa3S.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-cmEIQ_Ic.js";import"./.pnpm-9dA1PEvN.js";import"./add-nav.vue_vue_type_script_setup_true_lang-BdcpQCmR.js";import"./index-DyHvFort.js";import"./index-Cz9kp4CD.js";import"./picker-jTXO690S.js";import"./index-B90a91_l.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./article-BQ6iauIT.js";import"./usePaging-Boz1Q6w3.js";import"./picker-CbsMoheE.js";import"./index-Bs4lHLA-.js";import"./index-C4QA4TrT.js";import"./file-CLzBc42F.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-BuLfPiUG.js";import"./.pnpm-9dA1PEvN.js";import"./index.vue_vue_type_script_setup_true_lang-DNrIEWkc.js";import"./picker-CbsMoheE.js";import"./index-B90a91_l.js";import"./index-Cz9kp4CD.js";import"./index-Bs4lHLA-.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./index-DyHvFort.js";import"./index-C4QA4TrT.js";import"./file-CLzBc42F.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";import"./usePaging-Boz1Q6w3.js";export{o as default};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user