Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6325ba88ff | ||
|
|
f8b6196205 | ||
|
|
aa0d22bbe2 |
@@ -45,3 +45,16 @@ export function unbindWorkWechat() {
|
||||
export function changeFirstPassword(params: { password: string; password_confirm: string }) {
|
||||
return request.post({ url: '/login/changeFirstPassword', params })
|
||||
}
|
||||
|
||||
// 统一账号登录开关和服务器生成的固定登录入口
|
||||
export function getIamConfig() {
|
||||
return request.get({ url: '/iam/config' }, { withToken: false })
|
||||
}
|
||||
|
||||
// 浏览器绑定的一次性兑换码;不重试,也不将旧业务 token 带入认证
|
||||
export function iamLogin(ticket: string) {
|
||||
return request.post(
|
||||
{ url: '/iam/exchange', params: { ticket, terminal: config.terminal }, withCredentials: true },
|
||||
{ withToken: false, isOpenRetry: false }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { getUserInfo, login, logout, workWechatLogin } from '@/api/user'
|
||||
import { getUserInfo, iamLogin, login, logout, workWechatLogin } from '@/api/user'
|
||||
import { TOKEN_KEY } from '@/enums/cacheEnums'
|
||||
import { PageEnum } from '@/enums/pageEnum'
|
||||
import router, { filterAsyncRoutes } from '@/router'
|
||||
@@ -83,6 +83,13 @@ const useUserStore = defineStore({
|
||||
})
|
||||
})
|
||||
},
|
||||
async iamLogin(ticket: string) {
|
||||
const data = await iamLogin(ticket)
|
||||
this.token = data.token
|
||||
this.isPaw = data.is_paw ?? 1
|
||||
cache.set(TOKEN_KEY, data.token)
|
||||
return data
|
||||
},
|
||||
getUserInfo() {
|
||||
return new Promise((resolve, reject) => {
|
||||
getUserInfo()
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<div class="text-center text-3xl font-medium mb-8">{{ config.web_name }}</div>
|
||||
|
||||
<!-- 企业微信自动授权中 -->
|
||||
<div v-if="wxWorkAutoLogin" class="text-center py-10">
|
||||
<div v-if="wxWorkAutoLogin || iamLoading" class="text-center py-10">
|
||||
<el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<div class="text-gray-500">企业微信授权登录中...</div>
|
||||
<div class="text-gray-500">{{ iamLoading ? '统一账号登录中...' : '企业微信授权登录中...' }}</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
@@ -78,6 +78,20 @@
|
||||
请使用企业微信扫描二维码登录
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-if="iamEnabled" class="iam-login-alternative" aria-label="其他登录方式">
|
||||
<div class="iam-login-divider" aria-hidden="true">其他登录方式</div>
|
||||
<el-button class="iam-login-entry" size="large" @click="handleIamLogin">
|
||||
<span class="iam-login-entry__content">
|
||||
<icon name="local-icon-anquan" size="22" />
|
||||
<span>统一账号快捷登录</span>
|
||||
</span>
|
||||
<span class="iam-login-entry__arrow" aria-hidden="true">
|
||||
<icon name="el-icon-ArrowRight" size="16" />
|
||||
</span>
|
||||
</el-button>
|
||||
<p class="iam-login-hint">使用统一身份平台账号登录</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,7 +113,7 @@ import LayoutFooter from '@/layout/components/footer.vue'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import cache from '@/utils/cache'
|
||||
import { getWorkWechatConfig } from '@/api/user'
|
||||
import { getIamConfig, getWorkWechatConfig } from '@/api/user'
|
||||
|
||||
const passwordRef = shallowRef<InputInstance>()
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
@@ -118,6 +132,66 @@ const rules = {
|
||||
password: [{ required: true, message: '请输入密码', trigger: ['blur'] }]
|
||||
}
|
||||
|
||||
// 统一账号登录是可选入口,不取代账号密码或企业微信登录。
|
||||
const iamEnabled = ref(false)
|
||||
const iamLoginUrl = ref('')
|
||||
const iamLoading = ref(false)
|
||||
let iamCallbackHandled = false
|
||||
|
||||
const loadIamConfig = async () => {
|
||||
try {
|
||||
const result = await getIamConfig()
|
||||
const url = new URL(result?.loginUrl || '', window.location.origin)
|
||||
if (result?.enabled === true && url.protocol === 'https:') {
|
||||
iamLoginUrl.value = url.href
|
||||
iamEnabled.value = true
|
||||
}
|
||||
} catch {
|
||||
// IAM 不可用时,原有登录入口保持可用。
|
||||
}
|
||||
}
|
||||
|
||||
const handleIamLogin = () => {
|
||||
if (iamEnabled.value && iamLoginUrl.value) {
|
||||
window.location.assign(iamLoginUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIamCallback = async (ticket: string | null, error: string | null) => {
|
||||
if (iamCallbackHandled) return
|
||||
iamCallbackHandled = true
|
||||
// 在任何 await 和兑换前移除票据,刷新不会重复兑换;保留其他 query/hash。
|
||||
const cleanUrl = new URL(window.location.href)
|
||||
cleanUrl.searchParams.delete('iam_ticket')
|
||||
cleanUrl.searchParams.delete('iam_error')
|
||||
// 同一回调不得随后被识别为企业微信授权。
|
||||
cleanUrl.searchParams.delete('code')
|
||||
cleanUrl.searchParams.delete('state')
|
||||
window.history.replaceState(window.history.state, '', cleanUrl.pathname + cleanUrl.search + cleanUrl.hash)
|
||||
if (error || !ticket) {
|
||||
ElMessage.error(error || '统一账号登录凭证无效,请重新登录')
|
||||
return
|
||||
}
|
||||
iamLoading.value = true
|
||||
try {
|
||||
const result = await userStore.iamLogin(ticket)
|
||||
if (result.is_paw === 0) {
|
||||
await router.push('/change-password')
|
||||
return
|
||||
}
|
||||
if (result.need_bind_work_wechat) {
|
||||
await router.push('/bind-work-wechat')
|
||||
return
|
||||
}
|
||||
redirectAfterLogin()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.msg || error?.message || '统一账号登录失败,请重新登录或使用账号密码')
|
||||
loginMode.value = 'account'
|
||||
} finally {
|
||||
iamLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 企业微信相关
|
||||
const loginMode = ref<'account' | 'wxwork'>('account')
|
||||
const wxWorkEnabled = ref(false)
|
||||
@@ -243,7 +317,12 @@ onMounted(async () => {
|
||||
|
||||
// 检查 URL 中是否有企业微信回调 code
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const wxCode = urlParams.get('code')
|
||||
void loadIamConfig()
|
||||
const hasIamCallback = urlParams.has('iam_ticket') || urlParams.has('iam_error')
|
||||
if (hasIamCallback) {
|
||||
await handleIamCallback(urlParams.get('iam_ticket'), urlParams.get('iam_error'))
|
||||
}
|
||||
const wxCode = hasIamCallback ? null : urlParams.get('code')
|
||||
const wxState = urlParams.get('state')
|
||||
|
||||
if (wxCode && wxState === 'admin_login') {
|
||||
@@ -263,7 +342,7 @@ onMounted(async () => {
|
||||
wxWorkConfig.value = { corp_id: res.corp_id, agent_id: res.agent_id }
|
||||
|
||||
// 在企业微信内:自动跳转 OAuth 授权
|
||||
if (isInWxWork()) {
|
||||
if (isInWxWork() && !hasIamCallback) {
|
||||
const redirectUri = encodeURIComponent(getRedirectUri())
|
||||
const authUrl =
|
||||
`https://open.weixin.qq.com/connect/oauth2/authorize` +
|
||||
@@ -286,6 +365,82 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.iam-login-alternative {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.iam-login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--el-border-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.iam-login-entry.el-button {
|
||||
--el-button-bg-color: var(--el-color-primary-light-9);
|
||||
--el-button-text-color: var(--el-color-primary);
|
||||
--el-button-border-color: var(--el-color-primary-light-5);
|
||||
--el-button-hover-bg-color: var(--el-color-primary-light-8);
|
||||
--el-button-hover-text-color: var(--el-color-primary);
|
||||
--el-button-hover-border-color: var(--el-color-primary);
|
||||
--el-button-active-bg-color: var(--el-color-primary-light-8);
|
||||
--el-button-active-border-color: var(--el-color-primary);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 12px 34px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.iam-login-entry__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.iam-login-entry__arrow {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iam-login-hint {
|
||||
margin: 14px 0 0;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.iam-login-entry.el-button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login {
|
||||
background-image: url('./images/login_bg.png');
|
||||
@apply min-h-screen bg-no-repeat bg-center bg-cover;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,590 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class EjMedicineIncrementalPushService
|
||||
{
|
||||
private const SOURCE_SYSTEM = 'zyt';
|
||||
private const APPLY_CONFIRMATION = 'INCREMENTAL_NO_DELETE';
|
||||
private const DEFAULT_RUN_ID = 'zyt-incremental';
|
||||
private const STATE_ID = 1;
|
||||
private const LOCK_TTL = 120;
|
||||
|
||||
public static function assertCommandGate(bool $apply, string $confirm, int $batchSize): void
|
||||
{
|
||||
self::assertBatchSize($batchSize);
|
||||
if ($apply && !hash_equals(self::APPLY_CONFIRMATION, $confirm)) {
|
||||
throw new InvalidArgumentException(
|
||||
'执行增量写入必须提供 --confirm=' . self::APPLY_CONFIRMATION
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||||
* @param null|callable(array<int,int>):array<int,array<string,mixed>> $mappingLoader
|
||||
* @return array{source_count:int,candidate_count:int,batch_count:int,mapped_count:int,unmapped_count:int,preserved_inactive:int,remote_delete_count:int,local_delete_count:int}
|
||||
*/
|
||||
public static function plan(
|
||||
int $batchSize = 100,
|
||||
?callable $sourceLoader = null,
|
||||
?callable $mappingLoader = null
|
||||
): array {
|
||||
self::assertBatchSize($batchSize);
|
||||
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
|
||||
$items = EjMedicineBootstrapItem::fromRows($sourceRows);
|
||||
$localIds = array_map('intval', array_column($items, 'source_medicine_id'));
|
||||
$mappingRows = $mappingLoader === null
|
||||
? self::loadMappingRows($localIds)
|
||||
: $mappingLoader($localIds);
|
||||
$selection = self::selectCandidates($items, $mappingRows);
|
||||
|
||||
return [
|
||||
'source_count' => count($items),
|
||||
'candidate_count' => count($selection['candidates']),
|
||||
'batch_count' => (int) ceil(count($selection['candidates']) / $batchSize),
|
||||
'mapped_count' => $selection['mapped_count'],
|
||||
'unmapped_count' => $selection['unmapped_count'],
|
||||
'preserved_inactive' => $selection['preserved_inactive'],
|
||||
'remote_delete_count' => 0,
|
||||
'local_delete_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally imports every active local medicine through EJ's idempotent
|
||||
* source identity and upserts only the returned ZYT projection rows.
|
||||
* Existing EJ-only medicines and unrelated local projections are untouched.
|
||||
*
|
||||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||||
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
|
||||
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionUpserter
|
||||
* @param null|callable(array<int,int>):array<int,array<string,mixed>> $mappingLoader
|
||||
* @param null|callable(string):bool $lockAcquirer
|
||||
* @param null|callable(string):bool $lockRenewer
|
||||
* @param null|callable(string):void $lockReleaser
|
||||
* @return array<string,int|string>
|
||||
*/
|
||||
public static function execute(
|
||||
int $batchSize = 100,
|
||||
string $runId = '',
|
||||
?callable $sourceLoader = null,
|
||||
?callable $importer = null,
|
||||
?callable $projectionUpserter = null,
|
||||
?callable $mappingLoader = null,
|
||||
?callable $lockAcquirer = null,
|
||||
?callable $lockRenewer = null,
|
||||
?callable $lockReleaser = null
|
||||
): array {
|
||||
self::assertBatchSize($batchSize);
|
||||
|
||||
$customLockCallbacks = count(array_filter(
|
||||
[$lockAcquirer, $lockRenewer, $lockReleaser],
|
||||
static fn (?callable $callback): bool => $callback !== null
|
||||
));
|
||||
if ($customLockCallbacks !== 0 && $customLockCallbacks !== 3) {
|
||||
throw new InvalidArgumentException('增量同步锁回调必须同时提供 acquire、renew 和 release');
|
||||
}
|
||||
$lockAcquirer ??= static fn (string $token): bool => self::acquireLock($token);
|
||||
$lockRenewer ??= static fn (string $token): bool => self::renewLock($token);
|
||||
$lockReleaser ??= static function (string $token): void {
|
||||
self::releaseLock($token);
|
||||
};
|
||||
|
||||
$lockToken = bin2hex(random_bytes(16));
|
||||
if (!$lockAcquirer($lockToken)) {
|
||||
throw new DomainException('恩济药材同步正在执行,请稍后重试');
|
||||
}
|
||||
|
||||
try {
|
||||
return self::executeLocked(
|
||||
$batchSize,
|
||||
$runId,
|
||||
$sourceLoader,
|
||||
$importer,
|
||||
$projectionUpserter,
|
||||
$mappingLoader,
|
||||
$lockRenewer,
|
||||
$lockToken
|
||||
);
|
||||
} finally {
|
||||
$lockReleaser($lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||||
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
|
||||
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionUpserter
|
||||
* @param null|callable(array<int,int>):array<int,array<string,mixed>> $mappingLoader
|
||||
* @param callable(string):bool $lockRenewer
|
||||
* @return array<string,int|string>
|
||||
*/
|
||||
private static function executeLocked(
|
||||
int $batchSize,
|
||||
string $runId,
|
||||
?callable $sourceLoader,
|
||||
?callable $importer,
|
||||
?callable $projectionUpserter,
|
||||
?callable $mappingLoader,
|
||||
callable $lockRenewer,
|
||||
string $lockToken
|
||||
): array {
|
||||
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
|
||||
$allItems = EjMedicineBootstrapItem::fromRows($sourceRows);
|
||||
$localIds = array_map('intval', array_column($allItems, 'source_medicine_id'));
|
||||
$mappingRows = $mappingLoader === null
|
||||
? self::loadMappingRows($localIds)
|
||||
: $mappingLoader($localIds);
|
||||
$selection = self::selectCandidates($allItems, $mappingRows);
|
||||
$items = $selection['candidates'];
|
||||
$runId = self::normalizeRunId($runId);
|
||||
$batches = self::buildBatches($items, $batchSize, $runId);
|
||||
|
||||
if ($items !== [] && $importer === null) {
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
throw new RuntimeException('恩济药房接口未启用或配置不完整');
|
||||
}
|
||||
$client = new EjPharmacyClient();
|
||||
$importer = static fn (array $payload): array => $client->importMedicines($payload);
|
||||
}
|
||||
|
||||
$sourceById = [];
|
||||
foreach ($items as $item) {
|
||||
$sourceById[(string) $item['source_medicine_id']] = $item;
|
||||
}
|
||||
$seenCodes = [];
|
||||
$seenVersions = [];
|
||||
$projectionRows = [];
|
||||
$remoteCreated = 0;
|
||||
$remoteExisting = 0;
|
||||
foreach ($batches as $payload) {
|
||||
self::assertLockLease($lockRenewer, $lockToken);
|
||||
$response = $importer($payload);
|
||||
self::assertLockLease($lockRenewer, $lockToken);
|
||||
$responseItems = self::validateImportResponse(
|
||||
$response,
|
||||
$payload,
|
||||
$seenCodes,
|
||||
$seenVersions
|
||||
);
|
||||
foreach ($responseItems as $responseItem) {
|
||||
$sourceId = (string) $responseItem['source_medicine_id'];
|
||||
$source = $sourceById[$sourceId] ?? null;
|
||||
if (!is_array($source)) {
|
||||
throw new RuntimeException("恩济增量导入返回未知 source_medicine_id:{$sourceId}");
|
||||
}
|
||||
$action = (string) $responseItem['action'];
|
||||
$medicineCode = (string) $responseItem['medicine_code'];
|
||||
$expectedCode = $selection['active_mapping_codes'][$sourceId] ?? null;
|
||||
if ($expectedCode !== null && !hash_equals($expectedCode, $medicineCode)) {
|
||||
throw new DomainException(
|
||||
"本地药材 {$sourceId} 的启用映射编码 {$expectedCode} 与恩济返回 {$medicineCode} 不一致"
|
||||
);
|
||||
}
|
||||
$remoteCreated += $action === 'created' ? 1 : 0;
|
||||
$remoteExisting += $action === 'existing' ? 1 : 0;
|
||||
$projectionRows[] = [
|
||||
'local_medicine_id' => $sourceId,
|
||||
'medicine_code' => $medicineCode,
|
||||
'name' => (string) $source['name'],
|
||||
'brand' => (string) ($source['brand'] ?? ''),
|
||||
'unit' => (string) $source['unit'],
|
||||
'settlement_price' => (string) $source['settlement_price'],
|
||||
'retail_price' => (string) $source['retail_price'],
|
||||
'status' => (int) $source['status'],
|
||||
'catalog_version' => (int) $responseItem['catalog_version'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$projectionStats = [
|
||||
'catalog_created' => 0,
|
||||
'catalog_updated' => 0,
|
||||
'mapping_created' => 0,
|
||||
'mapping_updated' => 0,
|
||||
'mapping_unchanged' => 0,
|
||||
];
|
||||
if ($projectionRows !== []) {
|
||||
self::assertLockLease($lockRenewer, $lockToken);
|
||||
$projectionStats = $projectionUpserter === null
|
||||
? self::upsertProjection($projectionRows, $lockToken)
|
||||
: $projectionUpserter($projectionRows);
|
||||
}
|
||||
|
||||
return array_merge([
|
||||
'run_id' => $runId,
|
||||
'source_count' => count($allItems),
|
||||
'candidate_count' => count($items),
|
||||
'batch_count' => count($batches),
|
||||
'preserved_inactive' => $selection['preserved_inactive'],
|
||||
'remote_created' => $remoteCreated,
|
||||
'remote_existing' => $remoteExisting,
|
||||
'remote_delete_count' => 0,
|
||||
'local_delete_count' => 0,
|
||||
], $projectionStats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $items
|
||||
* @return list<array{source_system:string,import_id:string,items:array<int,array<string,mixed>>}>
|
||||
*/
|
||||
public static function buildBatches(array $items, int $batchSize, string $runId): array
|
||||
{
|
||||
self::assertBatchSize($batchSize);
|
||||
$runId = self::normalizeRunId($runId);
|
||||
usort($items, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||||
(string) ($left['source_medicine_id'] ?? ''),
|
||||
(string) ($right['source_medicine_id'] ?? '')
|
||||
));
|
||||
|
||||
$batches = [];
|
||||
foreach (array_chunk($items, $batchSize) as $index => $batchItems) {
|
||||
$contentJson = json_encode(
|
||||
$batchItems,
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
$batches[] = [
|
||||
'source_system' => self::SOURCE_SYSTEM,
|
||||
'import_id' => sprintf(
|
||||
'%s-%04d-%s',
|
||||
$runId,
|
||||
$index + 1,
|
||||
substr(hash('sha256', $contentJson), 0, 32)
|
||||
),
|
||||
'items' => $batchItems,
|
||||
];
|
||||
}
|
||||
|
||||
return $batches;
|
||||
}
|
||||
|
||||
private static function assertBatchSize(int $batchSize): void
|
||||
{
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('--batch-size 必须在 1 到 500 之间');
|
||||
}
|
||||
}
|
||||
|
||||
private static function normalizeRunId(string $runId): string
|
||||
{
|
||||
$runId = trim($runId);
|
||||
if ($runId === '') {
|
||||
$runId = self::DEFAULT_RUN_ID;
|
||||
}
|
||||
if (strlen($runId) > 25 || preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/D', $runId) !== 1) {
|
||||
throw new InvalidArgumentException('--run-id 必须为不超过 25 位的字母、数字、点、下划线或短横线');
|
||||
}
|
||||
|
||||
return $runId;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private static function loadSourceRows(): array
|
||||
{
|
||||
return Db::name('doctor_medicine')
|
||||
->field('id,name,unit,settlement_price,retail_price,status')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,int> $localIds @return array<int,array<string,mixed>> */
|
||||
private static function loadMappingRows(array $localIds): array
|
||||
{
|
||||
if ($localIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Db::name('ej_medicine_mapping')
|
||||
->whereIn('local_medicine_id', $localIds)
|
||||
->field('id,local_medicine_id,medicine_code,status,delete_time')
|
||||
->order('local_medicine_id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active mappings are replayed so EJ can recover a missing remote medicine.
|
||||
* A medicine with an inactive or soft-deleted mapping is intentionally
|
||||
* excluded; an operator decision must never be undone by synchronization.
|
||||
*
|
||||
* @param list<array<string,mixed>> $items
|
||||
* @param array<int,array<string,mixed>> $mappingRows
|
||||
* @return array{candidates:list<array<string,mixed>>,mapped_count:int,unmapped_count:int,preserved_inactive:int,active_mapping_codes:array<string,string>}
|
||||
*/
|
||||
private static function selectCandidates(array $items, array $mappingRows): array
|
||||
{
|
||||
$byLocalId = [];
|
||||
foreach ($mappingRows as $mapping) {
|
||||
$localId = (int) ($mapping['local_medicine_id'] ?? 0);
|
||||
if ($localId < 1 || isset($byLocalId[$localId])) {
|
||||
throw new DomainException("本地药材 {$localId} 存在重复的恩济映射记录");
|
||||
}
|
||||
$byLocalId[$localId] = $mapping;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
$mappedCount = 0;
|
||||
$unmappedCount = 0;
|
||||
$preservedInactive = 0;
|
||||
$activeMappingCodes = [];
|
||||
foreach ($items as $item) {
|
||||
$localId = (int) $item['source_medicine_id'];
|
||||
$mapping = $byLocalId[$localId] ?? null;
|
||||
if ($mapping === null) {
|
||||
++$unmappedCount;
|
||||
$candidates[] = $item;
|
||||
continue;
|
||||
}
|
||||
if ((int) ($mapping['status'] ?? 0) === 1 && ($mapping['delete_time'] ?? null) === null) {
|
||||
$medicineCode = trim((string) ($mapping['medicine_code'] ?? ''));
|
||||
if ($medicineCode === '') {
|
||||
throw new DomainException("本地药材 {$localId} 的启用恩济映射编码为空");
|
||||
}
|
||||
++$mappedCount;
|
||||
$candidates[] = $item;
|
||||
$activeMappingCodes[(string) $localId] = $medicineCode;
|
||||
continue;
|
||||
}
|
||||
++$preservedInactive;
|
||||
}
|
||||
|
||||
return [
|
||||
'candidates' => $candidates,
|
||||
'mapped_count' => $mappedCount,
|
||||
'unmapped_count' => $unmappedCount,
|
||||
'preserved_inactive' => $preservedInactive,
|
||||
'active_mapping_codes' => $activeMappingCodes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $response
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,bool> $seenCodes
|
||||
* @param array<int,bool> $seenVersions
|
||||
* @return list<array{source_medicine_id:string,medicine_code:string,catalog_version:int,action:string}>
|
||||
*/
|
||||
private static function validateImportResponse(
|
||||
array $response,
|
||||
array $payload,
|
||||
array &$seenCodes,
|
||||
array &$seenVersions
|
||||
): array {
|
||||
$nextSeenCodes = $seenCodes;
|
||||
$nextSeenVersions = $seenVersions;
|
||||
$items = EjMedicineBootstrapService::validateImportResponse(
|
||||
$response,
|
||||
$payload,
|
||||
$nextSeenCodes,
|
||||
$nextSeenVersions
|
||||
);
|
||||
|
||||
$data = $response['body']['data'] ?? null;
|
||||
if (!is_array($data) || !hash_equals(self::SOURCE_SYSTEM, (string) ($data['source_system'] ?? ''))) {
|
||||
throw new RuntimeException('恩济药材导入响应 source_system 不匹配');
|
||||
}
|
||||
$expectedPayloadHash = hash('sha256', json_encode(
|
||||
self::canonicalize($payload),
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
));
|
||||
$actualPayloadHash = strtolower(trim((string) ($data['payload_hash'] ?? '')));
|
||||
if (
|
||||
preg_match('/^[a-f0-9]{64}$/D', $actualPayloadHash) !== 1
|
||||
|| !hash_equals($expectedPayloadHash, $actualPayloadHash)
|
||||
) {
|
||||
throw new RuntimeException('恩济药材导入响应 payload_hash 不匹配');
|
||||
}
|
||||
|
||||
$seenCodes = $nextSeenCodes;
|
||||
$seenVersions = $nextSeenVersions;
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function canonicalize(mixed $value): mixed
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (array_is_list($value)) {
|
||||
return array_map([self::class, 'canonicalize'], $value);
|
||||
}
|
||||
ksort($value, SORT_STRING);
|
||||
foreach ($value as $key => $child) {
|
||||
$value[$key] = self::canonicalize($child);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @param callable(string):bool $lockRenewer */
|
||||
private static function assertLockLease(callable $lockRenewer, string $lockToken): void
|
||||
{
|
||||
if (!$lockRenewer($lockToken)) {
|
||||
throw new DomainException('恩济药材同步锁已失效,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
private static function acquireLock(string $token): bool
|
||||
{
|
||||
$now = time();
|
||||
return Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where(function ($query) use ($now): void {
|
||||
$query->where('lock_token', '')->whereOr('lock_expires_at', '<', $now);
|
||||
})
|
||||
->update([
|
||||
'lock_token' => $token,
|
||||
'lock_expires_at' => $now + self::LOCK_TTL,
|
||||
'update_time' => $now,
|
||||
]) === 1;
|
||||
}
|
||||
|
||||
private static function renewLock(string $token): bool
|
||||
{
|
||||
$now = time();
|
||||
$query = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)
|
||||
->where('lock_expires_at', '>=', $now);
|
||||
$updated = $query->update([
|
||||
'lock_expires_at' => $now + self::LOCK_TTL,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($updated === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$state = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)
|
||||
->find();
|
||||
return is_array($state) && (int) ($state['lock_expires_at'] ?? 0) >= $now;
|
||||
}
|
||||
|
||||
private static function releaseLock(string $token): void
|
||||
{
|
||||
Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)
|
||||
->update([
|
||||
'lock_token' => '',
|
||||
'lock_expires_at' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @return array{catalog_created:int,catalog_updated:int,mapping_created:int,mapping_updated:int,mapping_unchanged:int}
|
||||
*/
|
||||
private static function upsertProjection(array $rows, string $lockToken): array
|
||||
{
|
||||
return Db::transaction(static function () use ($rows, $lockToken): array {
|
||||
$stats = [
|
||||
'catalog_created' => 0,
|
||||
'catalog_updated' => 0,
|
||||
'mapping_created' => 0,
|
||||
'mapping_updated' => 0,
|
||||
'mapping_unchanged' => 0,
|
||||
];
|
||||
$state = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (
|
||||
!is_array($state)
|
||||
|| !hash_equals($lockToken, (string) ($state['lock_token'] ?? ''))
|
||||
|| (int) ($state['lock_expires_at'] ?? 0) < time()
|
||||
) {
|
||||
throw new DomainException('恩济药材同步锁已失效,请重试');
|
||||
}
|
||||
$now = time();
|
||||
foreach ($rows as $row) {
|
||||
$localId = (int) ($row['local_medicine_id'] ?? 0);
|
||||
$medicineCode = trim((string) ($row['medicine_code'] ?? ''));
|
||||
if ($localId < 1 || $medicineCode === '') {
|
||||
throw new RuntimeException('恩济增量导入投影缺少本地药材 ID 或 medicine_code');
|
||||
}
|
||||
|
||||
$conflict = Db::name('ej_medicine_mapping')
|
||||
->where('medicine_code', $medicineCode)
|
||||
->where('local_medicine_id', '<>', $localId)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($conflict) {
|
||||
throw new DomainException(
|
||||
"恩济药材编码 {$medicineCode} 已映射到本地药材 " . (int) $conflict['local_medicine_id']
|
||||
);
|
||||
}
|
||||
|
||||
$catalogValues = [
|
||||
'name' => (string) $row['name'],
|
||||
'brand' => (string) ($row['brand'] ?? ''),
|
||||
'unit' => (string) $row['unit'],
|
||||
'settlement_price' => (string) $row['settlement_price'],
|
||||
'retail_price' => (string) $row['retail_price'],
|
||||
'status' => (int) $row['status'],
|
||||
'catalog_version' => (int) $row['catalog_version'],
|
||||
'remote_deleted' => 0,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$catalog = Db::name('ej_medicine_catalog')
|
||||
->where('medicine_code', $medicineCode)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($catalog) {
|
||||
Db::name('ej_medicine_catalog')->where('id', (int) $catalog['id'])->update($catalogValues);
|
||||
++$stats['catalog_updated'];
|
||||
} else {
|
||||
Db::name('ej_medicine_catalog')->insert($catalogValues + [
|
||||
'medicine_code' => $medicineCode,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
++$stats['catalog_created'];
|
||||
}
|
||||
|
||||
$mapping = Db::name('ej_medicine_mapping')
|
||||
->where('local_medicine_id', $localId)
|
||||
->lock(true)
|
||||
->find();
|
||||
$mappingValues = [
|
||||
'medicine_code' => $medicineCode,
|
||||
'status' => 1,
|
||||
'operator_id' => 0,
|
||||
'operator_name' => 'system-incremental',
|
||||
'delete_time' => null,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if (!$mapping) {
|
||||
Db::name('ej_medicine_mapping')->insert($mappingValues + [
|
||||
'local_medicine_id' => $localId,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
++$stats['mapping_created'];
|
||||
} elseif ((int) $mapping['status'] !== 1 || ($mapping['delete_time'] ?? null) !== null) {
|
||||
throw new DomainException("本地药材 {$localId} 的恩济映射已被停用,增量同步保持该状态不变");
|
||||
} elseif (hash_equals((string) $mapping['medicine_code'], $medicineCode)) {
|
||||
++$stats['mapping_unchanged'];
|
||||
} else {
|
||||
throw new DomainException(
|
||||
"本地药材 {$localId} 的启用映射编码 "
|
||||
. (string) $mapping['medicine_code']
|
||||
. " 与恩济返回 {$medicineCode} 不一致,增量同步未改写该映射"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
ZYT_ROOT=${ZYT_ROOT:-/Users/long/Work/zyt-ej-medicine-sync}
|
||||
EJ_ROOT=${EJ_ROOT:-/Users/long/Work/ej}
|
||||
ZYT_BASE=27fbef9321c67f962e4d73f04a52272887c04f95
|
||||
EJ_BASE=a7439cc2e8c37dd6c32c926f0cc39c1a9c9b4b67
|
||||
|
||||
git -C "$ZYT_ROOT" checkout "$ZYT_BASE" -- \
|
||||
server/config/console.php \
|
||||
server/tests/pharmacy/route_contracts.php
|
||||
rm -f \
|
||||
"$ZYT_ROOT/docs/ej-pharmacy-incremental-medicine-sync.md" \
|
||||
"$ZYT_ROOT/server/app/command/EjPharmacyPushMedicines.php" \
|
||||
"$ZYT_ROOT/server/app/common/service/pharmacy/EjMedicineIncrementalPushService.php" \
|
||||
"$ZYT_ROOT/server/tests/pharmacy/incremental_medicine_push.php"
|
||||
|
||||
git -C "$EJ_ROOT" checkout "$EJ_BASE" -- \
|
||||
server/app/common/service/pharmacy/MedicineImportService.php \
|
||||
server/tests/pharmacy/route_contracts.php \
|
||||
server/tests/pharmacy/medicine_import_mysql_integration.php
|
||||
|
||||
echo 'ROLLBACK_OK: ZYT incremental push removed; EJ unrelated-catalog preflight restored.'
|
||||
@@ -1,170 +0,0 @@
|
||||
OBJECT=ZYT_TO_EJ_INCREMENTAL_MEDICINE_SYNC
|
||||
RESULT=NON_DESTRUCTIVE_INCREMENTAL_PUSH_DEPLOYED_EXECUTED_AND_VERIFIED
|
||||
NEXT=RETRY_THE_PREVIOUSLY_FAILED_PHARMACY_ORDER
|
||||
BRANCH_ZYT=codex/ej-medicine-incremental-sync
|
||||
BRANCH_EJ=codex/ej-additive-medicine-import
|
||||
ZYT_COMMIT=17e9e7b6b
|
||||
ZYT_PUSH=origin/codex/ej-medicine-incremental-sync
|
||||
EJ_COMMIT=495b02341ac940d7f7b3dac5254fb6714b41311b
|
||||
EJ_PUSH=origin/codex/ej-additive-medicine-import
|
||||
CHANGED_BRANCH_FIELD=ZYT ej-pharmacy:push-medicines add-only command + EJ medicine-imports unrelated-catalog preservation
|
||||
CLARIFIED_BEHAVIOR=restore soft-deleted ZYT-origin EJ medicine in place; re-enable disabled ZYT-origin medicine; append new ZYT medicine; preserve active existing and EJ-only medicines
|
||||
|
||||
ARTIFACTS:
|
||||
MODIFIED_FILE=/Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/MODIFIED_FILE
|
||||
DIFF_FILE=/Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/DIFF_FILE
|
||||
VERIFICATION=/Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/VERIFICATION.txt
|
||||
ROLLBACK=/Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/ROLLBACK.sh
|
||||
|
||||
ORIGINAL:
|
||||
ZYT_BASE_COMMIT=27fbef9321c67f962e4d73f04a52272887c04f95
|
||||
ZYT_CONSOLE_SHA256=c722a3445f5027edcec9bb5be0981252bb6b3c28ba6f27e87364cc6829e7be6c
|
||||
ZYT_ROUTE_CONTRACTS_SHA256=d17a7d25b1903f5f4bb52740f068b493a6b7842b57adc4f503bf995e43d26d7e
|
||||
EJ_BASE_COMMIT=a7439cc2e8c37dd6c32c926f0cc39c1a9c9b4b67
|
||||
EJ_IMPORT_SERVICE_SHA256=70f2444db461be0773348814849eeec1742c622e8a807d4b50b2c1190f664b29
|
||||
EJ_IMPORT_TEST_SHA256=f91eca5aeb6b493a345dfb79e30614356a83695cc0cd37fe2b8f3d58b0c80e6f
|
||||
|
||||
BASELINE_1:
|
||||
COMMAND=cd /Users/long/Work/zyt/server && php tests/pharmacy/callback_auth_integration.php
|
||||
INPUT=baseline checkout 27fbef9321c67f962e4d73f04a52272887c04f95
|
||||
LITERAL_OUTPUT=zyt pharmacy callback/auth integration tests passed: 57
|
||||
EXIT_STATUS=0
|
||||
|
||||
BASELINE_2:
|
||||
COMMAND=cd /Users/long/Work/zyt/server && php tests/pharmacy/run.php
|
||||
INPUT=baseline checkout 27fbef9321c67f962e4d73f04a52272887c04f95
|
||||
LITERAL_OUTPUT=Fatal error: Uncaught RuntimeException: tracking correction must append a strict operation log containing old and new logistics values
|
||||
EXIT_STATUS=255
|
||||
BASELINE_STATUS=pre-existing unrelated tracking-log contract failure; identical after this change
|
||||
|
||||
BASELINE_3:
|
||||
COMMAND=cd /Users/long/Work/ej/server && php tests/pharmacy/run.php
|
||||
INPUT=baseline checkout a7439cc2e8c37dd6c32c926f0cc39c1a9c9b4b67
|
||||
LITERAL_OUTPUT=pharmacy contract tests passed: 67; pharmacy domain contract tests passed: 41; admin pharmacy contracts passed; workflow template contracts passed: 14
|
||||
EXIT_STATUS=0
|
||||
|
||||
MODIFIED_1:
|
||||
COMMAND=php /Users/long/Work/zyt/server/tests/pharmacy/incremental_medicine_push.php
|
||||
INPUT=callback fixtures covering dry-run, stable import IDs, HMAC response identity, locks, created/existing responses, inactive mapping preservation, and zero deletes
|
||||
LITERAL_OUTPUT=incremental EJ medicine push tests passed: 40
|
||||
EXIT_STATUS=0
|
||||
|
||||
MODIFIED_2:
|
||||
COMMAND=php heredoc harness requiring /Users/long/Work/zyt/server/tests/pharmacy/route_contracts.php
|
||||
INPUT=modified command/service registration contracts
|
||||
LITERAL_OUTPUT=route contracts passed: 26
|
||||
EXIT_STATUS=0
|
||||
|
||||
MODIFIED_3:
|
||||
COMMAND=cd /Users/long/Work/zyt/server && php think ej-pharmacy:push-medicines
|
||||
INPUT=default dry-run; no --apply
|
||||
LITERAL_OUTPUT=dry-run source=654 candidates=654 batches=7 mapped=654 unmapped=0 preserved_inactive=0 remote_delete=0 local_delete=0
|
||||
EXIT_STATUS=0
|
||||
MODIFIED_RESULT=no EJ HTTP write; no ZYT projection write; no delete
|
||||
|
||||
MODIFIED_4:
|
||||
COMMAND=cd /Users/long/Work/zyt/server && php think ej-pharmacy:push-medicines --apply
|
||||
INPUT=apply requested without confirmation token
|
||||
LITERAL_OUTPUT=执行增量写入必须提供 --confirm=INCREMENTAL_NO_DELETE
|
||||
EXIT_STATUS=1
|
||||
MODIFIED_RESULT=write gate stopped before lock, HTTP, or projection mutation
|
||||
|
||||
MODIFIED_5:
|
||||
COMMAND=cd /Users/long/Work/ej/server && php tests/pharmacy/run.php
|
||||
INPUT=EJ additive medicine-import service with unrelated catalog rows preserved
|
||||
LITERAL_OUTPUT=pharmacy contract tests passed: 67; pharmacy domain contract tests passed: 41; admin pharmacy contracts passed; workflow template contracts passed: 14
|
||||
EXIT_STATUS=0
|
||||
|
||||
MODIFIED_6:
|
||||
COMMAND=cd /Users/long/Work/ej/server && php tests/pharmacy/medicine_import_mysql_integration.php
|
||||
INPUT=isolated temporary database containing an unrelated EJ medicine plus a new ZYT source medicine
|
||||
LITERAL_OUTPUT=medicine import MySQL integration passed: 15 (temporary_database)
|
||||
EXIT_STATUS=0
|
||||
MODIFIED_RESULT=unrelated EJ medicine and active existing ZYT medicine preserved byte-for-byte; soft-deleted medicine restored in place with original id/code/stock; disabled medicine re-enabled; one new medicine appended; temporary database cleaned
|
||||
|
||||
MODIFIED_7:
|
||||
COMMAND=php -l on ZYT service, ZYT command, ZYT console config, and EJ MedicineImportService
|
||||
INPUT=all changed PHP runtime files
|
||||
LITERAL_OUTPUT=No syntax errors detected
|
||||
EXIT_STATUS=0
|
||||
|
||||
ROLLBACK:
|
||||
COMMAND=ZYT_ROOT=/tmp/ej-sync-rollback-latest.eAdq7T/zyt EJ_ROOT=/tmp/ej-sync-rollback-latest.eAdq7T/ej /Users/long/Work/zyt-ej-medicine-sync/artifacts/ej-medicine-incremental-sync/ROLLBACK.sh
|
||||
INPUT=detached copies at ZYT 27fbef932 and EJ a7439cc with clarified diffs applied; BEFORE_ZYT=6; BEFORE_EJ=3
|
||||
LITERAL_OUTPUT=ROLLBACK_OK: ZYT incremental push removed; EJ unrelated-catalog preflight restored.
|
||||
EXIT_STATUS=0
|
||||
ROLLBACK_RESULT=AFTER_ZYT=0; AFTER_EJ=0; both copies restored to clean base behavior/status
|
||||
CURRENT_STATUS=ZYT and EJ code deployed; EJ import enabled only for merchant ZYT; production incremental apply completed; target medicine is active; zero deletes
|
||||
RESTORED_BEHAVIOR=ROLLBACK removes the ZYT push command/service/docs/tests and restores EJ bootstrap-only unrelated-catalog rejection
|
||||
|
||||
DEPLOYMENT_1:
|
||||
COMMAND=ssh zyt deployment transaction installing ZYT commit 192c31fb8c601d78ed5fc7dd635ee80372a9a760 and EJ commit 495b02341ac940d7f7b3dac5254fb6714b41311b
|
||||
INPUT=HOST 39.97.232.35; ZYT /www/wwwroot/zyt; EJ /www/wwwroot/ej; targeted files only
|
||||
LITERAL_OUTPUT=BACKUP=/www/deploy-backups/ej-medicine-sync-20260910-120752; ZYT_DEPLOYED=192c31fb8c601d78ed5fc7dd635ee80372a9a760; EJ_DEPLOYED=495b02341ac940d7f7b3dac5254fb6714b41311b
|
||||
EXIT_STATUS=0
|
||||
DEPLOYMENT_RESULT=unrelated ZYT ACME file and EJ DoctorLogic/.well-known changes preserved
|
||||
|
||||
DEPLOYMENT_2:
|
||||
COMMAND=/etc/init.d/php-fpm-82 reload
|
||||
INPUT=production PHP 8.2 service after targeted file installation
|
||||
LITERAL_OUTPUT=Reload service php-fpm done
|
||||
EXIT_STATUS=0
|
||||
|
||||
DEPLOYMENT_3:
|
||||
COMMAND=sha256sum deployed runtime files and git show COMMIT:PATH
|
||||
INPUT=ZYT console/command/service plus EJ MedicineImportService
|
||||
LITERAL_OUTPUT=all four expected hashes equal actual hashes; match=YES
|
||||
EXIT_STATUS=0
|
||||
|
||||
DEPLOYMENT_4:
|
||||
COMMAND=cd /www/wwwroot/zyt/server && php think ej-pharmacy:push-medicines
|
||||
INPUT=production default dry-run; no --apply
|
||||
LITERAL_OUTPUT=dry-run source=654 candidates=654 batches=7 mapped=654 unmapped=0 preserved_inactive=0 remote_delete=0 local_delete=0
|
||||
EXIT_STATUS=0
|
||||
DEPLOYMENT_RESULT=no EJ HTTP write; no ZYT projection write; no delete
|
||||
|
||||
DEPLOYMENT_5:
|
||||
COMMAND=curl https://admin.zhenyangtang.com.cn/ and curl https://lyej.lyenji.com/api/openapi/v1/medicines?after=0&limit=1 without HMAC headers
|
||||
INPUT=public post-deployment health verification
|
||||
LITERAL_OUTPUT=admin HTTP=200 text/html; EJ HTTP=401 application/json with message Missing authentication headers
|
||||
EXIT_STATUS=0
|
||||
DEPLOYMENT_RESULT=public admin and EJ gateway are reachable; EJ authentication middleware is active
|
||||
|
||||
PRODUCTION_BACKUP=/www/deploy-backups/ej-medicine-sync-20260910-120752
|
||||
PRODUCTION_ROLLBACK=/www/deploy-backups/ej-medicine-sync-20260910-120752/ROLLBACK.sh
|
||||
PRODUCTION_ROLLBACK_CHECK=bash -n exit 0; ZYT_BACKUP_FILES=2; EJ_BACKUP_FILES=3; EJ env restore and PHP-FPM reload included
|
||||
PRODUCTION_APPLY=SUCCESS
|
||||
|
||||
APPLY_1:
|
||||
COMMAND=cd /www/wwwroot/zyt/server && php think ej-pharmacy:push-medicines --apply --confirm=INCREMENTAL_NO_DELETE --batch-size=100 --run-id=repair-20260910
|
||||
INPUT=production source=654 candidates=654; EJ import feature flag initially false
|
||||
LITERAL_OUTPUT=恩济药材导入失败 HTTP 403:Medicine import is disabled
|
||||
EXIT_STATUS=1
|
||||
CORRECTION=backed up /www/wwwroot/ej/server/.env; set PHARMACY_MEDICINE_IMPORT_ENABLED=true; retained PHARMACY_MEDICINE_IMPORT_ALLOWED_MERCHANTS=ZYT; reloaded PHP-FPM
|
||||
|
||||
APPLY_2:
|
||||
COMMAND=cd /www/wwwroot/zyt/server && php think ej-pharmacy:push-medicines --apply --confirm=INCREMENTAL_NO_DELETE --batch-size=100 --run-id=repair-20260910
|
||||
INPUT=production after EJ import enablement restricted to merchant ZYT
|
||||
LITERAL_OUTPUT=incremental 完成 run_id=repair-20260910 source=654 candidates=654 batches=7 remote_created=0 remote_existing=654 preserved_inactive=0 catalog_created=0 catalog_updated=654 mapping_created=0 mapping_updated=0 mapping_unchanged=654 remote_delete=0 local_delete=0
|
||||
EXIT_STATUS=0
|
||||
APPLY_RESULT=seven EJ import batches completed; no remote or local deletes; existing mappings retained
|
||||
|
||||
APPLY_3:
|
||||
COMMAND=signed EJ GET catalog verification through EjPharmacyClient and EjMedicineCatalogSyncPolicy
|
||||
INPUT=target medicine_code EJ954E7F38D7E3
|
||||
LITERAL_OUTPUT=REMOTE_RECEIVED=670; TARGET_CODE=EJ954E7F38D7E3; TARGET_NAME=生地黄; TARGET_STATUS=1; CATALOG_VERSION=1371; VERIFY_EXIT=0
|
||||
EXIT_STATUS=0
|
||||
APPLY_RESULT=previously rejected target medicine is present and active; EJ catalog still contains 670 rows versus 654 ZYT source medicines, so EJ-only medicines remain present
|
||||
|
||||
APPLY_4:
|
||||
COMMAND=tail production EJ nginx access/error logs
|
||||
INPUT=/api/openapi/v1/medicine-imports after 2026-09-10 12:14:51 +0800
|
||||
LITERAL_OUTPUT=seven POST requests returned HTTP 201; no new medicine import PHP/upstream errors
|
||||
EXIT_STATUS=0
|
||||
|
||||
POST_APPLY_AGGREGATE:
|
||||
COMMAND=read-only aggregate over EJ pharmacy_medicine joined to pharmacy_medicine_source for the 2026-09-10 12:14:45-12:15:00 apply window
|
||||
INPUT=source_system=zyt; production SELECT only
|
||||
LITERAL_OUTPUT=RESTORED_OR_REENABLED=628; ZYT_ACTIVE=654; ZYT_TOTAL=654; EJ_VISIBLE_TOTAL=668; UNCHANGED_ACTIVE=26; READ_ONLY_EXIT=0
|
||||
EXIT_STATUS=0
|
||||
RESULT=628 previously disabled or soft-deleted ZYT-origin medicines were restored/re-enabled; all 654 ZYT-origin medicines are active; 14 additional visible EJ medicines remain
|
||||
@@ -1,49 +0,0 @@
|
||||
# EJ 药材非破坏性增量同步
|
||||
|
||||
`ej-pharmacy:push-medicines` 将 ZYT 中启用且未删除的药材,通过现有 HMAC OpenAPI 增量推送到 EJ。
|
||||
|
||||
## 不变式
|
||||
|
||||
- EJ 只执行 `POST /api/openapi/v1/medicine-imports` 的新增/幂等确认,不删除或清空 EJ 药材。
|
||||
- EJ 中由其他来源或人工录入的药材保持不变。
|
||||
- ZYT 已同步且在 EJ 中仍正常启用的药材保持名称、价格、库存和编码不变。
|
||||
- ZYT 已同步但在 EJ 中被软删除的药材恢复原记录和原 `medicine_code`;被停用的药材重新启用。
|
||||
- ZYT 后续新增、且从未同步过的药材追加到 EJ,并初始化零库存。
|
||||
- ZYT 只增量写入或更新 EJ 返回的目录投影,不清空目录和映射。
|
||||
- ZYT 中已停用或软删除的人工映射不重新启用。
|
||||
- 命令默认 dry-run;只有同时提供 `--apply` 和确认令牌才执行远端导入。
|
||||
|
||||
## 先预检
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/zyt/server
|
||||
php think ej-pharmacy:push-medicines
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```text
|
||||
dry-run source=654 candidates=654 batches=7 mapped=654 unmapped=0 preserved_inactive=0 remote_delete=0 local_delete=0
|
||||
```
|
||||
|
||||
## 执行增量同步
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/zyt/server
|
||||
php think ej-pharmacy:push-medicines \
|
||||
--apply \
|
||||
--confirm=INCREMENTAL_NO_DELETE \
|
||||
--batch-size=100
|
||||
```
|
||||
|
||||
可用 `--run-id=<ID>` 固定本次批次的幂等标识;ID 最长 25 位。若省略,命令使用稳定默认值 `zyt-incremental`,相同内容重试时复用同一 import ID。若旧批次已经完成、但需要修复 EJ 中后来被单独移除的药材,应提供新的维修 run ID,例如 `--run-id=repair-20260910`。
|
||||
|
||||
执行结果会分别报告 EJ 新增、EJ 已存在、本地目录新增/更新、本地映射新增/更新/不变,以及两侧删除数量;删除数量固定为零。
|
||||
|
||||
## 方向说明
|
||||
|
||||
- `ej-pharmacy:push-medicines`:ZYT → EJ,非破坏性增量新增或恢复远端缺项。
|
||||
- `ej-pharmacy:sync-catalog`:EJ → ZYT,拉取 EJ 目录变化。
|
||||
- `ej-pharmacy:bootstrap-medicines`:一次性初始化并替换本地投影,不用于已有业务数据的生产环境增量同步。
|
||||
|
||||
同步范围以 `source_system=zyt` 和 `source_medicine_id` 标识来源。恢复和重新启用只作用于原来由 ZYT 同步过去的药材,因此不会修改 EJ 自己新增的药材。
|
||||
@@ -0,0 +1,73 @@
|
||||
# IAM 统一账号快捷登录
|
||||
|
||||
在原账号密码、企业微信入口之外提供可关闭的 OIDC 登录方式。不开启配置时不访问 IAM、不创建事务文件,也不改变原登录行为。
|
||||
|
||||
## 配置
|
||||
|
||||
在服务器私密 `server/.env` 增加:
|
||||
|
||||
```ini
|
||||
[IAM]
|
||||
ENABLED = false
|
||||
ISSUER = https://login.zhenyangtang.com.cn/auth/realms/iam-hub
|
||||
CLIENT_ID = zyt
|
||||
CLIENT_SECRET = CONFIGURE_PRIVATE_CLIENT_SECRET
|
||||
REDIRECT_URI = https://admin.zhenyangtang.com.cn/adminapi/iam/callback
|
||||
API_URL = https://login.zhenyangtang.com.cn/iam-api
|
||||
APPLICATION_ID = zyt
|
||||
APPLICATION_TOKEN = CONFIGURE_PRIVATE_APPLICATION_TOKEN
|
||||
PUBLIC_URL = https://admin.zhenyangtang.com.cn
|
||||
```
|
||||
|
||||
Keycloak 的 `zyt` client 仅登记上述精确 HTTPS 回调,开启 Authorization Code 与 PKCE S256;不启用密码授权。配置完成后再设 `ENABLED=true`。
|
||||
|
||||
## 账号和权限
|
||||
|
||||
- IAM 必须存在有效员工、`zyt` 应用授权和经管理员确认的账号绑定。`externalAccountId` 是已存在的甄养堂管理员 ID,不是账号名称。
|
||||
- 不按用户名或邮箱自动合并,不自动生成 root、不改已有角色/部门/数据范围。
|
||||
- 本次新员工按用户指定的 **医助角色 ID 2** 开通,沿用原新增账号流程(含 IM 账号导入),再显式绑定到 IAM。后续人员也需由管理员明确开通与绑定;没有绑定时快捷登录拒绝,而非猜测账号。
|
||||
- 登录签发原 `AdminTokenService` token,继续遵守本地 disable、软删除、单点/多点登录、首次改密以及强制绑定企微规则。
|
||||
- 新账号默认 `is_paw=0`。完成首次改密、绑定企微之前不会跳过原有业务门禁。
|
||||
- 此版本在每次新快捷登录时检查 IAM 状态,不批量撤销既有业务会话,也不宣称已实现 IAM 停权事件实时踢下线。
|
||||
|
||||
## 协议与部署
|
||||
|
||||
端点为 `/adminapi/iam/config`、`start`、`callback`、`exchange`。浏览器保存独立 HttpOnly/Secure/SameSite=Lax 事务 Cookie;state、nonce、PKCE 与一次性兑换码绑定浏览器。URL 中不传业务 token,兑换只能 POST 且校验来源。事务文件保存在应用 runtime 的 `iam-login` 私有子目录,0600,受文件锁保护。当前单服务器 PHP-FPM 多进程共享该目录;扩展到多机前须换成共享原子存储。
|
||||
|
||||
JWT 使用锁定的 firebase/php-jwt,校验 RS256、签名、issuer、audience/azp、有效期和 nonce;出站 TLS 校验保持开启,不跟随重定向。
|
||||
前端通过服务器开关显示按钮,兑换后复用现有 token 缓存与页面分流,IAM 失败时原账号及企微入口仍可使用。
|
||||
发布静态文件保留旧哈希资源,最后更新入口,避免已打开页面加载旧资源时报错。
|
||||
|
||||
## 验证与回退
|
||||
|
||||
```sh
|
||||
php server/tests/IamOidcClientTest.php
|
||||
php server/tests/IamLoginTransactionTest.php
|
||||
php server/tests/AdminDesktopAuthContractTest.php
|
||||
php server/tests/AdminDesktopSessionBehaviorTest.php
|
||||
php server/tests/AdminMultiRoleRegressionTest.php
|
||||
php server/tests/DataScopeMultiRoleTest.php
|
||||
NODE_PATH=admin/node_modules node server/tests/IamLoginUiContractTest.mjs
|
||||
```
|
||||
|
||||
回退时先关闭 IAM 开关,再恢复本次代码和静态入口备份,不清空会话、用户、角色、业务数据库或全部缓存。新创建的员工与医助账号保留,由管理员决定是否停用。
|
||||
|
||||
## 2026-09-10: authorized first-login provisioning
|
||||
|
||||
Deploy `server/database/migrations/20260910_iam_local_identity.sql` before the adapter update (adjust the `zyt_` prefix only when configured differently). This additive InnoDB ledger is keyed by SHA-256 of the exact issuer/application/subject tuple and uniquely associates a new local account. Keep the ledger when rolling code back; never drop it or delete real accounts as a code rollback.
|
||||
|
||||
The authenticated provisioning-context API is the authority for active employee + application grant. Only an explicit `shouldCreateLocalAccount=true` with an empty external ID creates an account. The local transaction inserts the immutable ledger, a random local account name and password, and role **2 / 医助** only; a missing, deleted, renamed or disabled default role fails closed. Neither name, phone nor email merges accounts. Existing explicit bindings use existing permissions without modification. Password setup (`is_paw=0`), non-root status, and original WeCom binding gates remain intact. The normal physician IM import runs best-effort after central binding confirmation, matching `AdminLogic::add` semantics; provider errors are logged by the existing helper.
|
||||
|
||||
After local commit, the adapter uses application authentication and stable idempotency metadata to POST bindings, accepts HTTP 201, then reads provisioning-context again and requires the exact created ID before issuing a login ticket. Remote failures keep the local ledger for retry, with no business token. Parallel workers cannot create another ledger/account for that immutable identity. A transient DB deadlock can fail one login attempt; retry resumes the winning account.
|
||||
|
||||
The IAM server must reject changing an existing binding to a different external ID on this endpoint (rather than an upsert overwrite). Ship that conflict fix before this adapter. Admin-driven deliberate rebindings require a separate verified workflow.
|
||||
|
||||
Focused local tests:
|
||||
|
||||
```sh
|
||||
php server/tests/IamOidcClientTest.php
|
||||
php server/tests/IamLoginTransactionTest.php
|
||||
php server/tests/IamProvisioningTest.php
|
||||
# Dedicated disposable local MariaDB only; drops four fixture tables in iam_fixture.
|
||||
IAM_TEST_MYSQL=1 php server/tests/IamProvisioningTest.php
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller;
|
||||
|
||||
use app\adminapi\service\iam\IamLoginService;
|
||||
use think\facade\Log;
|
||||
|
||||
/** Dedicated optional endpoints: existing account and WeCom actions are untouched. */
|
||||
class IamController extends BaseAdminController
|
||||
{
|
||||
public array $notNeedLogin = ['config', 'start', 'callback', 'exchange'];
|
||||
private const COOKIE = 'ZYT_IAM_BROWSER';
|
||||
|
||||
public function config()
|
||||
{
|
||||
return $this->data(IamLoginService::settings())->header(['Cache-Control' => 'no-store']);
|
||||
}
|
||||
|
||||
public function start()
|
||||
{
|
||||
try {
|
||||
$service = new IamLoginService();
|
||||
$browser = $this->browser();
|
||||
if ($browser === '') {
|
||||
$browser = bin2hex(random_bytes(32));
|
||||
}
|
||||
setcookie(self::COOKIE, $browser, ['expires' => time() + 600, 'path' => '/adminapi/iam', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);
|
||||
return redirect($service->start($browser, $this->request->ip()))->header($this->privateHeaders());
|
||||
} catch (\Throwable $error) {
|
||||
return $this->back(['iam_error' => $this->message($error)]);
|
||||
}
|
||||
}
|
||||
|
||||
public function callback()
|
||||
{
|
||||
try {
|
||||
if ($this->browser() === '' || $this->request->get('error', '') !== '') {
|
||||
throw new \RuntimeException('授权已取消或浏览器状态过期,请重新登录');
|
||||
}
|
||||
$ticket = (new IamLoginService())->callback($this->browser(), (string) $this->request->get('state', ''), (string) $this->request->get('code', ''));
|
||||
return $this->back(['iam_ticket' => $ticket]);
|
||||
} catch (\Throwable $error) {
|
||||
return $this->back(['iam_error' => $this->message($error)]);
|
||||
}
|
||||
}
|
||||
|
||||
public function exchange()
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
return $this->fail('请使用 POST 兑换登录状态')->code(405);
|
||||
}
|
||||
try {
|
||||
if ($this->browser() === '') {
|
||||
throw new \RuntimeException('浏览器登录状态已过期,请重新登录');
|
||||
}
|
||||
$payload = (new IamLoginService())->exchange($this->browser(), (string) $this->request->post('ticket', ''), (string) $this->request->header('origin', ''));
|
||||
return $this->data($payload)->header($this->privateHeaders());
|
||||
} catch (\Throwable $error) {
|
||||
return $this->fail($this->message($error))->header($this->privateHeaders());
|
||||
}
|
||||
}
|
||||
|
||||
private function browser(): string
|
||||
{
|
||||
$value = (string) ($_COOKIE[self::COOKIE] ?? '');
|
||||
return preg_match('/^[a-f0-9]{64}$/D', $value) ? $value : '';
|
||||
}
|
||||
|
||||
private function back(array $query)
|
||||
{
|
||||
// Relative, fixed path: never derive the return origin from request headers or query strings.
|
||||
return redirect('/admin/login?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986))->header($this->privateHeaders());
|
||||
}
|
||||
|
||||
private function privateHeaders(): array
|
||||
{
|
||||
return ['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer'];
|
||||
}
|
||||
|
||||
private function message(\Throwable $error): string
|
||||
{
|
||||
Log::warning('IAM login rejected: ' . get_class($error));
|
||||
$message = $error->getMessage();
|
||||
return preg_match('/^[\x{4e00}-\x{9fff}]/u', $message) ? $message : '统一账号登录失败,请重试或使用原账号登录';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\service\iam;
|
||||
|
||||
use RuntimeException;
|
||||
use app\adminapi\logic\auth\AdminLogic;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
|
||||
/** Local identity ledger survives IAM timeouts; no names/phones/emails are identity proofs. */
|
||||
final class IamAccountProvisioner
|
||||
{
|
||||
private $imImport;
|
||||
|
||||
public function __construct(?callable $imImport = null)
|
||||
{
|
||||
$this->imImport = $imImport ?? static function (int $id, string $name): void {
|
||||
AdminLogic::importDoctorAccountToIm($id, $name);
|
||||
};
|
||||
}
|
||||
|
||||
public static function identity(array $config, string $subject): string
|
||||
{
|
||||
return json_encode([$config['issuer'], $config['application_id'], $subject], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public static function validateContext(array $context, string $subject, string $applicationId): void
|
||||
{
|
||||
if (($context['applicationId'] ?? '') !== $applicationId || ($context['localIdentityKey'] ?? '') !== $subject
|
||||
|| ($context['employee']['oidcSubject'] ?? '') !== $subject || ($context['employee']['status'] ?? '') !== 'active') {
|
||||
throw new RuntimeException('当前统一账号未获得甄养堂访问权限');
|
||||
}
|
||||
}
|
||||
|
||||
public function resolve(IamOidcClient $client, array $config, string $subject): int
|
||||
{
|
||||
$context = $client->provisioning($subject);
|
||||
self::validateContext($context, $subject, $config['application_id']);
|
||||
if (($context['shouldCreateLocalAccount'] ?? null) !== true) {
|
||||
return IamLoginService::boundAdminId($context, $subject, $config['application_id']);
|
||||
}
|
||||
if (($context['externalAccountId'] ?? null) !== '') {
|
||||
throw new RuntimeException('统一账号绑定状态不一致');
|
||||
}
|
||||
$id = $this->createOrResume(self::identity($config, $subject), $context);
|
||||
// Creation is durable before the remote call. On timeout retry this SAME account, never another one.
|
||||
$client->bind($subject, $id);
|
||||
$confirmed = IamLoginService::boundAdminId($client->provisioning($subject), $subject, $config['application_id']);
|
||||
if ($confirmed !== $id) {
|
||||
throw new RuntimeException('统一账号绑定冲突,请联系管理员');
|
||||
}
|
||||
// Match AdminLogic::add's best-effort doctor IM initialization only after central confirmation.
|
||||
// importDoctorAccountToIm catches/logs provider failures; it does not grant permissions.
|
||||
($this->imImport)($id, (string) Db::name('admin')->where('id', $id)->value('name'));
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function createOrResume(string $identity, array $context): int
|
||||
{
|
||||
$key = hash('sha256', $identity);
|
||||
try {
|
||||
return Db::transaction(function () use ($key, $identity, $context): int {
|
||||
$row = Db::name('iam_local_identity')->where('identity_key', $key)->lock(true)->find();
|
||||
if ($row) {
|
||||
return $this->existing($row, $identity);
|
||||
}
|
||||
// Unique PK serializes concurrent workers, including workers on different servers.
|
||||
Db::name('iam_local_identity')->insert(['identity_key' => $key, 'identity_value' => $identity,
|
||||
'admin_id' => null, 'create_time' => time()]);
|
||||
$role = Db::name('system_role')->where('id', 2)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$role || ($role['name'] ?? '') !== '医助' || (isset($role['disable']) && (int) $role['disable'] !== 0)) {
|
||||
throw new RuntimeException('默认医助角色未就绪,请联系管理员');
|
||||
}
|
||||
// Random local login name; never adopt an existing similarly named local account.
|
||||
$account = 'iam_' . bin2hex(random_bytes(12));
|
||||
if (Db::name('admin')->where('account', $account)->find()) {
|
||||
throw new RuntimeException('新账号标识冲突,请重新登录');
|
||||
}
|
||||
$name = (string) ($context['employee']['displayName'] ?? '统一账号用户');
|
||||
$id = (int) Db::name('admin')->insertGetId([
|
||||
'account' => $account, 'name' => mb_substr($name !== '' ? $name : '统一账号用户', 0, 20),
|
||||
'password' => create_password(bin2hex(random_bytes(32)), Config::get('project.unique_identification')),
|
||||
'avatar' => (string) Config::get('project.default_image.admin_avatar', ''),
|
||||
'root' => 0, 'disable' => 0, 'is_paw' => 0, 'multipoint_login' => 0,
|
||||
'gender' => 1, 'enable_image_consult' => 1, 'enable_video_consult' => 1, 'enable_charge' => 0,
|
||||
'create_time' => time(), 'update_time' => time(),
|
||||
]);
|
||||
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => 2]);
|
||||
Db::name('iam_local_identity')->where('identity_key', $key)->update(['admin_id' => $id]);
|
||||
return $id;
|
||||
});
|
||||
} catch (\Throwable $error) {
|
||||
// A concurrent insert may have won while our transaction rolled back. Only the exact ledger is reusable.
|
||||
$row = Db::name('iam_local_identity')->where('identity_key', $key)->find();
|
||||
if ($row) {
|
||||
return $this->existing($row, $identity);
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
private function existing(array $row, string $identity): int
|
||||
{
|
||||
if (!hash_equals((string) $row['identity_value'], $identity) || (int) $row['admin_id'] <= 0) {
|
||||
throw new RuntimeException('统一账号本地开户状态异常');
|
||||
}
|
||||
$admin = Db::name('admin')->where('id', (int) $row['admin_id'])->find();
|
||||
if (!$admin || (int) $admin['disable'] !== 0 || !empty($admin['delete_time'])) {
|
||||
throw new RuntimeException('甄养堂账号已停用,请联系管理员');
|
||||
}
|
||||
// Existing permissions and password/WeCom requirements are never rewritten.
|
||||
return (int) $row['admin_id'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\service\iam;
|
||||
|
||||
use app\adminapi\logic\LoginLogic;
|
||||
use app\common\enum\AdminTerminalEnum;
|
||||
use app\common\model\auth\Admin;
|
||||
use RuntimeException;
|
||||
use think\facade\Config;
|
||||
|
||||
final class IamLoginService
|
||||
{
|
||||
private array $config;
|
||||
private IamOidcClient $client;
|
||||
private IamLoginTransactionStore $transactions;
|
||||
|
||||
public static function settings(): array
|
||||
{
|
||||
$config = (array) Config::get('iam', []);
|
||||
$enabled = in_array(strtolower((string) ($config['enabled'] ?? '')), ['1', 'true', 'yes', 'on'], true);
|
||||
if (!$enabled) {
|
||||
return ['enabled' => false, 'loginUrl' => ''];
|
||||
}
|
||||
try {
|
||||
new IamOidcClient($config);
|
||||
$origin = self::publicOrigin($config);
|
||||
if (($config['redirect_uri'] ?? '') !== $origin . '/adminapi/iam/callback') {
|
||||
return ['enabled' => false, 'loginUrl' => ''];
|
||||
}
|
||||
return ['enabled' => true, 'loginUrl' => $origin . '/adminapi/iam/start'];
|
||||
} catch (\Throwable $error) {
|
||||
return ['enabled' => false, 'loginUrl' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
if (!self::settings()['enabled']) {
|
||||
throw new RuntimeException('统一账号快捷登录尚未启用,请使用原账号登录');
|
||||
}
|
||||
$this->config = (array) Config::get('iam');
|
||||
$this->client = new IamOidcClient($this->config);
|
||||
$this->transactions = new IamLoginTransactionStore(app()->getRuntimePath() . 'iam-login');
|
||||
}
|
||||
|
||||
public function start(string $browser, string $ip): string
|
||||
{
|
||||
if (!$this->transactions->allowStart($ip)) {
|
||||
throw new RuntimeException('快捷登录请求过于频繁,请稍后重试');
|
||||
}
|
||||
$state = bin2hex(random_bytes(32));
|
||||
$nonce = bin2hex(random_bytes(32));
|
||||
$verifier = bin2hex(random_bytes(32));
|
||||
$target = $this->client->authorizationUrl($state, $nonce, $verifier);
|
||||
$this->transactions->put('state', $state, $browser, ['nonce' => $nonce, 'verifier' => $verifier], 600);
|
||||
return $target;
|
||||
}
|
||||
|
||||
public function callback(string $browser, string $state, string $code): string
|
||||
{
|
||||
$transaction = $this->transactions->consume('state', $state, $browser);
|
||||
if ($code === '' || strlen($code) > 4096) {
|
||||
throw new RuntimeException('快捷登录回调无效,请重新登录');
|
||||
}
|
||||
$claims = $this->client->exchange($code, $transaction['verifier'], $transaction['nonce']);
|
||||
$this->verifiedAdmin($claims['sub']);
|
||||
$ticket = bin2hex(random_bytes(32));
|
||||
$this->transactions->put('ticket', $ticket, $browser, ['subject' => $claims['sub']], 60);
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
public function exchange(string $browser, string $ticket, string $origin): array
|
||||
{
|
||||
if ($origin !== '' && $origin !== self::publicOrigin($this->config)) {
|
||||
throw new RuntimeException('快捷登录来源无效,请重新登录');
|
||||
}
|
||||
$transaction = $this->transactions->consume('ticket', $ticket, $browser);
|
||||
// Recheck entitlement, binding and local status immediately before issuing the existing business token.
|
||||
$admin = $this->verifiedAdmin($transaction['subject']);
|
||||
return (array) (new LoginLogic())->login(['account' => $admin->account, 'terminal' => AdminTerminalEnum::PC]);
|
||||
}
|
||||
|
||||
public static function boundAdminId(array $context, string $subject, string $applicationId): int
|
||||
{
|
||||
if (($context['applicationId'] ?? '') !== $applicationId || ($context['localIdentityKey'] ?? '') !== $subject
|
||||
|| ($context['employee']['oidcSubject'] ?? '') !== $subject || ($context['employee']['status'] ?? '') !== 'active') {
|
||||
throw new RuntimeException('当前统一账号未获得甄养堂访问权限');
|
||||
}
|
||||
$rawId = $context['externalAccountId'] ?? '';
|
||||
if (!is_string($rawId) && !is_int($rawId)) {
|
||||
throw new RuntimeException('统一账号绑定格式无效,请联系管理员');
|
||||
}
|
||||
$id = (string) $rawId;
|
||||
if (!preg_match('/^[1-9][0-9]{0,9}$/D', $id) || ($context['shouldCreateLocalAccount'] ?? true) !== false) {
|
||||
throw new RuntimeException('统一账号尚未开通甄养堂账号,请联系管理员');
|
||||
}
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
private function verifiedAdmin(string $subject): Admin
|
||||
{
|
||||
$id = (new IamAccountProvisioner())->resolve($this->client, $this->config, $subject);
|
||||
$admin = Admin::findOrEmpty($id);
|
||||
if ($admin->isEmpty() || (int) $admin->disable !== 0 || !empty($admin->getData('delete_time'))) {
|
||||
throw new RuntimeException('甄养堂账号已停用,请联系管理员');
|
||||
}
|
||||
return $admin;
|
||||
}
|
||||
|
||||
private static function publicOrigin(array $config): string
|
||||
{
|
||||
$value = rtrim((string) ($config['public_url'] ?? ''), '/');
|
||||
$parts = parse_url($value);
|
||||
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])
|
||||
|| isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])
|
||||
|| !empty($parts['path']) || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) {
|
||||
throw new RuntimeException('IAM public origin must be fixed HTTPS');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\service\iam;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** Private, browser-bound, single-use state. File locks work with the existing PHP-FPM deployment. */
|
||||
final class IamLoginTransactionStore
|
||||
{
|
||||
public function __construct(private string $directory)
|
||||
{
|
||||
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('IAM transaction storage unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
public function put(string $kind, string $value, string $browser, array $payload, int $ttl): void
|
||||
{
|
||||
$handle = fopen($this->path($kind, $value), 'x');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('IAM transaction creation failed');
|
||||
}
|
||||
try {
|
||||
chmod($this->path($kind, $value), 0600);
|
||||
$data = json_encode(['browser' => hash('sha256', $browser), 'expires' => time() + $ttl, 'payload' => $payload], JSON_THROW_ON_ERROR);
|
||||
if (fwrite($handle, $data) !== strlen($data)) {
|
||||
throw new RuntimeException('IAM transaction write failed');
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
// Only expired private IAM files are collected, and never a file held by another request.
|
||||
if (random_int(1, 20) === 1) {
|
||||
foreach (array_slice(glob($this->directory . '/*.json') ?: [], 0, 200) as $file) {
|
||||
if (filemtime($file) >= time() - 1200) {
|
||||
continue;
|
||||
}
|
||||
$lock = @fopen($file, 'r+');
|
||||
if ($lock !== false) {
|
||||
if (flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
@unlink($file);
|
||||
flock($lock, LOCK_UN);
|
||||
}
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function consume(string $kind, string $value, string $browser): array
|
||||
{
|
||||
$handle = @fopen($this->path($kind, $value), 'r+');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('登录状态已失效,请重新发起快捷登录');
|
||||
}
|
||||
try {
|
||||
if (!flock($handle, LOCK_EX)) {
|
||||
throw new RuntimeException('IAM transaction lock failed');
|
||||
}
|
||||
$data = json_decode(stream_get_contents($handle), true);
|
||||
if (!is_array($data) || ($data['expires'] ?? 0) <= time() || !isset($data['browser'])
|
||||
|| !hash_equals($data['browser'], hash('sha256', $browser)) || !is_array($data['payload'] ?? null)) {
|
||||
throw new RuntimeException('登录状态已失效,请重新发起快捷登录');
|
||||
}
|
||||
// Truncate under the lock before returning, so previously opened file descriptors cannot replay.
|
||||
if (!ftruncate($handle, 0) || !fflush($handle)) {
|
||||
throw new RuntimeException('IAM transaction consumption failed');
|
||||
}
|
||||
return $data['payload'];
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
public function allowStart(string $ip): bool
|
||||
{
|
||||
$file = $this->directory . '/limit-' . hash('sha256', $ip) . '.json';
|
||||
$handle = fopen($file, 'c+');
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
chmod($file, 0600);
|
||||
if (!flock($handle, LOCK_EX)) {
|
||||
return false;
|
||||
}
|
||||
$data = json_decode(stream_get_contents($handle), true);
|
||||
if (!is_array($data) || ($data['expires'] ?? 0) <= time()) {
|
||||
$data = ['count' => 0, 'expires' => time() + 600];
|
||||
}
|
||||
if ($data['count'] >= 20) {
|
||||
return false;
|
||||
}
|
||||
++$data['count'];
|
||||
rewind($handle);
|
||||
ftruncate($handle, 0);
|
||||
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
|
||||
return fwrite($handle, $encoded) === strlen($encoded) && fflush($handle);
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
private function path(string $kind, string $value): string
|
||||
{
|
||||
if (!in_array($kind, ['state', 'ticket'], true) || !preg_match('/^[a-f0-9]{64}$/D', $value)) {
|
||||
throw new RuntimeException('登录状态无效,请重新发起快捷登录');
|
||||
}
|
||||
return $this->directory . '/' . $kind . '-' . hash('sha256', $value) . '.json';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\service\iam;
|
||||
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use RuntimeException;
|
||||
|
||||
/** Fixed-origin OIDC client. Transaction storage and account binding belong to the caller. */
|
||||
final class IamOidcClient
|
||||
{
|
||||
private array $config;
|
||||
private $transport;
|
||||
private ?array $discovery = null;
|
||||
|
||||
/** Transport signature: (method, url, headers, body): ['status'=>int, 'body'=>string]. */
|
||||
public function __construct(array $config, ?callable $transport = null)
|
||||
{
|
||||
foreach (['issuer', 'client_id', 'client_secret', 'redirect_uri', 'api_url', 'application_id', 'application_token'] as $key) {
|
||||
if (!isset($config[$key]) || !is_string($config[$key]) || trim($config[$key]) === '' || preg_match('/[\r\n]/', $config[$key])) {
|
||||
throw new RuntimeException('IAM configuration is incomplete');
|
||||
}
|
||||
}
|
||||
$this->config = $config;
|
||||
$this->transport = $transport;
|
||||
$this->httpsUrl($config['issuer']);
|
||||
$this->httpsUrl($config['redirect_uri']);
|
||||
$this->endpoint($config['api_url']);
|
||||
if (parse_url($config['issuer'], PHP_URL_QUERY) !== null || parse_url($config['api_url'], PHP_URL_QUERY) !== null) {
|
||||
throw new RuntimeException('IAM base URL must not contain a query');
|
||||
}
|
||||
}
|
||||
|
||||
public function authorizationUrl(string $state, string $nonce, string $verifier): string
|
||||
{
|
||||
$this->verifier($verifier);
|
||||
if (strlen($state) < 32 || strlen($nonce) < 32) {
|
||||
throw new RuntimeException('IAM state and nonce must be random transaction values');
|
||||
}
|
||||
$metadata = $this->metadata();
|
||||
return $metadata['authorization_endpoint'] . (str_contains($metadata['authorization_endpoint'], '?') ? '&' : '?') . http_build_query([
|
||||
'response_type' => 'code', 'client_id' => $this->config['client_id'],
|
||||
'redirect_uri' => $this->config['redirect_uri'], 'scope' => 'openid profile',
|
||||
'state' => $state, 'nonce' => $nonce,
|
||||
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
|
||||
'code_challenge_method' => 'S256',
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
public function exchange(string $code, string $verifier, string $nonce): array
|
||||
{
|
||||
$this->verifier($verifier);
|
||||
if ($code === '' || $nonce === '') {
|
||||
throw new RuntimeException('IAM authorization response is incomplete');
|
||||
}
|
||||
$metadata = $this->metadata();
|
||||
$response = $this->request('POST', $metadata['token_endpoint'], [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Authorization: Basic ' . base64_encode(urlencode($this->config['client_id']) . ':' . urlencode($this->config['client_secret'])),
|
||||
], http_build_query([
|
||||
'grant_type' => 'authorization_code', 'code' => $code, 'code_verifier' => $verifier,
|
||||
'redirect_uri' => $this->config['redirect_uri'],
|
||||
], '', '&', PHP_QUERY_RFC3986));
|
||||
if (!isset($response['id_token']) || !is_string($response['id_token']) || strlen($response['id_token']) > 65536) {
|
||||
throw new RuntimeException('IAM ID token is missing');
|
||||
}
|
||||
$jwks = $this->request('GET', $metadata['jwks_uri']);
|
||||
$keys = [];
|
||||
$kids = [];
|
||||
foreach ($jwks['keys'] ?? [] as $key) {
|
||||
if (is_array($key) && ($key['kty'] ?? '') === 'RSA' && ($key['alg'] ?? 'RS256') === 'RS256'
|
||||
&& ($key['use'] ?? 'sig') === 'sig' && (!isset($key['key_ops']) || in_array('verify', $key['key_ops'], true))) {
|
||||
if (!is_string($key['kid'] ?? null) || $key['kid'] === '' || isset($kids[$key['kid']])) {
|
||||
throw new RuntimeException('IAM signing key identifier is invalid');
|
||||
}
|
||||
$kids[$key['kid']] = true;
|
||||
$key['alg'] = 'RS256';
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
if ($keys === []) {
|
||||
throw new RuntimeException('IAM signing keys are unavailable');
|
||||
}
|
||||
try {
|
||||
$claims = (array) JWT::decode($response['id_token'], JWK::parseKeySet(['keys' => $keys]));
|
||||
} catch (\Throwable $error) {
|
||||
throw new RuntimeException('IAM ID token verification failed');
|
||||
}
|
||||
$aud = $claims['aud'] ?? null;
|
||||
$audiences = is_string($aud) ? [$aud] : (is_array($aud) ? $aud : []);
|
||||
$now = time();
|
||||
if (($claims['iss'] ?? null) !== $this->config['issuer']
|
||||
|| !in_array($this->config['client_id'], $audiences, true)
|
||||
|| (count($audiences) > 1 && !isset($claims['azp']))
|
||||
|| (isset($claims['azp']) && $claims['azp'] !== $this->config['client_id'])
|
||||
|| !is_string($claims['sub'] ?? null) || $claims['sub'] === ''
|
||||
|| !is_string($claims['nonce'] ?? null) || !hash_equals($nonce, $claims['nonce'])
|
||||
|| !is_int($claims['exp'] ?? null) || $claims['exp'] <= $now
|
||||
|| !is_int($claims['iat'] ?? null) || $claims['iat'] > $now
|
||||
|| (isset($claims['nbf']) && (!is_int($claims['nbf']) || $claims['nbf'] > $now))) {
|
||||
throw new RuntimeException('IAM ID token claims are invalid');
|
||||
}
|
||||
return $claims;
|
||||
}
|
||||
|
||||
public function provisioning(string $subject): array
|
||||
{
|
||||
if ($subject === '' || strlen($subject) > 512) {
|
||||
throw new RuntimeException('IAM subject is invalid');
|
||||
}
|
||||
return $this->request('GET', rtrim($this->config['api_url'], '/') . '/internal/v1/applications/'
|
||||
. rawurlencode($this->config['application_id']) . '/employees/' . rawurlencode($subject) . '/provisioning-context',
|
||||
['Authorization: Bearer ' . $this->config['application_token']]);
|
||||
}
|
||||
|
||||
public function bind(string $subject, int $adminId): array
|
||||
{
|
||||
if ($subject === '' || strlen($subject) > 512 || $adminId <= 0) {
|
||||
throw new RuntimeException('IAM binding input is invalid');
|
||||
}
|
||||
$key = hash('sha256', json_encode([$this->config['issuer'], $this->config['application_id'], $subject, $adminId], JSON_THROW_ON_ERROR));
|
||||
return $this->request('POST', rtrim($this->config['api_url'], '/') . '/internal/v1/applications/'
|
||||
. rawurlencode($this->config['application_id']) . '/bindings', [
|
||||
'Authorization: Bearer ' . $this->config['application_token'],
|
||||
'Content-Type: application/json', 'X-Request-ID: ' . bin2hex(random_bytes(16)),
|
||||
'Idempotency-Key: zyt-provision-' . $key,
|
||||
], json_encode(['employeeSubject' => $subject, 'externalAccountId' => (string) $adminId,
|
||||
'verificationMethod' => 'oidc_verified_subject_local_creation'], JSON_THROW_ON_ERROR), [201]);
|
||||
}
|
||||
|
||||
private function metadata(): array
|
||||
{
|
||||
if ($this->discovery !== null) {
|
||||
return $this->discovery;
|
||||
}
|
||||
$metadata = $this->request('GET', rtrim($this->config['issuer'], '/') . '/.well-known/openid-configuration');
|
||||
if (($metadata['issuer'] ?? null) !== $this->config['issuer']) {
|
||||
throw new RuntimeException('IAM discovery issuer mismatch');
|
||||
}
|
||||
foreach (['authorization_endpoint', 'token_endpoint', 'jwks_uri'] as $key) {
|
||||
if (!isset($metadata[$key]) || !is_string($metadata[$key])) {
|
||||
throw new RuntimeException('IAM discovery endpoint is missing');
|
||||
}
|
||||
$this->endpoint($metadata[$key]);
|
||||
}
|
||||
return $this->discovery = $metadata;
|
||||
}
|
||||
|
||||
private function verifier(string $verifier): void
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9._~-]{43,128}$/D', $verifier)) {
|
||||
throw new RuntimeException('IAM PKCE verifier is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
private function httpsUrl(string $url): array
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])
|
||||
|| isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment'])
|
||||
|| preg_match('/[\x00-\x20\x7f\\\\]/', $url)) {
|
||||
throw new RuntimeException('IAM URL must be a fixed HTTPS URL');
|
||||
}
|
||||
return $parts;
|
||||
}
|
||||
|
||||
private function endpoint(string $url): void
|
||||
{
|
||||
$urlParts = $this->httpsUrl($url);
|
||||
$issuer = $this->httpsUrl($this->config['issuer']);
|
||||
if (strtolower($urlParts['host']) !== strtolower($issuer['host']) || ($urlParts['port'] ?? 443) !== ($issuer['port'] ?? 443)) {
|
||||
throw new RuntimeException('IAM endpoint origin mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
private function request(string $method, string $url, array $headers = [], string $body = '', array $statuses = [200]): array
|
||||
{
|
||||
$this->endpoint($url);
|
||||
$headers[] = 'Accept: application/json';
|
||||
if ($this->transport !== null) {
|
||||
$result = ($this->transport)($method, $url, $headers, $body);
|
||||
} else {
|
||||
$curl = curl_init($url);
|
||||
$response = '';
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$response): int {
|
||||
if (strlen($response) + strlen($chunk) > 1048576) {
|
||||
return 0;
|
||||
}
|
||||
$response .= $chunk;
|
||||
return strlen($chunk);
|
||||
},
|
||||
]);
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$ok = curl_exec($curl);
|
||||
$result = ['status' => curl_getinfo($curl, CURLINFO_RESPONSE_CODE), 'body' => $ok === false ? false : $response];
|
||||
curl_close($curl);
|
||||
}
|
||||
if (!is_array($result) || !in_array($result['status'] ?? 0, $statuses, true) || !is_string($result['body'] ?? null) || strlen($result['body']) > 1048576) {
|
||||
throw new RuntimeException('IAM request failed');
|
||||
}
|
||||
try {
|
||||
$data = json_decode($result['body'], true, 64, JSON_THROW_ON_ERROR);
|
||||
} catch (\Throwable $error) {
|
||||
throw new RuntimeException('IAM response is invalid');
|
||||
}
|
||||
if (!is_array($data) || isset($data['error'])) {
|
||||
throw new RuntimeException('IAM response is invalid');
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\pharmacy\EjMedicineIncrementalPushService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
final class EjPharmacyPushMedicines extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('ej-pharmacy:push-medicines')
|
||||
->setDescription('非破坏性增量推送 ZYT 药材到恩济药房;保留双方已有药材')
|
||||
->addOption('apply', null, Option::VALUE_NONE, '执行远端增量导入;缺省仅做 dry-run')
|
||||
->addOption('confirm', null, Option::VALUE_OPTIONAL, '增量写入确认令牌:INCREMENTAL_NO_DELETE', '')
|
||||
->addOption('batch-size', null, Option::VALUE_OPTIONAL, '每批药材数量(1-500)', 100)
|
||||
->addOption('run-id', null, Option::VALUE_OPTIONAL, '幂等运行标识(缺省 zyt-incremental,最长 25 位)', '');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
try {
|
||||
$apply = (bool) $input->getOption('apply');
|
||||
$confirm = (string) $input->getOption('confirm');
|
||||
$batchSize = (int) $input->getOption('batch-size');
|
||||
EjMedicineIncrementalPushService::assertCommandGate($apply, $confirm, $batchSize);
|
||||
|
||||
if (!$apply) {
|
||||
$plan = EjMedicineIncrementalPushService::plan($batchSize);
|
||||
$output->writeln(sprintf(
|
||||
'dry-run source=%d candidates=%d batches=%d mapped=%d unmapped=%d '
|
||||
. 'preserved_inactive=%d remote_delete=0 local_delete=0',
|
||||
$plan['source_count'],
|
||||
$plan['candidate_count'],
|
||||
$plan['batch_count'],
|
||||
$plan['mapped_count'],
|
||||
$plan['unmapped_count'],
|
||||
$plan['preserved_inactive']
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
|
||||
$result = EjMedicineIncrementalPushService::execute(
|
||||
$batchSize,
|
||||
(string) $input->getOption('run-id')
|
||||
);
|
||||
$output->writeln(sprintf(
|
||||
'incremental 完成 run_id=%s source=%d candidates=%d batches=%d '
|
||||
. 'remote_created=%d remote_existing=%d preserved_inactive=%d '
|
||||
. 'catalog_created=%d catalog_updated=%d mapping_created=%d mapping_updated=%d '
|
||||
. 'mapping_unchanged=%d remote_delete=0 local_delete=0',
|
||||
$result['run_id'],
|
||||
$result['source_count'],
|
||||
$result['candidate_count'],
|
||||
$result['batch_count'],
|
||||
$result['remote_created'],
|
||||
$result['remote_existing'],
|
||||
$result['preserved_inactive'],
|
||||
$result['catalog_created'],
|
||||
$result['catalog_updated'],
|
||||
$result['mapping_created'],
|
||||
$result['mapping_updated'],
|
||||
$result['mapping_unchanged']
|
||||
));
|
||||
return 0;
|
||||
} catch (\Throwable $exception) {
|
||||
$output->error($exception->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,590 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class EjMedicineIncrementalPushService
|
||||
{
|
||||
private const SOURCE_SYSTEM = 'zyt';
|
||||
private const APPLY_CONFIRMATION = 'INCREMENTAL_NO_DELETE';
|
||||
private const DEFAULT_RUN_ID = 'zyt-incremental';
|
||||
private const STATE_ID = 1;
|
||||
private const LOCK_TTL = 120;
|
||||
|
||||
public static function assertCommandGate(bool $apply, string $confirm, int $batchSize): void
|
||||
{
|
||||
self::assertBatchSize($batchSize);
|
||||
if ($apply && !hash_equals(self::APPLY_CONFIRMATION, $confirm)) {
|
||||
throw new InvalidArgumentException(
|
||||
'执行增量写入必须提供 --confirm=' . self::APPLY_CONFIRMATION
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||||
* @param null|callable(array<int,int>):array<int,array<string,mixed>> $mappingLoader
|
||||
* @return array{source_count:int,candidate_count:int,batch_count:int,mapped_count:int,unmapped_count:int,preserved_inactive:int,remote_delete_count:int,local_delete_count:int}
|
||||
*/
|
||||
public static function plan(
|
||||
int $batchSize = 100,
|
||||
?callable $sourceLoader = null,
|
||||
?callable $mappingLoader = null
|
||||
): array {
|
||||
self::assertBatchSize($batchSize);
|
||||
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
|
||||
$items = EjMedicineBootstrapItem::fromRows($sourceRows);
|
||||
$localIds = array_map('intval', array_column($items, 'source_medicine_id'));
|
||||
$mappingRows = $mappingLoader === null
|
||||
? self::loadMappingRows($localIds)
|
||||
: $mappingLoader($localIds);
|
||||
$selection = self::selectCandidates($items, $mappingRows);
|
||||
|
||||
return [
|
||||
'source_count' => count($items),
|
||||
'candidate_count' => count($selection['candidates']),
|
||||
'batch_count' => (int) ceil(count($selection['candidates']) / $batchSize),
|
||||
'mapped_count' => $selection['mapped_count'],
|
||||
'unmapped_count' => $selection['unmapped_count'],
|
||||
'preserved_inactive' => $selection['preserved_inactive'],
|
||||
'remote_delete_count' => 0,
|
||||
'local_delete_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally imports every active local medicine through EJ's idempotent
|
||||
* source identity and upserts only the returned ZYT projection rows.
|
||||
* Existing EJ-only medicines and unrelated local projections are untouched.
|
||||
*
|
||||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||||
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
|
||||
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionUpserter
|
||||
* @param null|callable(array<int,int>):array<int,array<string,mixed>> $mappingLoader
|
||||
* @param null|callable(string):bool $lockAcquirer
|
||||
* @param null|callable(string):bool $lockRenewer
|
||||
* @param null|callable(string):void $lockReleaser
|
||||
* @return array<string,int|string>
|
||||
*/
|
||||
public static function execute(
|
||||
int $batchSize = 100,
|
||||
string $runId = '',
|
||||
?callable $sourceLoader = null,
|
||||
?callable $importer = null,
|
||||
?callable $projectionUpserter = null,
|
||||
?callable $mappingLoader = null,
|
||||
?callable $lockAcquirer = null,
|
||||
?callable $lockRenewer = null,
|
||||
?callable $lockReleaser = null
|
||||
): array {
|
||||
self::assertBatchSize($batchSize);
|
||||
|
||||
$customLockCallbacks = count(array_filter(
|
||||
[$lockAcquirer, $lockRenewer, $lockReleaser],
|
||||
static fn (?callable $callback): bool => $callback !== null
|
||||
));
|
||||
if ($customLockCallbacks !== 0 && $customLockCallbacks !== 3) {
|
||||
throw new InvalidArgumentException('增量同步锁回调必须同时提供 acquire、renew 和 release');
|
||||
}
|
||||
$lockAcquirer ??= static fn (string $token): bool => self::acquireLock($token);
|
||||
$lockRenewer ??= static fn (string $token): bool => self::renewLock($token);
|
||||
$lockReleaser ??= static function (string $token): void {
|
||||
self::releaseLock($token);
|
||||
};
|
||||
|
||||
$lockToken = bin2hex(random_bytes(16));
|
||||
if (!$lockAcquirer($lockToken)) {
|
||||
throw new DomainException('恩济药材同步正在执行,请稍后重试');
|
||||
}
|
||||
|
||||
try {
|
||||
return self::executeLocked(
|
||||
$batchSize,
|
||||
$runId,
|
||||
$sourceLoader,
|
||||
$importer,
|
||||
$projectionUpserter,
|
||||
$mappingLoader,
|
||||
$lockRenewer,
|
||||
$lockToken
|
||||
);
|
||||
} finally {
|
||||
$lockReleaser($lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||||
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
|
||||
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionUpserter
|
||||
* @param null|callable(array<int,int>):array<int,array<string,mixed>> $mappingLoader
|
||||
* @param callable(string):bool $lockRenewer
|
||||
* @return array<string,int|string>
|
||||
*/
|
||||
private static function executeLocked(
|
||||
int $batchSize,
|
||||
string $runId,
|
||||
?callable $sourceLoader,
|
||||
?callable $importer,
|
||||
?callable $projectionUpserter,
|
||||
?callable $mappingLoader,
|
||||
callable $lockRenewer,
|
||||
string $lockToken
|
||||
): array {
|
||||
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
|
||||
$allItems = EjMedicineBootstrapItem::fromRows($sourceRows);
|
||||
$localIds = array_map('intval', array_column($allItems, 'source_medicine_id'));
|
||||
$mappingRows = $mappingLoader === null
|
||||
? self::loadMappingRows($localIds)
|
||||
: $mappingLoader($localIds);
|
||||
$selection = self::selectCandidates($allItems, $mappingRows);
|
||||
$items = $selection['candidates'];
|
||||
$runId = self::normalizeRunId($runId);
|
||||
$batches = self::buildBatches($items, $batchSize, $runId);
|
||||
|
||||
if ($items !== [] && $importer === null) {
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
throw new RuntimeException('恩济药房接口未启用或配置不完整');
|
||||
}
|
||||
$client = new EjPharmacyClient();
|
||||
$importer = static fn (array $payload): array => $client->importMedicines($payload);
|
||||
}
|
||||
|
||||
$sourceById = [];
|
||||
foreach ($items as $item) {
|
||||
$sourceById[(string) $item['source_medicine_id']] = $item;
|
||||
}
|
||||
$seenCodes = [];
|
||||
$seenVersions = [];
|
||||
$projectionRows = [];
|
||||
$remoteCreated = 0;
|
||||
$remoteExisting = 0;
|
||||
foreach ($batches as $payload) {
|
||||
self::assertLockLease($lockRenewer, $lockToken);
|
||||
$response = $importer($payload);
|
||||
self::assertLockLease($lockRenewer, $lockToken);
|
||||
$responseItems = self::validateImportResponse(
|
||||
$response,
|
||||
$payload,
|
||||
$seenCodes,
|
||||
$seenVersions
|
||||
);
|
||||
foreach ($responseItems as $responseItem) {
|
||||
$sourceId = (string) $responseItem['source_medicine_id'];
|
||||
$source = $sourceById[$sourceId] ?? null;
|
||||
if (!is_array($source)) {
|
||||
throw new RuntimeException("恩济增量导入返回未知 source_medicine_id:{$sourceId}");
|
||||
}
|
||||
$action = (string) $responseItem['action'];
|
||||
$medicineCode = (string) $responseItem['medicine_code'];
|
||||
$expectedCode = $selection['active_mapping_codes'][$sourceId] ?? null;
|
||||
if ($expectedCode !== null && !hash_equals($expectedCode, $medicineCode)) {
|
||||
throw new DomainException(
|
||||
"本地药材 {$sourceId} 的启用映射编码 {$expectedCode} 与恩济返回 {$medicineCode} 不一致"
|
||||
);
|
||||
}
|
||||
$remoteCreated += $action === 'created' ? 1 : 0;
|
||||
$remoteExisting += $action === 'existing' ? 1 : 0;
|
||||
$projectionRows[] = [
|
||||
'local_medicine_id' => $sourceId,
|
||||
'medicine_code' => $medicineCode,
|
||||
'name' => (string) $source['name'],
|
||||
'brand' => (string) ($source['brand'] ?? ''),
|
||||
'unit' => (string) $source['unit'],
|
||||
'settlement_price' => (string) $source['settlement_price'],
|
||||
'retail_price' => (string) $source['retail_price'],
|
||||
'status' => (int) $source['status'],
|
||||
'catalog_version' => (int) $responseItem['catalog_version'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$projectionStats = [
|
||||
'catalog_created' => 0,
|
||||
'catalog_updated' => 0,
|
||||
'mapping_created' => 0,
|
||||
'mapping_updated' => 0,
|
||||
'mapping_unchanged' => 0,
|
||||
];
|
||||
if ($projectionRows !== []) {
|
||||
self::assertLockLease($lockRenewer, $lockToken);
|
||||
$projectionStats = $projectionUpserter === null
|
||||
? self::upsertProjection($projectionRows, $lockToken)
|
||||
: $projectionUpserter($projectionRows);
|
||||
}
|
||||
|
||||
return array_merge([
|
||||
'run_id' => $runId,
|
||||
'source_count' => count($allItems),
|
||||
'candidate_count' => count($items),
|
||||
'batch_count' => count($batches),
|
||||
'preserved_inactive' => $selection['preserved_inactive'],
|
||||
'remote_created' => $remoteCreated,
|
||||
'remote_existing' => $remoteExisting,
|
||||
'remote_delete_count' => 0,
|
||||
'local_delete_count' => 0,
|
||||
], $projectionStats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $items
|
||||
* @return list<array{source_system:string,import_id:string,items:array<int,array<string,mixed>>}>
|
||||
*/
|
||||
public static function buildBatches(array $items, int $batchSize, string $runId): array
|
||||
{
|
||||
self::assertBatchSize($batchSize);
|
||||
$runId = self::normalizeRunId($runId);
|
||||
usort($items, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||||
(string) ($left['source_medicine_id'] ?? ''),
|
||||
(string) ($right['source_medicine_id'] ?? '')
|
||||
));
|
||||
|
||||
$batches = [];
|
||||
foreach (array_chunk($items, $batchSize) as $index => $batchItems) {
|
||||
$contentJson = json_encode(
|
||||
$batchItems,
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
$batches[] = [
|
||||
'source_system' => self::SOURCE_SYSTEM,
|
||||
'import_id' => sprintf(
|
||||
'%s-%04d-%s',
|
||||
$runId,
|
||||
$index + 1,
|
||||
substr(hash('sha256', $contentJson), 0, 32)
|
||||
),
|
||||
'items' => $batchItems,
|
||||
];
|
||||
}
|
||||
|
||||
return $batches;
|
||||
}
|
||||
|
||||
private static function assertBatchSize(int $batchSize): void
|
||||
{
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('--batch-size 必须在 1 到 500 之间');
|
||||
}
|
||||
}
|
||||
|
||||
private static function normalizeRunId(string $runId): string
|
||||
{
|
||||
$runId = trim($runId);
|
||||
if ($runId === '') {
|
||||
$runId = self::DEFAULT_RUN_ID;
|
||||
}
|
||||
if (strlen($runId) > 25 || preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/D', $runId) !== 1) {
|
||||
throw new InvalidArgumentException('--run-id 必须为不超过 25 位的字母、数字、点、下划线或短横线');
|
||||
}
|
||||
|
||||
return $runId;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private static function loadSourceRows(): array
|
||||
{
|
||||
return Db::name('doctor_medicine')
|
||||
->field('id,name,unit,settlement_price,retail_price,status')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,int> $localIds @return array<int,array<string,mixed>> */
|
||||
private static function loadMappingRows(array $localIds): array
|
||||
{
|
||||
if ($localIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Db::name('ej_medicine_mapping')
|
||||
->whereIn('local_medicine_id', $localIds)
|
||||
->field('id,local_medicine_id,medicine_code,status,delete_time')
|
||||
->order('local_medicine_id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active mappings are replayed so EJ can recover a missing remote medicine.
|
||||
* A medicine with an inactive or soft-deleted mapping is intentionally
|
||||
* excluded; an operator decision must never be undone by synchronization.
|
||||
*
|
||||
* @param list<array<string,mixed>> $items
|
||||
* @param array<int,array<string,mixed>> $mappingRows
|
||||
* @return array{candidates:list<array<string,mixed>>,mapped_count:int,unmapped_count:int,preserved_inactive:int,active_mapping_codes:array<string,string>}
|
||||
*/
|
||||
private static function selectCandidates(array $items, array $mappingRows): array
|
||||
{
|
||||
$byLocalId = [];
|
||||
foreach ($mappingRows as $mapping) {
|
||||
$localId = (int) ($mapping['local_medicine_id'] ?? 0);
|
||||
if ($localId < 1 || isset($byLocalId[$localId])) {
|
||||
throw new DomainException("本地药材 {$localId} 存在重复的恩济映射记录");
|
||||
}
|
||||
$byLocalId[$localId] = $mapping;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
$mappedCount = 0;
|
||||
$unmappedCount = 0;
|
||||
$preservedInactive = 0;
|
||||
$activeMappingCodes = [];
|
||||
foreach ($items as $item) {
|
||||
$localId = (int) $item['source_medicine_id'];
|
||||
$mapping = $byLocalId[$localId] ?? null;
|
||||
if ($mapping === null) {
|
||||
++$unmappedCount;
|
||||
$candidates[] = $item;
|
||||
continue;
|
||||
}
|
||||
if ((int) ($mapping['status'] ?? 0) === 1 && ($mapping['delete_time'] ?? null) === null) {
|
||||
$medicineCode = trim((string) ($mapping['medicine_code'] ?? ''));
|
||||
if ($medicineCode === '') {
|
||||
throw new DomainException("本地药材 {$localId} 的启用恩济映射编码为空");
|
||||
}
|
||||
++$mappedCount;
|
||||
$candidates[] = $item;
|
||||
$activeMappingCodes[(string) $localId] = $medicineCode;
|
||||
continue;
|
||||
}
|
||||
++$preservedInactive;
|
||||
}
|
||||
|
||||
return [
|
||||
'candidates' => $candidates,
|
||||
'mapped_count' => $mappedCount,
|
||||
'unmapped_count' => $unmappedCount,
|
||||
'preserved_inactive' => $preservedInactive,
|
||||
'active_mapping_codes' => $activeMappingCodes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $response
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,bool> $seenCodes
|
||||
* @param array<int,bool> $seenVersions
|
||||
* @return list<array{source_medicine_id:string,medicine_code:string,catalog_version:int,action:string}>
|
||||
*/
|
||||
private static function validateImportResponse(
|
||||
array $response,
|
||||
array $payload,
|
||||
array &$seenCodes,
|
||||
array &$seenVersions
|
||||
): array {
|
||||
$nextSeenCodes = $seenCodes;
|
||||
$nextSeenVersions = $seenVersions;
|
||||
$items = EjMedicineBootstrapService::validateImportResponse(
|
||||
$response,
|
||||
$payload,
|
||||
$nextSeenCodes,
|
||||
$nextSeenVersions
|
||||
);
|
||||
|
||||
$data = $response['body']['data'] ?? null;
|
||||
if (!is_array($data) || !hash_equals(self::SOURCE_SYSTEM, (string) ($data['source_system'] ?? ''))) {
|
||||
throw new RuntimeException('恩济药材导入响应 source_system 不匹配');
|
||||
}
|
||||
$expectedPayloadHash = hash('sha256', json_encode(
|
||||
self::canonicalize($payload),
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
));
|
||||
$actualPayloadHash = strtolower(trim((string) ($data['payload_hash'] ?? '')));
|
||||
if (
|
||||
preg_match('/^[a-f0-9]{64}$/D', $actualPayloadHash) !== 1
|
||||
|| !hash_equals($expectedPayloadHash, $actualPayloadHash)
|
||||
) {
|
||||
throw new RuntimeException('恩济药材导入响应 payload_hash 不匹配');
|
||||
}
|
||||
|
||||
$seenCodes = $nextSeenCodes;
|
||||
$seenVersions = $nextSeenVersions;
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function canonicalize(mixed $value): mixed
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (array_is_list($value)) {
|
||||
return array_map([self::class, 'canonicalize'], $value);
|
||||
}
|
||||
ksort($value, SORT_STRING);
|
||||
foreach ($value as $key => $child) {
|
||||
$value[$key] = self::canonicalize($child);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @param callable(string):bool $lockRenewer */
|
||||
private static function assertLockLease(callable $lockRenewer, string $lockToken): void
|
||||
{
|
||||
if (!$lockRenewer($lockToken)) {
|
||||
throw new DomainException('恩济药材同步锁已失效,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
private static function acquireLock(string $token): bool
|
||||
{
|
||||
$now = time();
|
||||
return Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where(function ($query) use ($now): void {
|
||||
$query->where('lock_token', '')->whereOr('lock_expires_at', '<', $now);
|
||||
})
|
||||
->update([
|
||||
'lock_token' => $token,
|
||||
'lock_expires_at' => $now + self::LOCK_TTL,
|
||||
'update_time' => $now,
|
||||
]) === 1;
|
||||
}
|
||||
|
||||
private static function renewLock(string $token): bool
|
||||
{
|
||||
$now = time();
|
||||
$query = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)
|
||||
->where('lock_expires_at', '>=', $now);
|
||||
$updated = $query->update([
|
||||
'lock_expires_at' => $now + self::LOCK_TTL,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($updated === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$state = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)
|
||||
->find();
|
||||
return is_array($state) && (int) ($state['lock_expires_at'] ?? 0) >= $now;
|
||||
}
|
||||
|
||||
private static function releaseLock(string $token): void
|
||||
{
|
||||
Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->where('lock_token', $token)
|
||||
->update([
|
||||
'lock_token' => '',
|
||||
'lock_expires_at' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @return array{catalog_created:int,catalog_updated:int,mapping_created:int,mapping_updated:int,mapping_unchanged:int}
|
||||
*/
|
||||
private static function upsertProjection(array $rows, string $lockToken): array
|
||||
{
|
||||
return Db::transaction(static function () use ($rows, $lockToken): array {
|
||||
$stats = [
|
||||
'catalog_created' => 0,
|
||||
'catalog_updated' => 0,
|
||||
'mapping_created' => 0,
|
||||
'mapping_updated' => 0,
|
||||
'mapping_unchanged' => 0,
|
||||
];
|
||||
$state = Db::name('ej_pharmacy_sync_state')
|
||||
->where('id', self::STATE_ID)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (
|
||||
!is_array($state)
|
||||
|| !hash_equals($lockToken, (string) ($state['lock_token'] ?? ''))
|
||||
|| (int) ($state['lock_expires_at'] ?? 0) < time()
|
||||
) {
|
||||
throw new DomainException('恩济药材同步锁已失效,请重试');
|
||||
}
|
||||
$now = time();
|
||||
foreach ($rows as $row) {
|
||||
$localId = (int) ($row['local_medicine_id'] ?? 0);
|
||||
$medicineCode = trim((string) ($row['medicine_code'] ?? ''));
|
||||
if ($localId < 1 || $medicineCode === '') {
|
||||
throw new RuntimeException('恩济增量导入投影缺少本地药材 ID 或 medicine_code');
|
||||
}
|
||||
|
||||
$conflict = Db::name('ej_medicine_mapping')
|
||||
->where('medicine_code', $medicineCode)
|
||||
->where('local_medicine_id', '<>', $localId)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($conflict) {
|
||||
throw new DomainException(
|
||||
"恩济药材编码 {$medicineCode} 已映射到本地药材 " . (int) $conflict['local_medicine_id']
|
||||
);
|
||||
}
|
||||
|
||||
$catalogValues = [
|
||||
'name' => (string) $row['name'],
|
||||
'brand' => (string) ($row['brand'] ?? ''),
|
||||
'unit' => (string) $row['unit'],
|
||||
'settlement_price' => (string) $row['settlement_price'],
|
||||
'retail_price' => (string) $row['retail_price'],
|
||||
'status' => (int) $row['status'],
|
||||
'catalog_version' => (int) $row['catalog_version'],
|
||||
'remote_deleted' => 0,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$catalog = Db::name('ej_medicine_catalog')
|
||||
->where('medicine_code', $medicineCode)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($catalog) {
|
||||
Db::name('ej_medicine_catalog')->where('id', (int) $catalog['id'])->update($catalogValues);
|
||||
++$stats['catalog_updated'];
|
||||
} else {
|
||||
Db::name('ej_medicine_catalog')->insert($catalogValues + [
|
||||
'medicine_code' => $medicineCode,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
++$stats['catalog_created'];
|
||||
}
|
||||
|
||||
$mapping = Db::name('ej_medicine_mapping')
|
||||
->where('local_medicine_id', $localId)
|
||||
->lock(true)
|
||||
->find();
|
||||
$mappingValues = [
|
||||
'medicine_code' => $medicineCode,
|
||||
'status' => 1,
|
||||
'operator_id' => 0,
|
||||
'operator_name' => 'system-incremental',
|
||||
'delete_time' => null,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if (!$mapping) {
|
||||
Db::name('ej_medicine_mapping')->insert($mappingValues + [
|
||||
'local_medicine_id' => $localId,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
++$stats['mapping_created'];
|
||||
} elseif ((int) $mapping['status'] !== 1 || ($mapping['delete_time'] ?? null) !== null) {
|
||||
throw new DomainException("本地药材 {$localId} 的恩济映射已被停用,增量同步保持该状态不变");
|
||||
} elseif (hash_equals((string) $mapping['medicine_code'], $medicineCode)) {
|
||||
++$stats['mapping_unchanged'];
|
||||
} else {
|
||||
throw new DomainException(
|
||||
"本地药材 {$localId} 的启用映射编码 "
|
||||
. (string) $mapping['medicine_code']
|
||||
. " 与恩济返回 {$medicineCode} 不一致,增量同步未改写该映射"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"firebase/php-jwt": "^7.1",
|
||||
"php": ">=8.0",
|
||||
"topthink/framework": "^8.0.2",
|
||||
"topthink/think-orm": "^3.0",
|
||||
|
||||
Generated
+67
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a5f0d3c9e4372fa66156ac28411cd86e",
|
||||
"content-hash": "72ffe2f144b34a313ce8f815e0f0e5bd",
|
||||
"packages": [
|
||||
{
|
||||
"name": "adbario/php-dot-notation",
|
||||
@@ -583,6 +583,72 @@
|
||||
},
|
||||
"time": "2022-09-18T07:06:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "firebase/php-jwt",
|
||||
"version": "v7.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/googleapis/php-jwt.git",
|
||||
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
|
||||
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"phpfastcache/phpfastcache": "^9.2",
|
||||
"phpseclib/phpseclib": "~3.0",
|
||||
"phpspec/prophecy-phpunit": "^2.0",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"psr/cache": "^2.0||^3.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-sodium": "Support EdDSA (Ed25519) signatures",
|
||||
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
|
||||
"phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Firebase\\JWT\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Neuman Vong",
|
||||
"email": "neuman+pear@twilio.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Anant Narayanan",
|
||||
"email": "anant@php.net",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
|
||||
"homepage": "https://github.com/googleapis/php-jwt",
|
||||
"keywords": [
|
||||
"jwt",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/googleapis/php-jwt/issues",
|
||||
"source": "https://github.com/googleapis/php-jwt/tree/v7.1.0"
|
||||
},
|
||||
"time": "2026-06-11T17:54:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/command",
|
||||
"version": "1.3.0",
|
||||
|
||||
@@ -41,7 +41,6 @@ return [
|
||||
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
|
||||
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
|
||||
'ej-pharmacy:bootstrap-medicines' => 'app\\command\\EjPharmacyBootstrapMedicines',
|
||||
'ej-pharmacy:push-medicines' => 'app\\command\\EjPharmacyPushMedicines',
|
||||
// 历史 internal_cost:批量甘草预报价回填(CTM_PREVIEW)
|
||||
'tcm:backfill-internal-cost' => 'app\\command\\TcmBackfillPrescriptionOrderInternalCost',
|
||||
// 批量回填业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
// Optional integration: disabled installations never touch IAM or its transaction store.
|
||||
return [
|
||||
'enabled' => env('iam.enabled', false),
|
||||
'issuer' => env('iam.issuer', ''),
|
||||
'client_id' => env('iam.client_id', 'zyt'),
|
||||
'client_secret' => env('iam.client_secret', ''),
|
||||
'redirect_uri' => env('iam.redirect_uri', ''),
|
||||
'api_url' => env('iam.api_url', ''),
|
||||
'application_id' => env('iam.application_id', 'zyt'),
|
||||
'application_token' => env('iam.application_token', ''),
|
||||
'public_url' => env('iam.public_url', ''),
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Apply once before enabling the new adapter. Replace zyt_ only if database.prefix differs.
|
||||
-- Do not drop this ledger while IAM-created accounts exist: it is retry/deduplication state.
|
||||
CREATE TABLE IF NOT EXISTS `zyt_iam_local_identity` (
|
||||
`identity_key` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`identity_value` text NOT NULL,
|
||||
`admin_id` int unsigned DEFAULT NULL,
|
||||
`create_time` int unsigned NOT NULL,
|
||||
PRIMARY KEY (`identity_key`),
|
||||
UNIQUE KEY `uniq_iam_admin` (`admin_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1 @@
|
||||
import r from"./error-BerWbCRn.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-CWHdHlgZ.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-CbYn5zW9.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-MKPKssyT.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
|
||||
@@ -0,0 +1 @@
|
||||
import r from"./error-DDb6iC9p.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-CWHdHlgZ.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-CbYn5zW9.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-dDVfDL1w.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
|
||||
@@ -0,0 +1 @@
|
||||
import o from"./error-BerWbCRn.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-CWHdHlgZ.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-CbYn5zW9.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-MKPKssyT.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
|
||||
@@ -0,0 +1 @@
|
||||
import o from"./error-DDb6iC9p.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-CWHdHlgZ.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-CbYn5zW9.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-dDVfDL1w.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
|
||||
@@ -0,0 +1 @@
|
||||
function a(e){"@babel/helpers - typeof";return a=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(t){return typeof t}:function(t){return t&&typeof Symbol=="function"&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},a(e)}function c(e,t,r,n,f,y,i){try{var u=e[y](i),o=u.value}catch(l){return void r(l)}u.done?t(o):Promise.resolve(o).then(n,f)}function p(e){return function(){var t=this,r=arguments;return new Promise(function(n,f){var y=e.apply(t,r);function i(o){c(y,n,f,i,u,"next",o)}function u(o){c(y,n,f,i,u,"throw",o)}i(void 0)})}}function b(e,t){if(a(e)!="object"||!e)return e;var r=e[Symbol.toPrimitive];if(r!==void 0){var n=r.call(e,t);if(a(n)!="object")return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return(t==="string"?String:Number)(e)}function m(e){var t=b(e,"string");return a(t)=="symbol"?t:t+""}function s(e,t,r){return(t=m(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}export{a as _,p as a,s as b};
|
||||
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 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import{H as u}from"../highlight.js-Bxt7hFFy.js";import{f as c,i as g,w as s,A as n}from"../@vue/runtime-core-C6bnekPw.js";import{n as h}from"../@vue/reactivity-DiY1c2vO.js";var i=c({props:{code:{type:String,required:!0},language:{type:String,default:""},autodetect:{type:Boolean,default:!0},ignoreIllegals:{type:Boolean,default:!0}},setup:function(e){var t=h(e.language);s((function(){return e.language}),(function(a){t.value=a}));var r=n((function(){return e.autodetect||!t.value})),o=n((function(){return!r.value&&!u.getLanguage(t.value)}));return{className:n((function(){return o.value?"":"hljs "+t.value})),highlightedCode:n((function(){var a;if(o.value)return console.warn('The language "'+t.value+'" you specified could not be found.'),e.code.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");if(r.value){var l=u.highlightAuto(e.code);return t.value=(a=l.language)!==null&&a!==void 0?a:"",l.value}return(l=u.highlight(e.code,{language:t.value,ignoreIllegals:e.ignoreIllegals})).value}))}},render:function(){return g("pre",{},[g("code",{class:this.className,innerHTML:this.highlightedCode})])}}),v={install:function(e){e.component("highlightjs",i)},component:i};export{v as o};
|
||||
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
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 @@
|
||||
import"./uikit-base-component-vue3-YgTqL4da.js";import{A as r}from"../tuikit-atomicx-vue3-Dln8Zi6e.js";import{f as s,ak as c,I as l,aq as m,G as u,at as i,H as p,A as d}from"../@vue/runtime-core-C6bnekPw.js";import{y as v}from"../@vue/reactivity-DiY1c2vO.js";import"./chat-uikit-engine-zx802ozq.js";const _=(t,o)=>{const a=t.__vccOpts||t;for(const[e,n]of o)a[e]=n;return a},f={key:0,class:"chat"},h=s({name:"Chat",__name:"Chat",props:{PlaceholderEmpty:{default:null}},setup(t){const{activeConversation:o}=r(),a=d(()=>{var e;return!((e=o.value)!=null&&e.conversationID)});return(e,n)=>v(o)?(c(),l("div",f,[m(e.$slots,"default",{},void 0,!0)])):a.value&&t.PlaceholderEmpty?(c(),u(i(t.PlaceholderEmpty),{key:1})):p("",!0)}}),A=_(h,[["__scopeId","data-v-1c9c77cd"]]);typeof window<"u"&&(window.__CHAT_ATOMICX_VUE3__={name:"@tencentcloud/chat-uikit-vue3",version:"4.5.4"},console.log("[@tencentcloud/chat-uikit-vue3] v4.5.4"));export{A as E};
|
||||
@@ -0,0 +1 @@
|
||||
.chat[data-v-1c9c77cd]{display:flex;flex-direction:column;min-width:0}.uikit-chat-header[data-v-a0c42ddc]{padding:14px 10px;height:64px;display:flex;justify-content:center;background-color:var(--bg-color-operate)}.uikit-chat-header__container[data-v-a0c42ddc]{padding:0 10px;flex-direction:row;align-items:center;justify-content:space-between}.uikit-chat-header__left[data-v-a0c42ddc]{flex:1 1 auto;display:flex;flex-direction:row;align-items:center}.uikit-chat-header__avatar[data-v-a0c42ddc]{margin-right:12px}.uikit-chat-header__info[data-v-a0c42ddc]{flex:1;display:flex;flex-direction:column;justify-content:center}.uikit-chat-header__title[data-v-a0c42ddc]{display:block;margin:0;font-size:16px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-color-primary)}.uikit-chat-header__typing-indicator[data-v-a0c42ddc]{font-size:12px;color:var(--text-color-secondary)}.uikit-chat-header__live[data-v-a0c42ddc]{margin-top:4px;font-size:12px;color:var(--text-color-secondary)}/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}:root{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,*:after,*:before{box-sizing:border-box}ul,li{list-style:none;padding:0;margin:0}picture,img,video,canvas,svg{display:block;max-width:100%}img{max-width:100%;height:auto;vertical-align:middle;image-rendering:-webkit-optimize-contrast;aspect-ratio:attr(width)/attr(height);display:inline-block;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}img:not([src],[srcset]){visibility:hidden}
|
||||
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
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 @@
|
||||
|
||||
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
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 @@
|
||||
import{C as A,F as C,a as w,L as h,q as D,J as R,H as g,u as k,n as W}from"../@vue/reactivity-DiY1c2vO.js";import{w as b,b as x,n as L,g as F,$ as M,a5 as P}from"../@vue/runtime-core-C6bnekPw.js";function J(e){return A()?(C(e),!0):!1}const d=new WeakMap,z=(...e)=>{var t;const r=e[0],n=(t=F())==null?void 0:t.proxy;if(n==null&&!M())throw new Error("injectLocal must be called in setup");return n&&d.has(n)&&r in d.get(n)?d.get(n)[r]:P(...e)},B=typeof window<"u"&&typeof document<"u";typeof WorkerGlobalScope<"u"&&globalThis instanceof WorkerGlobalScope;const K=e=>typeof e<"u",I=Object.prototype.toString,Q=e=>I.call(e)==="[object Object]",m=()=>{};function j(e,t){function r(...n){return new Promise((a,o)=>{Promise.resolve(e(()=>t.apply(this,n),{fn:t,thisArg:this,args:n})).then(a).catch(o)})}return r}const S=e=>e();function V(...e){let t=0,r,n=!0,a=m,o,s,i,u,c;!w(e[0])&&typeof e[0]=="object"?{delay:s,trailing:i=!0,leading:u=!0,rejectOnCancel:c=!1}=e[0]:[s,i=!0,u=!0,c=!1]=e;const f=()=>{r&&(clearTimeout(r),r=void 0,a(),a=m)};return O=>{const l=h(s),v=Date.now()-t,p=()=>o=O();return f(),l<=0?(t=Date.now(),p()):(v>l&&(u||!n)?(t=Date.now(),p()):i&&(o=new Promise((y,T)=>{a=c?T:y,r=setTimeout(()=>{t=Date.now(),n=!0,y(p()),f()},Math.max(0,l-v))})),!u&&!r&&(r=setTimeout(()=>n=!0,l)),n=!1,o)}}function E(e=S,t={}){const{initialState:r="active"}=t,n=N(r==="active");function a(){n.value=!1}function o(){n.value=!0}const s=(...i)=>{n.value&&e(...i)};return{isActive:g(n),pause:a,resume:o,eventFilter:s}}function U(e){return e.endsWith("rem")?Number.parseFloat(e)*16:Number.parseFloat(e)}function G(e){return F()}function X(e){return Array.isArray(e)?e:[e]}function N(...e){if(e.length!==1)return R(...e);const t=e[0];return typeof t=="function"?g(k(()=>({get:t,set:m}))):W(t)}function Y(e,t=200,r=!1,n=!0,a=!1){return j(V(t,r,n,a),e)}function _(e,t,r={}){const{eventFilter:n=S,...a}=r;return b(e,j(n,t),a)}function Z(e,t,r={}){const{eventFilter:n,initialState:a="active",...o}=r,{eventFilter:s,pause:i,resume:u,isActive:c}=E(n,{initialState:a});return{stop:_(e,t,{...o,eventFilter:s}),pause:i,resume:u,isActive:c}}function ee(e,t=!0,r){G()?x(e,r):t?e():L(e)}function te(e=!1,t={}){const{truthyValue:r=!0,falsyValue:n=!1}=t,a=w(e),o=D(e);function s(i){if(arguments.length)return o.value=i,o.value;{const u=h(r);return o.value=o.value===u?h(n):u,o.value}}return a?s:[o,s]}function ne(e,t,r){return b(e,t,{...r,immediate:!0})}export{N as a,ee as b,Q as c,X as d,Z as e,z as f,K as g,Y as h,B as i,U as p,J as t,te as u,ne as w};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
import{i as C,Q as y,a as E}from"./editor-Cyf37SuL.js";import{ak as g,I as h,f as w,b as P,w as O,aJ as b}from"../@vue/runtime-core-C6bnekPw.js";import{n as d,t as $,q as F}from"../@vue/reactivity-DiY1c2vO.js";var B=Object.defineProperty,D=Object.defineProperties,j=Object.getOwnPropertyDescriptors,m=Object.getOwnPropertySymbols,H=Object.prototype.hasOwnProperty,S=Object.prototype.propertyIsEnumerable,_=(e,t,o)=>t in e?B(e,t,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[t]=o,A=(e,t)=>{for(var o in t||(t={}))H.call(t,o)&&_(e,o,t[o]);if(m)for(var o of m(t))S.call(t,o)&&_(e,o,t[o]);return e},M=(e,t)=>D(e,j(t));function u(e){let t=`请使用 '@${e}' 事件,不要放在 props 中`;return t+=`
|
||||
Please use '@${e}' event instead of props`,t}var v=(e,t)=>{for(const[o,a]of t)e[o]=a;return e};const V=w({props:{mode:{type:String,default:"default"},defaultContent:{type:Array,default:[]},defaultHtml:{type:String,default:""},defaultConfig:{type:Object,default:{}},modelValue:{type:String,default:""}},setup(e,t){const o=d(null),a=F(null),i=d(""),s=()=>{if(!o.value)return;const f=$(e.defaultContent);C({selector:o.value,mode:e.mode,content:f||[],html:e.defaultHtml||e.modelValue||"",config:M(A({},e.defaultConfig),{onCreated(r){if(a.value=r,t.emit("onCreated",r),e.defaultConfig.onCreated){const n=u("onCreated");throw new Error(n)}},onChange(r){const n=r.getHtml();if(i.value=n,t.emit("update:modelValue",n),t.emit("onChange",r),e.defaultConfig.onChange){const l=u("onChange");throw new Error(l)}},onDestroyed(r){if(t.emit("onDestroyed",r),e.defaultConfig.onDestroyed){const n=u("onDestroyed");throw new Error(n)}},onMaxLength(r){if(t.emit("onMaxLength",r),e.defaultConfig.onMaxLength){const n=u("onMaxLength");throw new Error(n)}},onFocus(r){if(t.emit("onFocus",r),e.defaultConfig.onFocus){const n=u("onFocus");throw new Error(n)}},onBlur(r){if(t.emit("onBlur",r),e.defaultConfig.onBlur){const n=u("onBlur");throw new Error(n)}},customAlert(r,n){if(t.emit("customAlert",r,n),e.defaultConfig.customAlert){const l=u("customAlert");throw new Error(l)}},customPaste:(r,n)=>{if(e.defaultConfig.customPaste){const c=u("customPaste");throw new Error(c)}let l;return t.emit("customPaste",r,n,c=>{l=c}),l}})})};function p(f){const r=a.value;r!=null&&r.setHtml(f)}return P(()=>{s()}),O(()=>e.modelValue,f=>{f!==i.value&&p(f)}),{box:o}}}),I={ref:"box",style:{height:"100%"}};function L(e,t,o,a,i,s){return g(),h("div",I,null,512)}var J=v(V,[["render",L]]);const T=w({props:{editor:{type:Object},mode:{type:String,default:"default"},defaultConfig:{type:Object,default:{}}},setup(e){const t=d(null),o=a=>{if(t.value){if(a==null)throw new Error("Not found instance of Editor when create <Toolbar/> component");y.getToolbar(a)||E({editor:a,selector:t.value||"<div></div>",mode:e.mode,config:e.defaultConfig})}};return b(()=>{const{editor:a}=e;a!=null&&o(a)}),{selector:t}}}),R={ref:"selector"};function k(e,t,o,a,i,s){return g(),h("div",R,null,512)}var N=v(T,[["render",k]]);export{J as E,N as T};
|
||||
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 @@
|
||||
import{M as T,N as C,T as $,r as D,d as L,L as k}from"./element-plus-CWHdHlgZ.js";import{Y as E}from"./@element-plus/icons-vue-CbYn5zW9.js";import{ag as P}from"./tcm-CSYiFMlc.js";import{f as A,w as B,ak as p,I as b,aP as F,G as h,aN as n,a as i,O as m,J as M}from"./@vue/runtime-core-C6bnekPw.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{y as V,n as w}from"./@vue/reactivity-DiY1c2vO.js";import{_ as K}from"./index-MKPKssyT.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const Y={class:"assign-log-panel"},q={key:1,class:"text-gray-400"},z=A({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(N,{expose:v}){const _=N,d=w(!1),u=w([]);function y(o){const e=o.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(o){const e=o.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const a=new Date(t*1e3);if(Number.isNaN(a.getTime()))return"—";const r=l=>String(l).padStart(2,"0");return`${a.getFullYear()}-${r(a.getMonth()+1)}-${r(a.getDate())} ${r(a.getHours())}:${r(a.getMinutes())}:${r(a.getSeconds())}`}function f(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",a=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const l=Number(o[a]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(_.diagnosisId){d.value=!0;try{const o=await P({id:_.diagnosisId}),e=Array.isArray(o)?o:[];u.value=e}catch(o){console.error(o),u.value=[]}finally{d.value=!1}}};return B(()=>_.diagnosisId,()=>{g()},{immediate:!0}),v({refresh:g}),(o,e)=>{const t=C,a=$,r=L,l=D,S=T,I=k;return p(),b("div",Y,[F((p(),h(S,{data:u.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[i(t,{label:"操作时间",width:"175",prop:"create_time_text"}),i(t,{label:"原医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"from")),1)]),_:1}),i(t,{label:"新医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"to")),1)]),_:1}),i(t,{label:"继承",width:"72",align:"center"},{default:n(({row:s})=>[Number(s.is_inherit)===1?(p(),h(a,{key:0,type:"success",size:"small"},{default:n(()=>[...e[0]||(e[0]=[m("是",-1)])]),_:1})):(p(),b("span",q,"否"))]),_:1}),i(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:s})=>[m(c(y(s)),1)]),_:1}),i(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[1]||(e[1]=M("span",null,"快照·业务单创建时间",-1)),i(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[i(r,{class:"assign-log-col-hint"},{default:n(()=>[i(V(E))]),_:1})]),_:1})]),default:n(({row:s})=>[m(c(x(s)),1)]),_:1}),i(t,{label:"操作人",width:"110",prop:"operator_name"}),i(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),i(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,d.value]])])}}}),Ct=K(z,[["__scopeId","data-v-0a09e3d2"]]);export{Ct as default};
|
||||
@@ -0,0 +1 @@
|
||||
.assign-log-col-hint[data-v-0a09e3d2]{margin-left:4px;vertical-align:middle;color:var(--el-text-color-secondary);cursor:help}
|
||||
@@ -0,0 +1 @@
|
||||
import{M as T,N as C,T as $,r as D,d as L,L as k}from"./element-plus-CWHdHlgZ.js";import{Y as E}from"./@element-plus/icons-vue-CbYn5zW9.js";import{ag as P}from"./tcm-CdJxXEws.js";import{f as A,w as B,ak as p,I as b,aP as F,G as h,aN as n,a as i,O as m,J as M}from"./@vue/runtime-core-C6bnekPw.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{y as V,n as w}from"./@vue/reactivity-DiY1c2vO.js";import{_ as K}from"./index-dDVfDL1w.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const Y={class:"assign-log-panel"},q={key:1,class:"text-gray-400"},z=A({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(N,{expose:v}){const _=N,d=w(!1),u=w([]);function y(o){const e=o.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(o){const e=o.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const a=new Date(t*1e3);if(Number.isNaN(a.getTime()))return"—";const r=l=>String(l).padStart(2,"0");return`${a.getFullYear()}-${r(a.getMonth()+1)}-${r(a.getDate())} ${r(a.getHours())}:${r(a.getMinutes())}:${r(a.getSeconds())}`}function f(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",a=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const l=Number(o[a]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(_.diagnosisId){d.value=!0;try{const o=await P({id:_.diagnosisId}),e=Array.isArray(o)?o:[];u.value=e}catch(o){console.error(o),u.value=[]}finally{d.value=!1}}};return B(()=>_.diagnosisId,()=>{g()},{immediate:!0}),v({refresh:g}),(o,e)=>{const t=C,a=$,r=L,l=D,S=T,I=k;return p(),b("div",Y,[F((p(),h(S,{data:u.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[i(t,{label:"操作时间",width:"175",prop:"create_time_text"}),i(t,{label:"原医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"from")),1)]),_:1}),i(t,{label:"新医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"to")),1)]),_:1}),i(t,{label:"继承",width:"72",align:"center"},{default:n(({row:s})=>[Number(s.is_inherit)===1?(p(),h(a,{key:0,type:"success",size:"small"},{default:n(()=>[...e[0]||(e[0]=[m("是",-1)])]),_:1})):(p(),b("span",q,"否"))]),_:1}),i(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:s})=>[m(c(y(s)),1)]),_:1}),i(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[1]||(e[1]=M("span",null,"快照·业务单创建时间",-1)),i(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[i(r,{class:"assign-log-col-hint"},{default:n(()=>[i(V(E))]),_:1})]),_:1})]),default:n(({row:s})=>[m(c(x(s)),1)]),_:1}),i(t,{label:"操作人",width:"110",prop:"operator_name"}),i(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),i(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,d.value]])])}}}),Ct=K(z,[["__scopeId","data-v-0a09e3d2"]]);export{Ct as default};
|
||||
@@ -0,0 +1 @@
|
||||
.watch-state[data-v-8c719418]{min-height:200px;display:flex;align-items:center;justify-content:center;color:var(--el-text-color-secondary);font-size:14px}.watch-error[data-v-8c719418]{color:var(--el-color-danger)}.watch-grid[data-v-8c719418]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;min-height:220px}.watch-tile[data-v-8c719418]{background:#0f0f0f;border-radius:8px;overflow:hidden;aspect-ratio:16 / 10;display:flex;flex-direction:column}.watch-tile-cap[data-v-8c719418]{padding:6px 10px;font-size:12px;color:#e5e5e5;background:#0000008c}.watch-tile-view[data-v-8c719418]{flex:1;min-height:0;position:relative}.watch-hint[data-v-8c719418]{float:left;line-height:32px;font-size:12px;color:var(--el-text-color-secondary)}
|
||||
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
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 @@
|
||||
import{i as L,L as T,N as V,T as D,M as z,W as M}from"./element-plus-CWHdHlgZ.js";import{ao as O}from"./tcm-CSYiFMlc.js";import{f as P,b as F,w as j,ak as a,I as n,a as i,aN as s,O as m,H as w,aP as A,G as g,F as H,J as R}from"./@vue/runtime-core-C6bnekPw.js";import{y as d,n as k}from"./@vue/reactivity-DiY1c2vO.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{_ as G}from"./index-MKPKssyT.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-CbYn5zW9.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const J={class:"case-record-list"},Q={key:0,class:"mb-3 flex justify-end"},W={key:0},Y={key:1,class:"text-gray-400"},q={class:"void-detail text-xs text-gray-500 mt-1"},K=P({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const _=y,h=C,l=k([]),p=k(!1),u=async()=>{if(_.diagnosisId){p.value=!0;try{const e=await O({diagnosis_id:_.diagnosisId});l.value=Array.isArray(e)?e:[]}catch(e){console.error("获取病历记录失败:",e),l.value=[]}finally{p.value=!1}}},S=e=>{if(!e)return"";const t=new Date(e*1e3);return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`},$=e=>{h("view",e)},B=()=>{h("openPrescription")};return F(()=>{u()}),j(()=>_.diagnosisId,()=>{u()}),x({refresh:u}),(e,t)=>{const b=L,r=V,v=D,E=z,N=M,I=T;return a(),n("div",J,[y.readOnly?w("",!0):(a(),n("div",Q,[i(b,{type:"primary",size:"small",onClick:B},{default:s(()=>[...t[0]||(t[0]=[m("开方",-1)])]),_:1})])),A((a(),g(E,{data:d(l),border:""},{default:s(()=>[i(r,{prop:"prescription_date",label:"就诊日期",width:"120"}),i(r,{prop:"visit_no",label:"门诊号",width:"120"}),i(r,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),i(r,{label:"处方摘要","min-width":"180"},{default:s(({row:o})=>[o.herbs&&o.herbs.length?(a(),n("span",W,c(o.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}克`).join("、"))+c(o.herbs.length>3?"...":""),1)):(a(),n("span",Y,"—"))]),_:1}),i(r,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),i(r,{label:"状态",width:"140",align:"center"},{default:s(({row:o})=>[o.void_status===1?(a(),n(H,{key:0},[i(v,{type:"danger",size:"small"},{default:s(()=>[...t[1]||(t[1]=[m("已作废",-1)])]),_:1}),R("div",q,c(o.void_by_name||"—")+" "+c(S(o.void_time)),1)],64)):(a(),g(v,{key:1,type:"success",size:"small"},{default:s(()=>[...t[2]||(t[2]=[m("正常",-1)])]),_:1}))]),_:1}),i(r,{label:"操作",width:"120",fixed:"right"},{default:s(({row:o})=>[i(b,{link:"",type:"primary",size:"small",onClick:f=>$(o)},{default:s(()=>[...t[3]||(t[3]=[m(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[I,d(p)]]),!d(p)&&d(l).length===0?(a(),g(N,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):w("",!0)])}}}),zt=G(K,[["__scopeId","data-v-043d2738"]]);export{zt as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{i as L,L as T,N as V,T as D,M as z,W as M}from"./element-plus-CWHdHlgZ.js";import{ao as O}from"./tcm-CdJxXEws.js";import{f as P,b as F,w as j,ak as a,I as n,a as i,aN as s,O as m,H as w,aP as A,G as g,F as H,J as R}from"./@vue/runtime-core-C6bnekPw.js";import{y as d,n as k}from"./@vue/reactivity-DiY1c2vO.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{_ as G}from"./index-dDVfDL1w.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-CbYn5zW9.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const J={class:"case-record-list"},Q={key:0,class:"mb-3 flex justify-end"},W={key:0},Y={key:1,class:"text-gray-400"},q={class:"void-detail text-xs text-gray-500 mt-1"},K=P({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const _=y,h=C,l=k([]),p=k(!1),u=async()=>{if(_.diagnosisId){p.value=!0;try{const e=await O({diagnosis_id:_.diagnosisId});l.value=Array.isArray(e)?e:[]}catch(e){console.error("获取病历记录失败:",e),l.value=[]}finally{p.value=!1}}},S=e=>{if(!e)return"";const t=new Date(e*1e3);return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`},$=e=>{h("view",e)},B=()=>{h("openPrescription")};return F(()=>{u()}),j(()=>_.diagnosisId,()=>{u()}),x({refresh:u}),(e,t)=>{const b=L,r=V,v=D,E=z,N=M,I=T;return a(),n("div",J,[y.readOnly?w("",!0):(a(),n("div",Q,[i(b,{type:"primary",size:"small",onClick:B},{default:s(()=>[...t[0]||(t[0]=[m("开方",-1)])]),_:1})])),A((a(),g(E,{data:d(l),border:""},{default:s(()=>[i(r,{prop:"prescription_date",label:"就诊日期",width:"120"}),i(r,{prop:"visit_no",label:"门诊号",width:"120"}),i(r,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),i(r,{label:"处方摘要","min-width":"180"},{default:s(({row:o})=>[o.herbs&&o.herbs.length?(a(),n("span",W,c(o.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}克`).join("、"))+c(o.herbs.length>3?"...":""),1)):(a(),n("span",Y,"—"))]),_:1}),i(r,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),i(r,{label:"状态",width:"140",align:"center"},{default:s(({row:o})=>[o.void_status===1?(a(),n(H,{key:0},[i(v,{type:"danger",size:"small"},{default:s(()=>[...t[1]||(t[1]=[m("已作废",-1)])]),_:1}),R("div",q,c(o.void_by_name||"—")+" "+c(S(o.void_time)),1)],64)):(a(),g(v,{key:1,type:"success",size:"small"},{default:s(()=>[...t[2]||(t[2]=[m("正常",-1)])]),_:1}))]),_:1}),i(r,{label:"操作",width:"120",fixed:"right"},{default:s(({row:o})=>[i(b,{link:"",type:"primary",size:"small",onClick:f=>$(o)},{default:s(()=>[...t[3]||(t[3]=[m(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[I,d(p)]]),!d(p)&&d(l).length===0?(a(),g(N,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):w("",!0)])}}}),zt=G(K,[["__scopeId","data-v-043d2738"]]);export{zt as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.daily-matrix[data-v-a5368e74]{padding:16px}.daily-matrix__toolbar[data-v-a5368e74]{display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px}.daily-matrix__toolbar-left[data-v-a5368e74],.daily-matrix__toolbar-right[data-v-a5368e74]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.daily-matrix__table[data-v-a5368e74],.daily-matrix__table-wrap[data-v-a5368e74]{width:100%}.daily-matrix__chart[data-v-a5368e74]{margin-top:16px;padding:16px 18px;border:1px solid var(--el-border-color-lighter);border-radius:10px;background:linear-gradient(180deg,#fff,#f8fafc)}.daily-matrix__chart-canvas[data-v-a5368e74]{height:280px;width:100%}.daily-matrix__cell[data-v-a5368e74]{min-height:32px;display:inline-flex;align-items:center;justify-content:center;gap:2px;width:100%;color:var(--el-text-color-regular)}.daily-matrix__cell.is-clickable[data-v-a5368e74]{cursor:pointer}.daily-matrix__cell.is-empty[data-v-a5368e74]{color:var(--el-text-color-placeholder)}.daily-matrix__cell.is-high[data-v-a5368e74]{color:#dc2626;font-weight:700}.daily-matrix__cell.is-patient-self[data-v-a5368e74]{position:relative;background:linear-gradient(180deg,#8b5cf60a,#8b5cf61a);border-radius:4px}.daily-matrix__cell-up[data-v-a5368e74]{color:#dc2626;font-size:13px}.daily-matrix__cell-patient[data-v-a5368e74]{display:inline-block;margin-left:4px;padding:1px 6px;font-size:11px;font-weight:600;color:#6d28d9;background:#ede9fe;border:1px solid #ddd6fe;border-radius:999px;line-height:1.2;letter-spacing:.5px;white-space:nowrap}.daily-matrix__legend[data-v-a5368e74]{display:inline-flex;align-items:center;gap:6px;margin-right:12px;padding:2px 10px 2px 6px;background:#f8f6ff;border:1px dashed #ddd6fe;border-radius:999px}.daily-matrix__legend-text[data-v-a5368e74]{font-size:12px;color:#6d28d9;font-weight:500}.daily-matrix__todo[data-v-a5368e74]{margin-top:16px}.daily-matrix__section-title[data-v-a5368e74]{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--el-text-color-primary)}.daily-matrix__tracking-existing[data-v-a5368e74]{width:100%;max-height:180px;overflow:auto;padding:8px 10px;border:1px solid var(--el-border-color);border-radius:6px;background:var(--el-fill-color-light)}.daily-matrix__tracking-line[data-v-a5368e74]{font-size:12.5px;line-height:1.6;color:var(--el-text-color-regular);word-break:break-word}.daily-matrix__tracking-preview[data-v-a5368e74]{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;width:100%;text-align:left}.daily-matrix__tracking-tooltip[data-v-a5368e74]{max-width:320px}.daily-matrix__tracking-tooltip-line[data-v-a5368e74]{font-size:12.5px;line-height:1.6;word-break:break-word}@media(max-width:768px){.daily-matrix[data-v-a5368e74],.daily-matrix__chart[data-v-a5368e74]{padding:12px}.daily-matrix__chart-canvas[data-v-a5368e74]{height:240px}}
|
||||
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 @@
|
||||
.diagnosis-todo-list .toolbar[data-v-e71c3261]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px}.diagnosis-todo-list .todo-table[data-v-e71c3261]{width:100%}.diagnosis-todo-list .pagination-wrap[data-v-e71c3261]{margin-top:12px;display:flex;justify-content:flex-end}.diagnosis-todo-list .text-danger[data-v-e71c3261]{color:var(--el-color-danger)}.diagnosis-todo-list .text-muted[data-v-e71c3261]{color:var(--el-text-color-placeholder)}
|
||||
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
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-CxutNvPm.js";import"./element-plus-CWHdHlgZ.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/runtime-core-C6bnekPw.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@vue/shared-mAAVTE9n.js";import"./@element-plus/icons-vue-CbYn5zW9.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./tcm-CdJxXEws.js";import"./index-dDVfDL1w.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-BsDgX_Z_.js";import"./element-plus-CWHdHlgZ.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/runtime-core-C6bnekPw.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@vue/shared-mAAVTE9n.js";import"./@element-plus/icons-vue-CbYn5zW9.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./tcm-CSYiFMlc.js";import"./index-MKPKssyT.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";export{o as default};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{i as U,V as k,K as F,D,B as M,G,I as B,C as A}from"./element-plus-CWHdHlgZ.js";import{s as P}from"./@vue/runtime-dom-DDAG46FW.js";import{p as q}from"./tcm-CSYiFMlc.js";import{j as f}from"./index-MKPKssyT.js";import{f as T,as as K,ak as C,I as L,F as j,aP as z,G as N,aN as t,O as u,a as r,H as b,A as H}from"./@vue/runtime-core-C6bnekPw.js";import{n as g,r as W}from"./@vue/reactivity-DiY1c2vO.js";const h=T({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(v,{emit:V}){const d=v,y=V,w=H(()=>{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=g(!1),_=g(!1),o=W({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function I(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function S(){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()){f.msgError("请填写甘草药方单号");return}if(!o.note.trim()){f.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await q({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),f.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=U,c=F,m=B,p=G,a=M,E=A,O=D,R=k,x=K("perms");return w.value?(C(),L(j,{key:0},[z((C(),N(n,{type:"danger",size:"small",plain:"",onClick:I},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[x,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(R,{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:S},{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(O,{"label-width":"100px",onSubmit:P(S,["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"?(C(),N(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(E,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):b("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(E,{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)):b("",!0)}}});export{h as _};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{i as U,V as k,K as F,D,B as M,G,I as B,C as A}from"./element-plus-CWHdHlgZ.js";import{s as P}from"./@vue/runtime-dom-DDAG46FW.js";import{p as q}from"./tcm-CdJxXEws.js";import{j as f}from"./index-dDVfDL1w.js";import{f as T,as as K,ak as C,I as L,F as j,aP as z,G as N,aN as t,O as u,a as r,H as b,A as H}from"./@vue/runtime-core-C6bnekPw.js";import{n as g,r as W}from"./@vue/reactivity-DiY1c2vO.js";const h=T({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(v,{emit:V}){const d=v,y=V,w=H(()=>{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=g(!1),_=g(!1),o=W({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function I(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function S(){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()){f.msgError("请填写甘草药方单号");return}if(!o.note.trim()){f.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await q({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),f.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=U,c=F,m=B,p=G,a=M,E=A,O=D,R=k,x=K("perms");return w.value?(C(),L(j,{key:0},[z((C(),N(n,{type:"danger",size:"small",plain:"",onClick:I},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[x,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(R,{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:S},{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(O,{"label-width":"100px",onSubmit:P(S,["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"?(C(),N(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(E,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):b("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(E,{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)):b("",!0)}}});export{h as _};
|
||||
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 @@
|
||||
import{_ as o}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-IK1V16Ju.js";import"./element-plus-CWHdHlgZ.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/runtime-core-C6bnekPw.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@vue/shared-mAAVTE9n.js";import"./@element-plus/icons-vue-CbYn5zW9.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";export{o as default};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{Q as g,P as V}from"./element-plus-CWHdHlgZ.js";import{f as C,ak as o,G as s,aN as v,I as p,F as h,ap as B,H as k,A as m}from"./@vue/runtime-core-C6bnekPw.js";import{y as c}from"./@vue/reactivity-DiY1c2vO.js";import{p as w,o as S}from"./@vue/shared-mAAVTE9n.js";const N=C({__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:i}){const u=e,n=i,d=m(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=m(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{n("visible-change",l)};return(l,t)=>{const r=g,y=V;return o(),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=>n("update:modelValue",a)),onChange:t[1]||(t[1]=a=>n("change",a)),onVisibleChange:b},{default:v(()=>[(o(!0),p(h,null,B(c(d),a=>(o(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(o(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):k("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{N as _};
|
||||
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 @@
|
||||
.msg-bubble[data-v-80799394]{display:flex;gap:10px;padding:10px 20px;align-items:flex-start}.msg-bubble.is-staff[data-v-80799394]{flex-direction:row-reverse}.msg-bubble.is-staff .msg-body[data-v-80799394]{align-items:flex-end}.msg-bubble.is-staff .msg-content.bubble-text[data-v-80799394],.msg-bubble.is-staff .msg-content.bubble-file[data-v-80799394]{background:#95ec69;color:#000}.msg-bubble .msg-avatar[data-v-80799394]{flex-shrink:0;background:#d0d7de;color:#fff;font-size:12px}.msg-bubble .msg-body[data-v-80799394]{display:flex;flex-direction:column;gap:4px;max-width:65%;min-width:0}.msg-bubble .msg-meta[data-v-80799394]{display:flex;gap:8px;align-items:center;font-size:12px;color:#999}.msg-bubble .msg-sender[data-v-80799394]{font-weight:500;color:#555}.msg-bubble .msg-content[data-v-80799394]{padding:8px 12px;border-radius:6px;background:#fff;word-break:break-word;line-height:1.6;max-width:100%}.msg-bubble .msg-content.bubble-media[data-v-80799394]{padding:0;background:transparent}.msg-bubble .content-text[data-v-80799394]{white-space:pre-wrap}.msg-bubble .content-image[data-v-80799394]{max-width:300px;max-height:300px;border-radius:6px;display:block}.msg-bubble .content-video[data-v-80799394]{max-width:320px;max-height:320px;border-radius:6px;display:block}.msg-bubble .content-audio[data-v-80799394]{max-width:260px}.msg-bubble .content-file[data-v-80799394]{display:flex;gap:12px;align-items:center;min-width:220px;padding:4px 8px}.msg-bubble .content-file .file-meta[data-v-80799394]{flex:1;min-width:0}.msg-bubble .content-file .file-name[data-v-80799394]{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.msg-bubble .content-file .file-info[data-v-80799394]{font-size:12px;color:#888}.msg-bubble .content-pending[data-v-80799394]{display:flex;gap:6px;align-items:center;color:#999;font-size:13px}.msg-bubble .content-fallback .fallback-title[data-v-80799394]{font-size:13px;color:#666;margin-bottom:4px}.msg-bubble .content-fallback .fallback-desc[data-v-80799394]{font-size:14px;color:#333}
|
||||
@@ -0,0 +1,2 @@
|
||||
import{i as se,W as ne,C as le,V as ae,v as re,d as me,a as de}from"./element-plus-CWHdHlgZ.js";import{_ as pe}from"./picker-BLjenmJu.js";import{e as ue,c as ce,j as g,_ as ge}from"./index-dDVfDL1w.js";import{s as U}from"./@vue/runtime-dom-DDAG46FW.js";import{b as z,G as fe}from"./@element-plus/icons-vue-CbYn5zW9.js";import{a as P,d as ve}from"./patient-CM6Ecpu4.js";import{h as _e}from"./perm-CaVgB9g1.js";import{f as ye,w as he,ak as o,I as n,G as k,aN as r,O as M,H as p,a as l,J as m,F as w,ap as E,A as ke}from"./@vue/runtime-core-C6bnekPw.js";import{n as f,y as N}from"./@vue/reactivity-DiY1c2vO.js";import{Q as T}from"./@vue/shared-mAAVTE9n.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./lodash-D3kF6u-c.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./index-C7ezMv-n.js";import"./index-Dha7YAn3.js";import"./index.vue_vue_type_script_setup_true_lang-CmPbpooh.js";import"./index-R2JX164b.js";import"./index-DLpdFML6.js";import"./cos-js-sdk-v5-DSMN0LUT.js";import"./file-BwsCOSPj.js";import"./index.vue_vue_type_script_setup_true_lang-BHxYugPa.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./usePaging-DofycyjW.js";import"./vuedraggable-5bKFmC7X.js";import"./vue-BVs68q8v.js";import"./@vue/compiler-dom-CqayqxS7.js";import"./@vue/compiler-core-CvbQOXIO.js";import"./sortablejs-dsEAUXlE.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},xe={class:"upload-trigger"},Ve={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ne={class:"timeline-body"},be={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},Ae={key:2,class:"timeline-images"},De={key:0,class:"thumb-wrap"},Be={key:1,class:"file-wrap"},Ue=["href","title"],ze={class:"file-name"},j=8e3,Pe=ye({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(d,{emit:O}){const u=d,I=O,X=ke(()=>_e(["doctor.appointment/addDoctorNote"])),v=f(!1),C=f(""),b=f(!1),H=ue(),_=t=>H.getImageUrl(t),S=f([]),A=f([]),y=f(0),h=f(0),J=["jpg","jpeg","png","gif","bmp","webp","svg"],D=t=>{var s;const e=((s=t.split(".").pop())==null?void 0:s.toLowerCase().split("?")[0])||"";return J.includes(e)},$=t=>{var s;const e=t.split("/");return decodeURIComponent(((s=e[e.length-1])==null?void 0:s.split("?")[0])||"文件")},Q=t=>t.filter(D).map(_),Z=(t,e)=>{const s=t.filter(D),x=t[e];return s.indexOf(x)},q=t=>t?t.split(`
|
||||
`).filter(Boolean):[],K=t=>{if(!u.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const s=e.slice(y.value);y.value=e.length,s.length>0&&P({diagnosis_id:u.diagnosisId,tongue_images:s}).then(()=>{g.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!u.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const s=e.slice(h.value);h.value=e.length,s.length>0&&P({diagnosis_id:u.diagnosisId,report_files:s}).then(()=>{g.msgSuccess("检查报告已添加"),I("refresh")})};he(()=>u.notes,()=>{S.value=[],A.value=[],y.value=0,h.value=0});const ee=async()=>{if(!u.diagnosisId){g.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){g.msgWarning("请输入备注内容");return}b.value=!0;try{await P({diagnosis_id:u.diagnosisId,content:t}),g.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){g.msgError((e==null?void 0:e.message)||"保存失败")}finally{b.value=!1}},B=async(t,e,s)=>{try{await de.confirm("确认删除?","提示",{type:"warning"})}catch{return}await ve({note_id:t,image_type:e,image_path:s}),g.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const s=se,x=ce,F=pe,G=re,V=me,te=ne,ie=le,oe=ae;return o(),n("div",we,[!d.readonly&&d.diagnosisId?(o(),n("div",Ie,[X.value?(o(),k(s,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=i=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[M(" 添加备注 ",-1)])]),_:1})):p("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=i=>S.value=i),limit:99,type:"image","exclude-domain":!0,onChange:K},{upload:r(()=>[m("div",Ce,[l(x,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=m("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:A.value,"onUpdate:modelValue":e[2]||(e[2]=i=>A.value=i),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[m("div",xe,[l(x,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=m("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):p("",!0),d.notes.length?(o(),n("div",Ve,[(o(!0),n(w,null,E(d.notes,i=>{var L,R;return o(),n("div",{key:i.id,class:"timeline-node"},[e[11]||(e[11]=m("div",{class:"timeline-dot"},null,-1)),m("div",Ee,T(i.note_date),1),m("div",Ne,[i.content?(o(),n("div",be,[(o(!0),n(w,null,E(q(i.content),(a,c)=>(o(),n("div",{key:c,class:"content-line"},T(a),1))),128))])):p("",!0),(L=i.tongue_images)!=null&&L.length?(o(),n("div",Se,[e[9]||(e[9]=m("span",{class:"images-label"},"舌苔照片",-1)),(o(!0),n(w,null,E(i.tongue_images,(a,c)=>(o(),n("div",{key:c,class:"thumb-wrap"},[l(G,{src:_(a),"preview-src-list":i.tongue_images.map(_),"initial-index":c,"z-index":j,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),d.readonly?p("",!0):(o(),k(V,{key:0,class:"thumb-delete",onClick:U(W=>B(i.id,"tongue_images",a),["stop"])},{default:r(()=>[l(N(z))]),_:1},8,["onClick"]))]))),128))])):p("",!0),(R=i.report_files)!=null&&R.length?(o(),n("div",Ae,[e[10]||(e[10]=m("span",{class:"images-label"},"检查报告",-1)),(o(!0),n(w,null,E(i.report_files,(a,c)=>(o(),n(w,{key:c},[D(a)?(o(),n("div",De,[l(G,{src:_(a),"preview-src-list":Q(i.report_files),"initial-index":Z(i.report_files,c),"z-index":j,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),d.readonly?p("",!0):(o(),k(V,{key:0,class:"thumb-delete",onClick:U(W=>B(i.id,"report_files",a),["stop"])},{default:r(()=>[l(N(z))]),_:1},8,["onClick"]))])):(o(),n("div",Be,[m("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(V,{size:20},{default:r(()=>[l(N(fe))]),_:1}),m("span",ze,T($(a)),1)],8,Ue),d.readonly?p("",!0):(o(),k(V,{key:0,class:"file-delete",onClick:U(W=>B(i.id,"report_files",a),["stop"])},{default:r(()=>[l(N(z))]),_:1},8,["onClick"]))]))],64))),128))])):p("",!0)])])}),128))])):p("",!0),!d.notes.length&&d.readonly?(o(),k(te,{key:2,description:"暂无备注","image-size":48})):p("",!0),l(oe,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=i=>v.value=i),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(s,{onClick:e[4]||(e[4]=i=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[M("取消",-1)])]),_:1}),l(s,{type:"primary",loading:b.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[M("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(ie,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=i=>C.value=i),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Mt=ge(Pe,[["__scopeId","data-v-e548427d"]]);export{Mt as default};
|
||||
@@ -0,0 +1 @@
|
||||
.timeline-actions[data-v-e548427d]{display:flex;flex-direction:column;align-items:flex-start;gap:12px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px dashed #ebeef5}.upload-trigger[data-v-e548427d]{width:90px;height:90px;border:1px dashed #dcdfe6;border-radius:6px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;cursor:pointer;color:#909399;font-size:12px;transition:border-color .2s}.upload-trigger[data-v-e548427d]:hover{border-color:#409eff;color:#409eff}.note-timeline[data-v-e548427d]{position:relative;padding-left:22px}.note-timeline[data-v-e548427d]:before{content:"";position:absolute;left:6px;top:4px;bottom:4px;width:2px;background:#ebeef5;border-radius:1px}.timeline-node[data-v-e548427d]{position:relative;padding-bottom:16px}.timeline-node[data-v-e548427d]:last-child{padding-bottom:0}.timeline-dot[data-v-e548427d]{position:absolute;left:-19px;top:5px;width:10px;height:10px;border-radius:50%;background:#409eff;border:2px solid #ecf5ff}.timeline-date[data-v-e548427d]{font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;font-family:IBM Plex Mono,Consolas,monospace}.timeline-body[data-v-e548427d]{display:flex;flex-direction:column;gap:6px}.timeline-content[data-v-e548427d]{display:flex;flex-direction:column;gap:2px}.content-line[data-v-e548427d]{font-size:12.5px;color:#606266;line-height:1.6;word-break:break-word}.timeline-images[data-v-e548427d]{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.images-label[data-v-e548427d]{font-size:12px;color:#909399;flex-shrink:0}.thumb-wrap[data-v-e548427d]{position:relative;display:inline-block}.timeline-thumb[data-v-e548427d]{width:64px;height:64px;border-radius:6px;border:1px solid #ebeef5;cursor:pointer;transition:transform .15s ease}.timeline-thumb[data-v-e548427d]:hover{transform:scale(1.05)}.thumb-delete[data-v-e548427d]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.thumb-wrap:hover .thumb-delete[data-v-e548427d]{display:block}.file-wrap[data-v-e548427d]{position:relative;display:inline-flex;align-items:center}.file-link[data-v-e548427d]{display:inline-flex;align-items:center;gap:4px;padding:6px 10px;border:1px solid #ebeef5;border-radius:6px;text-decoration:none;color:#409eff;font-size:12px;max-width:180px;transition:border-color .2s}.file-link[data-v-e548427d]:hover{border-color:#409eff}.file-name[data-v-e548427d]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px}.file-delete[data-v-e548427d]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.file-wrap:hover .file-delete[data-v-e548427d]{display:block}
|
||||
@@ -0,0 +1,2 @@
|
||||
import{i as se,W as ne,C as le,V as ae,v as re,d as me,a as de}from"./element-plus-CWHdHlgZ.js";import{_ as pe}from"./picker-DHsMuf-G.js";import{e as ue,c as ce,j as g,_ as ge}from"./index-MKPKssyT.js";import{s as U}from"./@vue/runtime-dom-DDAG46FW.js";import{b as z,G as fe}from"./@element-plus/icons-vue-CbYn5zW9.js";import{a as P,d as ve}from"./patient-L2l0ImS9.js";import{h as _e}from"./perm-CV4h-gMd.js";import{f as ye,w as he,ak as o,I as n,G as k,aN as r,O as M,H as p,a as l,J as m,F as w,ap as E,A as ke}from"./@vue/runtime-core-C6bnekPw.js";import{n as f,y as N}from"./@vue/reactivity-DiY1c2vO.js";import{Q as T}from"./@vue/shared-mAAVTE9n.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./lodash-D3kF6u-c.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./index-Cf-TA1fk.js";import"./index-BQtV1Jt0.js";import"./index.vue_vue_type_script_setup_true_lang-CmPbpooh.js";import"./index-D4AKSG1L.js";import"./index-BOOuIgrt.js";import"./cos-js-sdk-v5-DSMN0LUT.js";import"./file-pgRi9w1O.js";import"./index.vue_vue_type_script_setup_true_lang-BHxYugPa.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./usePaging-DofycyjW.js";import"./vuedraggable-5bKFmC7X.js";import"./vue-BVs68q8v.js";import"./@vue/compiler-dom-CqayqxS7.js";import"./@vue/compiler-core-CvbQOXIO.js";import"./sortablejs-dsEAUXlE.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},xe={class:"upload-trigger"},Ve={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ne={class:"timeline-body"},be={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},Ae={key:2,class:"timeline-images"},De={key:0,class:"thumb-wrap"},Be={key:1,class:"file-wrap"},Ue=["href","title"],ze={class:"file-name"},j=8e3,Pe=ye({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(d,{emit:O}){const u=d,I=O,X=ke(()=>_e(["doctor.appointment/addDoctorNote"])),v=f(!1),C=f(""),b=f(!1),H=ue(),_=t=>H.getImageUrl(t),S=f([]),A=f([]),y=f(0),h=f(0),J=["jpg","jpeg","png","gif","bmp","webp","svg"],D=t=>{var s;const e=((s=t.split(".").pop())==null?void 0:s.toLowerCase().split("?")[0])||"";return J.includes(e)},$=t=>{var s;const e=t.split("/");return decodeURIComponent(((s=e[e.length-1])==null?void 0:s.split("?")[0])||"文件")},Q=t=>t.filter(D).map(_),Z=(t,e)=>{const s=t.filter(D),x=t[e];return s.indexOf(x)},q=t=>t?t.split(`
|
||||
`).filter(Boolean):[],K=t=>{if(!u.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const s=e.slice(y.value);y.value=e.length,s.length>0&&P({diagnosis_id:u.diagnosisId,tongue_images:s}).then(()=>{g.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!u.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const s=e.slice(h.value);h.value=e.length,s.length>0&&P({diagnosis_id:u.diagnosisId,report_files:s}).then(()=>{g.msgSuccess("检查报告已添加"),I("refresh")})};he(()=>u.notes,()=>{S.value=[],A.value=[],y.value=0,h.value=0});const ee=async()=>{if(!u.diagnosisId){g.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){g.msgWarning("请输入备注内容");return}b.value=!0;try{await P({diagnosis_id:u.diagnosisId,content:t}),g.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){g.msgError((e==null?void 0:e.message)||"保存失败")}finally{b.value=!1}},B=async(t,e,s)=>{try{await de.confirm("确认删除?","提示",{type:"warning"})}catch{return}await ve({note_id:t,image_type:e,image_path:s}),g.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const s=se,x=ce,F=pe,G=re,V=me,te=ne,ie=le,oe=ae;return o(),n("div",we,[!d.readonly&&d.diagnosisId?(o(),n("div",Ie,[X.value?(o(),k(s,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=i=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[M(" 添加备注 ",-1)])]),_:1})):p("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=i=>S.value=i),limit:99,type:"image","exclude-domain":!0,onChange:K},{upload:r(()=>[m("div",Ce,[l(x,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=m("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:A.value,"onUpdate:modelValue":e[2]||(e[2]=i=>A.value=i),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[m("div",xe,[l(x,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=m("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):p("",!0),d.notes.length?(o(),n("div",Ve,[(o(!0),n(w,null,E(d.notes,i=>{var L,R;return o(),n("div",{key:i.id,class:"timeline-node"},[e[11]||(e[11]=m("div",{class:"timeline-dot"},null,-1)),m("div",Ee,T(i.note_date),1),m("div",Ne,[i.content?(o(),n("div",be,[(o(!0),n(w,null,E(q(i.content),(a,c)=>(o(),n("div",{key:c,class:"content-line"},T(a),1))),128))])):p("",!0),(L=i.tongue_images)!=null&&L.length?(o(),n("div",Se,[e[9]||(e[9]=m("span",{class:"images-label"},"舌苔照片",-1)),(o(!0),n(w,null,E(i.tongue_images,(a,c)=>(o(),n("div",{key:c,class:"thumb-wrap"},[l(G,{src:_(a),"preview-src-list":i.tongue_images.map(_),"initial-index":c,"z-index":j,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),d.readonly?p("",!0):(o(),k(V,{key:0,class:"thumb-delete",onClick:U(W=>B(i.id,"tongue_images",a),["stop"])},{default:r(()=>[l(N(z))]),_:1},8,["onClick"]))]))),128))])):p("",!0),(R=i.report_files)!=null&&R.length?(o(),n("div",Ae,[e[10]||(e[10]=m("span",{class:"images-label"},"检查报告",-1)),(o(!0),n(w,null,E(i.report_files,(a,c)=>(o(),n(w,{key:c},[D(a)?(o(),n("div",De,[l(G,{src:_(a),"preview-src-list":Q(i.report_files),"initial-index":Z(i.report_files,c),"z-index":j,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),d.readonly?p("",!0):(o(),k(V,{key:0,class:"thumb-delete",onClick:U(W=>B(i.id,"report_files",a),["stop"])},{default:r(()=>[l(N(z))]),_:1},8,["onClick"]))])):(o(),n("div",Be,[m("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(V,{size:20},{default:r(()=>[l(N(fe))]),_:1}),m("span",ze,T($(a)),1)],8,Ue),d.readonly?p("",!0):(o(),k(V,{key:0,class:"file-delete",onClick:U(W=>B(i.id,"report_files",a),["stop"])},{default:r(()=>[l(N(z))]),_:1},8,["onClick"]))]))],64))),128))])):p("",!0)])])}),128))])):p("",!0),!d.notes.length&&d.readonly?(o(),k(te,{key:2,description:"暂无备注","image-size":48})):p("",!0),l(oe,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=i=>v.value=i),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(s,{onClick:e[4]||(e[4]=i=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[M("取消",-1)])]),_:1}),l(s,{type:"primary",loading:b.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[M("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(ie,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=i=>C.value=i),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Mt=ge(Pe,[["__scopeId","data-v-e548427d"]]);export{Mt as default};
|
||||
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 @@
|
||||
.form-grid[data-v-3c102918]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.form-tip[data-v-3c102918]{margin-left:10px;color:#8a95a6;font-size:12px}.danger-menu-item{color:var(--el-color-danger)!important}@media(max-width:720px){.form-grid[data-v-3c102918]{grid-template-columns:1fr}}
|
||||
File diff suppressed because one or more lines are too long
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user