Commit 1506e2fa by luoqi

feat(action-urls): 会话下发 actionUrls + 「新建预约」按钮消费(配置驱动,不写死)

- /auth/session 返回当前 host 的 actionUrls(AuthService.getActionUrls),所有角色可拿,
  不再依赖 admin 端点;SessionResponse 加 actionUrls 字段
- 前端 loadSession 把 actionUrls 存进会话 user;「新建预约」读 actionUrls.CREATE_APPOINTMENT
  → 替换 {patientId}(=external_id)/{doctorId} → window.open(_top);未配置则提示去配
- 宿主动作 URL 仍是宿主在 admin 配的数据,代码零硬编码

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 6182b2f5
Pipeline #3342 failed in 0 seconds
...@@ -87,7 +87,7 @@ export class AuthController { ...@@ -87,7 +87,7 @@ export class AuthController {
summary: summary:
'当前会话视图 — 把 JWT.orgScope 展开成 clinicIds/sourceUnits 返给前端(设计 B:JWT 只装通用 orgScope,前端展不开)', '当前会话视图 — 把 JWT.orgScope 展开成 clinicIds/sourceUnits 返给前端(设计 B:JWT 只装通用 orgScope,前端展不开)',
}) })
session( async session(
@CurrentUser() user: AccessTokenPayload, @CurrentUser() user: AccessTokenPayload,
@TenantScope() scope: TenantScopeContext, @TenantScope() scope: TenantScopeContext,
) { ) {
...@@ -101,6 +101,8 @@ export class AuthController { ...@@ -101,6 +101,8 @@ export class AuthController {
clinicIds: scope.clinicIds, // 拦截器已按 host org 树展开 clinicIds: scope.clinicIds, // 拦截器已按 host org 树展开
sourceUnits: scope.sourceUnits, sourceUnits: scope.sourceUnits,
...(user.dictionary ? { dictionary: user.dictionary } : {}), ...(user.dictionary ? { dictionary: user.dictionary } : {}),
// 宿主动作跳转模板 —— 前端"新建预约"等按钮据此打开宿主页(缺失=不渲染/不跳)
actionUrls: await this.auth.getActionUrls(user.hostId),
}; };
} }
} }
...@@ -67,6 +67,15 @@ export class AuthService { ...@@ -67,6 +67,15 @@ export class AuthService {
)); ));
} }
/** 当前 host 的 actionUrls(跳转模板)—— 会话视图带给前端;缺失返 {}。 */
async getActionUrls(hostId: string): Promise<Record<string, string | null>> {
const host = await this.prisma.host.findUnique({
where: { id: hostId },
select: { actionUrls: true },
});
return (host?.actionUrls as Record<string, string | null>) ?? {};
}
async exchangeToken(req: TokenExchangeRequest): Promise<TokenExchangeResponse> { async exchangeToken(req: TokenExchangeRequest): Promise<TokenExchangeResponse> {
const host = await this.prisma.host.findUnique({ where: { appId: req.appId } }); const host = await this.prisma.host.findUnique({ where: { appId: req.appId } });
if (!host || !host.active) { if (!host || !host.active) {
......
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { plansApi } from '@/components/plans/plans-api'; import { plansApi } from '@/components/plans/plans-api';
import { usePlanSyncStore } from '@/stores/plan-sync-store'; import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAuthStore } from '@/stores/auth-store';
import { IdentityCluster } from '@/components/identity-cluster'; import { IdentityCluster } from '@/components/identity-cluster';
import { isEmbedded } from '@/lib/embed'; import { isEmbedded } from '@/lib/embed';
import { import {
...@@ -484,10 +485,21 @@ export function PlanDetailApp({ ...@@ -484,10 +485,21 @@ export function PlanDetailApp({
plan={effectivePlan} plan={effectivePlan}
onSubmit={submitOutcome} onSubmit={submitOutcome}
defaultChannel={effectivePlan.recommendedChannel} defaultChannel={effectivePlan.recommendedChannel}
onCreateAppointment={() => onCreateAppointment={() => {
// 占位:真实接入时由宿主 actionUrls / env 下发 URL,代码不写死。 // 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 占位替换 → 打开宿主页。
showToast('emerald', '打开宿主预约页', '带上患者 + 医生 + 治疗项预填') // 未配置则提示去配,不写死任何 URL。
const tpl = useAuthStore.getState().user?.actionUrls?.CREATE_APPOINTMENT;
if (!tpl) {
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return;
} }
const url = tpl
.replace(/\{patientId\}/g, encodeURIComponent(patient.externalId ?? ''))
.replace(/\{doctorId\}/g, '')
.replace(/\{[a-zA-Z]+\}/g, ''); // 其余未知占位清空
window.open(url, '_top');
showToast('emerald', '已打开宿主预约页', '带上患者 id');
}}
/> />
</div> </div>
</section> </section>
......
...@@ -12,6 +12,8 @@ import type { AccessTokenPayload } from '@pac/types'; ...@@ -12,6 +12,8 @@ import type { AccessTokenPayload } from '@pac/types';
export type SessionUser = AccessTokenPayload & { export type SessionUser = AccessTokenPayload & {
clinicIds?: string[]; clinicIds?: string[];
sourceUnits?: string[]; sourceUnits?: string[];
/// 宿主动作跳转模板(actionUrls)—— 从 /auth/session 拉;"新建预约"等按钮据此打开宿主页。
actionUrls?: Record<string, string | null>;
}; };
/** /**
...@@ -102,7 +104,14 @@ export const useAuthStore = create<AuthState>()( ...@@ -102,7 +104,14 @@ export const useAuthStore = create<AuthState>()(
const s = await getSession(); const s = await getSession();
set((st) => set((st) =>
st.user st.user
? { user: { ...st.user, clinicIds: s.clinicIds, sourceUnits: s.sourceUnits } } ? {
user: {
...st.user,
clinicIds: s.clinicIds,
sourceUnits: s.sourceUnits,
actionUrls: s.actionUrls,
},
}
: {}, : {},
); );
} catch { } catch {
......
...@@ -194,5 +194,7 @@ export const SessionResponseSchema = z.object({ ...@@ -194,5 +194,7 @@ export const SessionResponseSchema = z.object({
clinicIds: z.array(z.string()), clinicIds: z.array(z.string()),
sourceUnits: z.array(z.string()), sourceUnits: z.array(z.string()),
dictionary: TokenDictionarySchema.optional(), dictionary: TokenDictionarySchema.optional(),
/// 宿主动作跳转模板(actionUrls)—— 前端"新建预约"等按钮据此打开宿主页;缺失=不渲染。
actionUrls: z.record(z.string(), z.string().nullable()).optional(),
}); });
export type SessionResponse = z.infer<typeof SessionResponseSchema>; export type SessionResponse = z.infer<typeof SessionResponseSchema>;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment