;rgb:0000/0000/0000

This commit is contained in:
gr
2026-09-09 10:02:43 +08:00
640 changed files with 51641 additions and 20099 deletions
@@ -15,6 +15,7 @@
namespace app\adminapi\controller;
use app\common\cache\AdminAuthCache;
use app\common\service\DirectUploadService;
use app\common\service\UploadService;
use Exception;
@@ -86,7 +87,12 @@ class UploadController extends BaseAdminController
{
$type = trim((string)$this->request->post('type', 'video'));
try {
$result = DirectUploadService::issueCredentials($type);
$this->assertDirectUploadPermission($type);
$result = DirectUploadService::issueCredentials(
$type,
$this->adminId,
trim((string)$this->request->post('name', ''))
);
return $this->success('ok', $result);
} catch (Exception $e) {
return $this->fail($e->getMessage());
@@ -100,8 +106,10 @@ class UploadController extends BaseAdminController
public function ossConfirm()
{
try {
$type = trim((string)$this->request->post('type', 'video'));
$this->assertDirectUploadPermission($type);
$result = DirectUploadService::confirm([
'type' => trim((string)$this->request->post('type', 'video')),
'type' => $type,
'key' => trim((string)$this->request->post('key', '')),
'name' => trim((string)$this->request->post('name', '')),
'size' => (int)$this->request->post('size', 0),
@@ -115,4 +123,22 @@ class UploadController extends BaseAdminController
}
}
/**
* 安装包属于发布能力,不能沿用普通素材上传的“登录即放行”。
* @throws Exception
*/
private function assertDirectUploadPermission(string $type): void
{
if ($type !== DirectUploadService::TYPE_DESKTOP_PACKAGE
|| (int)($this->adminInfo['root'] ?? 0) === 1) {
return;
}
$permissions = (new AdminAuthCache($this->adminId))->getAdminUri() ?? [];
$permissions = array_map('strtolower', $permissions);
if (!in_array('setting.desktop_workstation/setconfig', $permissions, true)) {
throw new Exception('权限不足,无法上传医生工作站安装包');
}
}
}
@@ -5,13 +5,48 @@ 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\WecomAcquisitionCustomerLogic;
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
use app\common\service\qywx\QywxPromotionContactApiService;
use app\common\service\qywx\QywxPromotionMediaService;
use app\common\service\qywx\QywxPromotionOperatorAccess;
class WecomPromotionController extends BaseAdminController
{
private const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
public function tagOptions()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->tagOptions()));
}
public function createTag()
{
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
if (!$this->request->isPost()) {
return $this->fail('请使用 POST 创建标签');
}
$name = $this->request->post('name', '');
if (!is_string($name)) {
return $this->fail('标签名称格式不正确');
}
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->createTag($name)));
}
public function uploadWelcomeMedia()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data((new QywxPromotionMediaService())->upload(
$this->request->file('file'),
(string) $this->request->post('type', ''),
$this->adminId
)));
}
public function overview()
{
@@ -31,8 +66,25 @@ class WecomPromotionController extends BaseAdminController
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$params = $this->request->post();
if ((int) ($params['id'] ?? 0) <= 0 && !$this->hasBasePagePermission()) {
return $this->fail('共享操作人只能编辑已授权方案,不能新建分流方案');
}
return $this->run(fn () => $this->success('分流方案已保存', WecomPromotionLogic::savePool(
$params,
$this->adminId,
$this->adminInfo
)));
}
public function batchUpdatePools()
{
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('分流方案配置已批量更新', WecomPromotionLogic::batchUpdatePools(
$this->request->post(),
$this->adminId,
$this->adminInfo
@@ -52,9 +104,22 @@ class WecomPromotionController extends BaseAdminController
)));
}
public function batchSetOperators()
{
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('方案操作人已批量更新', WecomPromotionLogic::batchSetOperators(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function deletePool()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
@@ -108,7 +173,7 @@ class WecomPromotionController extends BaseAdminController
public function checkApiPermission()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
@@ -117,7 +182,7 @@ class WecomPromotionController extends BaseAdminController
public function syncRemoteLinks()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
$poolId = (int) $this->request->post('pool_id', 0);
@@ -145,7 +210,7 @@ class WecomPromotionController extends BaseAdminController
public function deleteRemoteLink()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
@@ -200,7 +265,7 @@ class WecomPromotionController extends BaseAdminController
public function deleteLink()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
@@ -223,10 +288,11 @@ class WecomPromotionController extends BaseAdminController
private function hasPagePermission(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return QywxPromotionOperatorAccess::hasPagePermission($this->adminId, $this->adminInfo);
}
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
private function hasBasePagePermission(): bool
{
return QywxPromotionOperatorAccess::hasBasePagePermission($this->adminId, $this->adminInfo);
}
}
@@ -6,6 +6,7 @@ namespace app\adminapi\controller\order;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\order\OrderLists;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\order\OrderActionLogLogic;
use app\adminapi\logic\order\OrderLogic;
use app\adminapi\validate\order\OrderValidate;
@@ -17,6 +18,8 @@ use app\adminapi\validate\order\OrderValidate;
*/
class OrderController extends BaseAdminController
{
private const EDIT_TIME_PERMISSION = 'order.order/editTime';
/**
* @notes 订单列表
* @return \think\response\Json
@@ -341,13 +344,21 @@ class OrderController extends BaseAdminController
}
/**
* @notes 编辑订单(关联患者、订单类型)
* @notes 编辑订单(关联患者、订单类型、支付时间、创建时间
* 权限:超管或指定角色组可修改任意订单;其他用户只能修改自己创建的订单
* @return \think\response\Json
*/
public function edit()
{
$params = (new OrderValidate())->post()->goCheck('edit');
$hasTimeParams = array_key_exists('payment_time', $params)
|| array_key_exists('create_time', $params);
if ($hasTimeParams) {
if (!$this->canEditOrderTime()) {
return $this->fail('无权限修改订单支付时间或创建时间');
}
$params = (new OrderValidate())->post()->goCheck('edit_time');
}
$orderId = (int)$params['id'];
$order = \app\common\model\Order::find($orderId);
if (!$order) {
@@ -357,11 +368,15 @@ class OrderController extends BaseAdminController
return $this->fail('无权限修改此订单');
}
$result = OrderLogic::edit($orderId, $params);
$result = OrderLogic::edit($orderId, $params, $hasTimeParams);
if (!$result) {
return $this->fail(OrderLogic::getError());
}
$this->logOrderAction($orderId, 'edit', '编辑患者/订单类型等');
$this->logOrderAction(
$orderId,
'edit',
$hasTimeParams ? '编辑患者/订单类型/支付时间/创建时间等' : '编辑患者/订单类型等'
);
return $this->success('编辑成功');
}
@@ -382,6 +397,18 @@ class OrderController extends BaseAdminController
return (int)$order->creator_id === $this->adminId;
}
/**
* @notes 是否拥有支付单时间修正权限
*/
private function canEditOrderTime(): bool
{
if (!empty($this->adminInfo['root']) && (int)$this->adminInfo['root'] === 1) {
return true;
}
return in_array(self::EDIT_TIME_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
/**
* @notes 支付订单
* @return \think\response\Json
@@ -4,16 +4,19 @@ declare(strict_types=1);
namespace app\adminapi\controller\qywx;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\qywx\CustomerLists;
use app\adminapi\logic\qywx\CustomerLogic;
use app\adminapi\validate\qywx\CustomerValidate;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\qywx\CustomerLists;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\qywx\CustomerLogic;
use app\adminapi\validate\qywx\CustomerValidate;
/**
* 企业微信客户管理控制器
*/
class CustomerController extends BaseAdminController
{
class CustomerController extends BaseAdminController
{
private const DELETE_PERMISSION = 'qywx.customer/delete';
/**
* @notes 客户列表
*/
@@ -25,16 +28,34 @@ class CustomerController extends BaseAdminController
/**
* @notes 同步企业微信客户
*/
public function sync()
{
public function sync()
{
$result = CustomerLogic::triggerBackgroundSync();
if ($result === false) {
return $this->fail(CustomerLogic::getError());
}
$msg = is_array($result) && isset($result['message']) ? (string) $result['message'] : '已提交同步';
return $this->success($msg, $result);
}
return $this->success($msg, $result);
}
/**
* @notes 删除一条本地企业微信客户同步记录
*/
public function delete()
{
// 显式鉴权,避免权限菜单迁移漏执行时被通用中间件当成“未受控 URI”放行。
if (!$this->canDeleteCustomer()) {
return $this->fail('权限不足,无法删除企业微信客户');
}
$params = (new CustomerValidate())->post()->goCheck('delete');
if (!CustomerLogic::deleteCustomer((int) $params['id'])) {
return $this->fail(CustomerLogic::getError());
}
return $this->success('删除成功');
}
/**
* @notes 获取统计信息
@@ -84,13 +105,22 @@ class CustomerController extends BaseAdminController
/**
* @notes 保存同步设置
*/
public function saveSyncSettings()
public function saveSyncSettings()
{
$params = (new CustomerValidate())->post()->goCheck('syncSettings');
$result = CustomerLogic::saveSyncSettings($params);
if ($result === false) {
return $this->fail(CustomerLogic::getError());
}
return $this->success('保存成功');
}
}
return $this->success('保存成功');
}
private function canDeleteCustomer(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return in_array(self::DELETE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
}
@@ -1010,13 +1010,20 @@ class DiagnosisController extends BaseAdminController
}
if ($result === null) {
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
'code' => DiagnosisAiLogic::getAssistantErrorCode(),
'message' => DiagnosisAiLogic::getError(),
]);
} else {
$emit('done', $result);
}
} catch (\Throwable $e) {
\think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode([
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
'profile' => (string) ($prepared['profile'] ?? ''),
'task' => (string) ($prepared['task'] ?? ''),
'admin_id' => (int) ($prepared['admin_id'] ?? 0),
'exception_class' => get_class($e),
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
@@ -425,6 +425,18 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('关联支付单成功', $result);
}
/** 解除单笔收款关联,总金额不变,同步更新已付金额和需代收。 */
public function unlinkPayOrder()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('unlinkPayOrder');
$result = PrescriptionOrderLogic::unlinkPayOrder($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('收款关联已移除,金额已同步更新', $result);
}
/**
* 已发货/已签收:仅提交完单申请(不新增/关联支付单),并重置支付审核为待审核
*/
@@ -16,8 +16,9 @@ declare (strict_types=1);
namespace app\adminapi\http\middleware;
use app\adminapi\logic\LoginLogic;
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
use app\adminapi\logic\LoginLogic;
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
use app\common\service\qywx\QywxPromotionOperatorAccess;
use app\common\{
cache\AdminAuthCache,
service\JsonService
@@ -69,10 +70,23 @@ class AuthMiddleware
$adminAuthCache = new AdminAuthCache($request->adminInfo['admin_id']);
// 当前访问路径
$accessUri = strtolower($request->controller() . '/' . $request->action());
// 全部路由
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
// 当前访问路径
$accessUri = strtolower($request->controller() . '/' . $request->action());
// 获客助手的子接口多数不是独立菜单权限。整组动作统一绑定页面权限,
// 共享操作人的动态页面权限也必须先经过这一层,再由业务层校验具体方案。
if (str_starts_with($accessUri, 'firstvisit.wecompromotion/')) {
$adminUris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
if ($this->isKnownWecomPromotionAction($accessUri)
&& in_array(strtolower(QywxPromotionOperatorAccess::PAGE_PERMISSION), $adminUris, true)) {
return $next($request);
}
return JsonService::fail('权限不足,无法访问或操作');
}
// 全部路由
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
// 判断该当前访问的uri是否存在,不存在无需验证
if (!in_array($accessUri, $allUri, true)
@@ -204,6 +218,32 @@ class AuthMiddleware
'tcm.prescription/audit',
], true);
}
private function isKnownWecomPromotionAction(string $accessUri): bool
{
return in_array($accessUri, [
'firstvisit.wecompromotion/tagoptions',
'firstvisit.wecompromotion/createtag',
'firstvisit.wecompromotion/uploadwelcomemedia',
'firstvisit.wecompromotion/overview',
'firstvisit.wecompromotion/savepool',
'firstvisit.wecompromotion/batchupdatepools',
'firstvisit.wecompromotion/savewidget',
'firstvisit.wecompromotion/batchsetoperators',
'firstvisit.wecompromotion/deletepool',
'firstvisit.wecompromotion/savelink',
'firstvisit.wecompromotion/savemember',
'firstvisit.wecompromotion/togglemember',
'firstvisit.wecompromotion/checkapipermission',
'firstvisit.wecompromotion/syncremotelinks',
'firstvisit.wecompromotion/remotelinkdetail',
'firstvisit.wecompromotion/deleteremotelink',
'firstvisit.wecompromotion/synccustomers',
'firstvisit.wecompromotion/customerstatistics',
'firstvisit.wecompromotion/togglelink',
'firstvisit.wecompromotion/deletelink',
], true);
}
/**
* 处方库 lists:与开方、处方库维护菜单权限互通(避免开方页「从处方库导入」403)
+210 -18
View File
@@ -230,20 +230,49 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]);
}
return false;
}
private function baseQuery()
return false;
}
/**
* 按客户当前跟进关系中的添加方式筛选。
*
* follow_users 由同步逻辑使用 json_encode 写入,匹配数字值及历史字符串值;
* 数字后必须紧跟逗号或对象结束符,避免 add_way=1 误命中 16。
*/
private function applyAddWayFilter($query): void
{
if (!array_key_exists('add_way', $this->params) || $this->params['add_way'] === '') {
return;
}
$addWay = self::normalizeAddWay($this->params['add_way']);
if ($addWay === null) {
$query->whereRaw('1=0');
return;
}
$numberPrefix = '%"add_way":' . $addWay;
$stringPrefix = '%"add_way":"' . $addWay;
$query->where(function ($q) use ($numberPrefix, $stringPrefix) {
$q->where('follow_users', 'like', $numberPrefix . ',%')
->whereOr('follow_users', 'like', $numberPrefix . '}%')
->whereOr('follow_users', 'like', $stringPrefix . '",%')
->whereOr('follow_users', 'like', $stringPrefix . '"}%');
});
}
private function baseQuery()
{
$query = QywxExternalContact::where($this->searchWhere);
// 添加时间可能已按「跟进人+事件流水」收窄;此时不必再 LIKE follow_users
$followAlreadyScoped = $this->applyAddTimeFilter($query);
if (!$followAlreadyScoped) {
$this->applyFollowUserFilter($query);
}
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
if (!$followAlreadyScoped) {
$this->applyFollowUserFilter($query);
}
$this->applyAddWayFilter($query);
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
// 走 zyt_qywx_external_contact_tag.idx_tag 索引,比 LIKE follow_users 快得多
$tagIds = $this->normalizeTagIds();
if ($tagIds !== []) {
@@ -262,6 +291,154 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
return $query;
}
/**
* 批量读取当前页客户的加客渠道流水。
*
* 事件表是一对多关系,不能直接 JOIN 到分页主查询,否则会放大列表行数与 count。
* 同一客户可能被不同员工重复添加,因此保留所有不同的非空 state,并按最近事件排序。
*
* @param string[] $externalUserids
* @return array<string, array<int, array<string, mixed>>>
*/
private function loadAddChannelsByExternalUserid(array $externalUserids): array
{
$ids = [];
foreach ($externalUserids as $externalUserid) {
$externalUserid = trim((string) $externalUserid);
if ($externalUserid !== '') {
$ids[$externalUserid] = true;
}
}
$ids = array_keys($ids);
if ($ids === []) {
return [];
}
$events = Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('state', '<>', '')
->whereIn('external_userid', $ids)
->field(['id', 'external_userid', 'user_id', 'state', 'event_time'])
->order('event_time', 'desc')
->order('id', 'desc')
->select()
->toArray();
$poolIds = [];
foreach ($events as $event) {
$state = trim((string) ($event['state'] ?? ''));
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
$poolIds[(int) $matches[1]] = true;
}
}
$poolNamesById = [];
if ($poolIds !== []) {
// 历史渠道仍应显示已删除方案原来的名称,因此这里不限制 delete_time。
$poolNamesById = Db::name('qywx_promotion_pool')
->whereIn('id', array_keys($poolIds))
->column('name', 'id');
}
return self::projectAddChannelEvents($events, $poolNamesById);
}
/**
* @param array<int, array<string, mixed>> $events 已按 event_time DESC, id DESC 排序
* @param array<int|string, string> $poolNamesById
* @return array<string, array<int, array<string, mixed>>>
*/
private static function projectAddChannelEvents(array $events, array $poolNamesById): array
{
$channelsByExternalUserid = [];
$seen = [];
foreach ($events as $event) {
$externalUserid = trim((string) ($event['external_userid'] ?? ''));
$state = trim((string) ($event['state'] ?? ''));
if ($externalUserid === '' || $state === '' || isset($seen[$externalUserid][$state])) {
continue;
}
$seen[$externalUserid][$state] = true;
$poolId = 0;
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
$poolId = (int) $matches[1];
}
$poolName = $poolId > 0 ? trim((string) ($poolNamesById[$poolId] ?? '')) : '';
$channelsByExternalUserid[$externalUserid][] = [
'state' => $state,
'label' => $poolName !== ''
? $poolName
: ($poolId > 0 ? '获客助手方案 #' . $poolId : $state),
'source_type' => $poolId > 0 ? 'promotion_pool' : 'state',
'pool_id' => $poolId,
'user_id' => trim((string) ($event['user_id'] ?? '')),
'event_time' => (int) ($event['event_time'] ?? 0),
];
}
return $channelsByExternalUserid;
}
/**
* 企业微信客户详情 follow_user.add_way 的可读文案。
*
* add_way 是固定添加方式,state 是企业自定义渠道参数,两者不能混用。
* 未识别的新枚举保留原值,避免后续企微扩展时页面退化成“未记录”。
*/
private static function addWayLabel(int $addWay): string
{
$labels = [
0 => '未知添加方式',
1 => '通过扫描二维码添加',
2 => '通过搜索手机号添加',
3 => '通过名片分享添加',
4 => '通过群聊添加',
5 => '通过手机通讯录添加',
6 => '通过微信联系人添加',
8 => '安装第三方应用时自动添加',
9 => '通过搜索邮箱添加',
10 => '通过视频号添加',
11 => '通过日程参与人添加',
12 => '通过会议参与人添加',
13 => '通过微信好友添加',
14 => '通过智慧硬件专属客服添加',
15 => '通过上门服务客服添加',
16 => '通过获客链接添加',
17 => '通过定制开发添加',
18 => '通过需求回复添加',
21 => '通过第三方售前客服添加',
22 => '通过可能的商务伙伴添加',
24 => '通过接受微信好友申请添加',
201 => '通过内部成员共享添加',
202 => '通过管理员或负责人分配添加',
];
return $labels[$addWay] ?? '其他添加方式(' . $addWay . '';
}
/**
* @param mixed $value
*/
private static function normalizeAddWay($value): ?int
{
if (is_int($value)) {
return $value >= 0 ? $value : null;
}
if (!is_string($value)) {
return null;
}
$value = trim($value);
if ($value === '' || preg_match('/^\d+$/D', $value) !== 1) {
return null;
}
return (int) $value;
}
/**
* @notes 获取列表
*/
@@ -278,7 +455,12 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
->toArray();
$wxUserids = [];
$externalUserids = [];
foreach ($lists as $item) {
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
if ($externalUserid !== '') {
$externalUserids[$externalUserid] = true;
}
$raw = json_decode($item['follow_users'] ?? '[]', true);
if (!is_array($raw)) {
continue;
@@ -298,19 +480,25 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
if ($wxUserids !== []) {
$adminNameByWx = Admin::whereIn('work_wechat_userid', $wxUserids)->column('name', 'work_wechat_userid');
}
$addChannelsByExternalUserid = $this->loadAddChannelsByExternalUserid(array_keys($externalUserids));
foreach ($lists as &$item) {
$followUsers = json_decode($item['follow_users'] ?? '[]', true);
$followUsers = is_array($followUsers) ? $followUsers : [];
foreach ($followUsers as &$fu) {
if (!is_array($fu)) {
continue;
}
$wx = trim((string) ($fu['userid'] ?? ''));
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
$fu['admin_name'] = $adminNameByWx[$wx];
}
}
foreach ($followUsers as &$fu) {
if (!is_array($fu)) {
continue;
}
$wx = trim((string) ($fu['userid'] ?? ''));
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
$fu['admin_name'] = $adminNameByWx[$wx];
}
$addWay = self::normalizeAddWay($fu['add_way'] ?? $fu['AddWay'] ?? null);
if ($addWay !== null) {
$fu['add_way'] = $addWay;
$fu['add_way_label'] = self::addWayLabel($addWay);
}
}
unset($fu);
$item['follow_users'] = $followUsers;
$followAdminIds = json_decode($item['follow_admin_ids'] ?? '[]', true);
@@ -320,6 +508,10 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
$tags = json_decode((string) ($item['tags'] ?? '[]'), true);
$item['tags'] = is_array($tags) ? $tags : [];
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
$item['add_channels'] = $addChannelsByExternalUserid[$externalUserid] ?? [];
$item['add_channel_states'] = array_column($item['add_channels'], 'state');
$fromDb = (int) ($item['external_first_add_time'] ?? 0);
$fromJson = CustomerLogic::minFollowCreatetime($followUsers);
$item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson;
@@ -638,7 +638,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$allOids = array_values(array_unique($allOids));
$amountByOid = [];
if ($allOids !== []) {
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')->column('amount', 'id');
// 与详情已付总额一致:退款记录可展示,但不再计入实付。
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')
->whereIn('status', [2, 5])->column('amount', 'id');
}
foreach ($poIds as $pid) {
$s = 0.0;
@@ -1161,6 +1163,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
->join('order o', 'l.pay_order_id = o.id')
->whereIn('l.prescription_order_id', $poIds)
->whereNull('o.delete_time')
->whereIn('o.status', [2, 5])
->sum('o.amount');
return round($sum, 2);
+21 -6
View File
@@ -18,6 +18,7 @@ use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\common\model\auth\SystemMenu;
use app\common\model\auth\SystemRoleMenu;
use app\common\service\qywx\QywxPromotionOperatorAccess;
/**
@@ -74,11 +75,9 @@ class AuthLogic
->column('perms');
$hasAllAuth = array_diff($allAuth, $roleAuth);
if (empty($hasAllAuth)) {
return ['*'];
}
$permissions = empty($hasAllAuth) ? ['*'] : $roleAuth;
return $roleAuth;
return self::appendSharedPromotionPermission($permissions, (int) ($admin['id'] ?? 0));
}
@@ -94,12 +93,28 @@ class AuthLogic
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
$menuId = SystemRoleMenu::whereIn('role_id', $roleIds)->column('menu_id');
return SystemMenu::distinct(true)
$permissions = SystemMenu::distinct(true)
->where([
['is_disable', '=', 0],
['perms', '<>', ''],
['id', 'in', array_unique($menuId)],
])
->column('perms');
return self::appendSharedPromotionPermission($permissions, $adminId);
}
}
private static function appendSharedPromotionPermission(array $permissions, int $adminId): array
{
if (in_array('*', $permissions, true)
|| in_array(QywxPromotionOperatorAccess::PAGE_PERMISSION, $permissions, true)
|| !QywxPromotionOperatorAccess::hasSharedPagePermission($adminId)) {
return $permissions;
}
$permissions[] = QywxPromotionOperatorAccess::PAGE_PERMISSION;
return array_values(array_unique($permissions));
}
}
+47 -1
View File
@@ -20,6 +20,7 @@ use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\SystemMenu;
use app\common\model\auth\SystemRoleMenu;
use app\common\service\qywx\QywxPromotionOperatorAccess;
/**
@@ -51,6 +52,12 @@ class MenuLogic extends BaseLogic
if ($admin['root'] != 1) {
$roleMenu = SystemRoleMenu::whereIn('role_id', $admin['role_id'])->column('menu_id');
if (QywxPromotionOperatorAccess::hasSharedPagePermission((int) $adminId)) {
$roleMenu = array_values(array_unique(array_merge(
array_map('intval', $roleMenu),
self::sharedPromotionMenuIds()
)));
}
$where[] = ['id', 'in', $roleMenu];
}
@@ -62,6 +69,45 @@ class MenuLogic extends BaseLogic
}
/** @return list<int> */
private static function sharedPromotionMenuIds(): array
{
$menus = SystemMenu::where('is_disable', 0)
->field('id,pid,perms')
->select()
->toArray();
$byId = [];
$pageIds = [];
foreach ($menus as $menu) {
$id = (int) ($menu['id'] ?? 0);
if ($id <= 0) {
continue;
}
$byId[$id] = $menu;
if ((string) ($menu['perms'] ?? '') === QywxPromotionOperatorAccess::PAGE_PERMISSION) {
$pageIds[] = $id;
}
}
$result = [];
foreach ($pageIds as $pageId) {
$chain = [];
$currentId = $pageId;
while ($currentId > 0) {
if (!isset($byId[$currentId])) {
$chain = [];
break;
}
$chain[] = $currentId;
$currentId = (int) ($byId[$currentId]['pid'] ?? 0);
}
$result = array_merge($result, $chain);
}
return array_values(array_unique($result));
}
/**
* @notes 添加菜单
* @param array $params
@@ -181,4 +227,4 @@ class MenuLogic extends BaseLogic
return linear_to_tree($data, 'children');
}
}
}
@@ -144,7 +144,7 @@ class FirstVisitConversionLogic
}
unset($row);
}
return [
return self::withDeletedFansVisibility([
'meta' => [
'time_type' => $timeType,
'time_label' => $timeLabel,
@@ -178,7 +178,7 @@ class FirstVisitConversionLogic
],
'rows' => $rows,
'target' => $target,
];
], $adminInfo);
}
/** @return array<string,mixed> */
@@ -190,14 +190,14 @@ class FirstVisitConversionLogic
);
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
$empty = [
$empty = self::withDeletedFansVisibility([
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'date_range' => [$context['start_date'], $context['end_date']],
'entity' => null,
];
], $adminInfo, true);
$entityType = strtolower(trim((string) ($params['entity_type'] ?? '')));
if (!in_array($entityType, ['dept', 'member'], true)) {
@@ -253,9 +253,54 @@ class FirstVisitConversionLogic
];
unset($result['deleted_count']);
return self::withDeletedFansVisibility($result, $adminInfo, true);
}
/**
* 删除客户统计是账号专属能力,与root、角色、财务权限和DataScope无关。
* adminInfo由认证token缓存提供;缺失账号时拒绝,不能从HTTP参数补齐或标准化账号。
*/
private static function canViewDeletedFans(array $adminInfo): bool
{
return ($adminInfo['account'] ?? null) === 'admin';
}
/**
* 只裁剪本页响应,不改变通用统计口径、加粉客户集合、排序或分页。
* 对整个响应递归处理,避免嵌套成员、排名或未来新增位置泄露同一敏感指标。
*/
private static function withDeletedFansVisibility(array $result, array $adminInfo, bool $detail = false): array
{
$canView = self::canViewDeletedFans($adminInfo);
if (!$canView) {
$fields = $detail
? ['deleted_fans_count', 'deleted_count', 'is_deleted', 'delete_time']
: ['deleted_fans_count', 'deleted_count'];
$result = self::removeDeletedFansFields($result, $fields);
}
if ($detail) {
$result['can_view_deleted_fans'] = $canView;
} else {
$result['meta']['can_view_deleted_fans'] = $canView;
}
return $result;
}
/** @param string[] $fields */
private static function removeDeletedFansFields(array $value, array $fields): array
{
foreach ($fields as $field) {
unset($value[$field]);
}
foreach ($value as &$item) {
if (is_array($item)) {
$item = self::removeDeletedFansFields($item, $fields);
}
}
unset($item);
return $value;
}
/**
* Resolve only the clicked entity and its authorized target range. This is
* deliberately structural: it avoids recomputing all overview metrics,
@@ -6,6 +6,7 @@ namespace app\adminapi\logic\firstvisit;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use app\common\service\qywx\QywxPromotionOperatorAccess;
use RuntimeException;
use think\facade\Db;
@@ -20,7 +21,12 @@ class WecomAcquisitionCustomerLogic
->whereNull('l.delete_time')
->where('l.remote_link_id', '<>', '')
->where('l.remote_status', 1);
self::applyScope($query, 'l', DataScopeService::getVisibleAdminIds($adminId, $adminInfo));
self::applyScope(
$query,
'l',
QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo),
self::operatorPoolIds($adminId)
);
if ($localLinkId > 0) {
$query->where('l.id', $localLinkId);
}
@@ -55,8 +61,10 @@ class WecomAcquisitionCustomerLogic
{
$page = max(1, (int) ($params['page_no'] ?? $params['page'] ?? 1));
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$base = self::customerQuery($params, $visibleIds);
$hasBasePagePermission = QywxPromotionOperatorAccess::hasBasePagePermission($adminId, $adminInfo);
$visibleIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo);
$operatorPoolIds = self::operatorPoolIds($adminId);
$base = self::customerQuery($params, $visibleIds, $operatorPoolIds);
$total = (int) (clone $base)->count();
$rows = $base
->field('c.id,c.promotion_link_id,c.link_id,c.external_userid,c.userid,c.owner_admin_id,c.dept_id,c.state,c.chat_status,c.recv_msg_cnt,c.message_count_known,c.first_acquired_time,c.last_chat_time,c.last_sync_time,c.create_time,c.update_time,a.name as owner_name,d.name as dept_name,l.name as link_name,p.name as pool_name')
@@ -71,7 +79,7 @@ class WecomAcquisitionCustomerLogic
}
unset($row);
$summaryQuery = self::customerQuery($params, $visibleIds);
$summaryQuery = self::customerQuery($params, $visibleIds, $operatorPoolIds);
$summaryRow = $summaryQuery->fieldRaw(
'COUNT(*) AS customer_count, '
. 'COALESCE(SUM(CASE WHEN c.message_count_known = 1 THEN c.recv_msg_cnt ELSE 0 END),0) AS recv_msg_cnt, '
@@ -81,7 +89,9 @@ class WecomAcquisitionCustomerLogic
return [
'meta' => [
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
'scope_label' => $hasBasePagePermission
? DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo))
: '仅共享方案',
'generated_at' => date('Y-m-d H:i:s'),
],
'summary' => [
@@ -98,14 +108,14 @@ class WecomAcquisitionCustomerLogic
];
}
private static function customerQuery(array $params, ?array $visibleIds)
private static function customerQuery(array $params, ?array $visibleIds, array $operatorPoolIds)
{
$query = Db::name('qywx_customer_acquisition_customer')->alias('c')
->leftJoin('admin a', 'a.id = c.owner_admin_id AND a.delete_time IS NULL')
->leftJoin('dept d', 'd.id = c.dept_id')
->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id')
->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id');
self::applyCustomerScope($query, $visibleIds);
self::applyCustomerScope($query, $visibleIds, $operatorPoolIds);
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0));
if ($localLinkId > 0) {
$query->where('c.promotion_link_id', $localLinkId);
@@ -131,35 +141,58 @@ class WecomAcquisitionCustomerLogic
* 企微 customer_list 的 state/customer_channel 允许为空,不能因此丢掉已返回的客户;
* 同时也不能直接放开全部 owner_admin_id=0 的记录,否则会跨部门泄露未映射客户。
*/
private static function applyCustomerScope($query, ?array $visibleIds): void
private static function applyCustomerScope($query, ?array $visibleIds, array $operatorPoolIds): void
{
if ($visibleIds === null) {
return;
}
if ($visibleIds === []) {
if ($visibleIds === [] && $operatorPoolIds === []) {
$query->whereRaw('1 = 0');
return;
}
$ids = array_values(array_unique(array_map('intval', $visibleIds)));
$query->where(function ($scope) use ($ids): void {
$scope->whereIn('c.owner_admin_id', $ids)
->whereOr(function ($unmapped) use ($ids): void {
$unmapped->where('c.owner_admin_id', 0)
->whereIn('l.owner_admin_id', $ids);
});
$query->where(function ($scope) use ($ids, $operatorPoolIds): void {
if ($ids !== []) {
$scope->whereIn('c.owner_admin_id', $ids)
->whereOr(function ($unmapped) use ($ids): void {
$unmapped->where('c.owner_admin_id', 0)
->whereIn('l.owner_admin_id', $ids);
});
if ($operatorPoolIds !== []) {
$scope->whereOr('p.id', 'in', $operatorPoolIds);
}
return;
}
$scope->whereIn('p.id', $operatorPoolIds);
});
}
private static function applyScope($query, string $alias, ?array $visibleIds): void
private static function applyScope($query, string $alias, ?array $visibleIds, array $operatorPoolIds): void
{
if ($visibleIds === null) {
return;
}
if ($visibleIds === []) {
if ($visibleIds === [] && $operatorPoolIds === []) {
$query->whereRaw('1 = 0');
return;
}
$query->whereIn($alias . '.owner_admin_id', array_values(array_unique(array_map('intval', $visibleIds))));
$ids = array_values(array_unique(array_map('intval', $visibleIds)));
$query->where(function ($scope) use ($alias, $ids, $operatorPoolIds): void {
if ($ids !== []) {
$scope->whereIn($alias . '.owner_admin_id', $ids);
if ($operatorPoolIds !== []) {
$scope->whereOr($alias . '.pool_id', 'in', $operatorPoolIds);
}
return;
}
$scope->whereIn($alias . '.pool_id', $operatorPoolIds);
});
}
/** @return list<int> */
private static function operatorPoolIds(int $adminId): array
{
return QywxPromotionOperatorAccess::activePoolIds($adminId);
}
private static function maskIdentifier(string $value): string
File diff suppressed because it is too large Load Diff
+41 -1
View File
@@ -357,9 +357,10 @@ class OrderLogic
* @notes 编辑订单
* @param int $id
* @param array $params
* @param bool $canEditTime 是否已通过订单时间修改权限校验
* @return bool
*/
public static function edit(int $id, array $params): bool
public static function edit(int $id, array $params, bool $canEditTime = false): bool
{
try {
$order = Order::find($id);
@@ -368,6 +369,13 @@ class OrderLogic
return false;
}
$hasTimeParams = array_key_exists('payment_time', $params)
|| array_key_exists('create_time', $params);
if ($hasTimeParams && !$canEditTime) {
self::setError('无权限修改订单支付时间或创建时间');
return false;
}
if (isset($params['remark'])) {
$order->remark = $params['remark'];
}
@@ -379,6 +387,26 @@ class OrderLogic
if (isset($params['order_type'])) {
$order->order_type = (int)$params['order_type'];
}
// 支付时间参与营业额等统计,仅已支付/已退款订单允许修正且不可清空
if (array_key_exists('payment_time', $params)) {
$paymentTime = trim((string)$params['payment_time']);
if (!in_array((int)$order->status, [2, 4], true)) {
self::setError('仅已支付或已退款订单可修改支付时间');
return false;
}
if ($paymentTime === '') {
self::setError('已支付或已退款订单的支付时间不能为空');
return false;
}
$order->payment_time = $paymentTime;
}
if (isset($params['create_time'])) {
$createTime = (string)$params['create_time'];
$order->create_time = self::normalizeEditedCreateTime(
$order->getData('create_time'),
$createTime
);
}
$order->save();
return true;
@@ -388,6 +416,18 @@ class OrderLogic
}
}
/**
* 兼容历史库中 create_time 为 INT 时间戳的表结构
*/
private static function normalizeEditedCreateTime(mixed $storedValue, string $dateTime): int|string
{
if (is_int($storedValue) || (is_string($storedValue) && ctype_digit($storedValue))) {
return (int)strtotime($dateTime);
}
return $dateTime;
}
/**
* @notes 支付订单
* @param int $id
@@ -9,6 +9,7 @@ use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use app\common\model\QywxSyncSettings;
use app\common\service\qywx\MediaChannelService;
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
use app\common\service\wechat\WechatWorkService;
use think\facade\Cache;
use think\facade\Db;
@@ -589,7 +590,11 @@ class CustomerLogic extends BaseLogic
*
* @see https://developer.work.weixin.qq.com/document/path/92130
*/
public static function upsertSingleExternalContactFromApi(string $externalUserId): void
public static function upsertSingleExternalContactFromApi(
string $externalUserId,
int $snapshotEventId = 0,
string $snapshotFollowUserId = ''
): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
@@ -652,6 +657,13 @@ class CustomerLogic extends BaseLogic
$updateCount,
$skippedCount
);
if ($snapshotEventId > 0 && $snapshotFollowUserId !== '') {
QywxExternalContactEventTagSnapshotService::captureFromFollowUsers(
$snapshotEventId,
$snapshotFollowUserId,
$followUsers
);
}
MediaChannelService::forgetCurrentTagCatalogCache();
}
@@ -674,12 +686,12 @@ class CustomerLogic extends BaseLogic
*
* @param array<string, mixed> $data
*/
public static function recordExternalContactEvent(array $data): void
public static function recordExternalContactEvent(array $data): int
{
$changeType = (string) ($data['change_type'] ?? '');
if ($changeType === '') {
// 没有 ChangeType 的事件流水没有价值,直接丢弃
return;
return 0;
}
$eventTime = (int) ($data['event_time'] ?? 0);
@@ -716,9 +728,85 @@ class CustomerLogic extends BaseLogic
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $cols) . '`) VALUES ('
. implode(',', array_fill(0, count($cols), '?')) . ')';
Db::execute($sql, array_values($row));
return (int) Db::name('qywx_external_contact_event')
->where('change_type', $row['change_type'])
->where('user_id', $row['user_id'])
->where('external_userid', $row['external_userid'])
->where('event_time', $row['event_time'])
->value('id');
} catch (\Throwable $e) {
// 事件流水只用于统计,失败只记日志不阻塞主回调
Log::warning('qywx external contact event insert failed: ' . $e->getMessage());
return 0;
}
}
/**
* 后台手工删除一条本地同步记录。
*
* 仅按列表行主键软删除,不调用企业微信删除客户关系;兼容历史库中可能存在的重复
* external_userid。只有该客户已无其他有效行时才清理共享的标签关系。
*/
public static function deleteCustomer(int $id): bool
{
if ($id <= 0) {
self::$error = '客户参数错误';
return false;
}
try {
$externalUserId = Db::transaction(static function () use ($id): string {
$row = Db::name('qywx_external_contact')
->where('id', $id)
->whereNull('delete_time')
->lock(true)
->find();
if (!$row) {
throw new \DomainException('客户不存在或已删除');
}
$now = time();
Db::name('qywx_external_contact')
->where('id', $id)
->whereNull('delete_time')
->update([
'delete_time' => $now,
'update_time' => $now,
]);
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
if ($externalUserId !== '') {
$activeRows = (int) Db::name('qywx_external_contact')
->where('external_userid', $externalUserId)
->whereNull('delete_time')
->count();
if ($activeRows === 0) {
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
}
}
return $externalUserId;
});
if ($externalUserId !== '') {
MediaChannelService::forgetCurrentTagCatalogCache();
}
return true;
} catch (\DomainException $e) {
self::$error = $e->getMessage();
return false;
} catch (\Throwable $e) {
Log::error('后台删除企业微信客户同步记录失败: ' . $e->getMessage());
self::$error = '删除失败,请稍后重试';
return false;
}
}
@@ -340,8 +340,8 @@ class ConversionLogic
return $result;
}
/**
* Return the distinct external contacts behind an add_fans_count row.
/**
* Return the add events behind an add_fans_count row.
*
* The caller must pass the already-authorized admin range and the exact
* department ids represented by the clicked tree node. This keeps the
@@ -505,7 +505,7 @@ class ConversionLogic
return $empty;
}
$pairs = self::loadFanDetailRows(
$pairs = self::loadFanDetailRows(
$startTimestamp,
$endTimestamp,
$mediaChannel,
@@ -515,15 +515,20 @@ class ConversionLogic
);
// Target membership has already been resolved before the event query,
// so sorting and pagination operate on the clicked row's small set.
$matched = $pairs;
$matched = $pairs;
usort($matched, static function (array $left, array $right): int {
$timeCompare = ((int) ($right['add_time'] ?? 0)) <=> ((int) ($left['add_time'] ?? 0));
if ($timeCompare !== 0) {
return $timeCompare;
}
return strcmp(
if ($timeCompare !== 0) {
return $timeCompare;
}
$eventCompare = ((int) ($right['add_event_id'] ?? 0)) <=> ((int) ($left['add_event_id'] ?? 0));
if ($eventCompare !== 0) {
return $eventCompare;
}
return strcmp(
(string) ($left['external_userid'] ?? ''),
(string) ($right['external_userid'] ?? '')
) ?: strcmp(
@@ -552,45 +557,45 @@ class ConversionLogic
$pageRows,
static fn (array $row): bool => !empty($row['is_deleted'])
));
$deleteTimesByPair = [];
if ($deletedPageRows !== []) {
$deletedUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
$deletedPageRows
))));
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
$deletedPageRows
))));
$deletedEvents = Db::name('qywx_external_contact_event')
->where('change_type', 'del_external_contact')
->where('event_time', '<=', $endTimestamp)
->whereIn('user_id', $deletedUserIds)
->whereIn('external_userid', $deletedExternalUserIds)
->field('user_id,external_userid,MAX(event_time) AS delete_time')
->group('user_id,external_userid')
->select()
->toArray();
foreach ($deletedEvents as $deletedEvent) {
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
. "\0"
. trim((string) ($deletedEvent['external_userid'] ?? ''));
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
}
}
$deleteTimesByPair = [];
if ($deletedPageRows !== []) {
$deletedUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
$deletedPageRows
))));
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
$deletedPageRows
))));
$deletedEvents = Db::name('qywx_external_contact_event')
->where('change_type', 'del_external_contact')
->where('event_time', '<=', $endTimestamp)
->whereIn('user_id', $deletedUserIds)
->whereIn('external_userid', $deletedExternalUserIds)
->field('user_id,external_userid,MAX(event_time) AS delete_time')
->group('user_id,external_userid')
->select()
->toArray();
foreach ($deletedEvents as $deletedEvent) {
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
. "\0"
. trim((string) ($deletedEvent['external_userid'] ?? ''));
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
}
}
$staffNames = self::resolveQywxUserNames(array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
$pageRows
)))));
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
$addTime = max(0, (int) ($row['add_time'] ?? 0));
$deleted = !empty($row['is_deleted']);
$deleteTime = $deleted
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
: 0;
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
$addTime = max(0, (int) ($row['add_time'] ?? 0));
$deleted = !empty($row['is_deleted']);
$deleteTime = $deleted
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
: 0;
if ($deleteTime < $addTime) {
$deleteTime = 0;
}
@@ -606,18 +611,18 @@ class ConversionLogic
];
}, $pageRows);
return [
'lists' => $lists,
'count' => $count,
'deleted_count' => $deletedCount,
return [
'lists' => $lists,
'count' => $count,
'deleted_count' => $deletedCount,
'page_no' => $pageNo,
'page_size' => $pageSize,
'date_range' => [$startDate, $endDate],
];
}
/**
* Department scope imposed by an actively maintained media-channel cost
'date_range' => [$startDate, $endDate],
];
}
/**
* Department scope imposed by an actively maintained media-channel cost
* binding. null means the channel has no binding and therefore does not
* restrict business statistics.
*
@@ -1262,17 +1267,17 @@ class ConversionLogic
}
}
/**
* 区间新增加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数)
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计)
/**
* 区间新增客户关系:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径:
* - (user_id, external_userid) 作为唯一组合,同一员工的同一客户只计一次
* - 同一客户添加到不同员工名下时,因 user_id 不同,每名员工分别计一次;
* - 同一组合在区间开始前已经产生过 add_external_contact 时,不再算区间新增
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除,仍计入加粉总数,
* 并额外计入已删提示子集;不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 组合在统计结束时已删除,仍计入加粉总数,并额外计入已删提示子集;
* 不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
*
@@ -1285,25 +1290,21 @@ class ConversionLogic
int $endTimestamp,
?array $mediaChannel,
?array $adminIds = null
): array {
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
$effectivePairs = array_values(array_filter(
$detailRows,
static fn (array $row): bool => empty($row['is_deleted'])
));
return self::buildFanCountRows($detailRows, $effectivePairs);
}
/**
* Load the exact distinct (user_id, external_userid) pairs represented by
* add_fans_count. Both aggregate statistics and the detail endpoint consume
* this method, so channel, add-way, prior-add and deletion rules cannot
* drift apart.
): array {
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
return self::buildFanCountRows($detailRows);
}
/**
* Load the exact distinct (user_id, external_userid) pairs represented by
* add_fans_count. Both aggregate statistics and the detail endpoint consume
* this method, so channel, add-way, prior-add and deletion rules cannot
* drift apart.
*
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/
private static function loadFanDetailRows(
int $startTimestamp,
@@ -1335,7 +1336,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-detail-v1', [
$baseKey = self::requestRowsCacheKey('fans-detail-v3-employee-customer', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1379,23 +1380,56 @@ class ConversionLogic
}
}
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
. ' WHERE prev_e.user_id = e.user_id'
. ' AND prev_e.external_userid = e.external_userid'
. ' AND prev_e.change_type = ?'
. ' AND prev_e.event_time < ?)',
['add_external_contact', $startTimestamp]
)
->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time')
->group('e.user_id, e.external_userid');
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
$livePairPredicate =
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time <= ?'
. ' AND (surviving_del.event_time > surviving_e.event_time'
. ' OR (surviving_del.event_time = surviving_e.event_time'
. ' AND surviving_del.id > surviving_e.id))))';
$livePairBindings = [
'add_external_contact',
$startTimestamp,
$endTimestamp,
'del_external_contact',
$endTimestamp,
];
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
. ' WHERE prev_e.user_id = e.user_id'
. ' AND prev_e.external_userid = e.external_userid'
. ' AND prev_e.change_type = ?'
. ' AND prev_e.event_time < ?)',
['add_external_contact', $startTimestamp]
)
// 同一员工+客户只保留区间内最早事件;不同员工拥有不同 user_id,会分别保留。
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` earlier_e'
. ' WHERE earlier_e.user_id = e.user_id'
. ' AND earlier_e.external_userid = e.external_userid'
. ' AND earlier_e.change_type = ?'
. ' AND earlier_e.event_time >= ?'
. ' AND earlier_e.event_time <= ?'
. ' AND (earlier_e.event_time < e.event_time'
. ' OR (earlier_e.event_time = e.event_time AND earlier_e.id < e.id)))',
['add_external_contact', $startTimestamp, $endTimestamp]
)
->field('e.id AS add_event_id,e.user_id,e.external_userid,e.event_time AS add_time');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
} elseif ($unboundOnly) {
@@ -1406,58 +1440,49 @@ class ConversionLogic
. ' AND bound_a.delete_time IS NULL)'
);
}
$effectiveQuery = clone $query;
if ($mediaChannel !== null) {
// 有效加粉保持原口径:只按未删客户的当前标签/渠道关系筛选。
MediaChannelService::applyExternalUserChannelFilter($effectiveQuery, 'e.external_userid', $mediaChannel);
}
$effectivePairs = $effectiveQuery
->whereRaw(
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time >= surviving_e.event_time'
. ' AND surviving_del.event_time <= ?))',
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
)
->select()
->toArray();
$deletedQuery = clone $query;
if ($mediaChannel !== null) {
// del_external_contact 会软删 contact 并清掉关系表,已删归属改用保留的 follow_users 快照。
MediaChannelService::applyHistoricalExternalUserChannelFilter($deletedQuery, 'e.external_userid', $mediaChannel);
}
$deletedPairs = $deletedQuery
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time >= surviving_e.event_time'
. ' AND surviving_del.event_time <= ?))',
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
)
->select()
->toArray();
// add_way 筛选对有效与已删粉丝使用同一口径。
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
$rows = self::buildFanDetailRows($candidatePairs, $effectivePairs);
$effectiveQuery = clone $query;
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserEventChannelFilter(
$effectiveQuery,
'e.id',
'e.external_userid',
'e.user_id',
$mediaChannel
);
}
$effectivePairs = $effectiveQuery
->whereRaw($livePairPredicate, $livePairBindings)
->select()
->toArray();
foreach ($effectivePairs as &$effectivePair) {
$effectivePair['is_deleted'] = false;
}
unset($effectivePair);
$deletedQuery = clone $query;
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserEventChannelFilter(
$deletedQuery,
'e.id',
'e.external_userid',
'e.user_id',
$mediaChannel,
true
);
}
$deletedPairs = $deletedQuery
->whereRaw('NOT (' . $livePairPredicate . ')', $livePairBindings)
->select()
->toArray();
foreach ($deletedPairs as &$deletedPair) {
$deletedPair['is_deleted'] = true;
}
unset($deletedPair);
// add_way 筛选对有效与已删组合使用同一口径。
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
$rows = self::buildFanDetailRows($candidatePairs);
self::$requestRowsCache[$cacheKey] = $rows;
@@ -1465,97 +1490,69 @@ class ConversionLogic
}
/**
* @param array<int,array<string,mixed>> $candidatePairs
* @param array<int,array<string,mixed>> $effectivePairs
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/
private static function buildFanDetailRows(
array $candidatePairs,
array $effectivePairs
): array {
$effectiveKeys = [];
foreach ($effectivePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId !== '' && $externalUserId !== '') {
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
}
}
$detailsByKey = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
$addTime = max(0, (int) ($pair['add_time'] ?? 0));
if (isset($detailsByKey[$key])) {
$existingTime = (int) ($detailsByKey[$key]['add_time'] ?? 0);
if ($addTime > 0 && ($existingTime <= 0 || $addTime < $existingTime)) {
$detailsByKey[$key]['add_time'] = $addTime;
}
continue;
}
$detailsByKey[$key] = [
'user_id' => $userId,
'external_userid' => $externalUserId,
'add_time' => $addTime,
'is_deleted' => !isset($effectiveKeys[$key]),
'delete_time' => 0,
];
}
return array_values($detailsByKey);
}
/**
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。
*
* 候选对已经过“区间前未添加”、渠道与 add_way 规则,每个去重候选对都计入
* add_fans_count;有效对是期末未删除的子集,因此候选对与有效对的差集另计入
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
* 若区间内删除后又重加且期末仍有效,该对仍在有效子集中,不会误计为已删。
*
* @param array<int, array<string, mixed>> $candidatePairs
* @param array<int, array<string, mixed>> $effectivePairs
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
*/
private static function buildFanCountRows(array $candidatePairs, array $effectivePairs): array
{
$effectiveKeys = [];
foreach ($effectivePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
}
$countsByUser = [];
$seenCandidateKeys = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($seenCandidateKeys[$key])) {
continue;
}
$seenCandidateKeys[$key] = true;
$countsByUser[$userId] ??= [
* @param array<int,array<string,mixed>> $candidatePairs
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/
private static function buildFanDetailRows(array $candidatePairs): array
{
$detailsByPair = [];
foreach ($candidatePairs as $pair) {
$eventId = max(0, (int) ($pair['add_event_id'] ?? 0));
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($eventId <= 0 || $userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($detailsByPair[$key])) {
continue;
}
$detailsByPair[$key] = [
'add_event_id' => $eventId,
'user_id' => $userId,
'external_userid' => $externalUserId,
'add_time' => max(0, (int) ($pair['add_time'] ?? 0)),
'is_deleted' => !empty($pair['is_deleted']),
'delete_time' => 0,
];
}
return array_values($detailsByPair);
}
/**
* 按员工聚合全部新增员工+客户组合,并标记其中期末已删除的子集。
*
* 每个不同 (user_id, external_userid) 都计入 add_fans_countis_deleted=true 的组合同时计入
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
*
* @param array<int, array<string, mixed>> $candidatePairs
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
*/
private static function buildFanCountRows(array $candidatePairs): array
{
$countsByUser = [];
$seenPairs = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($seenPairs[$key])) {
continue;
}
$seenPairs[$key] = true;
$countsByUser[$userId] ??= [
'user_id' => $userId,
'add_fans_count' => 0,
'deleted_fans_count' => 0,
];
++$countsByUser[$userId]['add_fans_count'];
if (!isset($effectiveKeys[$key])) {
++$countsByUser[$userId]['deleted_fans_count'];
}
];
++$countsByUser[$userId]['add_fans_count'];
if (!empty($pair['is_deleted'])) {
++$countsByUser[$userId]['deleted_fans_count'];
}
}
return array_values($countsByUser);
@@ -1569,25 +1566,25 @@ class ConversionLogic
* - add_way=201/202 继承/分配(内部成员共享、管理员/负责人分配,含在职/离职继承)。
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
*
* @param array<int, array<string, mixed>> $pairs
* @return array<int, array<string, mixed>>
*/
private static function excludeUncountedFanPairs(array $pairs): array
{
if ($pairs === []) {
return [];
}
$externalUserIds = [];
foreach ($pairs as $pair) {
$extId = trim((string) ($pair['external_userid'] ?? ''));
* @param array<int, array<string, mixed>> $pairs
* @return array<int, array<string, mixed>>
*/
private static function excludeUncountedFanPairs(array $pairs): array
{
if ($pairs === []) {
return [];
}
$externalUserIds = [];
foreach ($pairs as $pair) {
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($extId !== '') {
$externalUserIds[$extId] = true;
}
}
$externalIdList = array_keys($externalUserIds);
if ($externalIdList === []) {
return $pairs;
$externalIdList = array_keys($externalUserIds);
if ($externalIdList === []) {
return $pairs;
}
/** @var array<string, true> $excludedKeys user_id\0external_userid */
@@ -1628,21 +1625,21 @@ class ConversionLogic
}
}
if ($excludedKeys === []) {
return $pairs;
}
$kept = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($excludedKeys === []) {
return $pairs;
}
$kept = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $extId === '') {
continue;
}
if (isset($excludedKeys[$userId . "\0" . $extId])) {
continue;
}
$kept[] = $pair;
$kept[] = $pair;
}
return $kept;
@@ -18,6 +18,9 @@ use think\facade\Log;
*/
class DiagnosisAiLogic extends BaseLogic
{
/** @var string Safe machine-readable code for the current assistant request. */
private static $assistantErrorCode = 'AI_ASSISTANT_FAILED';
private const PROMPT_VERSION = 'patient-context-case-explain-v2';
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-v2';
@@ -354,6 +357,7 @@ class DiagnosisAiLogic extends BaseLogic
int $adminId,
array $adminInfo
): ?array {
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
$diagnosis = self::loadAuthorizedDiagnosis(
$diagnosisId,
$adminId,
@@ -433,6 +437,14 @@ class DiagnosisAiLogic extends BaseLogic
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
$profile = (string) ($prepared['profile'] ?? '');
$adminId = (int) ($prepared['admin_id'] ?? 0);
$deliveredDelta = false;
$forwardDelta = static function (string $delta) use (&$deliveredDelta, $onDelta) {
$accepted = $onDelta($delta);
if ($accepted !== false) {
$deliveredDelta = true;
}
return $accepted;
};
try {
$result = DifyChatService::streamChat(
@@ -440,7 +452,7 @@ class DiagnosisAiLogic extends BaseLogic
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
$onDelta,
$forwardDelta,
$shouldAbort,
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
);
@@ -452,6 +464,7 @@ class DiagnosisAiLogic extends BaseLogic
$e,
(string) ($prepared['task'] ?? '')
);
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
@@ -464,6 +477,47 @@ class DiagnosisAiLogic extends BaseLogic
(string) ($prepared['task'] ?? ''),
is_array($result) ? $result : []
);
// Some Dify-compatible gateways accept blocking chat but reject or
// incompletely terminate streaming responses. Before any delta has
// reached the doctor it is safe to make one blocking compatibility
// attempt; after a delta, retrying could duplicate clinical text.
$streamErrorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
if (
!$deliveredDelta
&& in_array(
$streamErrorCode,
['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE'],
true
)
) {
try {
$result = DifyChatService::chat(
$profile,
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
);
} catch (\Throwable $e) {
self::logAssistantFailure(
$diagnosisId,
$profile,
$adminId,
$e,
(string) ($prepared['task'] ?? '')
);
}
if (empty($result['ok'])) {
self::logAssistantUpstreamError(
$diagnosisId,
$profile,
$adminId,
(string) ($prepared['task'] ?? ''),
is_array($result) ? $result : []
);
}
}
}
return self::formatAssistantResult($prepared, $result);
@@ -480,6 +534,7 @@ class DiagnosisAiLogic extends BaseLogic
// 附带上游错误码,让医生反馈时管理员能直接定位是配置、体积还是上游拒绝。
$message = (string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试');
$errorCode = trim((string) ($result['error_code'] ?? ''));
self::$assistantErrorCode = self::normaliseAssistantErrorCode($errorCode);
if ($errorCode !== '') {
$message .= '' . $errorCode . '';
}
@@ -488,6 +543,7 @@ class DiagnosisAiLogic extends BaseLogic
}
$content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true);
if ($content === '') {
self::$assistantErrorCode = 'EMPTY_RESPONSE';
self::setError('AI 助手未返回内容,请重试');
return null;
}
@@ -520,6 +576,20 @@ class DiagnosisAiLogic extends BaseLogic
return $payload;
}
/** Return a safe code for the current SSE terminal error event. */
public static function getAssistantErrorCode(): string
{
return self::normaliseAssistantErrorCode(self::$assistantErrorCode);
}
private static function normaliseAssistantErrorCode(string $code): string
{
$code = strtoupper(trim($code));
return preg_match('/^[A-Z][A-Z0-9_]{2,63}$/', $code) === 1
? $code
: 'AI_ASSISTANT_FAILED';
}
/** @return array<string,mixed>|null */
private static function parsePrescriptionDraft(string $content): ?array
{
@@ -621,14 +691,13 @@ class DiagnosisAiLogic extends BaseLogic
\Throwable $exception,
string $task = ''
): void {
Log::warning('diagnosis ai assistant upstream call failed', [
Log::warning('diagnosis ai assistant upstream call failed ' . json_encode([
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'task' => $task,
'admin_id' => $adminId,
'exception_class' => get_class($exception),
'exception_message' => $exception->getMessage(),
]);
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
}
/**
@@ -647,15 +716,15 @@ class DiagnosisAiLogic extends BaseLogic
$errorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
$errorMessage = trim((string) ($result['error'] ?? ''));
$latencyMs = (int) ($result['latency_ms'] ?? 0);
Log::warning('diagnosis ai assistant upstream rejected', [
Log::warning('diagnosis ai assistant upstream rejected ' . json_encode([
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'task' => $task,
'admin_id' => $adminId,
'upstream_error_code' => $errorCode !== '' ? $errorCode : 'UNKNOWN',
'upstream_error' => $errorMessage,
'has_upstream_error' => $errorMessage !== '',
'latency_ms' => $latencyMs,
]);
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
}
/**
@@ -799,6 +799,31 @@ class PrescriptionOrderLogic
return null;
}
/** 收款关联增删共用订单行锁,失败时连同金额和日志一起回滚。 */
private static function mutatePayOrderLinks(int $id, callable $mutation)
{
self::$error = '';
try {
return Db::transaction(static function () use ($id, $mutation) {
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->lock(true)->find();
if (!$order) {
throw new \DomainException('订单不存在');
}
$result = $mutation();
if ($result === false) {
throw new \RuntimeException(self::$error ?: '收款关联变更失败');
}
return $result;
});
} catch (\Throwable $e) {
if (self::$error === '') {
self::$error = $e->getMessage();
}
return false;
}
}
/**
* @param int[] $payOrderIds
*/
@@ -2729,6 +2754,14 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function addPayOrder(array $params, int $adminId, array $adminInfo)
{
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::addPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function addPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) $params['id'];
@@ -2833,6 +2866,14 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function linkPayOrder(array $params, int $adminId, array $adminInfo)
{
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::linkPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function linkPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) $params['id'];
@@ -2938,6 +2979,88 @@ class PrescriptionOrderLogic
return $out;
}
/**
* 移除单笔收款关联:保留原支付单、总金额与履约/审核状态,同步已付及代收金额。
* 普通订单 paid 按剩余有效收款重算;退款订单的 paid 保留退款后的余额口径。
*/
public static function unlinkPayOrder(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
// 显式校验独立权限,菜单迁移未执行时也不能绕过鉴权中间件的默认放行逻辑。
if ((int) ($adminInfo['root'] ?? 0) !== 1
&& !in_array('tcm.prescriptionOrder/unlinkPayOrder', AuthLogic::getAuthByAdminId($adminId), true)) {
self::$error = '无权限移除收款关联';
return false;
}
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::unlinkPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function unlinkPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
$id = (int) $params['id'];
$payOrderId = (int) ($params['pay_order_id'] ?? 0);
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作此订单';
return false;
}
if (in_array((int) $order->fulfillment_status, [3, 4], true)) {
self::$error = '已完成或已取消的订单不允许移除收款关联';
return false;
}
if (!in_array($payOrderId, self::linkedPayOrderIdList($id), true)) {
self::$error = '该收款记录未关联当前订单,请刷新后重试';
return false;
}
$payOrder = Order::where('id', $payOrderId)->whereNull('delete_time')->lock(true)->find();
if (!$payOrder || !in_array((int) $payOrder->status, [2, 5], true)) {
self::$error = '仅已支付或待审核的收款记录可移除,已退款记录不可移除';
return false;
}
$oldPaidCents = (int) round((float) $order->paid * 100);
$removedCents = (int) round((float) $payOrder->amount * 100);
if ($removedCents < 0) {
self::$error = '收款金额异常,请先核对金额';
return false;
}
$deleted = PrescriptionOrderPayOrder::where('prescription_order_id', $id)
->where('pay_order_id', $payOrderId)->delete();
if ($deleted !== 1) {
throw new \RuntimeException('收款关联已变化,请刷新后重试');
}
$remainingIds = self::linkedPayOrderIdList($id);
$remainingPaidCents = $remainingIds === [] ? 0 : (int) round((float) Order::whereIn('id', $remainingIds)
->whereNull('delete_time')->whereIn('status', [2, 5])->sum('amount') * 100);
$order->linked_pay_order_id = $remainingIds[0] ?? null;
// 部分退款可能未拆分收款单金额,不能用剩余收款原额覆盖退款后的 paid 余额。
$hasRefund = (float) ($order->refund_amount ?? 0) > 0 || (int) $order->fulfillment_status === 10;
$newPaidCents = $hasRefund
? min($remainingPaidCents, max(0, $oldPaidCents - $removedCents))
: $remainingPaidCents;
$order->paid = $newPaidCents / 100;
// 与详情的关联已付总额口径一致;订单 amount 不变。
$order->agency_collect_amount = round((float) $order->amount - $remainingPaidCents / 100, 2);
$order->save();
self::writeLog($id, $adminId, $adminInfo, 'unlink_pay_order', sprintf(
'移除收款关联 #%d(%s,¥%.2f),订单总金额 ¥%.2f 不变;已付金额(paid)¥%.2f → ¥%.2f;原收款记录保留',
$payOrderId, (string) $payOrder->order_no, $removedCents / 100,
(float) $order->amount, $oldPaidCents / 100, $newPaidCents / 100
), true);
$out = PrescriptionOrder::where('id', $id)->find()->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* 已发货/已签收订单:不新增/关联支付单,仅提交完单申请并将支付审核置为待审核。
*
@@ -5484,7 +5607,7 @@ class PrescriptionOrderLogic
$log->save();
} catch (\Throwable $e) {
if ($strict) {
throw new \RuntimeException('操作日志写入失败,快递信息未保存', 0, $e);
throw new \RuntimeException('操作日志写入失败,变更未保存', 0, $e);
}
// 非关键日志沿用历史容错行为
}
@@ -28,6 +28,8 @@ class OrderValidate extends BaseValidate
'assistant_id' => 'require|integer|gt:0',
'amounts' => 'require|array',
'order_types' => 'require|array',
'payment_time' => 'dateFormat:Y-m-d H:i:s',
'create_time' => 'require|dateFormat:Y-m-d H:i:s',
];
protected $message = [
@@ -38,11 +40,15 @@ class OrderValidate extends BaseValidate
'status.require' => '订单状态必填',
'status.in' => '订单状态不正确',
'payment_method.in' => '支付方式不正确',
'payment_time.dateFormat' => '支付时间格式不正确',
'create_time.require' => '创建时间必填',
'create_time.dateFormat' => '创建时间格式不正确',
];
protected $scene = [
'create' => ['patient_id', 'order_type', 'amount'],
'edit' => ['id'],
'edit' => ['id', 'patient_id', 'order_type', 'remark'],
'edit_time' => ['id', 'payment_time', 'create_time'],
'detail' => ['id'],
'pay' => ['payment_method'],
'cancel' => ['id'],
@@ -12,11 +12,15 @@ use app\common\validate\BaseValidate;
class CustomerValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|integer|gt:0',
'auto_sync' => 'require|boolean',
'interval' => 'require|integer|between:3600,86400',
];
protected $message = [
'id.require' => '请选择要删除的客户',
'id.integer' => '客户参数格式错误',
'id.gt' => '客户参数格式错误',
'auto_sync.require' => '请选择是否自动同步',
'auto_sync.boolean' => '自动同步参数格式错误',
'interval.require' => '请选择同步间隔',
@@ -31,4 +35,12 @@ class CustomerValidate extends BaseValidate
{
return $this->only(['auto_sync', 'interval']);
}
/**
* @notes 删除客户场景
*/
public function sceneDelete()
{
return $this->only(['id']);
}
}
@@ -88,6 +88,7 @@ class PrescriptionOrderValidate extends BaseValidate
'paidPayOrders' => ['diagnosis_id'],
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
'linkPayOrder' => ['id', 'pay_order_id'],
'unlinkPayOrder' => ['id', 'pay_order_id'],
'requestCompletion' => ['id'],
'complete' => ['id', 'fulfillment_status'],
'refund' => ['id', 'reason', 'refund_amount'],
@@ -101,6 +102,13 @@ class PrescriptionOrderValidate extends BaseValidate
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
];
public function sceneUnlinkPayOrder(): PrescriptionOrderValidate
{
return $this->only(['id', 'pay_order_id'])
->append('id', 'require|integer|gt:0')
->append('pay_order_id', 'require|integer|gt:0');
}
public function updateAmount(): PrescriptionOrderValidate
{
return $this->only(['id', 'amount'])
@@ -6,8 +6,11 @@ namespace app\api\controller;
use app\adminapi\logic\qywx\CustomerLogic;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use app\common\service\qywx\QywxPromotionAutomationService;
use app\common\service\qywx\QywxPromotionEnqueueException;
use EasyWeChat\Kernel\Exceptions\BadRequestException;
use EasyWeChat\Work\Application;
use EasyWeChat\Work\Message;
@@ -64,6 +67,9 @@ class QywxExternalContactCallbackController extends BaseApiController
$server->addEventListener('change_external_contact', function (Message $message, \Closure $next) {
try {
$this->handleChangeExternalContact($message);
} catch (QywxPromotionEnqueueException $e) {
// 未持久化不能假应答成功:外层返回500,让企微重新投递。
throw $e;
} catch (\Throwable $e) {
Log::error('qywx external contact callback: ' . $e->getMessage(), [
'exception' => $e,
@@ -96,6 +102,11 @@ class QywxExternalContactCallbackController extends BaseApiController
}
return response($content, 200, $headers);
} catch (QywxPromotionEnqueueException) {
// 异常调用栈可能携带原始事件参数;这里只记固定信息,不记录WelcomeCode。
Log::error('qywx external contact callback: promotion event persistence failed');
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
} catch (BadRequestException $e) {
Log::warning('qywx external contact callback: bad request ' . $e->getMessage());
@@ -120,9 +131,17 @@ class QywxExternalContactCallbackController extends BaseApiController
$failReason = (string) ($message['FailReason'] ?? '');
$eventTime = (int) ($message['CreateTime'] ?? 0);
// 只接管已保存新配置且可核验方案/成员的推广事件。
// 回调内立即尝试欢迎语和正式客户标签,常驻/分钟任务继续承担重试及其余慢动作。
// 同时处理带欢迎码的半客户,避免原来的半客户早返回吞掉20秒欢迎语窗口。
$event = $message instanceof Message ? $message->toArray() : (array) $message;
$queued = (new QywxPromotionAutomationService())->enqueueVerifiedEvent($event, true);
$auditEvent = $event;
unset($auditEvent['WelcomeCode']);
// 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计;
// 独立于业务 UPSERT,即便后续 DB 逻辑抛错也不影响计数。
CustomerLogic::recordExternalContactEvent([
$eventId = CustomerLogic::recordExternalContactEvent([
'change_type' => $changeType,
'user_id' => $userId,
'external_userid' => $extId,
@@ -130,9 +149,16 @@ class QywxExternalContactCallbackController extends BaseApiController
'fail_reason' => $failReason,
'welcome_code' => $welcomeCode !== '' ? 1 : 0,
'event_time' => $eventTime,
'raw' => $message,
'raw' => $auditEvent,
]);
if ($queued) {
// 推广任务先于事件流水执行;事件落库后再用任务内不可变配置补写渠道快照。
QywxExternalContactEventTagSnapshotService::captureForPromotionEvent($eventId);
// 即时通道已尝试欢迎语/标签;分钟补偿完成备注、成员记账、范围与客户资料同步。
return;
}
if ($extId === '') {
Log::info(sprintf('qywx external contact callback: 无 ExternalUserID type=%s user=%s', $changeType, $userId));
@@ -204,6 +230,10 @@ class QywxExternalContactCallbackController extends BaseApiController
}
}
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
CustomerLogic::upsertSingleExternalContactFromApi($extId);
CustomerLogic::upsertSingleExternalContactFromApi(
$extId,
$changeType === 'add_external_contact' ? $eventId : 0,
$userId
);
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\qywx\QywxPromotionMediaService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class QywxRefreshPromotionMedia extends Command
{
protected function configure()
{
$this->setName('qywx:refresh-promotion-media')->setDescription('预热/刷新已保存欢迎语使用的三天临时素材');
}
protected function execute(Input $input, Output $output): int
{
$result = (new QywxPromotionMediaService())->refreshReferenced(100);
$output->writeln('QYWX_PROMOTION_MEDIA ' . json_encode($result));
return $result['failed'] > 0 ? 1 : 0;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\qywx\QywxPromotionAutomationService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class QywxRetryPromotionAutomation extends Command
{
protected function configure()
{
$this->setName('qywx:retry-promotion-automation')
->setDescription('补偿推广标签/备注/资料同步;过期欢迎语仅记过期,不补发');
}
protected function execute(Input $input, Output $output): int
{
$result = (new QywxPromotionAutomationService())->retryPending(100);
$output->writeln('QYWX_PROMOTION_RETRY ' . json_encode($result));
return $result['failed'] > 0 ? 1 : 0;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\qywx\QywxPromotionAutomationService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
/** Supervisor/systemd常驻:只消费欢迎语,不运行慢速客户同步或素材上传。 */
class QywxWorkPromotionAutomation extends Command
{
protected function configure()
{
$this->setName('qywx:work-promotion-automation')
->setDescription('秒级消费推广欢迎语(需常驻;欢迎码仅20秒有效)')
->addOption('once', null, Option::VALUE_NONE, '只消费一轮');
}
protected function execute(Input $input, Output $output): int
{
$running = true;
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
pcntl_signal(SIGTERM, static function () use (&$running): void { $running = false; });
pcntl_signal(SIGINT, static function () use (&$running): void { $running = false; });
}
$service = new QywxPromotionAutomationService();
do {
try {
$result = $service->processWelcomes(100);
if ($result['selected'] > 0 || $input->getOption('once')) {
$output->writeln('QYWX_PROMOTION_WELCOME ' . json_encode($result));
}
} catch (\Throwable) {
// 不输出异常堆栈/SQL/请求,避免把短期凭证带进守护进程日志。
$output->writeln('QYWX_PROMOTION_WELCOME worker storage unavailable');
if ($input->getOption('once')) {
return 1;
}
}
if (!$input->getOption('once') && $running) {
usleep(250000);
}
} while (!$input->getOption('once') && $running);
return 0;
}
}
+267 -103
View File
@@ -79,56 +79,80 @@ class DifyChatService
$formatted = null;
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
$requestSpecs = self::buildRequestSpecs(
$baseUrl,
$model,
$inputs,
$query,
$user,
false,
$attempt['files'],
$attempt['omitted']
);
$lastResponse = null;
$lastSpec = [];
$fileRejected = false;
$inputRejected = false;
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) {
if ($inputIndex > 0 && !$inputRejected) {
break;
}
$requestSpecs = self::buildRequestSpecs(
$baseUrl,
$model,
$attemptInputs,
$query,
$user,
false,
$attempt['files'],
$attempt['omitted']
);
$lastResponse = null;
$lastSpec = [];
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
);
}
$response = self::sendRequest(
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout
);
$lastResponse = $response;
$lastSpec = $requestSpec;
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
$inputRejected = $inputRejected || self::isInputRejection(
$response,
$requestSpec,
$attemptInputs
);
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
// 避免因业务参数错误而重复提交同一份临床数据。
$hasFallback = isset($requestSpecs[$index + 1]);
if (
$hasFallback
&& !$inputRejected
&& self::shouldTryNextProtocol($response, false)
) {
continue;
}
break;
}
$response = self::sendRequest(
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout
);
$lastResponse = $response;
$lastSpec = $requestSpec;
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
// 避免因业务参数错误而重复提交同一份临床数据。
$hasFallback = isset($requestSpecs[$index + 1]);
if ($hasFallback && in_array($response['http_code'], [404, 405, 501], true)) {
$lastResponse = $lastResponse ?? ['body' => '', 'errno' => 0, 'http_code' => 0];
$formatted = self::formatResponse($lastResponse, $startedAt);
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
if (!empty($formatted['ok'])) {
return $formatted;
}
// Dify 只接受应用中已声明且满足长度约束的 inputs。病例正文已经完整
// 放在 query 中,因此 invalid_param 时可安全地用空 inputs 重试一次。
if ($inputIndex === 0 && $inputRejected) {
continue;
}
break;
}
$lastResponse = $lastResponse ?? ['body' => '', 'errno' => 0, 'http_code' => 0];
$formatted = self::formatResponse($lastResponse, $startedAt);
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
if (!empty($formatted['ok'])) {
return $formatted;
}
// 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。
if (!self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])) {
if (!$fileRejected) {
return $formatted;
}
}
@@ -191,64 +215,83 @@ class DifyChatService
$formatted = null;
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
$requestSpecs = self::buildRequestSpecs(
$baseUrl,
$model,
$inputs,
$query,
$user,
true,
$attempt['files'],
$attempt['omitted']
);
$lastResponse = null;
$lastSpec = [];
$fileRejected = false;
$inputRejected = false;
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) {
if ($inputIndex > 0 && !$inputRejected) {
break;
}
$requestSpecs = self::buildRequestSpecs(
$baseUrl,
$model,
$attemptInputs,
$query,
$user,
true,
$attempt['files'],
$attempt['omitted']
);
$lastResponse = null;
$lastSpec = [];
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
);
}
$response = self::sendStreamRequest(
$requestSpec['protocol'],
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout,
$onDelta,
$shouldAbort
);
$lastResponse = $response;
$lastSpec = $requestSpec;
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
$inputRejected = $inputRejected || self::isInputRejection(
$response,
$requestSpec,
$attemptInputs
);
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
$hasFallback = isset($requestSpecs[$index + 1]);
if (
$hasFallback
&& !$inputRejected
&& self::shouldTryNextProtocol($response, true)
) {
continue;
}
break;
}
$response = self::sendStreamRequest(
$requestSpec['protocol'],
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout,
$onDelta,
$shouldAbort
);
$lastResponse = $response;
$lastSpec = $requestSpec;
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
$hasFallback = isset($requestSpecs[$index + 1]);
$lastResponse = $lastResponse ?? self::emptyStreamResponse(0);
$formatted = self::formatStreamResponse($lastResponse, $startedAt);
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
if (!empty($formatted['ok'])) {
return $formatted;
}
if (
$hasFallback
&& empty($response['emitted'])
&& in_array($response['http_code'], [404, 405, 501], true)
$inputIndex === 0
&& $inputRejected
&& empty($lastResponse['emitted'])
) {
continue;
}
break;
}
$lastResponse = $lastResponse ?? self::emptyStreamResponse(0);
$formatted = self::formatStreamResponse($lastResponse, $startedAt);
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
if (!empty($formatted['ok'])) {
return $formatted;
}
// 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。
// 附件不可达时 Dify 会在 200 流里发 event:error,同样按附件问题降级。
$fileRejected = self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])
|| (!empty($lastResponse['upstream_error']) && $attempt['files'] !== []);
if (!empty($lastResponse['emitted']) || !$fileRejected) {
return $formatted;
}
@@ -450,6 +493,66 @@ class DifyChatService
return $attempts;
}
/**
* Dify 应用输入变量由发布时的表单定义决定。先保留结构化输入;若上游明确
* 拒绝输入,再使用空对象兼容旧应用。病例正文始终在 query 中,不会丢失。
*
* @param array<string,mixed> $inputs
* @return array<int,array<string,mixed>>
*/
private static function buildInputAttemptPlan(array $inputs): array
{
return $inputs === [] ? [[]] : [$inputs, []];
}
/**
* @param array<string,mixed> $response
* @param array<string,mixed> $requestSpec
* @param array<string,mixed> $inputs
*/
private static function isInputRejection(array $response, array $requestSpec, array $inputs): bool
{
if (
$inputs === []
|| ($requestSpec['protocol'] ?? '') !== 'dify'
|| (int) ($response['errno'] ?? 0) !== 0
|| !empty($response['emitted'])
) {
return false;
}
$httpCode = (int) ($response['http_code'] ?? 0);
if (in_array($httpCode, [413, 422], true)) {
return true;
}
$upstreamCode = strtolower(self::responseUpstreamCode($response));
if ($httpCode === 400) {
// 标准 Dify 会给出 invalid_param;部分兼容网关只保留 400,因此空码
// 也允许一次无 inputs 重试。额度、模型或应用状态错误不能重复提交。
return $upstreamCode === ''
|| in_array($upstreamCode, ['invalid_param', 'payload_too_large', 'request_too_large'], true);
}
return !empty($response['upstream_error'])
&& in_array(
$upstreamCode,
['invalid_param', 'payload_too_large', 'request_too_large'],
true
);
}
/** @param array<string,mixed> $response */
private static function responseUpstreamCode(array $response): string
{
$upstreamCode = self::cleanUpstreamCode($response['upstream_code'] ?? '');
if ($upstreamCode !== '' || !isset($response['body'])) {
return $upstreamCode;
}
$decoded = json_decode((string) $response['body'], true);
return is_array($decoded) ? self::cleanUpstreamCode($decoded['code'] ?? '') : '';
}
/**
* @param array<int,array<string,string>> $files
*/
@@ -458,6 +561,26 @@ class DifyChatService
return $files !== [] && in_array($httpCode, self::FILE_REJECTION_CODES, true);
}
/**
* 判断一次上游响应是否属于“这批附件我处理不了”。
*
* 除了 4xx 状态码,Dify 拉不到附件时会在 200 SSE 流里发 event:error
* 这两种形态都必须触发去掉附件的降级重试。
*
* @param array<string,mixed> $response
* @param array<int,array<string,string>> $files
*/
private static function isFileRejection(array $response, array $files): bool
{
if ($files === [] || (int) ($response['errno'] ?? 0) !== 0) {
return false;
}
if (self::shouldRetryWithoutFiles((int) ($response['http_code'] ?? 0), $files)) {
return true;
}
return !empty($response['upstream_error']);
}
/**
* 把无法随请求送达的附件写成显式清单。模型必须知道这些资料存在但读不到,
* 才不会把“没看到”当成“没有”。
@@ -509,6 +632,30 @@ class DifyChatService
return $baseUrl . '/v1/' . $endpoint;
}
/**
* Decide whether an ambiguous base URL should be tried with the other wire
* protocol. Only a missing/unsupported endpoint is a blocking-mode protocol
* signal. A 2xx stream with no delivered delta but no valid terminal frame
* is also safe to retry. Business validation, authentication, rate-limit and
* server failures retain their original diagnosis instead of being hidden.
*
* @param array<string,mixed> $response
*/
private static function shouldTryNextProtocol(array $response, bool $streaming): bool
{
if ((int) ($response['errno'] ?? 0) !== 0) {
return false;
}
$httpCode = (int) ($response['http_code'] ?? 0);
if (in_array($httpCode, [404, 405, 501], true)) {
return true;
}
if (!$streaming || $httpCode < 200 || $httpCode >= 300 || !empty($response['emitted'])) {
return false;
}
return !empty($response['upstream_error']) || empty($response['finished']);
}
private static function isValidBaseUrl(string $baseUrl): bool
{
if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) {
@@ -579,6 +726,21 @@ class DifyChatService
];
}
/**
* libcurl 7.32+ exposes CURLOPT_XFERINFOFUNCTION, while CentOS/RHEL 7 commonly
* ships libcurl 7.29 with only CURLOPT_PROGRESSFUNCTION. Resolve the option
* by name so loading this class never evaluates an undefined PHP constant.
*/
private static function curlProgressOption(): ?int
{
foreach (['CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'] as $name) {
if (defined($name)) {
return (int) constant($name);
}
}
return null;
}
/**
* @param array<string,mixed> $payload
* @param callable(string):mixed $onDelta
@@ -636,7 +798,7 @@ class DifyChatService
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
return $state['callback_error'] ? 0 : strlen($chunk);
};
$progress = static function () use (&$state, $shouldAbort): int {
$progress = static function (...$unused) use (&$state, $shouldAbort): int {
if ($shouldAbort !== null && $shouldAbort()) {
$state['client_aborted'] = true;
return 1;
@@ -644,7 +806,7 @@ class DifyChatService
return 0;
};
curl_setopt_array($ch, [
$curlOptions = [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
@@ -660,9 +822,13 @@ class DifyChatService
],
CURLOPT_HEADERFUNCTION => $header,
CURLOPT_WRITEFUNCTION => $write,
CURLOPT_NOPROGRESS => false,
CURLOPT_XFERINFOFUNCTION => $progress,
]);
];
$progressOption = self::curlProgressOption();
if ($progressOption !== null) {
$curlOptions[CURLOPT_NOPROGRESS] = false;
$curlOptions[$progressOption] = $progress;
}
curl_setopt_array($ch, $curlOptions);
curl_exec($ch);
$errno = curl_errno($ch);
@@ -1044,25 +1210,23 @@ class DifyChatService
return;
}
$url = (string) ($requestSpec['url'] ?? '');
$upstreamCode = (string) ($response['upstream_code'] ?? '');
if ($upstreamCode === '' && isset($response['body'])) {
$decoded = json_decode((string) $response['body'], true);
$upstreamCode = is_array($decoded)
? self::cleanUpstreamCode($decoded['code'] ?? '')
: '';
}
Log::warning('prescription ai upstream request failed', [
$context = [
'protocol' => (string) ($requestSpec['protocol'] ?? ''),
'endpoint_path' => (string) (parse_url($url, PHP_URL_PATH) ?? ''),
'http_code' => (int) ($response['http_code'] ?? 0),
'curl_errno' => (int) ($response['errno'] ?? 0),
// 上游自有错误码(如 invalid_param),用于区分附件超限、鉴权、模型故障。
'upstream_code' => $upstreamCode,
'upstream_code' => self::responseUpstreamCode($response),
'query_bytes' => strlen($query),
'file_count' => count($files),
'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'),
'latency_ms' => (int) ($formatted['latency_ms'] ?? 0),
]);
];
// ThinkPHP 文件日志不会自动输出未参与占位符替换的 context;显式序列化
// 这组不含凭据、主机名、患者正文的诊断字段,确保线上日志真正可用。
Log::warning(
'prescription ai upstream request failed ' . json_encode($context, JSON_UNESCAPED_SLASHES)
);
}
/**
@@ -20,6 +20,7 @@ class DirectUploadService
/** 视频允许的扩展名(沿用 config/project.file_video */
public const TYPE_VIDEO = 'video';
public const TYPE_VOICE = 'voice';
public const TYPE_DESKTOP_PACKAGE = 'desktop_package';
/** 默认凭证有效期 30 分钟 */
public const DEFAULT_DURATION = 1800;
@@ -28,6 +29,7 @@ class DirectUploadService
private const MAX_SIZE = [
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
self::TYPE_VOICE => 500 * 1024 * 1024, // 500MB
self::TYPE_DESKTOP_PACKAGE => 2 * 1024 * 1024 * 1024, // 2GB
];
/**
@@ -36,7 +38,7 @@ class DirectUploadService
* @return array
* @throws Exception
*/
public static function issueCredentials(string $type): array
public static function issueCredentials(string $type, int $adminId = 0, string $name = ''): array
{
if (!isset(self::MAX_SIZE[$type])) {
throw new Exception('不支持的上传类型: ' . $type);
@@ -54,9 +56,27 @@ class DirectUploadService
throw new Exception('腾讯云 COS 配置不完整');
}
$keyPrefix = self::buildKeyPrefix($type);
$keyPrefix = self::buildKeyPrefix($type, $adminId);
$objectKey = '';
// 兼容前后端错峰发布:旧 uploader 只传 type,不传 name。
// 新 uploader 仍使用更严格的单对象授权;旧版则限制在当前管理员当天目录,
// 并在 confirm 阶段校验文件名、扩展名与实际对象。
if ($type === self::TYPE_DESKTOP_PACKAGE && trim($name) !== '') {
$extension = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
$objectKey = $keyPrefix
. (int)round(microtime(true) * 1000)
. '-'
. bin2hex(random_bytes(8))
. ($extension !== '' ? '.' . $extension : '');
self::validateFileExtension($type, $objectKey, $name);
}
$engine = new QcloudEngine($storageConfig);
$sts = $engine->getStsCredentials($keyPrefix, self::MAX_SIZE[$type], self::DEFAULT_DURATION);
$sts = $engine->getStsCredentials(
$objectKey !== '' ? $objectKey : $keyPrefix,
self::MAX_SIZE[$type],
self::DEFAULT_DURATION,
$objectKey !== ''
);
return [
'provider' => 'qcloud',
@@ -66,6 +86,7 @@ class DirectUploadService
'host' => $sts['host'],
'cdn_domain' => rtrim((string)($storageConfig['domain'] ?? ''), '/'),
'key_prefix' => $keyPrefix,
'object_key' => $objectKey,
'max_size' => self::MAX_SIZE[$type],
'duration' => self::DEFAULT_DURATION,
'expired_time' => $sts['expiredTime'],
@@ -93,8 +114,7 @@ class DirectUploadService
}
$key = ltrim((string)($params['key'] ?? ''), '/');
$allowedPrefix = self::buildKeyPrefix($type);
if ($key === '' || strpos($key, $allowedPrefix) !== 0) {
if (!self::isAllowedObjectKey($type, $key, (int)($params['admin_id'] ?? 0))) {
throw new Exception('对象 Key 非法');
}
@@ -112,6 +132,7 @@ class DirectUploadService
if ($name === '') {
$name = basename($key);
}
self::validateFileExtension($type, $key, $name);
if (strlen($name) > 128) {
$name = substr($name, 0, 123) . substr($name, -5);
}
@@ -137,9 +158,16 @@ class DirectUploadService
];
}
private static function buildKeyPrefix(string $type): string
private static function buildKeyPrefix(string $type, int $adminId = 0): string
{
return 'uploads/' . $type . '/' . date('Ymd') . '/';
$prefix = 'uploads/' . $type . '/';
if ($type === self::TYPE_DESKTOP_PACKAGE) {
if ($adminId <= 0) {
throw new Exception('安装包上传账号无效');
}
$prefix .= $adminId . '/';
}
return $prefix . date('Ymd') . '/';
}
private static function resolveFileType(string $type): int
@@ -150,4 +178,46 @@ class DirectUploadService
default => FileEnum::FILE_TYPE,
};
}
/**
* 桌面安装包是可执行文件,只允许发布流程所需的 EXE / ZIP。
*/
private static function validateFileExtension(string $type, string $key, string $name): void
{
if ($type !== self::TYPE_DESKTOP_PACKAGE) {
return;
}
$nameExtension = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
$keyExtension = strtolower((string)pathinfo($key, PATHINFO_EXTENSION));
$allowedExtensions = ['exe', 'zip'];
if (!in_array($nameExtension, $allowedExtensions, true)
|| $nameExtension !== $keyExtension) {
throw new Exception('桌面安装包仅支持 EXE 或 ZIP 文件');
}
}
/**
* 安装包 Key 绑定上传管理员,并兼容跨午夜完成的上传。
*/
private static function isAllowedObjectKey(string $type, string $key, int $adminId): bool
{
if ($key === '') {
return false;
}
if ($type !== self::TYPE_DESKTOP_PACKAGE) {
return strpos($key, self::buildKeyPrefix($type)) === 0;
}
if ($adminId <= 0) {
return false;
}
$ownerPrefix = 'uploads/' . self::TYPE_DESKTOP_PACKAGE . '/' . $adminId . '/';
if (strpos($key, $ownerPrefix) !== 0) {
return false;
}
$date = substr($key, strlen($ownerPrefix), 8);
return in_array($date, [date('Ymd'), date('Ymd', time() - 86400)], true)
&& substr($key, strlen($ownerPrefix) + 8, 1) === '/';
}
}
@@ -422,9 +422,17 @@ SQL;
* multiplies facts. Enterprise tag channels use the normalized relation
* table, while legacy name-only channels keep a deduplicated JSON fallback.
*
* When the fact has an employee dimension, pass $followUserField so a tag
* applied by employee A cannot make employee B's event match the channel.
*
* @param array<string, mixed>|null $channel
*/
public static function applyExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
public static function applyExternalUserChannelFilter(
Query $query,
string $field,
?array $channel,
?string $followUserField = null
): void
{
if ($channel === null) {
return;
@@ -437,10 +445,14 @@ SQL;
$tagPredicate = count($tagIds) === 1
? 'channel_tag.tag_id = ?'
: 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
$followUserPredicate = $followUserField === null
? ''
: "AND channel_tag.follow_user_id = {$followUserField} ";
// 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} channel_tag "
. "WHERE channel_tag.external_userid = {$field} "
. $followUserPredicate
. "AND {$tagPredicate} "
. "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact "
. 'WHERE active_channel_contact.external_userid = channel_tag.external_userid '
@@ -472,6 +484,92 @@ SQL;
);
}
/**
* Filter an add-event fact by its append-only channel snapshot. Events that
* pre-date the snapshot migration explicitly fall back to the old projection,
* but the fallback is constrained to the event's exact employee.
*
* @param array<string, mixed>|null $channel
*/
public static function applyExternalUserEventChannelFilter(
Query $query,
string $eventIdField,
string $externalUserField,
string $followUserField,
?array $channel,
bool $historicalContact = false
): void
{
if ($channel === null) {
return;
}
$tagIds = self::channelTagIds($channel);
if ($tagIds === [] || !QywxExternalContactEventTagSnapshotService::installed()) {
if ($historicalContact) {
self::applyHistoricalExternalUserChannelFilter(
$query,
$externalUserField,
$channel,
$followUserField
);
} else {
self::applyExternalUserChannelFilter($query, $externalUserField, $channel, $followUserField);
}
return;
}
$snapshotTable = self::tableWithPrefix('qywx_external_contact_event_tag');
$snapshotPredicate = count($tagIds) === 1
? 'event_channel_tag.tag_id = ?'
: 'event_channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
$snapshotMatch = "EXISTS (SELECT 1 FROM {$snapshotTable} event_channel_tag"
. " WHERE event_channel_tag.event_id = {$eventIdField}"
. " AND event_channel_tag.follow_user_id = {$followUserField}"
. " AND {$snapshotPredicate})";
$snapshotMissing = "NOT EXISTS (SELECT 1 FROM {$snapshotTable} captured_event_channel"
. " WHERE captured_event_channel.event_id = {$eventIdField}"
. " AND captured_event_channel.follow_user_id = {$followUserField}"
. " AND captured_event_channel.tag_id = '')";
$query->where(function ($channelQuery) use (
$snapshotMatch,
$snapshotMissing,
$tagIds,
$historicalContact,
$externalUserField,
$followUserField,
$channel
): void {
$channelQuery->whereRaw($snapshotMatch, $tagIds)
->whereOr(function ($legacyQuery) use (
$snapshotMissing,
$historicalContact,
$externalUserField,
$followUserField,
$channel
): void {
$legacyQuery->whereRaw($snapshotMissing);
if ($historicalContact) {
self::applyHistoricalExternalUserChannelFilter(
$legacyQuery,
$externalUserField,
$channel,
$followUserField
);
} else {
self::applyExternalUserChannelFilter(
$legacyQuery,
$externalUserField,
$channel,
$followUserField
);
}
});
});
}
/**
* Filter an external_userid fact by the channel snapshot retained in
* qywx_external_contact.follow_users, including soft-deleted contacts.
@@ -481,14 +579,48 @@ SQL;
* contact row itself retains follow_users and is the best available channel
* snapshot for this specific historical statistic.
*
* When $followUserField is provided, JSON_SEARCH first locates that exact
* employee object and JSON_CONTAINS checks only its tag array.
*
* @param array<string, mixed>|null $channel
*/
public static function applyHistoricalExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
public static function applyHistoricalExternalUserChannelFilter(
Query $query,
string $field,
?array $channel,
?string $followUserField = null
): void
{
if ($channel === null) {
return;
}
$contactTable = self::tableWithPrefix('qywx_external_contact');
$tagIds = self::channelTagIds($channel);
if ($followUserField !== null && $tagIds !== []) {
$safeFollowUsers = "IF(JSON_VALID(historical_channel_contact.follow_users),"
. ' historical_channel_contact.follow_users, JSON_ARRAY())';
$userPath = "JSON_UNQUOTE(JSON_SEARCH({$safeFollowUsers}, 'one', {$followUserField},"
. " NULL, '$[*].userid'))";
$tagsPath = "IFNULL(REPLACE({$userPath}, '.userid', '.tags'), '$.__missing__')";
$tagsJson = "JSON_EXTRACT({$safeFollowUsers}, {$tagsPath})";
$tagPredicates = [];
$bindings = [];
foreach ($tagIds as $tagId) {
$tagPredicates[] = "JSON_CONTAINS({$tagsJson}, JSON_OBJECT('tag_id', ?))";
$bindings[] = $tagId;
}
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
. " WHERE historical_channel_contact.external_userid = {$field}"
. " AND {$userPath} IS NOT NULL"
. ' AND (' . implode(' OR ', $tagPredicates) . '))',
$bindings
);
return;
}
$patterns = self::buildLikePatterns($channel);
if ($patterns === []) {
$query->whereRaw('1 = 0');
@@ -502,7 +634,6 @@ SQL;
$segments[] = 'historical_channel_contact.follow_users LIKE ?';
$bindings[] = $pattern;
}
$contactTable = self::tableWithPrefix('qywx_external_contact');
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
. " WHERE historical_channel_contact.external_userid = {$field}"
@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use think\facade\Db;
use think\facade\Log;
/**
* 企业微信新增事件的标签快照。
*
* qywx_external_contact_tag 是当前状态投影,客户改标签或删除后会被覆盖/清理;
* 本服务只在 add_external_contact 发生时写入,之后永不更新或删除。
*/
class QywxExternalContactEventTagSnapshotService
{
public const SOURCE_CONTACT_DETAIL = 1;
public const SOURCE_PROMOTION_TASK = 2;
private static ?bool $installed = null;
/**
* 灰度发布保护:代码先于迁移生效时,读取侧可以显式降级而不是返回 500
*/
public static function installed(): bool
{
if (self::$installed !== null) {
return self::$installed;
}
try {
self::$installed = Db::name('qywx_external_contact_event_tag')->getFields() !== [];
return self::$installed;
} catch (\Throwable $e) {
$message = $e->getMessage();
if (str_contains($message, '42S02')
|| str_contains($message, '1146')
|| str_contains($message, 'no such table')) {
self::$installed = false;
return self::$installed;
}
throw $e;
}
}
/**
* /externalcontact/get follow_user[] 中,只截取产生事件的员工标签。
* 找不到该员工时不写空快照,避免把一次不完整同步误判为“当时无标签”。
*
* @param array<int, mixed> $followUsers
*/
public static function captureFromFollowUsers(int $eventId, string $followUserId, array $followUsers): void
{
$followUserId = trim($followUserId);
if ($eventId <= 0 || $followUserId === '') {
return;
}
foreach ($followUsers as $followUser) {
if (!is_array($followUser)
|| trim((string) ($followUser['userid'] ?? '')) !== $followUserId) {
continue;
}
self::capture(
$eventId,
$followUserId,
is_array($followUser['tags'] ?? null) ? $followUser['tags'] : [],
self::SOURCE_CONTACT_DETAIL
);
return;
}
}
/**
* 回调入库后,用同一推广任务的不可变 config_json 补写快照。
*/
public static function captureForPromotionEvent(int $eventId): void
{
if ($eventId <= 0) {
return;
}
try {
$event = Db::name('qywx_external_contact_event')->where('id', $eventId)->find();
if (!$event || (string) ($event['change_type'] ?? '') !== 'add_external_contact') {
return;
}
$task = Db::name('qywx_promotion_automation_task')
->where('change_type', (string) $event['change_type'])
->where('userid', (string) $event['user_id'])
->where('external_userid', (string) $event['external_userid'])
->where('event_time', (int) $event['event_time'])
->find();
if ($task) {
self::captureFromPromotionTask($task, $eventId);
}
} catch (\Throwable $e) {
self::logFailure($e, $eventId, 'promotion_event');
}
}
/**
* 标签动作成功后追加推广配置中的确定标签,但不写完成标记。
* 推广配置只描述自动添加的标签,不能证明客户当时没有其他标签;完整快照由后续客户详情同步完成。
*
* @param array<string, mixed> $task
*/
public static function captureFromPromotionTask(array $task, int $knownEventId = 0): void
{
if ((string) ($task['change_type'] ?? '') !== 'add_external_contact') {
return;
}
$actions = json_decode((string) ($task['actions_json'] ?? ''), true);
$tagStatus = is_array($actions)
? (string) ($actions['tags']['status'] ?? '')
: '';
if ($tagStatus !== 'success') {
return;
}
try {
$eventId = $knownEventId;
if ($eventId <= 0) {
$eventId = (int) Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('user_id', (string) ($task['userid'] ?? ''))
->where('external_userid', (string) ($task['external_userid'] ?? ''))
->where('event_time', (int) ($task['event_time'] ?? 0))
->value('id');
}
if ($eventId <= 0) {
return;
}
$tags = [];
$config = json_decode((string) ($task['config_json'] ?? ''), true);
foreach ((array) ($config['tag_ids'] ?? []) as $tagId) {
$tagId = trim((string) $tagId);
if ($tagId !== '') {
$tags[] = ['tag_id' => $tagId];
}
}
if ($tags === []) {
return;
}
self::appendTags(
$eventId,
(string) ($task['userid'] ?? ''),
$tags,
self::SOURCE_PROMOTION_TASK
);
} catch (\Throwable $e) {
self::logFailure($e, $knownEventId, 'promotion_task');
}
}
/**
* 追加推广任务能够证明的标签,不写 tag_id='' 完成标记。
*
* @param array<int, mixed> $tags
*/
private static function appendTags(int $eventId, string $followUserId, array $tags, int $source): void
{
$followUserId = mb_substr(trim($followUserId), 0, 64);
if ($eventId <= 0 || $followUserId === '') {
return;
}
try {
foreach (self::normalizeTags($tags) as $tag) {
self::insertIgnore([
'event_id' => $eventId,
'follow_user_id' => $followUserId,
'tag_id' => $tag['tag_id'],
'tag_name' => $tag['tag_name'],
'group_name' => $tag['group_name'],
'snapshot_source' => $source,
'create_time' => time(),
]);
}
} catch (\Throwable $e) {
self::logFailure($e, $eventId, 'append_tags');
}
}
/**
* 先写 tag_id='' 完成标记,再写真实标签;同一事务保证不会留下半份快照。
* 完成标记已存在时直接返回,使重复回调不会把后来新增的标签补进历史事件。
*
* @param array<int, mixed> $tags
*/
private static function capture(int $eventId, string $followUserId, array $tags, int $source): void
{
$followUserId = mb_substr(trim($followUserId), 0, 64);
if ($eventId <= 0 || $followUserId === '') {
return;
}
$normalized = self::normalizeTags($tags);
try {
Db::transaction(static function () use ($eventId, $followUserId, $normalized, $source): void {
$inserted = self::insertIgnore([
'event_id' => $eventId,
'follow_user_id' => $followUserId,
'tag_id' => '',
'tag_name' => '',
'group_name' => '',
'snapshot_source' => $source,
'create_time' => time(),
]);
if ($inserted === 0) {
return;
}
foreach ($normalized as $tag) {
self::insertIgnore([
'event_id' => $eventId,
'follow_user_id' => $followUserId,
'tag_id' => $tag['tag_id'],
'tag_name' => $tag['tag_name'],
'group_name' => $tag['group_name'],
'snapshot_source' => $source,
'create_time' => time(),
]);
}
});
} catch (\Throwable $e) {
// 快照是统计增强,迁移未执行或短时 DB 异常不能阻断企微回调主链路。
self::logFailure($e, $eventId, 'capture');
}
}
/**
* @param array<int, mixed> $tags
* @return array<string, array{tag_id:string,tag_name:string,group_name:string}>
*/
private static function normalizeTags(array $tags): array
{
$normalized = [];
foreach ($tags as $tag) {
if (!is_array($tag)) {
continue;
}
$tagId = mb_substr(trim((string) ($tag['tag_id'] ?? $tag['id'] ?? '')), 0, 64);
if ($tagId === '') {
continue;
}
$normalized[$tagId] = [
'tag_id' => $tagId,
'tag_name' => mb_substr((string) ($tag['tag_name'] ?? $tag['name'] ?? ''), 0, 128),
'group_name' => mb_substr((string) ($tag['group_name'] ?? ''), 0, 128),
];
}
return $normalized;
}
/** @param array<string, int|string> $row */
private static function insertIgnore(array $row): int
{
$table = (string) config('database.connections.mysql.prefix')
. 'qywx_external_contact_event_tag';
$columns = array_keys($row);
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $columns) . '`) VALUES ('
. implode(',', array_fill(0, count($columns), '?')) . ')';
return Db::execute($sql, array_values($row));
}
private static function logFailure(\Throwable $e, int $eventId, string $stage): void
{
Log::warning('qywx external contact event tag snapshot failed: ' . $e->getMessage(), [
'event_id' => $eventId,
'stage' => $stage,
]);
}
}
@@ -0,0 +1,383 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 推广客户自动化:短时欢迎语与可补偿关系动作分开消费。 */
class QywxPromotionAutomationService
{
private QywxPromotionContactApiService $api;
private QywxPromotionMediaService $media;
private QywxPromotionAutomationStore $store;
private QywxPromotionCodeCipher $cipher;
private $clock;
private const TERMINAL = ['sent', 'success', 'skipped', 'expired', 'uncertain', 'failed'];
public function __construct(
?QywxPromotionContactApiService $api = null,
?QywxPromotionMediaService $media = null,
?QywxPromotionAutomationStore $store = null,
?QywxPromotionCodeCipher $cipher = null,
?callable $clock = null
) {
$this->api = $api ?? new QywxPromotionContactApiService();
$this->media = $media ?? new QywxPromotionMediaService($this->api);
$this->store = $store ?? new QywxPromotionAutomationStore();
$this->cipher = $cipher ?? new QywxPromotionCodeCipher();
$this->clock = $clock ?? static fn (): int => time();
}
/**
* 仅供验签解密后的回调调用。false代表沿用旧同步流程;已接管的入队错误必须返回HTTP500。
* 默认仅入队;回调入口可启用即时通道,在当前请求内先发欢迎语并给正式客户打标。
*/
public function enqueueVerifiedEvent(array $event, bool $processImmediately = false): bool
{
$change = (string) ($event['ChangeType'] ?? '');
if (!in_array($change, ['add_external_contact', 'add_half_external_contact'], true)) {
return false;
}
$state = trim((string) ($event['State'] ?? ''));
$linkId = trim((string) ($event['LinkId'] ?? $event['LinkID'] ?? ''));
$userid = trim((string) ($event['UserID'] ?? $event['UserId'] ?? ''));
$external = trim((string) ($event['ExternalUserID'] ?? $event['ExternalUserId'] ?? ''));
if (($state === '' && $linkId === '') || $userid === '' || $external === '') {
return false;
}
try {
if (!$this->store->installed()) {
return false;
}
$attribution = $this->store->attribution($state, $linkId, $userid);
if ($attribution === null) {
return false;
}
$now = $this->now();
$eventTime = max(0, (int) ($event['CreateTime'] ?? 0));
$code = (string) ($event['WelcomeCode'] ?? '');
$config = $attribution['config'];
$half = $change === 'add_half_external_contact';
$welcomeStatus = 'pending';
$reason = '';
if (($config['welcome_mode'] ?? 'default') !== 'channel') {
$welcomeStatus = 'skipped';
$reason = 'mode_' . ($config['welcome_mode'] ?? 'default');
} elseif ($code === '') {
$welcomeStatus = 'skipped';
$reason = 'missing_welcome_code';
} elseif (strlen($code) > 1024) {
$welcomeStatus = 'failed';
$reason = 'invalid_welcome_code';
} elseif ($eventTime <= 0 || $eventTime > $now + 5 || $eventTime + 20 <= $now) {
$welcomeStatus = 'expired';
$reason = 'welcome_window_elapsed_or_invalid_event_time';
}
$actions = [
'welcome' => self::action($welcomeStatus, $reason),
'tags' => self::action(!$half && !empty($config['tags_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
'remark' => self::action(!$half && !empty($config['remark_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
'description' => self::action(!$half && !empty($config['description_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
'dispatch' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
'range' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
'sync' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
];
$corp = (string) ($event['ToUserName'] ?? config('pay.wechat_work.corp_id', ''));
$taskId = $this->store->enqueue([
'event_key' => hash('sha256', implode('|', [$corp, $change, $userid, $external, (string) $eventTime])),
'pool_id' => $attribution['pool_id'], 'member_admin_id' => $attribution['member_admin_id'],
'change_type' => $change, 'userid' => $userid, 'external_userid' => $external,
'event_time' => $eventTime, 'received_at' => $now,
'config_json' => self::json($config), 'actions_json' => self::json($actions),
'welcome_cipher' => $welcomeStatus === 'pending' ? $this->cipher->encrypt($code) : '',
'welcome_code_hash' => $code !== '' ? hash('sha256', $code) : '',
'welcome_expires_at' => $eventTime > 0 ? min($eventTime + 20, $now + 20) : 0,
'welcome_status' => $welcomeStatus, 'welcome_next_retry' => 0,
'status' => self::allTerminal($actions) ? 'done' : 'pending', 'next_retry' => 0,
'lock_token' => '', 'lock_until' => 0, 'create_time' => $now, 'update_time' => $now,
]);
if ($processImmediately) {
// 部署环境暂未启动常驻 worker 时仍要抢住 20 秒欢迎码窗口。
// 标签只在正式客户事件执行;其余慢动作仍由分钟补偿处理。
$this->consumeIds('welcome', [$taskId]);
if (!$half) {
$this->consumeIds('inline_metadata', [$taskId], ['tags']);
}
}
return true;
} catch (\Throwable) {
// 不附原异常,入库SQL可能包含密文和配置;回调层返回500触发企微重试。
throw new QywxPromotionEnqueueException('推广自动化事件未能持久化,请检查数据库迁移和私有存储');
}
}
/** 常驻秒级worker仅处理欢迎语,不被范围/客户同步或大文件上传阻塞。 */
public function processWelcomes(int $limit = 100): array
{
return $this->consume('welcome', $limit);
}
/** 分钟补偿:过期欢迎语只记过期,绝不尝试补发。 */
public function retryPending(int $limit = 100): array
{
return $this->consume('metadata', $limit);
}
public static function selectWelcome(array $config, int $eventTime): array
{
if (!empty($config['welcome_schedule_enabled'])) {
foreach ((array) ($config['welcome_schedule'] ?? []) as $slot) {
if (QywxPromotionConfig::matches($slot, $eventTime)) {
return ['text' => (string) ($slot['text'] ?? ''), 'attachments' => (array) ($slot['attachments'] ?? [])];
}
}
}
return ['text' => (string) ($config['welcome']['text'] ?? ''), 'attachments' => (array) ($config['welcome']['attachments'] ?? [])];
}
private function consume(string $lane, int $limit): array
{
return $this->consumeIds($lane, $this->store->due($lane, $this->now(), $limit));
}
/** @param list<int> $ids @param list<string>|null $metadataActions */
private function consumeIds(string $lane, array $ids, ?array $metadataActions = null): array
{
$result = ['selected' => 0, 'processed' => 0, 'failed' => 0];
foreach ($ids as $id) {
++$result['selected'];
try {
$row = $this->store->claim($id, $lane, $this->now());
if ($row === null) {
continue;
}
$actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR);
$config = json_decode($row['config_json'], true, 512, JSON_THROW_ON_ERROR);
// 即时标签与欢迎语共用同一任务,但不能把仍在 20 秒窗口内待重试的欢迎语判为过期。
if ($lane !== 'inline_metadata' && !self::terminal($actions['welcome']['status'])) {
if ($lane === 'welcome') {
$this->welcome($row, $actions, $config);
} else {
$running = $actions['welcome']['status'] === 'running';
$this->transition($row, $actions, 'welcome', $running ? 'uncertain' : 'expired',
$running ? 'worker_interrupted_after_send_started' : 'welcome_worker_not_available_in_window');
}
}
if ($lane !== 'welcome') {
$this->metadata($row, $actions, $config, $metadataActions);
}
$row['lock_until'] = 0;
$row['update_time'] = $this->now();
$this->store->save($row);
++$result['processed'];
} catch (\Throwable) {
// 失去DB/租约时保留running状态;欢迎语恢复时视为不确定,防止重复推送。
++$result['failed'];
}
}
return $result;
}
private function welcome(array &$row, array &$actions, array $config): void
{
if ($actions['welcome']['status'] === 'running') {
$this->transition($row, $actions, 'welcome', 'uncertain', 'worker_interrupted_after_send_started');
return;
}
if ((int) $row['welcome_expires_at'] <= $this->now() + 1) {
$this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed');
return;
}
$sendStarted = false;
try {
$message = self::selectWelcome($config, (int) $row['event_time']);
$text = $message['text'];
if (str_contains($text, '{customer_name}') || str_contains($text, '{employee_name}') || str_contains($text, '{add_time}')) {
$names = $this->names($row, $text, true);
$text = QywxPromotionConfig::render($text, $names['customer'], $names['employee'], (int) $row['event_time'], 1200);
}
$truncated = strlen($text) > 4000;
$text = mb_strcut($text, 0, 4000, 'UTF-8');
$attachments = $this->media->materialize($message['attachments'], $config);
$code = $this->cipher->decrypt($row['welcome_cipher']);
if ((int) $row['welcome_expires_at'] <= $this->now() + 1) {
$this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed_during_prepare');
return;
}
// running先持久化:如果HTTP成功后进程/DB断开,恢复时绝不再次使用同一code。
$this->transition($row, $actions, 'welcome', 'running', 'send_started');
$sendStarted = true;
try {
$this->api->sendWelcome($code, $text, $attachments);
$this->transition($row, $actions, 'welcome', 'sent', $truncated ? 'sent_text_truncated_4000_bytes' : 'sent');
} catch (QywxPromotionContactApiException $e) {
if ($e->uncertain) {
$this->transition($row, $actions, 'welcome', 'uncertain', 'network_result_unknown_do_not_resend', $e->getCode());
} elseif ($e->getCode() === 41051) {
$this->transition($row, $actions, 'welcome', 'skipped', 'welcome_code_already_consumed', 41051);
} else {
$this->welcomeRetry($row, $actions, 'explicit_api_rejection', $e->getCode());
}
} catch (\Throwable) {
$this->transition($row, $actions, 'welcome', 'uncertain', 'send_or_persist_result_unknown_do_not_resend');
} finally {
unset($code);
}
} catch (\Throwable $e) {
// 准备阶段没有执行发送,可以安全重试,且不会把错误原文/欢迎码写日志。
if ($sendStarted || $actions['welcome']['status'] === 'running') {
throw $e;
}
$this->welcomeRetry($row, $actions, 'prepare_failed_check_media_credentials_or_key', (int) $e->getCode());
}
}
private function welcomeRetry(array &$row, array &$actions, string $reason, int $code): void
{
$expired = (int) $row['welcome_expires_at'] <= $this->now() + 2;
$this->transition($row, $actions, 'welcome', $expired ? 'expired' : 'retry', $reason, $code, $this->now() + 1);
}
/** @param list<string>|null $only */
private function metadata(array &$row, array &$actions, array $config, ?array $only = null): void
{
$names = null;
$namesToProcess = ['tags', 'remark', 'description', 'dispatch', 'range', 'sync'];
if ($only !== null) {
$namesToProcess = array_values(array_intersect($namesToProcess, $only));
}
foreach ($namesToProcess as $name) {
if (self::terminal($actions[$name]['status']) || (int) ($actions[$name]['next_retry'] ?? 0) > $this->now()) {
continue;
}
$this->transition($row, $actions, $name, 'running', 'started');
try {
switch ($name) {
case 'tags':
$this->api->markTags($row['userid'], $row['external_userid'], (array) $config['tag_ids']);
break;
case 'remark':
$names = $names ?? $this->names($row, (string) $config['remark_template'], false);
$remark = QywxPromotionConfig::render($config['remark_template'], $names['customer'], $names['employee'], (int) $row['event_time'], 20);
$this->api->remark($row['userid'], $row['external_userid'], ['remark' => $remark]);
break;
case 'description':
$this->api->remark($row['userid'], $row['external_userid'], ['description' => (string) $config['description']]);
break;
case 'dispatch':
$this->store->dispatch($row);
break;
case 'range':
$this->store->syncRange($row);
break;
case 'sync':
$this->store->syncCustomer($row);
break;
}
$this->transition($row, $actions, $name, 'success', 'completed');
} catch (\Throwable $e) {
$attempt = (int) $actions[$name]['attempts'];
$failed = $attempt >= 10;
$this->transition($row, $actions, $name, $failed ? 'failed' : 'retry',
$failed ? 'retry_limit_reached' : 'action_failed', (int) $e->getCode(),
$this->now() + min(3600, 15 * (2 ** min(8, $attempt))));
}
}
}
private function names(array $row, string $template, bool $welcome): array
{
$names = ['customer' => '', 'employee' => ''];
try {
$names = $this->store->localNames($row);
} catch (\Throwable) {
// 本地资料失败不妨碍欢迎语使用明确的文案兜底。
}
$budget = fn (): bool => !$welcome || (int) $row['welcome_expires_at'] > $this->now() + 7;
if (str_contains($template, '{customer_name}') && $names['customer'] === ''
&& $row['change_type'] !== 'add_half_external_contact' && $budget()) {
try {
$detail = $this->api->getExternalContact($row['external_userid']);
$names['customer'] = (string) ($detail['external_contact']['name'] ?? '');
} catch (\Throwable) {
}
}
if (str_contains($template, '{employee_name}') && $budget()) {
try {
$user = $this->api->getUser($row['userid']);
$names['employee'] = trim((string) ($user['name'] ?? '')) ?: $names['employee'];
} catch (\Throwable) {
// 通讯录姓名接口权限不足时回退后台成员称呼。
}
}
$names['customer'] = $names['customer'] !== '' ? $names['customer'] : '您';
$names['employee'] = $names['employee'] !== '' ? $names['employee'] : '客户顾问';
return $names;
}
private function transition(array &$row, array &$actions, string $name, string $status, string $reason, int $code = 0, int $retryAt = 0): void
{
$now = $this->now();
$action = $actions[$name];
if ($status === 'running' || ($name === 'welcome' && $status === 'retry' && $action['status'] !== 'running')) {
++$action['attempts'];
}
$action = array_replace($action, ['status' => $status, 'reason' => $reason, 'error_code' => $code,
'next_retry' => $retryAt, 'update_time' => $now]);
if (self::terminal($status)) {
$action['finished_at'] = $now;
}
$actions[$name] = $action;
if ($name === 'welcome') {
$row['welcome_status'] = $status;
$row['welcome_next_retry'] = $retryAt;
if (self::terminal($status)) {
$row['welcome_cipher'] = '';
}
}
$row['status'] = self::allTerminal($actions) ? 'done' : 'pending';
$retry = [];
foreach ($actions as $key => $value) {
if ($key !== 'welcome' && !self::terminal($value['status'])) {
$retry[] = (int) ($value['next_retry'] ?? 0);
}
}
$row['next_retry'] = $retry === [] ? 0 : min($retry);
$row['actions_json'] = self::json($actions);
$row['update_time'] = $now;
$this->store->save($row, ['action' => $name, 'status' => $status, 'attempt' => $action['attempts'],
'reason' => $reason, 'error_code' => $code, 'create_time' => $now]);
}
private static function action(string $status, string $reason = ''): array
{
return ['status' => $status, 'reason' => $status === 'pending' ? '' : $reason, 'attempts' => 0, 'error_code' => 0, 'next_retry' => 0];
}
private static function allTerminal(array $actions): bool
{
foreach ($actions as $action) {
if (!self::terminal($action['status'])) {
return false;
}
}
return true;
}
private static function terminal(string $status): bool
{
return in_array($status, self::TERMINAL, true);
}
private static function json(array $value): string
{
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
private function now(): int
{
return (int) ($this->clock)();
}
}
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use app\adminapi\logic\qywx\CustomerLogic;
use RuntimeException;
use think\facade\Db;
/** DB 存储与既有同步边界;单测替换此类后不初始化业务数据库。 */
class QywxPromotionAutomationStore
{
public function installed(): bool
{
return QywxPromotionConfig::installed();
}
/** State只能定位,必须再核验真实方案、正式官方链接与实际成员关系。 */
public function attribution(string $state, string $linkId, string $userId): ?array
{
if ($state !== '') {
if (!preg_match('/^zyt_pool:([1-9][0-9]{0,9})$/', $state, $match)) {
return null;
}
$poolId = (int) $match[1];
} elseif ($linkId !== '') {
$poolId = (int) Db::name('qywx_promotion_link')->where('remote_link_id', $linkId)
->where('remote_status', 1)->whereNull('delete_time')->value('pool_id');
} else {
return null;
}
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->where('status', 1)->whereNull('delete_time')->find();
$member = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', $userId)
->whereNull('delete_time')->find();
$links = Db::name('qywx_promotion_link')->where('pool_id', $poolId)->where('remote_status', 1)
->where('remote_link_id', '<>', '')->whereNull('delete_time');
if ($linkId !== '') {
$links->where('remote_link_id', $linkId);
}
// 不用 enabled/当日额度验证:真实回调可能比排班切换晚到,不能漏掉已归属该方案的成员。
if (!$pool || !$member || !$links->find()) {
return null;
}
$configRow = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
if (!$configRow) {
// 尚未保存新增配置的旧方案仍保持原同步链路,不强制依赖新worker。
return null;
}
return ['pool_id' => $poolId, 'member_admin_id' => (int) $member['admin_id'],
'config' => QywxPromotionConfig::decode($configRow['config_json'])];
}
public function enqueue(array $row): int
{
$row['welcome_code_hash'] = $row['welcome_code_hash'] ?: null;
// 同一code可能同时出现在half/add:唯一索引把欢迎语消费权固定在第一次任务。
for ($attempt = 0; $attempt < 2; $attempt++) {
if ($row['welcome_code_hash'] !== null
&& Db::name('qywx_promotion_automation_task')->where('welcome_code_hash', $row['welcome_code_hash'])->find()) {
$actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR);
$actions['welcome']['status'] = 'skipped';
$actions['welcome']['reason'] = 'same_welcome_code_already_queued';
$row['actions_json'] = json_encode($actions, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$row['welcome_status'] = 'skipped';
$row['welcome_cipher'] = '';
$row['welcome_code_hash'] = null;
$pending = array_filter($actions, static fn (array $a): bool => in_array($a['status'], ['pending', 'retry', 'running'], true));
$row['status'] = $pending === [] ? 'done' : 'pending';
}
try {
return (int) Db::name('qywx_promotion_automation_task')->insertGetId($row);
} catch (\Throwable $e) {
$existing = Db::name('qywx_promotion_automation_task')->where('event_key', $row['event_key'])->value('id');
if ($existing) {
return (int) $existing;
}
if ($attempt === 1 || $row['welcome_code_hash'] === null) {
throw $e;
}
}
}
throw new RuntimeException('推广任务入队失败');
}
/** 两条消费通道:常驻worker只发欢迎语,分钟任务不锁住尚有时效的欢迎语任务。 */
public function due(string $lane, int $now, int $limit): array
{
$query = Db::name('qywx_promotion_automation_task')->where('status', '<>', 'done')
->where('lock_until', '<=', $now);
if ($lane === 'welcome') {
$query->whereIn('welcome_status', ['pending', 'retry', 'running'])->where('welcome_next_retry', '<=', $now)
->order('welcome_expires_at', 'asc');
} else {
$query->where('next_retry', '<=', $now)->where(function ($q) use ($now) {
$q->whereNotIn('welcome_status', ['pending', 'retry', 'running'])
->whereOr('welcome_expires_at', '<=', $now);
})->order('id', 'asc');
}
return array_map('intval', $query->limit(max(1, min(500, $limit)))->column('id'));
}
public function claim(int $id, string $lane, int $now): ?array
{
return Db::transaction(function () use ($id, $lane, $now): ?array {
$row = Db::name('qywx_promotion_automation_task')->where('id', $id)->lock(true)->find();
if (!$row || $row['status'] === 'done' || (int) $row['lock_until'] > $now) {
return null;
}
$pendingWelcome = in_array($row['welcome_status'], ['pending', 'retry', 'running'], true);
if (($lane === 'welcome' && (!$pendingWelcome || (int) $row['welcome_next_retry'] > $now))
|| ($lane === 'metadata' && (($pendingWelcome && (int) $row['welcome_expires_at'] > $now) || (int) $row['next_retry'] > $now))
|| ($lane === 'inline_metadata' && (int) $row['next_retry'] > $now)) {
return null;
}
$row['lock_token'] = bin2hex(random_bytes(16));
$row['lock_until'] = $now + (in_array($lane, ['welcome', 'inline_metadata'], true) ? 30 : 600);
Db::name('qywx_promotion_automation_task')->where('id', $id)->update([
'lock_token' => $row['lock_token'], 'lock_until' => $row['lock_until'], 'update_time' => $now,
]);
return $row;
});
}
public function save(array $row, ?array $log = null): void
{
Db::transaction(function () use ($row, $log): void {
$fields = array_intersect_key($row, array_flip([
'actions_json', 'welcome_status', 'welcome_cipher', 'welcome_next_retry', 'status',
'next_retry', 'lock_until', 'update_time',
]));
// 租约令牌校验不能依赖affected rows:同秒同值更新在MySQL可能返回0。
$current = Db::name('qywx_promotion_automation_task')->where('id', $row['id'])->lock(true)->find();
if (!$current || !hash_equals((string) $current['lock_token'], (string) $row['lock_token'])) {
throw new RuntimeException('推广任务处理租约已失效');
}
Db::name('qywx_promotion_automation_task')->where('id', $row['id'])->update($fields);
if ($log !== null) {
Db::name('qywx_promotion_automation_action_log')->insert($log + ['task_id' => $row['id']]);
}
});
// 标签动作成功/终止后,以任务入队时的配置冻结事件渠道;重复调用由完成标记幂等保护。
QywxExternalContactEventTagSnapshotService::captureFromPromotionTask($row);
}
public function localNames(array $task): array
{
return [
'customer' => (string) (Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('name') ?? ''),
'employee' => (string) (Db::name('admin')->where('id', $task['member_admin_id'])->value('name') ?? ''),
];
}
public function dispatch(array $task): void
{
$result = QywxPromotionMemberSchedulerService::recordFromState('zyt_pool:' . $task['pool_id'],
$task['userid'], $task['external_userid'], (int) $task['event_time'], 'external_contact');
if (!in_array($result['status'] ?? '', ['counted', 'counted_blocked', 'counted_stale', 'duplicate'], true)) {
throw new RuntimeException('推广成员记账未完成');
}
}
public function syncRange(array $task): void
{
// range服务自身有持久重试与版本保护;此调用负责触发。
(new QywxPromotionRangeSyncService())->syncPool((int) $task['pool_id']);
}
public function syncCustomer(array $task): void
{
$started = time();
$eventId = (int) Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('user_id', (string) $task['userid'])
->where('external_userid', (string) $task['external_userid'])
->where('event_time', (int) $task['event_time'])
->value('id');
CustomerLogic::upsertSingleExternalContactFromApi(
$task['external_userid'],
$eventId,
(string) $task['userid']
);
// 旧方法在API空结果时只log并返回void;必须核验本地实际更新,避免把未同步记为成功。
$updated = (int) Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('update_time');
if ($updated < $started) {
throw new RuntimeException('推广客户资料尚未同步到本地');
}
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 一次性欢迎码仅加密短存;密钥不写数据库。多节点须显式共享环境密钥。 */
class QywxPromotionCodeCipher
{
private ?string $key;
public function __construct(?string $key = null)
{
$this->key = $key;
}
public function encrypt(string $code): string
{
$iv = random_bytes(12);
$tag = '';
$encrypted = openssl_encrypt($code, 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, $iv, $tag);
if ($encrypted === false) {
throw new RuntimeException('无法加密欢迎码');
}
return base64_encode($iv . $tag . $encrypted);
}
public function decrypt(string $cipher): string
{
$value = base64_decode($cipher, true);
if ($value === false || strlen($value) <= 28) {
throw new RuntimeException('欢迎码密文无效');
}
$code = openssl_decrypt(substr($value, 28), 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, substr($value, 0, 12), substr($value, 12, 16));
if ($code === false) {
throw new RuntimeException('欢迎码解密失败,请核对工作进程密钥');
}
return $code;
}
private function key(): string
{
if ($this->key !== null) {
if (strlen($this->key) < 32) {
throw new RuntimeException('欢迎码加密密钥至少32字符');
}
return hash('sha256', $this->key, true);
}
$configured = (string) config('qywx_promotion_automation.encryption_key', '');
if ($configured !== '') {
$this->key = $configured;
return $this->key();
}
$directory = root_path('runtime') . 'qywx_promotion_private';
if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('无法创建欢迎码私有密钥目录');
}
$path = $directory . DIRECTORY_SEPARATOR . 'welcome.key';
$stream = @fopen($path, 'c+b');
if ($stream === false) {
throw new RuntimeException('无法读取欢迎码私有密钥');
}
try {
// 首次回调和多个worker可能同时启动;读写均持锁,避免读取尚未写完的密钥。
if (!flock($stream, LOCK_EX)) {
throw new RuntimeException('无法锁定欢迎码私有密钥');
}
@chmod($path, 0600);
$key = trim((string) stream_get_contents($stream));
if ($key === '') {
$key = bin2hex(random_bytes(32));
rewind($stream);
if (fwrite($stream, $key) !== strlen($key) || !fflush($stream)) {
throw new RuntimeException('无法保存欢迎码私有密钥');
}
}
if (!preg_match('/^[0-9a-f]{64}$/', $key)) {
throw new RuntimeException('欢迎码私有密钥损坏,请恢复原密钥');
}
$this->key = $key;
} finally {
flock($stream, LOCK_UN);
fclose($stream);
}
return $this->key();
}
}
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use DateTimeImmutable;
use DateTimeZone;
use RuntimeException;
use think\facade\Db;
/** 获客方案配置。时间规则统一使用 Asia/Shanghai,结束时间不包含在时段内。 */
class QywxPromotionConfig
{
public static function defaults(): array
{
return [
'reception_mode' => 'always', 'reception_schedule' => [],
'backup_member_admin_ids' => [], 'backup_userids' => [],
'tags_enabled' => false, 'tag_ids' => [],
'remark_enabled' => false, 'remark_template' => '{customer_name}',
'description_enabled' => false, 'description' => '',
'welcome_mode' => 'default', 'welcome' => ['text' => '', 'attachments' => []],
'welcome_schedule_enabled' => false, 'welcome_schedule' => [],
];
}
public static function installed(): bool
{
try {
foreach ([
'qywx_promotion_config',
'qywx_promotion_media',
'qywx_promotion_automation_task',
'qywx_promotion_automation_action_log',
] as $table) {
if (Db::name($table)->getFields() === []) {
return false;
}
}
return true;
} catch (\Throwable $error) {
// 仅旧部署未建表时回退。数据库故障不能退回全天路由、忽略排班配置。
if (str_contains($error->getMessage(), '42S02')
|| str_contains($error->getMessage(), '1146')
|| str_contains($error->getMessage(), 'no such table')) {
return false;
}
throw $error;
}
}
public static function assertInstalled(): void
{
if (!self::installed()) {
throw new RuntimeException('请先执行 server/sql/1.9.20260831/add_wecom_promotion_automation.sql 安装获客配置与任务表');
}
}
public static function decode(mixed $json): array
{
$value = is_array($json) ? $json : json_decode((string) $json, true);
return array_replace(self::defaults(), is_array($value) ? $value : []);
}
public static function forPool(int $poolId): array
{
if (!self::installed()) {
return self::defaults();
}
return self::decode(Db::name('qywx_promotion_config')->where('pool_id', $poolId)->value('config_json'));
}
public static function save(int $poolId, array $config): void
{
self::assertInstalled();
if ($poolId <= 0) {
throw new RuntimeException('分流方案不存在,无法保存自动化配置');
}
$row = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
$json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$data = ['config_json' => $json, 'update_time' => time()];
if ($row) {
Db::name('qywx_promotion_config')->where('pool_id', $poolId)->update($data);
} else {
Db::name('qywx_promotion_config')->insert($data + ['pool_id' => $poolId, 'create_time' => time()]);
}
$saved = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->value('config_json');
if (!is_string($saved) || !hash_equals($json, $saved)) {
throw new RuntimeException('获客标签与欢迎语配置未能完整落库,请重试');
}
}
/** 不接受浏览器提供的 userid;成员归属必须经过现有后台数据权限校验后再绑定。 */
public static function normalize(array $input): array
{
$config = self::defaults();
foreach (['tags_enabled', 'remark_enabled', 'description_enabled', 'welcome_schedule_enabled'] as $key) {
$value = $input[$key] ?? false;
if (!in_array($value, [true, false, 0, 1, '0', '1'], true)) {
throw new RuntimeException('配置开关格式不正确');
}
$config[$key] = in_array($value, [true, 1, '1'], true);
}
$config['reception_mode'] = self::choice($input['reception_mode'] ?? 'always', ['always', 'scheduled']);
$config['welcome_mode'] = self::choice($input['welcome_mode'] ?? 'default', ['default', 'channel', 'none']);
$config['backup_member_admin_ids'] = self::ids($input['backup_member_admin_ids'] ?? []);
$config['reception_schedule'] = self::schedule($input['reception_schedule'] ?? [], true);
if ($config['reception_mode'] === 'scheduled' && $config['reception_schedule'] === []) {
throw new RuntimeException('自动上下线模式至少需要一个接待时段');
}
if ($config['reception_mode'] === 'scheduled' && $config['backup_member_admin_ids'] === []) {
throw new RuntimeException('自动上下线须配置备用员工,避免非接待时段官方链接仍路由给原成员');
}
if (!is_array($input['tag_ids'] ?? [])) {
throw new RuntimeException('客户标签格式不正确');
}
if (count($input['tag_ids'] ?? []) > 1) {
// 兼容旧数组字段,但不能默默截断旧方案多选;编辑时须由用户重新确认单个标签。
throw new RuntimeException('推广方案仅支持单个客户标签,请重新选择一个标签');
}
$tags = [];
foreach ($input['tag_ids'] ?? [] as $tag) {
if (!is_string($tag) || trim($tag) === '' || strlen($tag) > 128) {
throw new RuntimeException('企业微信标签 ID 不正确');
}
$tags[] = trim($tag);
}
$config['tag_ids'] = array_values(array_unique($tags));
if ($config['tags_enabled'] && count($config['tag_ids']) !== 1) {
throw new RuntimeException('启用客户标签时请选择一个企业微信标签');
}
$config['remark_template'] = self::text($input['remark_template'] ?? '{customer_name}', 200, '客户备注模板');
$config['description'] = self::text($input['description'] ?? '', 150, '客户描述');
if ($config['remark_enabled'] && $config['remark_template'] === '') {
throw new RuntimeException('请填写客户备注模板');
}
if ($config['description_enabled'] && $config['description'] === '') {
throw new RuntimeException('请填写客户描述');
}
$config['welcome'] = self::message($input['welcome'] ?? []);
$config['welcome_schedule'] = self::schedule($input['welcome_schedule'] ?? [], false);
if ($config['welcome_mode'] === 'channel') {
self::assertMessage($config['welcome']);
if ($config['welcome_schedule_enabled'] && $config['welcome_schedule'] === []) {
throw new RuntimeException('请添加分时段欢迎语');
}
}
return $config;
}
public static function matches(array $slot, int $timestamp): bool
{
$date = (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Shanghai'));
$minute = $date->format('H:i');
$start = (string) ($slot['start'] ?? '');
$end = (string) ($slot['end'] ?? '');
$weekdays = array_map('intval', (array) ($slot['weekdays'] ?? []));
$day = (int) $date->format('N');
if ($start < $end) {
return in_array($day, $weekdays, true) && $minute >= $start && $minute < $end;
}
// 跨午夜时段归属于开始日期,例如周一 22:00—02:00 包含周二凌晨。
return ($minute >= $start && in_array($day, $weekdays, true))
|| ($minute < $end && in_array($day === 1 ? 7 : $day - 1, $weekdays, true));
}
public static function render(string $template, string $customer, string $employee, int $timestamp, int $limit): string
{
$date = (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Shanghai'));
return mb_substr(strtr($template, [
'{customer_name}' => $customer, '{employee_name}' => $employee,
'{add_time}' => $date->format('Y-m-d'),
]), 0, $limit);
}
private static function schedule(mixed $value, bool $reception): array
{
if (!is_array($value) || count($value) > 30) {
throw new RuntimeException('每类时间规则最多配置 30 条');
}
$rows = [];
foreach ($value as $row) {
if (!is_array($row)) {
throw new RuntimeException('时间规则格式不正确');
}
$days = self::ids($row['weekdays'] ?? []);
if ($days === [] || max($days) > 7) {
throw new RuntimeException('请选择星期一至星期日');
}
$start = (string) ($row['start'] ?? '');
$end = (string) ($row['end'] ?? '');
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $start)
|| !preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $end) || $start === $end) {
throw new RuntimeException('时段起止时间必须不同,格式为 HH:mm;全天在线请使用全天模式');
}
$clean = ['weekdays' => $days, 'start' => $start, 'end' => $end];
if ($reception) {
$clean['member_admin_ids'] = self::ids($row['member_admin_ids'] ?? []);
if ($clean['member_admin_ids'] === []) {
throw new RuntimeException('每个接待时段至少选择一名接待成员');
}
} else {
$clean += self::message($row);
self::assertMessage($clean);
}
$rows[] = $clean;
}
if (!$reception) {
// 分时欢迎语不可重叠,避免靠数组顺序决定发送内容。
$occupied = [];
foreach ($rows as $row) {
[$sh, $sm] = array_map('intval', explode(':', $row['start']));
[$eh, $em] = array_map('intval', explode(':', $row['end']));
$from = $sh * 60 + $sm;
$to = $eh * 60 + $em;
$duration = ($to - $from + 1440) % 1440;
foreach ($row['weekdays'] as $day) {
for ($i = 0; $i < $duration; $i++) {
$key = (($day - 1) * 1440 + $from + $i) % 10080;
if (isset($occupied[$key])) {
throw new RuntimeException('分时段欢迎语的时间范围不能重叠');
}
$occupied[$key] = true;
}
}
}
}
return $rows;
}
public static function message(mixed $value): array
{
if (!is_array($value) || !is_array($value['attachments'] ?? [])) {
throw new RuntimeException('欢迎语格式不正确');
}
$attachments = array_values($value['attachments'] ?? []);
if (count($attachments) > 9) {
throw new RuntimeException('欢迎语最多添加 9 个附件');
}
// 附件的详细格式与素材权限由 API/素材服务进一步验证。
foreach ($attachments as $attachment) {
if (!is_array($attachment) || !in_array($attachment['msgtype'] ?? '', ['image', 'link', 'miniprogram', 'video', 'file'], true)) {
throw new RuntimeException('不支持的欢迎语附件类型');
}
}
$text = self::text($value['text'] ?? '', 1200, '欢迎语');
if (strlen($text) > 4000) {
throw new RuntimeException('欢迎语不能超过 4000 个 UTF-8 字节(表情通常占 4 字节)');
}
return ['text' => $text, 'attachments' => $attachments];
}
private static function assertMessage(array $message): void
{
if (trim($message['text']) === '' && $message['attachments'] === []) {
throw new RuntimeException('渠道欢迎语必须包含文字或附件');
}
}
private static function choice(mixed $value, array $choices): string
{
if (!is_string($value) || !in_array($value, $choices, true)) {
throw new RuntimeException('不支持的配置模式');
}
return $value;
}
private static function ids(mixed $value): array
{
if (!is_array($value) || count($value) > 500) {
throw new RuntimeException('成员或星期列表格式不正确');
}
$result = [];
foreach ($value as $id) {
if ((!is_int($id) && !(is_string($id) && ctype_digit($id))) || (int) $id <= 0) {
throw new RuntimeException('成员或星期 ID 必须是正整数');
}
$result[] = (int) $id;
}
return array_values(array_unique($result));
}
private static function text(mixed $value, int $limit, string $label): string
{
if (!is_string($value) || mb_strlen($value) > $limit) {
throw new RuntimeException($label . '不能超过 ' . $limit . ' 个字符');
}
return trim($value);
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 不保存 Guzzle 原异常,避免请求 URL / token / welcome_code 进入日志。 */
class QywxPromotionContactApiException extends RuntimeException
{
public function __construct(string $message, int $code = 0, public bool $uncertain = false)
{
parent::__construct($message, $code);
}
}
@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Utils;
use RuntimeException;
use think\facade\Cache;
/** 客户联系可调用自建应用;不使用对外收款应用 Secret。 */
class QywxPromotionContactApiService
{
private const PROMOTION_TAG_GROUP = '推广渠道';
private Client $client;
private string $corpId;
private string $secret;
private $tokenResolver;
public function __construct(?Client $client = null, ?callable $tokenResolver = null)
{
$this->corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''))
?: trim((string) config('pay.wechat_work.corp_id', ''));
// 获客回调的 WelcomeCode 应交由相同的可调用应用发送。专用覆盖仅用于明确配置的同应用。
$this->secret = trim((string) config('qywx_promotion_automation.contact_secret', ''))
?: (trim((string) config('qywx_customer_acquisition.secret', ''))
?: trim((string) config('pay.wechat_work.customer_contact_secret', '')));
$caPath = dirname(__DIR__, 4) . '/cacert.pem';
$this->client = $client ?? new Client([
'base_uri' => 'https://qyapi.weixin.qq.com/',
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
'verify' => is_file($caPath) ? $caPath : true, 'allow_redirects' => false,
'headers' => ['Accept' => 'application/json'],
]);
$this->tokenResolver = $tokenResolver;
}
public function credentialFingerprint(): string
{
return hash('sha256', $this->corpId . '|' . $this->secret);
}
public function tagOptions(): array
{
$result = $this->request('POST', 'externalcontact/get_corp_tag_list', []);
$groups = [];
foreach ((array) ($result['tag_group'] ?? []) as $group) {
if (!is_array($group) || !empty($group['deleted'])) {
continue;
}
$tags = [];
foreach ((array) ($group['tag'] ?? []) as $tag) {
if (is_array($tag) && empty($tag['deleted']) && !empty($tag['id'])) {
$tags[] = ['id' => (string) $tag['id'], 'name' => (string) ($tag['name'] ?? '')];
}
}
$groups[] = ['group_id' => (string) ($group['group_id'] ?? ''),
'group_name' => (string) ($group['group_name'] ?? ''), 'tag' => $tags];
}
return ['tag_groups' => $groups];
}
/**
* 自定义企业客户标签:只写固定分组,先查重;创建结果不确定时只读回,不再次创建。
* @return array{tag:array{id:string,name:string},group_id:string,group_name:string,reused:bool}
* @see https://developer.work.weixin.qq.com/document/path/92117
*/
public function createTag(string $name): array
{
if (!mb_check_encoding($name, 'UTF-8') || preg_match('/[\p{C}\x{2028}\x{2029}]/u', $name)) {
throw new RuntimeException('标签名称不能包含控制字符或不可见格式字符');
}
$name = preg_replace('/^\p{Z}+|\p{Z}+$/u', '', trim($name)) ?? '';
if ($name === '' || mb_strlen($name, 'UTF-8') > 30) {
throw new RuntimeException('标签名称须为 1-30 个字符');
}
$groups = $this->tagOptions()['tag_groups'];
$existing = $this->findPromotionTag($groups, $name, true);
if ($existing !== null) {
return $existing;
}
$body = ['tag' => [['name' => $name]]];
foreach ($groups as $group) {
if (($group['group_name'] ?? '') === self::PROMOTION_TAG_GROUP && ($group['group_id'] ?? '') !== '') {
$body['group_id'] = $group['group_id'];
break;
}
}
if (!isset($body['group_id'])) {
// 官方保证同名分组存在时向该组添加,不额外创建同名分组;空分组不受支持。
$body['group_name'] = self::PROMOTION_TAG_GROUP;
}
$failure = null;
try {
$response = $this->request('POST', 'externalcontact/add_corp_tag', $body, true);
$created = $this->findPromotionTag([(array) ($response['tag_group'] ?? [])], $name, false);
if ($created !== null) {
return $created;
}
} catch (QywxPromotionContactApiException $error) {
$failure = $error;
}
// 同名并发、上游缺失返回ID或网络中断,均只读回一次。永不构造本地伪标签ID。
try {
$confirmed = $this->findPromotionTag($this->tagOptions()['tag_groups'], $name, true);
if ($confirmed !== null) {
return $confirmed;
}
} catch (\Throwable) {
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
}
if ($failure !== null && !$failure->uncertain) {
throw new RuntimeException('企业微信标签创建失败[' . $failure->getCode() . '],请检查客户联系应用权限或标签额度', $failure->getCode());
}
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
}
private function findPromotionTag(array $groups, string $name, bool $reused): ?array
{
foreach ($groups as $group) {
if (!is_array($group) || !empty($group['deleted'])
|| ($group['group_name'] ?? '') !== self::PROMOTION_TAG_GROUP
|| !is_string($group['group_id'] ?? null) || $group['group_id'] === '') {
continue;
}
foreach ((array) ($group['tag'] ?? []) as $tag) {
if (is_array($tag) && empty($tag['deleted']) && ($tag['name'] ?? '') === $name
&& is_string($tag['id'] ?? null) && $tag['id'] !== '') {
return ['tag' => ['id' => $tag['id'], 'name' => $name],
'group_id' => $group['group_id'], 'group_name' => self::PROMOTION_TAG_GROUP, 'reused' => $reused];
}
}
}
return null;
}
public function getExternalContact(string $externalUserId, string $cursor = ''): array
{
$query = ['external_userid' => $externalUserId];
if ($cursor !== '') {
$query['cursor'] = $cursor;
}
return $this->request('GET', 'externalcontact/get', $query);
}
public function getUser(string $userId): array
{
return $this->request('GET', 'user/get', ['userid' => $userId]);
}
public function markTags(string $userId, string $externalUserId, array $tagIds): void
{
if ($tagIds === []) {
throw new RuntimeException('企业标签不能为空');
}
$this->request('POST', 'externalcontact/mark_tag', [
'userid' => $userId, 'external_userid' => $externalUserId,
'add_tag' => array_values(array_unique($tagIds)),
]);
}
public function remark(string $userId, string $externalUserId, array $fields): void
{
$body = ['userid' => $userId, 'external_userid' => $externalUserId];
foreach (['remark' => 20, 'description' => 150] as $field => $limit) {
if (isset($fields[$field]) && $fields[$field] !== '') {
if (!is_string($fields[$field]) || mb_strlen($fields[$field]) > $limit) {
throw new RuntimeException('客户备注或描述长度不正确');
}
$body[$field] = $fields[$field];
}
}
if (count($body) === 2) {
throw new RuntimeException('没有启用需要修改的备注字段');
}
$this->request('POST', 'externalcontact/remark', $body);
}
public function sendWelcome(string $code, string $text, array $attachments): void
{
if ($code === '' || strlen($code) > 1024 || strlen($text) > 4000
|| count($attachments) > 9 || ($text === '' && $attachments === [])) {
throw new RuntimeException('欢迎语内容或欢迎码格式不正确');
}
$body = ['welcome_code' => $code];
if ($text !== '') {
$body['text'] = ['content' => $text];
}
if ($attachments !== []) {
$body['attachments'] = array_values($attachments);
}
$this->request('POST', 'externalcontact/send_welcome_msg', $body, true);
}
/** 仅由私有素材服务传入受控文件流,不接受 URL 或请求提供的任意路径。 */
public function uploadMedia($stream, string $type, string $filename): array
{
if (!is_resource($stream) || !in_array($type, ['image', 'video', 'file'], true)) {
throw new RuntimeException('临时素材类型或文件流不正确');
}
return $this->request('POST', 'media/upload', ['type' => $type], false, [
'multipart' => [['name' => 'media', 'contents' => Utils::streamFor($stream), 'filename' => $filename]],
'timeout' => 45,
]);
}
/** 仅明确的 token 失效响应允许重取一次;欢迎语/标签创建的网络异常不能直接重发。 */
private function request(string $method, string $path, array $body, bool $nonIdempotent = false, array $extra = [], bool $retried = false): array
{
$token = $this->accessToken();
$options = $extra + ['query' => ['access_token' => $token]];
if ($method === 'GET' || isset($extra['multipart'])) {
$options['query'] += $body;
} else {
$options['json'] = $body === [] ? (object) [] : $body;
}
try {
$response = $this->client->request($method, 'cgi-bin/' . $path, $options);
} catch (GuzzleException) {
throw new QywxPromotionContactApiException('企业微信客户联系接口网络异常', 0, $nonIdempotent);
}
$decoded = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300
|| !is_array($decoded) || !array_key_exists('errcode', $decoded)) {
// media/upload 成功返回可没有 errcode。
if ($path === 'media/upload' && $response->getStatusCode() === 200 && is_array($decoded) && !empty($decoded['media_id'])) {
return $decoded;
}
throw new QywxPromotionContactApiException('企业微信客户联系接口响应无法确认', 0, $nonIdempotent);
}
$code = (int) $decoded['errcode'];
if ($code === 0) {
return $decoded;
}
if (!$retried && in_array($code, [40001, 40014, 42001], true)) {
if ($this->tokenResolver === null) {
Cache::delete('qywx_promotion_contact_token:' . $this->credentialFingerprint());
}
if (isset($extra['multipart'])) {
$extra['multipart'][0]['contents']->rewind();
}
return $this->request($method, $path, $body, $nonIdempotent, $extra, true);
}
// 不回显上游 errmsg;部分错误会包含请求参数与一次性凭证。
throw new QywxPromotionContactApiException('企业微信客户联系接口失败[' . $code . ']', $code);
}
private function accessToken(): string
{
if ($this->tokenResolver !== null) {
$token = (string) ($this->tokenResolver)();
if ($token === '') {
throw new RuntimeException('客户联系托管 token 为空');
}
return $token;
}
if ($this->corpId === '' || $this->secret === '') {
throw new RuntimeException('请配置客户联系可调用自建应用的 corp_id 和 Secret');
}
$key = 'qywx_promotion_contact_token:' . $this->credentialFingerprint();
$token = (string) Cache::get($key, '');
if ($token !== '') {
return $token;
}
try {
$response = $this->client->request('GET', 'cgi-bin/gettoken', [
'query' => ['corpid' => $this->corpId, 'corpsecret' => $this->secret],
]);
} catch (GuzzleException) {
throw new QywxPromotionContactApiException('获取客户联系 token 网络异常');
}
$data = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() !== 200 || !is_array($data)
|| (int) ($data['errcode'] ?? 0) !== 0 || empty($data['access_token'])) {
throw new QywxPromotionContactApiException('获取客户联系 token 失败', (int) ($data['errcode'] ?? 0));
}
$token = (string) $data['access_token'];
Cache::set($key, $token, max(60, (int) ($data['expires_in'] ?? 7200) - 300));
return $token;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
class QywxPromotionEnqueueException extends \RuntimeException
{
}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
use think\file\UploadedFile;
/** 私有源文件 + 可刷新三天临时素材。欢迎语关键路径仅使用缓存,不下载/上传文件。 */
class QywxPromotionMediaService
{
private QywxPromotionContactApiService $api;
private QywxPromotionMediaStore $store;
private string $root;
public function __construct(?QywxPromotionContactApiService $api = null, ?QywxPromotionMediaStore $store = null, ?string $root = null)
{
$this->api = $api ?? new QywxPromotionContactApiService();
$this->store = $store ?? new QywxPromotionMediaStore();
// runtime_path()在adminapi/api/CLI间不同;使用项目级私有目录保证上传与worker共享。
$this->root = rtrim($root ?? (root_path('runtime') . 'qywx_promotion_private' . DIRECTORY_SEPARATOR . 'media'), '/\\');
}
/** @return array{asset_id:string,name:string,type:string} */
public function upload($file, string $type, int $adminId): array
{
if ($adminId <= 0 || !$file instanceof UploadedFile || !$file->isValid()) {
throw new RuntimeException('请上传有效文件');
}
if (!in_array($type, ['image', 'video', 'file'], true)) {
throw new RuntimeException('素材类型仅支持 image、video、file');
}
$size = (int) $file->getSize();
$limit = ($type === 'file' ? 20 : 10) * 1024 * 1024;
if ($size <= 5 || $size > $limit) {
throw new RuntimeException($type === 'file' ? '文件须大于5字节且不超过20MB' : '图片/视频须大于5字节且不超过10MB');
}
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($file->getPathname());
$name = str_replace('\\', '/', $file->getOriginalName());
$name = mb_substr(preg_replace('/[\x00-\x1f\x7f]/u', '', basename($name)) ?? '', 0, 180);
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($type === 'image') {
$info = @getimagesize($file->getPathname());
if (!in_array($mime, ['image/jpeg', 'image/png'], true) || $info === false
|| !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true)) {
throw new RuntimeException('图片仅支持真实 JPG/PNG 文件');
}
$extension = $mime === 'image/png' ? 'png' : 'jpg';
} elseif ($type === 'video') {
if ($mime !== 'video/mp4' || $extension !== 'mp4') {
throw new RuntimeException('视频仅支持 MP4');
}
} else {
// 私有存储也拒绝可执行内容/HTML/SVG;按实际 MIME 与扩展名双重检查。
$allowed = [
'pdf' => ['application/pdf'], 'txt' => ['text/plain'], 'csv' => ['text/plain', 'text/csv', 'application/csv'],
'doc' => ['application/msword', 'application/x-ole-storage', 'application/CDFV2'],
'xls' => ['application/vnd.ms-excel', 'application/x-ole-storage', 'application/CDFV2'],
'ppt' => ['application/vnd.ms-powerpoint', 'application/x-ole-storage', 'application/CDFV2'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip'],
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip'],
'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/zip'],
'zip' => ['application/zip'], 'jpg' => ['image/jpeg'], 'jpeg' => ['image/jpeg'], 'png' => ['image/png'],
'mp4' => ['video/mp4'],
];
if (!isset($allowed[$extension]) || !in_array($mime, $allowed[$extension], true)) {
throw new RuntimeException('不支持该文件格式,请上传PDF、Office、文本、ZIP、JPG/PNG或MP4');
}
}
if ($name === '') {
$name = '素材.' . $extension;
}
$this->ensureRoot();
$assetId = bin2hex(random_bytes(24));
$storageName = $assetId . '.' . $extension;
$hash = hash_file('sha256', $file->getPathname());
$file->move($this->root, $storageName);
@chmod($this->root . DIRECTORY_SEPARATOR . $storageName, 0600);
try {
$this->store->insert([
'asset_id' => $assetId, 'admin_id' => $adminId, 'name' => $name, 'type' => $type,
'mime' => $mime, 'size' => $size, 'sha256' => $hash, 'storage_name' => $storageName,
'media_id' => '', 'media_expires_at' => 0, 'credential_hash' => '',
'last_error' => '', 'create_time' => time(), 'update_time' => time(),
]);
} catch (\Throwable $e) {
@unlink($this->root . DIRECTORY_SEPARATOR . $storageName);
throw new RuntimeException('素材入库失败,请确认已安装推广自动化数据表', 0, $e);
}
// 配置阶段就上传企微素材。失败保留私有文件供后续排障,不对外提供文件路径。
$this->mediaId($assetId, $type, true);
return ['asset_id' => $assetId, 'name' => $name, 'type' => $type];
}
/** 旧方案授权由上层完成;只白名单旧配置实际已有资产,不接受请求单独声明的白名单。 */
public function validateConfig(array $config, int $adminId, array $existingConfig = []): array
{
$allowed = self::assetIds($existingConfig);
$config['welcome']['attachments'] = $this->validateAttachments((array) ($config['welcome']['attachments'] ?? []), $adminId, $allowed);
foreach ((array) ($config['welcome_schedule'] ?? []) as $index => $slot) {
$config['welcome_schedule'][$index]['attachments'] = $this->validateAttachments((array) ($slot['attachments'] ?? []), $adminId, $allowed);
}
return $config;
}
public function validateAttachments(array $attachments, int $adminId, array $allowedAssetIds = []): array
{
if (count($attachments) > 9) {
throw new RuntimeException('欢迎语最多9个附件');
}
$clean = [];
foreach ($attachments as $attachment) {
if (!is_array($attachment)) {
throw new RuntimeException('附件格式不正确');
}
$type = (string) ($attachment['msgtype'] ?? '');
$body = $attachment[$type] ?? null;
if (!is_array($body)) {
throw new RuntimeException('附件内容类型不匹配');
}
if (in_array($type, ['image', 'video', 'file'], true)) {
// image.pic_url 限企微 uploadimg URL;本服务仅接受私有资产,避免伪装任意外部地址。
$asset = $this->authorizedAsset((string) ($body['asset_id'] ?? ''), $type, $adminId, $allowedAssetIds);
$body = ['asset_id' => $asset['asset_id']];
} elseif ($type === 'link') {
$body = [
'title' => self::bytes($body['title'] ?? '', 128, '链接标题', true),
'url' => self::url($body['url'] ?? ''),
'desc' => self::bytes($body['desc'] ?? '', 512, '链接描述'),
] + (!empty($body['picurl']) ? ['picurl' => self::url($body['picurl'])] : []);
} elseif ($type === 'miniprogram') {
$asset = $this->authorizedAsset((string) ($body['pic_asset_id'] ?? ''), 'image', $adminId, $allowedAssetIds);
$appid = (string) ($body['appid'] ?? '');
$page = self::bytes($body['page'] ?? '', 1024, '小程序页面', true);
if (!preg_match('/^wx[0-9a-fA-F]{16}$/', $appid) || str_contains($page, '://')
|| str_contains($page, '..') || preg_match('/[\x00-\x1f]/', $page)) {
throw new RuntimeException('小程序 appid 或页面路径不正确');
}
$body = ['title' => self::bytes($body['title'] ?? '', 64, '小程序标题', true),
'appid' => $appid, 'page' => $page, 'pic_asset_id' => $asset['asset_id']];
} else {
throw new RuntimeException('不支持的附件类型');
}
$clean[] = ['msgtype' => $type, $type => $body];
}
return $clean;
}
/** 仅处理已授权并持久化的配置快照;绝不在欢迎语发送时进行网络文件上传。 */
public function materialize(array $attachments, array $config): array
{
$attachments = $this->validateAttachments($attachments, 0, self::assetIds($config));
foreach ($attachments as &$attachment) {
$type = $attachment['msgtype'];
if (in_array($type, ['image', 'video', 'file'], true)) {
$attachment[$type] = ['media_id' => $this->mediaId($attachment[$type]['asset_id'], $type, false)];
} elseif ($type === 'miniprogram') {
$attachment[$type]['pic_media_id'] = $this->mediaId($attachment[$type]['pic_asset_id'], 'image', false);
unset($attachment[$type]['pic_asset_id']);
}
}
unset($attachment);
return $attachments;
}
public function refreshReferenced(int $limit = 100): array
{
$result = ['selected' => 0, 'refreshed' => 0, 'failed' => 0];
foreach ($this->store->referencedAssetIds() as $id) {
$asset = $this->store->find($id);
if (!$asset || ($this->cacheValid($asset, 3600))) {
continue;
}
if ($result['selected'] >= max(1, $limit)) {
break;
}
++$result['selected'];
try {
$this->mediaId($id, $asset['type'], true, 3600);
++$result['refreshed'];
} catch (\Throwable) {
++$result['failed'];
}
}
return $result;
}
public static function assetIds(array $config): array
{
$ids = [];
$messages = array_merge([(array) ($config['welcome'] ?? [])], (array) ($config['welcome_schedule'] ?? []));
foreach ($messages as $message) {
foreach ((array) ($message['attachments'] ?? []) as $attachment) {
$type = $attachment['msgtype'] ?? '';
$key = $type === 'miniprogram' ? 'pic_asset_id' : 'asset_id';
$id = (string) ($attachment[$type][$key] ?? '');
if (preg_match('/^[0-9a-f]{48}$/', $id)) {
$ids[] = $id;
}
}
}
return array_values(array_unique($ids));
}
private function authorizedAsset(string $id, string $type, int $adminId, array $allowed): array
{
if (!preg_match('/^[0-9a-f]{48}$/', $id)) {
throw new RuntimeException('请先上传欢迎语素材');
}
$asset = $this->store->find($id);
if (!$asset || $asset['type'] !== $type || ((int) $asset['admin_id'] !== $adminId && !in_array($id, $allowed, true))) {
throw new RuntimeException('素材不存在、类型不匹配或无权使用');
}
return $asset;
}
private function mediaId(string $id, string $type, bool $allowUpload, int $margin = 300): string
{
$asset = $this->store->find($id);
if (!$asset || $asset['type'] !== $type) {
throw new RuntimeException('欢迎语素材不存在');
}
if ($this->cacheValid($asset, $margin)) {
return (string) $asset['media_id'];
}
if (!$allowUpload) {
throw new RuntimeException('欢迎语素材未预热或已过期,请检查素材刷新任务');
}
$stream = null;
try {
$path = $this->privatePath((string) $asset['storage_name']);
if (!is_file($path) || hash_file('sha256', $path) !== $asset['sha256']) {
throw new RuntimeException('欢迎语源文件缺失或完整性检查失败');
}
$stream = fopen($path, 'rb');
$result = $this->api->uploadMedia($stream, $type, (string) $asset['name']);
if (empty($result['media_id'])) {
throw new RuntimeException('企微素材接口未返回 media_id');
}
$created = min(time(), (int) ($result['created_at'] ?? time()));
$this->store->update($id, ['media_id' => (string) $result['media_id'],
'media_expires_at' => $created + 3 * 86400, 'credential_hash' => $this->api->credentialFingerprint(),
'last_error' => '', 'update_time' => time()]);
return (string) $result['media_id'];
} catch (\Throwable $e) {
$this->store->update($id, ['last_error' => '素材预热失败[' . (int) $e->getCode() . ']', 'update_time' => time()]);
throw $e;
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
}
private function cacheValid(array $asset, int $margin): bool
{
return !empty($asset['media_id']) && (int) $asset['media_expires_at'] > time() + $margin
&& hash_equals((string) $asset['credential_hash'], $this->api->credentialFingerprint());
}
private function privatePath(string $name): string
{
if (!preg_match('/^[0-9a-f]{48}\.[a-z0-9]{1,8}$/', $name)) {
throw new RuntimeException('素材存储标识不正确');
}
$root = realpath($this->root);
$path = realpath($this->root . DIRECTORY_SEPARATOR . $name);
if ($root === false || $path === false || !str_starts_with($path, $root . DIRECTORY_SEPARATOR)) {
throw new RuntimeException('素材文件不在私有存储目录');
}
return $path;
}
private function ensureRoot(): void
{
if (!is_dir($this->root) && !mkdir($this->root, 0700, true) && !is_dir($this->root)) {
throw new RuntimeException('无法创建私有素材目录');
}
}
private static function bytes(mixed $value, int $limit, string $label, bool $required = false): string
{
if (!is_string($value) || strlen($value) > $limit || ($required && trim($value) === '')) {
throw new RuntimeException($label . '须' . ($required ? '非空且' : '') . '不超过' . $limit . '字节');
}
return trim($value);
}
private static function url(mixed $value): string
{
if (!is_string($value) || strlen($value) > 2048 || filter_var($value, FILTER_VALIDATE_URL) === false) {
throw new RuntimeException('链接地址不正确');
}
$parts = parse_url($value);
if (!in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
|| isset($parts['user']) || isset($parts['pass'])) {
throw new RuntimeException('链接仅支持不含账号密码的HTTP(S)地址');
}
// 仅向企微传递链接;服务端永远不会抓取这些URL。
return $value;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use think\facade\Db;
/** 独立存储边界,测试可使用内存替身,禁止连接业务数据库。 */
class QywxPromotionMediaStore
{
public function find(string $assetId): ?array
{
return Db::name('qywx_promotion_media')->where('asset_id', $assetId)->find() ?: null;
}
public function insert(array $row): void
{
Db::name('qywx_promotion_media')->insert($row);
}
public function update(string $assetId, array $fields): void
{
Db::name('qywx_promotion_media')->where('asset_id', $assetId)->update($fields);
}
/** 只预热已保存方案引用的素材;未使用上传不永久续期。 */
public function referencedAssetIds(): array
{
$ids = [];
foreach (Db::name('qywx_promotion_config')->alias('cfg')
->join('qywx_promotion_pool pool', 'pool.id = cfg.pool_id')
->whereNull('pool.delete_time')->column('cfg.config_json') as $json) {
$ids = array_merge($ids, QywxPromotionMediaService::assetIds(QywxPromotionConfig::decode($json)));
}
return array_values(array_unique($ids));
}
}
@@ -11,9 +11,22 @@ class QywxPromotionMemberRange
* @param list<array<string,mixed>> $members
* @return array{userids:list<string>,members:list<array<string,mixed>>,eligible_count:int}
*/
public static function evaluate(array $members, string $today, int $now): array
public static function evaluate(array $members, string $today, int $now, array $config = []): array
{
$userIds = [];
$backups = array_fill_keys((array) ($config['backup_userids'] ?? []), true);
$backupIds = [];
$scheduled = ($config['reception_mode'] ?? 'always') === 'scheduled';
$scheduledUsers = [];
if ($scheduled) {
foreach ((array) ($config['reception_schedule'] ?? []) as $slot) {
if (QywxPromotionConfig::matches($slot, $now)) {
foreach ((array) ($slot['member_userids'] ?? []) as $userId) {
$scheduledUsers[$userId] = true;
}
}
}
}
foreach ($members as &$member) {
if ((string) ($member['today_date'] ?? '') !== $today) {
$member['today_date'] = $today;
@@ -25,15 +38,24 @@ class QywxPromotionMemberRange
}
$userId = trim((string) ($member['userid'] ?? ''));
if ($userId !== '') {
$userIds[$userId] = true;
if (isset($backups[$userId])) {
$backupIds[$userId] = true;
} elseif (!$scheduled || isset($scheduledUsers[$userId])) {
$userIds[$userId] = true;
}
}
}
unset($member);
$usingBackup = $userIds === [] && $backupIds !== [];
if ($usingBackup) {
$userIds = $backupIds;
}
return [
'userids' => array_keys($userIds),
'members' => array_values($members),
'eligible_count' => count($userIds),
'using_backup' => $usingBackup,
];
}
@@ -253,7 +253,7 @@ class QywxPromotionMemberSchedulerService
string $today,
int $now
): array {
$range = QywxPromotionMemberRange::evaluate($members, $today, $now);
$range = QywxPromotionMemberRange::evaluate($members, $today, $now, QywxPromotionConfig::forPool($poolId));
self::persistMemberCursors($range['members'], $now);
if ($range['userids'] === []) {
self::upsertSync($poolId, $linkId, false, $sync, $now, '所有成员均已禁用、未生效或达到今日上限');
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/** 分流方案共享操作人产生的页面入口与专用数据范围。 */
final class QywxPromotionOperatorAccess
{
public const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
public static function hasBasePagePermission(int $adminId, array $adminInfo = []): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
if ($adminId <= 0) {
return false;
}
return Db::name('admin_role')->alias('ar')
->join('system_role_menu rm', 'rm.role_id = ar.role_id')
->join('system_menu m', 'm.id = rm.menu_id')
->where('ar.admin_id', $adminId)
->where('m.perms', self::PAGE_PERMISSION)
->where('m.is_disable', 0)
->count() > 0;
}
public static function hasSharedPagePermission(int $adminId): bool
{
if ($adminId <= 0 || !self::pageMenuEnabled()) {
return false;
}
try {
return Db::name('qywx_promotion_pool_operator')->alias('po')
->join('qywx_promotion_pool p', 'p.id = po.pool_id')
->where('po.admin_id', $adminId)
->whereNull('po.delete_time')
->whereNull('p.delete_time')
->count() > 0;
} catch (\Throwable $error) {
if (self::isMissingTable($error)) {
return false;
}
throw $error;
}
}
public static function hasPagePermission(int $adminId, array $adminInfo = []): bool
{
return self::hasBasePagePermission($adminId, $adminInfo)
|| self::hasSharedPagePermission($adminId);
}
/** 基础页面权限沿用角色数据范围;纯共享账号只能通过 operator pool 范围访问。 */
public static function visibleAdminIds(int $adminId, array $adminInfo): ?array
{
return self::hasBasePagePermission($adminId, $adminInfo)
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
: [];
}
/** @return list<int> */
public static function activePoolIds(int $adminId): array
{
if ($adminId <= 0) {
return [];
}
try {
$ids = Db::name('qywx_promotion_pool_operator')->alias('po')
->join('qywx_promotion_pool p', 'p.id = po.pool_id')
->where('po.admin_id', $adminId)
->whereNull('po.delete_time')
->whereNull('p.delete_time')
->column('po.pool_id');
} catch (\Throwable $error) {
if (self::isMissingTable($error)) {
return [];
}
throw $error;
}
return array_values(array_unique(array_filter(array_map(
static fn ($value): int => (int) $value,
$ids
), static fn (int $value): bool => $value > 0)));
}
private static function pageMenuEnabled(): bool
{
return Db::name('system_menu')
->where('perms', self::PAGE_PERMISSION)
->where('is_disable', 0)
->count() > 0;
}
private static function isMissingTable(\Throwable $error): bool
{
$message = strtolower($error->getMessage());
return str_contains($message, '42s02')
|| str_contains($message, '1146')
|| str_contains($message, 'no such table');
}
}
@@ -66,7 +66,7 @@ class QywxPromotionRangeSyncService
if ($remoteLinkId === '') {
throw new RuntimeException('官方链接 ID 为空');
}
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time());
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId));
$desiredUserIds = $range['userids'];
if ($desiredUserIds === []) {
$message = '所有成员均已禁用、未生效或达到今日上限;企业微信官方链接至少需要保留一名成员';
@@ -93,7 +93,7 @@ class QywxPromotionRangeSyncService
$this->api->updateLink([
'link_id' => $remoteLinkId,
'link_name' => mb_substr((string) ($pool['name'] ?? '获客分流方案'), 0, 30),
'range' => ['user_list' => $desiredUserIds],
'range' => ['user_list' => $desiredUserIds, 'department_list' => []],
'skip_verify' => (int) ($link['skip_verify'] ?? 0) === 1,
]);
$response = $this->api->getLink($remoteLinkId);
@@ -116,13 +116,19 @@ class Qcloud extends Server
/**
* @notes 获取 STS 临时凭证(用于浏览器直传)
* @param string $keyPrefix 资源前缀,如 uploads/video/20260508/
* @param string $keyScope 资源前缀或完整对象 Key
* @param int $maxSizeBytes 单文件大小上限(字节)
* @param int $durationSeconds 凭证有效期(秒)
* @param bool $exactObject 是否只授权单个对象 Key
* @return array {credentials, expiredTime, requestId}
* @throws Exception
*/
public function getStsCredentials(string $keyPrefix, int $maxSizeBytes, int $durationSeconds = 1800): array
public function getStsCredentials(
string $keyScope,
int $maxSizeBytes,
int $durationSeconds = 1800,
bool $exactObject = false
): array
{
$bucket = $this->config['bucket'];
// bucket 形如 likeadmin-1300000000appId 即末段
@@ -137,15 +143,19 @@ class Qcloud extends Server
$shortBucket = substr($bucket, 0, strrpos($bucket, '-'));
$region = $this->config['region'];
$prefix = ltrim($keyPrefix, '/');
if ($prefix === '' || substr($prefix, -1) !== '/') {
$prefix = $prefix . '/';
$scope = ltrim($keyScope, '/');
if ($scope === '') {
throw new Exception('COS 授权对象不能为空');
}
if (!$exactObject && substr($scope, -1) !== '/') {
$scope .= '/';
}
$duration = max(900, min($durationSeconds, 7200));
// 自行构造 policy:对象级写动作收紧 + bucket 级 ListMultipartUploadscos-js-sdk-v5 续传探测必需)
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s*', $region, $appId, $bucket, $prefix);
$objectResource = $exactObject ? $scope : $scope . '*';
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s', $region, $appId, $bucket, $objectResource);
$bucketArn = sprintf('qcs::cos:%s:uid/%s:%s/*', $region, $appId, $bucket);
$policy = [
+3
View File
@@ -34,6 +34,9 @@ return [
'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
// 回调确认实际承接成员后,按权重/上限切换同一条官方获客链接的成员范围
'qywx:sync-promotion-ranges' => 'app\\command\\QywxSyncPromotionRanges',
'qywx:work-promotion-automation' => 'app\\command\\QywxWorkPromotionAutomation',
'qywx:retry-promotion-automation' => 'app\\command\\QywxRetryPromotionAutomation',
'qywx:refresh-promotion-media' => 'app\\command\\QywxRefreshPromotionMedia',
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
@@ -0,0 +1,8 @@
<?php
return [
// 缺省使用获客助手相同的可调用自建应用,绝不回退对外收款 Secret。
'contact_secret' => env('WECHAT_WORK_PROMOTION_CONTACT_SECRET', ''),
// 至少32字符的随机值;多节点必须使用同一密钥。缺省在私有runtime目录生成0600密钥。
'encryption_key' => env('WECHAT_WORK_PROMOTION_ENCRYPTION_KEY', ''),
];
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_event` (
PRIMARY KEY (`id`),
UNIQUE KEY `uk_change_user_ext_time` (`change_type`, `user_id`, `external_userid`, `event_time`),
KEY `idx_change_time` (`change_type`, `event_time`),
KEY `idx_change_ext_time` (`change_type`, `external_userid`, `event_time`, `id`),
KEY `idx_event_time` (`event_time`),
KEY `idx_state` (`state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业微信外部联系人事件流水(用于进入计数)';
@@ -0,0 +1,93 @@
-- 企业微信新增事件标签快照:历史渠道归属只追加,不随当前标签的修改/删除而回落。
-- 上线顺序:先执行本迁移,再发布读取 qywx_external_contact_event_tag 的代码。
CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_event_tag` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`event_id` bigint unsigned NOT NULL COMMENT 'qywx_external_contact_event.id',
`follow_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '产生新增事件的企微员工userid',
`tag_id` varchar(64) NOT NULL DEFAULT '' COMMENT '事件发生时标签ID;空串为快照完成标记',
`tag_name` varchar(128) NOT NULL DEFAULT '' COMMENT '事件发生时标签名',
`group_name` varchar(128) NOT NULL DEFAULT '' COMMENT '事件发生时标签组名',
`snapshot_source` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '1客户详情 2推广任务 3上线时当前关系回填',
`create_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_event_user_tag` (`event_id`, `follow_user_id`, `tag_id`),
KEY `idx_tag_event_user` (`tag_id`, `event_id`, `follow_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微新增事件标签快照(append-only';
-- 优先追加推广自动化任务中已确认成功的标签证据;这里不写完成标记,
-- 因为任务配置不能证明客户当时没有其他企微标签。
INSERT IGNORE INTO `zyt_qywx_external_contact_event_tag`
(`event_id`, `follow_user_id`, `tag_id`, `tag_name`, `group_name`, `snapshot_source`, `create_time`)
SELECT
e.`id`,
e.`user_id`,
LEFT(
COALESCE(
JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(t.`config_json`), t.`config_json`, '{}'), '$.tag_ids[0]')),
''
),
64
),
'',
'',
2,
UNIX_TIMESTAMP()
FROM `zyt_qywx_promotion_automation_task` t
INNER JOIN `zyt_qywx_external_contact_event` e
ON e.`change_type` = t.`change_type`
AND e.`user_id` = t.`userid`
AND e.`external_userid` = t.`external_userid`
AND e.`event_time` = t.`event_time`
WHERE t.`change_type` = 'add_external_contact'
AND JSON_UNQUOTE(
JSON_EXTRACT(IF(JSON_VALID(t.`actions_json`), t.`actions_json`, '{}'), '$.tags.status')
) = 'success'
AND COALESCE(
JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(t.`config_json`), t.`config_json`, '{}'), '$.tag_ids[0]')),
''
) <> '';
-- 尚未完成快照的老事件,仅按同一员工的当前标签尽力冻结;不做跨员工补偿。
INSERT IGNORE INTO `zyt_qywx_external_contact_event_tag`
(`event_id`, `follow_user_id`, `tag_id`, `tag_name`, `group_name`, `snapshot_source`, `create_time`)
SELECT
e.`id`,
e.`user_id`,
current_tag.`tag_id`,
current_tag.`tag_name`,
current_tag.`group_name`,
3,
UNIX_TIMESTAMP()
FROM `zyt_qywx_external_contact_event` e
INNER JOIN `zyt_qywx_external_contact_tag` current_tag
ON current_tag.`external_userid` = e.`external_userid`
AND current_tag.`follow_user_id` = e.`user_id`
WHERE e.`change_type` = 'add_external_contact'
AND EXISTS (
SELECT 1
FROM `zyt_qywx_external_contact` active_contact
WHERE active_contact.`external_userid` = e.`external_userid`
AND active_contact.`delete_time` IS NULL
)
AND NOT EXISTS (
SELECT 1
FROM `zyt_qywx_external_contact_event_tag` existing_snapshot
WHERE existing_snapshot.`event_id` = e.`id`
AND existing_snapshot.`follow_user_id` = e.`user_id`
AND existing_snapshot.`tag_id` = ''
);
-- 给“当前关系回填”补完成标记,确保后续改标签不会再追加到同一历史事件。
INSERT IGNORE INTO `zyt_qywx_external_contact_event_tag`
(`event_id`, `follow_user_id`, `tag_id`, `tag_name`, `group_name`, `snapshot_source`, `create_time`)
SELECT DISTINCT
snapshot_row.`event_id`,
snapshot_row.`follow_user_id`,
'',
'',
'',
3,
UNIX_TIMESTAMP()
FROM `zyt_qywx_external_contact_event_tag` snapshot_row
WHERE snapshot_row.`snapshot_source` = 3
AND snapshot_row.`tag_id` <> '';
+2
View File
@@ -38,6 +38,8 @@ HOST = "https://公开访问域名"
创建分流方案时必须选择一名或多名医助。一个方案只创建一条企业微信官方链接,当前全部可用医助会同时写入该链接的 `range.user_list`,由企业微信在打开、添加阶段执行官方多人路由。成员可配置启用状态、每日上限和有效时间,系统按实际获客回调累计数量。
批量修改方案支持按员工统一上线或下线。操作只影响所选方案中已经存在的员工规则,不会把员工自动加入其他方案;下线操作如果会令任一方案没有当前可用的上线员工,则整批在写入前拒绝。员工上下线需与其他方案配置分开保存;状态保存与同步意图在同一事务内提交,每个受影响方案只重算一次成员范围,并进入企业微信后台同步队列。
企业微信 `range.user_list` 只接受成员 userid 列表,不提供逐成员权重字段,因此本站不再展示或执行 2:1:1 一类权重规则。官方多人路由的实际承接还会受到成员可服务状态、客户已有好友关系等企业微信规则影响,不能承诺每次刷新严格随机或短期样本绝对平均。
系统只使用企业微信获客助手生成的链接:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import t from"./error-BRBhqisw.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-Dwh6tNxD.js";import"./index-CLwe6ftX.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-ypE3v-gF.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-CeZEm9rq.js";import"./index-vflU_jNS.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 @@
import e from"./error-BRBhqisw.js";import{o,q as r,r as t,v as s}from"./.pnpm-Dwh6tNxD.js";import"./index-CLwe6ftX.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-ypE3v-gF.js";import{o,q as r,r as t,v as s}from"./.pnpm-CeZEm9rq.js";import"./index-vflU_jNS.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 @@
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-Dwh6tNxD.js";import{a as V}from"./doctor-CD6XYjKU.js";import{m as A,_ as M}from"./index-CLwe6ftX.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-CeZEm9rq.js";import{a as V}from"./doctor-BR8GzTlW.js";import{m as A,_ as M}from"./index-vflU_jNS.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 @@
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-Dwh6tNxD.js";import{ae as V}from"./tcm-BzzBMfBw.js";import{_ as q}from"./index-CLwe6ftX.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-CeZEm9rq.js";import{af as V}from"./tcm-BmNVoRI3.js";import{_ as q}from"./index-vflU_jNS.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 @@
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,dg as c}from"./.pnpm-Dwh6tNxD.js";import{af as Y}from"./tcm-BzzBMfBw.js";import{_ as q}from"./index-CLwe6ftX.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(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}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 M(){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:M},{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};
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,di as c}from"./.pnpm-CeZEm9rq.js";import{ag as Y}from"./tcm-BmNVoRI3.js";import{_ as q}from"./index-vflU_jNS.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(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}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 M(){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:M},{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};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{o as N,di 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 j,T as u,s as y,bi as z,M as v}from"./.pnpm-Dwh6tNxD.js";import M from"./RecordingPlaybackBlock-CMHUdU8f.js";import{U as k}from"./index-BWeecgSM.js";import{i as c,_ as q}from"./index-CLwe6ftX.js";import{aj as K,ak as x,al as A}from"./tcm-BzzBMfBw.js";import"./RecordingVideoPlayer-CDqcv1v9.js";import"./file-CAEOOa9_.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 x({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 x({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=j,I=L,B=z;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{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(M,{"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(k,{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};
import{o as N,dk 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-CeZEm9rq.js";import j from"./RecordingPlaybackBlock-DGBQb6Ox.js";import{U as k}from"./index-BP4odEtD.js";import{i as c,_ as q}from"./index-vflU_jNS.js";import{ak as K,al as x,am as A}from"./tcm-BmNVoRI3.js";import"./RecordingVideoPlayer-jwST1qx7.js";import"./file-BXLECkux.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 x({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 x({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(k,{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(k,{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 @@
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-Dwh6tNxD.js";import{am as q}from"./tcm-BzzBMfBw.js";import{_ as H}from"./index-CLwe6ftX.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-CeZEm9rq.js";import{an as q}from"./tcm-BmNVoRI3.js";import{_ as H}from"./index-vflU_jNS.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};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-C6AeSFwh.js";import"./.pnpm-CeZEm9rq.js";import"./tcm-BmNVoRI3.js";import"./index-vflU_jNS.js";export{o as default};
@@ -1 +0,0 @@
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-hLNJ9AIP.js";import"./.pnpm-Dwh6tNxD.js";import"./tcm-BzzBMfBw.js";import"./index-CLwe6ftX.js";export{o as default};
@@ -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-Dwh6tNxD.js";import{p as j}from"./tcm-BzzBMfBw.js";import{i as C}from"./index-CLwe6ftX.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-CeZEm9rq.js";import{p as j}from"./tcm-BmNVoRI3.js";import{i as C}from"./index-vflU_jNS.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 @@
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 se,a8 as ae,E as C}from"./.pnpm-Dwh6tNxD.js";import{d as te}from"./dayjs-Cbxn44tS.js";import{ar as ne,as as oe}from"./tcm-BzzBMfBw.js";import{p as re}from"./im-business-message-parse-CVnz1EnV.js";import{_ as le}from"./index-CLwe6ftX.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=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):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,a)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=se,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[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}),a[1]||(a[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}),a[2]||(a[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,s=>(t(),n("div",{key:s.raw.msg_id,class:X(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(V,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(Y,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.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,cX 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 X,M as m,p as Q,ae as U,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-CeZEm9rq.js";import{d as te}from"./dayjs-CyERfvvz.js";import{as as ne,at as oe}from"./tcm-BmNVoRI3.js";import{p as re}from"./im-business-message-parse-Bzp_WHkq.js";import{_ as le}from"./index-vflU_jNS.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=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=Q(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):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,a)=>{const l=G,k=H,I=j,T=Z,Y=ee,V=se,z=W,A=X;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[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}),a[1]||(a[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}),a[2]||(a[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,s=>(t(),n("div",{key:s.raw.msg_id,class:U(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(Y,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(V,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.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 @@
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-B_HCfW5z.js";import"./.pnpm-Dwh6tNxD.js";export{m as default};
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-CF1cFioe.js";import"./.pnpm-CeZEm9rq.js";export{m as default};
@@ -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-Dwh6tNxD.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-CeZEm9rq.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 @@
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-Dwh6tNxD.js";import{t as j,_ as J}from"./index-CLwe6ftX.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-CeZEm9rq.js";import{t as j,_ as J}from"./index-vflU_jNS.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};
@@ -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,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-Dwh6tNxD.js";import{_ as fe}from"./picker-Dr1-lH_q.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-CLwe6ftX.js";import{a as T,d as he}from"./patient-4QJ3YgMJ.js";import{h as ke}from"./perm-CHalL7pT.js";import"./index-De13kk0O.js";import"./index-BhX1mXHA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-DUJcOhOZ.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.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(`
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,d9 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-CeZEm9rq.js";import{_ as fe}from"./picker-DeM3E1-N.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-vflU_jNS.js";import{a as T,d as he}from"./patient-DPw8Zw1m.js";import{h as ke}from"./perm-BCohqYdQ.js";import"./index-CxXw87QU.js";import"./index-mw3GnHGJ.js";import"./index.vue_vue_type_script_setup_true_lang-7atNyADr.js";import"./index-IdU3279O.js";import"./index-BP4odEtD.js";import"./file-BXLECkux.js";import"./index.vue_vue_type_script_setup_true_lang-DMxuZ4Hl.js";import"./usePaging-DUs81Q_K.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
File diff suppressed because one or more lines are too long
@@ -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-Dwh6tNxD.js";import{_ as V}from"./index-CLwe6ftX.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-CeZEm9rq.js";import{_ as V}from"./index-vflU_jNS.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};
@@ -0,0 +1 @@
@charset "UTF-8";.po-detail-drawer[data-v-3fe6d04f] .el-drawer__header{margin-bottom:0;padding:16px 24px;border-bottom:1px solid var(--el-border-color-lighter)}.stat-card[data-v-3fe6d04f]{border-radius:8px}.stat-title[data-v-3fe6d04f]{font-weight:500}.po-panel[data-v-3fe6d04f]{border-radius:8px;transition:all .3s}.po-panel[data-v-3fe6d04f] .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-3fe6d04f] .el-card__body{padding:16px}.po-desc[data-v-3fe6d04f] .el-descriptions__label{width:120px;color:var(--el-text-color-regular)}.po-audit-remark[data-v-3fe6d04f]{color:var(--el-color-danger);font-weight:600;white-space:pre-wrap;word-break:break-word}.audit-stamp[data-v-3fe6d04f]{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-3fe6d04f]: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-3fe6d04f]{text-align:center;line-height:1.1}.stamp-pass[data-v-3fe6d04f]{color:var(--el-color-success)}.stamp-reject[data-v-3fe6d04f]{color:var(--el-color-danger)}.po-diagnosis-creator-dept-breadcrumb[data-v-3fe6d04f] .el-breadcrumb__item{display:inline-flex;float:none}.po-diagnosis-creator-dept-breadcrumb[data-v-3fe6d04f] .el-breadcrumb__separator{margin:0 2px 0 4px}
@@ -1 +0,0 @@
@charset "UTF-8";.po-detail-drawer[data-v-04a9fc6d] .el-drawer__header{margin-bottom:0;padding:16px 24px;border-bottom:1px solid var(--el-border-color-lighter)}.stat-card[data-v-04a9fc6d]{border-radius:8px}.stat-title[data-v-04a9fc6d]{font-weight:500}.po-panel[data-v-04a9fc6d]{border-radius:8px;transition:all .3s}.po-panel[data-v-04a9fc6d] .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-04a9fc6d] .el-card__body{padding:16px}.po-desc[data-v-04a9fc6d] .el-descriptions__label{width:120px;color:var(--el-text-color-regular)}.po-audit-remark[data-v-04a9fc6d]{color:var(--el-color-danger);font-weight:600;white-space:pre-wrap;word-break:break-word}.audit-stamp[data-v-04a9fc6d]{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-04a9fc6d]: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-04a9fc6d]{text-align:center;line-height:1.1}.stamp-pass[data-v-04a9fc6d]{color:var(--el-color-success)}.stamp-reject[data-v-04a9fc6d]{color:var(--el-color-danger)}.po-diagnosis-creator-dept-breadcrumb[data-v-04a9fc6d] .el-breadcrumb__item{display:inline-flex;float:none}.po-diagnosis-creator-dept-breadcrumb[data-v-04a9fc6d] .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
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.automation-form[data-v-a7e42c87]{width:100%}.automation-note[data-v-a7e42c87]{margin-top:22px}.automation-note[data-v-a7e42c87] .el-alert__description{line-height:1.7}.automation-section[data-v-a7e42c87]{min-width:0;transition:opacity .2s ease}.automation-section.is-disabled[data-v-a7e42c87]{opacity:.58}.form-section-title[data-v-a7e42c87]{margin:28px 0 18px;padding:0 0 12px;border-bottom:1px solid #ebeef5;font-size:15px;font-weight:600;color:#303133}.field-help[data-v-a7e42c87]{width:100%;font-size:12px;line-height:1.7;margin:6px 0 0;color:#909399}.warning-help[data-v-a7e42c87]{color:#9f6d14}.full-width[data-v-a7e42c87]{width:100%}.inline-error[data-v-a7e42c87]{width:100%;color:#d93026;font-size:12px;line-height:1.7;margin:8px 0 0}.schedule-card[data-v-a7e42c87]{padding:16px;border:1px solid #e4e7ed;border-radius:6px;background:#fafbfd;margin-bottom:12px}.schedule-heading[data-v-a7e42c87]{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;font-size:13px}.weekday-select[data-v-a7e42c87]{display:flex;flex-wrap:wrap;gap:0 18px}.weekday-select[data-v-a7e42c87] .el-checkbox{margin-right:0}.time-row[data-v-a7e42c87]{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:12px 0}.time-row[data-v-a7e42c87] .el-date-editor.el-input{width:150px}.time-row>span[data-v-a7e42c87]{font-size:12px;color:#909399}.time-row>small[data-v-a7e42c87]{font-size:12px;color:#b88230}.reception-schedules[data-v-a7e42c87]{margin:0 0 20px}.tags-content[data-v-a7e42c87],.remark-content[data-v-a7e42c87],.description-input[data-v-a7e42c87]{margin-top:12px}.tag-select-row[data-v-a7e42c87]{display:flex;gap:10px;width:100%}.tag-select[data-v-a7e42c87]{flex:1;min-width:0}.token-buttons[data-v-a7e42c87]{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}.token-buttons .el-button+.el-button[data-v-a7e42c87]{margin-left:0}.remark-preview[data-v-a7e42c87]{display:flex;gap:14px;align-items:center;padding:10px 12px;background:#f5f7fa;margin-top:8px;border-radius:4px;line-height:1.7}.remark-preview span[data-v-a7e42c87],.remark-preview small[data-v-a7e42c87]{color:#909399;font-size:12px}.remark-preview strong[data-v-a7e42c87]{color:#303133;font-size:13px;font-weight:500;overflow-wrap:anywhere}.remark-preview small[data-v-a7e42c87]{margin-left:auto;white-space:nowrap}.welcome-block h4[data-v-a7e42c87]{font-size:13px;font-weight:600;margin:0 0 4px}.welcome-block>.field-help[data-v-a7e42c87]{margin-bottom:12px}.schedule-switch[data-v-a7e42c87]{margin-top:24px}.switch-help[data-v-a7e42c87]{margin-left:12px;color:#909399;font-size:12px}.welcome-schedule[data-v-a7e42c87]{background:#fff}.tag-select-row .el-button+.el-button[data-v-a7e42c87]{margin-left:0}.custom-tag-editor[data-v-a7e42c87]{margin-top:12px;padding:14px;background:#f5f7fa;border:1px solid #e4e7ed;border-radius:4px}.custom-tag-editor label[data-v-a7e42c87]{display:block;font-size:13px;color:#606266;margin-bottom:8px}.custom-tag-row[data-v-a7e42c87]{display:flex;align-items:center;gap:10px}.custom-tag-row .el-input[data-v-a7e42c87]{flex:1;min-width:0}.legacy-tags-warning[data-v-a7e42c87]{margin-top:10px;padding:10px 12px;background:#fdf6ec;border:1px solid #faecd8;border-radius:4px;color:#9f6d14}.legacy-tags-warning p[data-v-a7e42c87]{margin:0 0 6px;font-size:12px;line-height:1.7;overflow-wrap:anywhere}.tag-success[data-v-a7e42c87]{margin:8px 0 0;color:#27864c;font-size:12px;line-height:1.7}@media (max-width: 620px){.tag-select-row[data-v-a7e42c87]{flex-direction:column}.remark-preview[data-v-a7e42c87]{flex-wrap:wrap}.automation-form[data-v-a7e42c87] .el-radio{margin-right:14px}.weekday-select[data-v-a7e42c87]{gap:0 12px}}
File diff suppressed because one or more lines are too long
@@ -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-Dwh6tNxD.js";import H from"./RecordingVideoPlayer-CDqcv1v9.js";import{e as I,_ as P}from"./index-CLwe6ftX.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-CeZEm9rq.js";import H from"./RecordingVideoPlayer-jwST1qx7.js";import{e as I,_ as P}from"./index-vflU_jNS.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};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-Dwh6tNxD.js","assets/.pnpm-B3v8nGpq.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-Dwh6tNxD.js";import{e as ae,_ as ne}from"./index-CLwe6ftX.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?N(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 N(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 U(){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-Dwh6tNxD.js").then(M=>M.dN),__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,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 C(){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(()=>{C()})}),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:U},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};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-CeZEm9rq.js","assets/.pnpm-B3v8nGpq.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-CeZEm9rq.js";import{e as ae,_ as ne}from"./index-vflU_jNS.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-CeZEm9rq.js").then(M=>M.dP),__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,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 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};
File diff suppressed because one or more lines are too long
@@ -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-Dwh6tNxD.js";import{a4 as L}from"./tcm-BzzBMfBw.js";import{i as M,_ as S}from"./index-CLwe6ftX.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-CeZEm9rq.js";import{a5 as L}from"./tcm-BmNVoRI3.js";import{i as M,_ as S}from"./index-vflU_jNS.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)])}}}),Q=S(O,[["__scopeId","data-v-82b635bd"]]);export{Q as default};
@@ -0,0 +1 @@
.welcome-editor[data-v-1e5d0e03]{display:grid;grid-template-columns:minmax(0,1fr) 260px;align-items:start;gap:22px;width:100%}.welcome-editor__fields[data-v-1e5d0e03]{min-width:0}.text-tools[data-v-1e5d0e03]{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}.text-tools .el-button+.el-button[data-v-1e5d0e03]{margin-left:0}.emoji-grid[data-v-1e5d0e03]{display:grid;grid-template-columns:repeat(8,1fr);gap:4px}.emoji-grid button[data-v-1e5d0e03]{border:0;background:none;padding:4px;cursor:pointer;font-size:20px}.text-count[data-v-1e5d0e03]{text-align:right;font-size:12px;color:#909399;margin-top:4px}.text-count.is-error[data-v-1e5d0e03],.upload-error[data-v-1e5d0e03]{color:#d93026}.attachments-heading[data-v-1e5d0e03],.attachment-card__heading[data-v-1e5d0e03]{display:flex;justify-content:space-between;align-items:center;gap:8px}.attachments-heading[data-v-1e5d0e03]{margin:16px 0 10px}.attachments-heading strong[data-v-1e5d0e03]{font-size:13px}.attachments-heading strong span[data-v-1e5d0e03]{color:#909399;font-weight:400}.attachment-empty[data-v-1e5d0e03]{padding:18px 12px;color:#909399;background:#f7f8fa;border:1px dashed #dcdfe6;border-radius:4px;font-size:12px}.attachment-card[data-v-1e5d0e03]{border:1px solid #e4e7ed;border-radius:5px;padding:12px;margin-top:10px}.attachment-card__heading[data-v-1e5d0e03]{margin-bottom:10px}.attachment-card__heading strong[data-v-1e5d0e03]{font-size:13px}.attachment-card__heading .el-button[data-v-1e5d0e03]{padding:4px;margin:0}.attachment-label[data-v-1e5d0e03]{display:block;font-size:12px;color:#606266;margin:10px 0 4px}.attachment-label span[data-v-1e5d0e03]{color:#909399;float:right}.upload-field[data-v-1e5d0e03]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:12px;overflow-wrap:anywhere}.mini-upload[data-v-1e5d0e03]{margin-top:12px}.asset-ready[data-v-1e5d0e03]{color:#178758}.muted[data-v-1e5d0e03]{color:#909399}.field-tip[data-v-1e5d0e03]{display:block;color:#909399;line-height:1.6;margin-top:6px}.upload-error[data-v-1e5d0e03]{font-size:12px;line-height:1.6;margin-top:6px}.file-input[data-v-1e5d0e03]{display:none}.welcome-preview[data-v-1e5d0e03]{width:260px;border:1px solid #dcdfe6;border-radius:20px;padding:7px;background:#fff;overflow:hidden}.phone-heading[data-v-1e5d0e03]{display:flex;justify-content:space-between;align-items:center;padding:13px 12px;background:#ededed;border-radius:14px 14px 0 0;font-size:13px}.phone-heading>span[data-v-1e5d0e03]{font-size:19px}.phone-content[data-v-1e5d0e03]{min-height:330px;max-height:520px;overflow:auto;background:#ededed;padding:0 10px 18px}.preview-time[data-v-1e5d0e03]{font-size:10px;text-align:center;color:#999;padding:12px 0 18px}.chat-row[data-v-1e5d0e03]{display:flex;gap:7px;margin-bottom:12px;align-items:flex-start}.chat-avatar[data-v-1e5d0e03]{width:27px;height:27px;background:#6e92ae;color:#fff;flex-shrink:0;border-radius:4px;display:grid;place-items:center;font-size:11px}.chat-bubble[data-v-1e5d0e03]{background:#fff;padding:9px 10px;border-radius:4px;font-size:12px;line-height:1.65;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0;max-width:172px}.attachment-preview[data-v-1e5d0e03]{width:172px}.attachment-preview strong[data-v-1e5d0e03]{display:block;font-weight:500;font-size:12px}.attachment-preview p[data-v-1e5d0e03]{color:#909399;font-size:10px;margin:6px 0}.attachment-preview small[data-v-1e5d0e03]{display:block;font-size:9px;color:#909399;margin-top:7px}.attachment-preview img[data-v-1e5d0e03]{width:100%;max-height:160px;-o-object-fit:contain;object-fit:contain;display:block}.media-placeholder[data-v-1e5d0e03]{background:#f2f5f7;height:85px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;color:#909399;font-size:10px}.media-placeholder .el-icon[data-v-1e5d0e03],.file-icon[data-v-1e5d0e03]{font-size:28px;color:#8babc3}.preview-empty[data-v-1e5d0e03]{text-align:center;color:#aaa;font-size:12px;margin-top:100px}.phone-input[data-v-1e5d0e03]{display:flex;gap:10px;padding:9px;background:#f6f6f6;border-radius:0 0 14px 14px;align-items:center;color:#909399}.phone-input__blank[data-v-1e5d0e03]{flex:1;height:24px;border-radius:3px;background:#fff}.preview-note[data-v-1e5d0e03]{margin:10px 6px 6px;font-size:11px;color:#909399;line-height:1.6}@media (max-width: 850px){.welcome-editor[data-v-1e5d0e03]{grid-template-columns:1fr}.welcome-preview[data-v-1e5d0e03]{margin:8px auto 0}}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-D8V2sQ39.js";import"./.pnpm-Dwh6tNxD.js";import"./index-De13kk0O.js";import"./index-CLwe6ftX.js";export{o as default};
@@ -0,0 +1 @@
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-CWIJgHhb.js";import"./.pnpm-CeZEm9rq.js";import"./index-CxXw87QU.js";import"./index-vflU_jNS.js";export{o as default};
@@ -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-Dwh6tNxD.js";import{_ as L}from"./index-De13kk0O.js";import{i as V}from"./index-CLwe6ftX.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-CeZEm9rq.js";import{_ as L}from"./index-CxXw87QU.js";import{i as V}from"./index-vflU_jNS.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 _};

Some files were not shown because too many files have changed in this diff Show More