doctorAI/src/app/app.html

273 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@if (auth.isAuthenticated()) {
<div class="mobile-app-shell">
<!-- Top App Bar -->
<header class="top-bar">
<div class="logo">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary" style="color: var(--color-primary-dark)"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
<span class="logo-text">深维面诊</span>
</div>
<div style="flex: 1"></div>
<!-- Fullscreen Toggle -->
<button class="fullscreen-btn" (click)="toggleFullscreen()">
<svg *ngIf="!isFullscreen" xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.707l4.096-4.096a.5.5 0 0 0 0-.707m4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.793l-4.096-4.096a.5.5 0 0 1 0-.707"/>
<path fill-rule="evenodd" d="M10.172 5.828a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.793l-4.096 4.096a.5.5 0 0 0 0 .707m-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.707l4.096 4.096a.5.5 0 0 1 0 .707"/>
</svg>
<svg *ngIf="isFullscreen" xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.707l4.096-4.096a.5.5 0 0 0 0-.707m4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.793l-4.096-4.096a.5.5 0 0 1 0-.707"/>
<path fill-rule="evenodd" d="M10.172 5.828a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.793l-4.096 4.096a.5.5 0 0 0 0 .707m-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.707l4.096 4.096a.5.5 0 0 1 0 .707"/>
</svg>
{{ isFullscreen ? '退出全屏' : '全屏预览' }}
</button>
<!-- Profile Avatar triggers billing modal -->
<button class="avatar-btn" (click)="showBillingModal = true">
<div class="avatar-circle">
@if (auth.user()?.avatarUrl) {
<img [src]="auth.user()?.avatarUrl" alt="Avatar" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">
} @else {
{{ auth.user()?.name?.charAt(0) || auth.user()?.phone?.slice(-4, -3) || '我' }}
}
</div>
</button>
</header>
<!-- Scrollable Main Content -->
<main class="main-content">
<router-outlet></router-outlet>
</main>
<!-- Capsule Bottom Navigation (Like Get Notes app) -->
<nav class="pill-nav-container" *ngIf="!isContextPage">
<div class="pill-nav">
<!-- Nav Item 1 -->
<a routerLink="/workspace" routerLinkActive="active" class="pill-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
<span>笔记列表</span>
</a>
<!-- Central Record Bubble -->
<div class="pill-action">
<button class="mic-bubble"
(mousedown)="onPressStart($event)" (touchstart)="onPressStart($event)"
(mouseup)="onPressEnd($event)" (touchend)="onPressEnd($event)"
(mouseleave)="onPressEnd($event)" (touchcancel)="onPressEnd($event)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="22"></line></svg>
<span style="user-select: none;">面诊</span>
</button>
<!-- Hidden input mapped to long press -->
<input type="file" #fileInput hidden (change)="handleFileUpload($event)" accept="audio/*">
</div>
<!-- Nav Item 2 -->
<a routerLink="/clients" routerLinkActive="active" class="pill-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>客户通</span>
</a>
</div>
<div class="pill-tip">短按开始,长按上传</div>
</nav>
<!-- Report/Context Page Chat Pill -->
<div class="chat-pill-wrapper" *ngIf="isContextPage && !isContextGenerating" (click)="openChatSheet()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="2" style="margin-top:2px"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg>
<span class="placeholder">{{ chatPlaceholder }}</span>
<button class="send-btn-mock">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
</button>
</div>
<!-- Global Action Sheet for Recording, Processing, & Chat -->
<div class="action-sheet-overlay" [class.show]="showActionSheet || showChatSheet" (click)="closeSheets()">
<div class="action-sheet-panel" [class.show]="showActionSheet || showChatSheet" (click)="$event.stopPropagation()">
<div class="sheet-drag-handle"></div>
<!-- STATE 1: Actively Recording (only when showActionSheet is true) -->
<ng-container *ngIf="showActionSheet">
<div class="recording-state" *ngIf="isRecording">
<div class="rec-timer">
<div class="red-dot animate-pulse"></div>
{{ formatDuration(recordDuration) }}
</div>
<p class="rec-subtitle">深维云脑:正在实时接听面诊中...</p>
<button class="stop-btn" (click)="stopAndProcess()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" ry="2"></rect></svg>
结束对谈 · 生成绝杀 X光片
</button>
</div>
<!-- STATE 2: Processing (The Relay Bar) -->
<div class="processing-state" *ngIf="isProcessing">
<h3 class="process-title" *ngIf="!uploadComplete">🧠 深维专家团并行推理中...</h3>
<h3 class="process-title text-success" *ngIf="uploadComplete">✅ X光片就绪</h3>
<div class="relay-nodes">
@for (agent of agents; track agent.id; let i = $index) {
<div class="agent-node" [class.active]="agent.status === 'processing'" [class.done]="agent.status === 'completed'">
<div class="node-icon-wrapper">
<span class="node-icon" [innerHTML]="agent.iconHtml"></span>
<div class="pulse-ring" *ngIf="agent.status === 'processing'"></div>
</div>
<span class="node-name">{{ agent.name }}</span>
<div class="connector" *ngIf="i < agents.length - 1" [class.active]="agent.status === 'completed'"></div>
</div>
}
</div>
<p class="cost-hint text-center mt-2" *ngIf="!uploadComplete">预估消耗 1000 算力点数</p>
</div>
</ng-container>
<!-- STATE 3: Chat Sheet -->
<div class="chat-state" *ngIf="showChatSheet" style="display:flex; flex-direction:column; height: 50vh;">
<div class="chat-header">
<h4>💬 X光片追踪深打</h4>
<p *ngIf="chatCtx.contextType() === 'recording'">🎙️ 上下文锁定:本次面诊录音报告</p>
<p *ngIf="chatCtx.contextType() === 'client'">👤 上下文锁定:{{ chatCtx.contextTitle() }} 全景档案</p>
<p *ngIf="!chatCtx.contextType()">上下文锁定</p>
</div>
<div class="chat-history">
@for (msg of chatMessages; track $index) {
<div class="chat-message" [class.user]="msg.role === 'user'">
<!-- AI 头像:方形 -->
<div class="msg-avatar ai-avatar" *ngIf="msg.role !== 'user'">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
</div>
<div class="msg-bubble" [class.streaming]="msg.isStreaming" [innerHTML]="msg.content"></div>
<!-- 用户头像:圆形 -->
<div class="msg-avatar user-avatar" *ngIf="msg.role === 'user'">
{{ auth.user()?.name?.charAt(0) || '我' }}
</div>
</div>
}
</div>
<div class="chat-input-area">
<input type="text" [(ngModel)]="chatInput" placeholder="输入追问或索要话术..." (keyup.enter)="sendChatMessage()">
<button class="chat-send-btn" (click)="sendChatMessage()" [disabled]="!chatInput.trim()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
</button>
</div>
</div>
</div>
</div>
<!-- Dual-Tab Modal Overlay -->
<div class="modal-overlay" *ngIf="showBillingModal" (click)="showBillingModal = false" [class.show]="showBillingModal">
<div class="billing-modal" (click)="$event.stopPropagation()">
<div class="modal-header multi-tab">
<div class="tab-selectors">
<button class="modal-tab-btn" [class.active]="activeModalTab === 'billing'" (click)="activeModalTab = 'billing'">账户中心</button>
<button class="modal-tab-btn" [class.active]="activeModalTab === 'profile'" (click)="activeModalTab = 'profile'">个人档案</button>
</div>
<button class="close-btn" (click)="showBillingModal = false">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<!-- Tab 1: Billing -->
<div class="modal-body" *ngIf="activeModalTab === 'billing'">
<div class="points-card">
<div class="subtitle">剩余可用总点数</div>
<div class="points-val">{{ formatCredits(credits) }}</div>
</div>
<div class="quota-blocks">
<div class="progress-bar">
<div class="progress-fill" style="width: 30%"></div>
</div>
<div class="free-quota mt-2">今日免费评估额度: 300 / 1000</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" style="flex: 1;">点数订阅包</button>
<button class="btn btn-primary" style="flex: 1;" (click)="showBillingModal = false">充值点数</button>
</div>
</div>
<!-- Tab 2: Profile -->
<div class="modal-body profile-body" *ngIf="activeModalTab === 'profile'">
<div class="profile-form-group">
<label>系统头像</label>
<div class="avatar-edit-wrapper">
<div class="avatar-large">
@if (auth.user()?.avatarUrl) {
<img [src]="auth.user()?.avatarUrl" style="width:100%;height:100%;border-radius:50%;object-fit:cover" alt="avatar">
} @else {
{{ auth.user()?.name?.charAt(0) || auth.user()?.phone?.slice(-4, -3) || '我' }}
}
</div>
<div class="avatar-hint">头像是您的深维体系全局身份标识<br>暂不支持在此修改,请前往主系统配置</div>
</div>
</div>
<div class="profile-row">
<div class="profile-form-group" style="flex: 1">
<label>真实姓名</label>
<input type="text" class="profile-input" [(ngModel)]="profileRealName" placeholder="如: 李大夫">
</div>
<div class="profile-form-group" style="flex: 1">
<label>核心角色</label>
<div class="role-pills">
<button class="role-pill" [class.active]="profileRole === 'doctor'" (click)="profileRole = 'doctor'">医生</button>
<button class="role-pill" [class.active]="profileRole === 'assistant'" (click)="profileRole = 'assistant'">医助</button>
</div>
</div>
</div>
<div class="profile-form-group">
<label>所属机构名称 (公司名)</label>
<input type="text" class="profile-input" [(ngModel)]="profileCompany" placeholder="请输入您的执业机构全称">
</div>
<div class="profile-form-group">
<label>防干扰专属声纹特征 <span class="badge-pro" style="transform:scale(0.8);transform-origin:left;">Pro专用</span></label>
<p style="font-size: 13px; color: var(--color-slate-500); margin: 0 0 12px 0;">系统会通过声纹锁定主诊人屏蔽杂音。请录制一段10秒左右的本人说话声音</p>
<div class="voice-box">
<div *ngIf="profileVoiceUrl" class="voice-ready">
<svg class="text-success" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
<span style="flex:1">已登记个人声纹档案</span>
<button class="btn-text-danger" (click)="deleteProfileVoice()">删除重建</button>
</div>
<div *ngIf="!profileVoiceUrl && !isProfileRecording && !profileVoiceUploading" class="voice-record-btn" (click)="toggleProfileVoiceRecord()">
<svg style="margin-right:8px" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="22"></line></svg>
点击开始采集(推荐朗读上方文字)
</div>
<div *ngIf="isProfileRecording" class="voice-recording-btn with-progress" (click)="toggleProfileVoiceRecord()">
<div class="progress-fill-bg" [style.width.%]="recordProgress > 100 ? 100 : recordProgress" [style.background-color]="getRecordColor()"></div>
<div class="content-relative">
<div class="red-dot"></div>
正在聆听... {{ (recordProgress / 10).toFixed(1) }}s / 10s (点击停止)
</div>
</div>
<div *ngIf="profileVoiceUploading" class="voice-loading">
<div class="spinner"></div>正在提取波形并上传特征至云端...
</div>
</div>
</div>
<div class="modal-actions" style="margin-top: 24px">
<button class="btn btn-primary" style="flex: 1;" (click)="saveProfile()">
{{ profileSaveSuccess ? '✅ 保存成功' : '保存档案' }}
</button>
</div>
</div>
</div>
</div>
</div>
} @else {
<app-login />
}