alert-host.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
  2. import { AlertService } from '../../../core/alert.service';
  3. /**
  4. * 全局异常提示容器(2026-11-10)。
  5. *
  6. * 挂在应用根组件上,渲染 `AlertService` 里的提示。
  7. * **不会自动消失**,每条都带「我知道了」按钮由用户手动关闭。
  8. *
  9. * 层级用底座 `--z-toast`(通知层,永远最高),与设计令牌一致。
  10. */
  11. @Component({
  12. selector: 'app-alert-host',
  13. changeDetection: ChangeDetectionStrategy.OnPush,
  14. template: `
  15. @if (svc.alerts().length > 0) {
  16. <div class="alert-stack" role="alert" aria-live="assertive">
  17. @for (a of svc.alerts(); track a.id) {
  18. <div class="alert" [class.warn]="a.tone === 'warning'">
  19. <i
  20. class="fa-solid"
  21. [class]="a.tone === 'warning' ? 'fa-triangle-exclamation' : 'fa-circle-exclamation'"
  22. aria-hidden="true"
  23. ></i>
  24. <div class="alert-body">
  25. <p class="alert-title">{{ a.title }}</p>
  26. @if (a.detail) {
  27. <p class="alert-detail">{{ a.detail }}</p>
  28. }
  29. </div>
  30. <button type="button" class="alert-close" (click)="svc.dismiss(a.id)">
  31. 我知道了
  32. </button>
  33. </div>
  34. }
  35. </div>
  36. }
  37. `,
  38. styles: `
  39. @use '../../../core/styles/breakpoints' as bp;
  40. /*
  41. * ⚠️ CSS 顺序铁律:**基础样式在前,@media 覆盖在后**。
  42. * 第一版把 @media 写在 .alert 之前,结果 .alert 的 display:flex
  43. * 反把媒体查询里的 display:grid 覆盖掉,grid-area 全部失效,
  44. * 文字被挤成一列竖排(用户截图实测)。改样式时务必保持这个顺序。
  45. */
  46. :host {
  47. position: fixed;
  48. top: var(--sp-4);
  49. left: 50%;
  50. transform: translateX(-50%);
  51. z-index: var(--z-toast);
  52. pointer-events: none;
  53. }
  54. .alert-stack {
  55. display: flex;
  56. flex-direction: column;
  57. gap: var(--sp-2);
  58. width: min(92vw, 520px);
  59. }
  60. .alert {
  61. display: flex;
  62. align-items: flex-start;
  63. gap: var(--sp-3);
  64. padding: var(--sp-3) var(--sp-4);
  65. border: 1px solid var(--danger);
  66. border-radius: var(--r-lg);
  67. background: var(--surface);
  68. box-shadow: var(--shadow-overlay);
  69. color: var(--ink);
  70. pointer-events: auto;
  71. animation: alert-in var(--motion-base) var(--ease-standard);
  72. }
  73. .alert.warn {
  74. border-color: var(--warning);
  75. }
  76. .alert > i {
  77. flex-shrink: 0;
  78. margin-top: 2px;
  79. color: var(--danger);
  80. font-size: var(--fs-lg);
  81. }
  82. .alert.warn > i {
  83. color: var(--warning);
  84. }
  85. .alert-body {
  86. flex: 1;
  87. min-width: 0;
  88. }
  89. .alert-title {
  90. margin: 0;
  91. font-size: var(--fs-md);
  92. font-weight: 600;
  93. line-height: 1.4;
  94. }
  95. .alert-detail {
  96. margin: var(--sp-1) 0 0;
  97. font-size: var(--fs-sm);
  98. color: var(--ink-2);
  99. line-height: 1.5;
  100. }
  101. /* 触达区 ≥44px(可访问性底线) */
  102. .alert-close {
  103. flex-shrink: 0;
  104. min-height: 44px;
  105. padding: 0 var(--sp-3);
  106. border: 1px solid var(--edge-strong);
  107. border-radius: var(--r-md);
  108. background: var(--surface-2);
  109. color: var(--ink);
  110. font-size: var(--fs-sm);
  111. cursor: pointer;
  112. white-space: nowrap;
  113. transition: background-color var(--motion-fast) var(--ease-standard);
  114. }
  115. .alert-close:hover {
  116. background: var(--surface-hover);
  117. }
  118. @keyframes alert-in {
  119. from {
  120. opacity: 0;
  121. transform: translateY(-8px);
  122. }
  123. to {
  124. opacity: 1;
  125. transform: translateY(0);
  126. }
  127. }
  128. /* 尊重系统「减少动态效果」设置 */
  129. @media (prefers-reduced-motion: reduce) {
  130. .alert {
  131. animation: none;
  132. }
  133. }
  134. /*
  135. * ===== 移动端独立样式(2026-11-10 用户裁决)=====
  136. * 「不能和 PC 端一致,需要单独优化」。
  137. *
  138. * 移动端特点:
  139. * · 屏窄 → 整宽贴顶,留刘海安全区
  140. * · 触屏 → 按钮**整行铺满**、≥48px(手指比鼠标粗)
  141. * · 文字多 → 标题与正文分行,按钮独占一行,不跟文字抢宽度
  142. */
  143. @include bp.below-mobile {
  144. :host {
  145. top: 0;
  146. left: 0;
  147. right: 0;
  148. /* 用 inset 显式撑满,避免 :host 宽度塌成内容宽 */
  149. bottom: auto;
  150. width: 100%;
  151. box-sizing: border-box;
  152. transform: none;
  153. padding-top: max(var(--sp-3), env(safe-area-inset-top));
  154. padding-inline: var(--sp-2);
  155. }
  156. .alert-stack {
  157. width: 100%;
  158. max-width: none;
  159. gap: var(--sp-2);
  160. }
  161. .alert {
  162. display: flex;
  163. flex-wrap: wrap;
  164. align-items: flex-start;
  165. gap: var(--sp-2) var(--sp-2);
  166. padding: var(--sp-3);
  167. box-sizing: border-box;
  168. }
  169. .alert-body {
  170. flex: 1 1 0;
  171. min-width: 0;
  172. }
  173. /* 按钮独占一整行,触屏好点 */
  174. .alert-close {
  175. flex: 1 1 100%;
  176. width: 100%;
  177. min-height: 48px;
  178. margin-top: var(--sp-1);
  179. font-size: var(--fs-md);
  180. font-weight: 600;
  181. }
  182. .alert-title {
  183. font-size: var(--fs-lg);
  184. }
  185. }
  186. `,
  187. })
  188. export class AlertHostComponent {
  189. protected readonly svc = inject(AlertService);
  190. }