| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
- import { AlertService } from '../../../core/alert.service';
- /**
- * 全局异常提示容器(2026-11-10)。
- *
- * 挂在应用根组件上,渲染 `AlertService` 里的提示。
- * **不会自动消失**,每条都带「我知道了」按钮由用户手动关闭。
- *
- * 层级用底座 `--z-toast`(通知层,永远最高),与设计令牌一致。
- */
- @Component({
- selector: 'app-alert-host',
- changeDetection: ChangeDetectionStrategy.OnPush,
- template: `
- @if (svc.alerts().length > 0) {
- <div class="alert-stack" role="alert" aria-live="assertive">
- @for (a of svc.alerts(); track a.id) {
- <div class="alert" [class.warn]="a.tone === 'warning'">
- <i
- class="fa-solid"
- [class]="a.tone === 'warning' ? 'fa-triangle-exclamation' : 'fa-circle-exclamation'"
- aria-hidden="true"
- ></i>
- <div class="alert-body">
- <p class="alert-title">{{ a.title }}</p>
- @if (a.detail) {
- <p class="alert-detail">{{ a.detail }}</p>
- }
- </div>
- <button type="button" class="alert-close" (click)="svc.dismiss(a.id)">
- 我知道了
- </button>
- </div>
- }
- </div>
- }
- `,
- styles: `
- @use '../../../core/styles/breakpoints' as bp;
- /*
- * ⚠️ CSS 顺序铁律:**基础样式在前,@media 覆盖在后**。
- * 第一版把 @media 写在 .alert 之前,结果 .alert 的 display:flex
- * 反把媒体查询里的 display:grid 覆盖掉,grid-area 全部失效,
- * 文字被挤成一列竖排(用户截图实测)。改样式时务必保持这个顺序。
- */
- :host {
- position: fixed;
- top: var(--sp-4);
- left: 50%;
- transform: translateX(-50%);
- z-index: var(--z-toast);
- pointer-events: none;
- }
- .alert-stack {
- display: flex;
- flex-direction: column;
- gap: var(--sp-2);
- width: min(92vw, 520px);
- }
- .alert {
- display: flex;
- align-items: flex-start;
- gap: var(--sp-3);
- padding: var(--sp-3) var(--sp-4);
- border: 1px solid var(--danger);
- border-radius: var(--r-lg);
- background: var(--surface);
- box-shadow: var(--shadow-overlay);
- color: var(--ink);
- pointer-events: auto;
- animation: alert-in var(--motion-base) var(--ease-standard);
- }
- .alert.warn {
- border-color: var(--warning);
- }
- .alert > i {
- flex-shrink: 0;
- margin-top: 2px;
- color: var(--danger);
- font-size: var(--fs-lg);
- }
- .alert.warn > i {
- color: var(--warning);
- }
- .alert-body {
- flex: 1;
- min-width: 0;
- }
- .alert-title {
- margin: 0;
- font-size: var(--fs-md);
- font-weight: 600;
- line-height: 1.4;
- }
- .alert-detail {
- margin: var(--sp-1) 0 0;
- font-size: var(--fs-sm);
- color: var(--ink-2);
- line-height: 1.5;
- }
- /* 触达区 ≥44px(可访问性底线) */
- .alert-close {
- flex-shrink: 0;
- min-height: 44px;
- padding: 0 var(--sp-3);
- border: 1px solid var(--edge-strong);
- border-radius: var(--r-md);
- background: var(--surface-2);
- color: var(--ink);
- font-size: var(--fs-sm);
- cursor: pointer;
- white-space: nowrap;
- transition: background-color var(--motion-fast) var(--ease-standard);
- }
- .alert-close:hover {
- background: var(--surface-hover);
- }
- @keyframes alert-in {
- from {
- opacity: 0;
- transform: translateY(-8px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
- /* 尊重系统「减少动态效果」设置 */
- @media (prefers-reduced-motion: reduce) {
- .alert {
- animation: none;
- }
- }
- /*
- * ===== 移动端独立样式(2026-11-10 用户裁决)=====
- * 「不能和 PC 端一致,需要单独优化」。
- *
- * 移动端特点:
- * · 屏窄 → 整宽贴顶,留刘海安全区
- * · 触屏 → 按钮**整行铺满**、≥48px(手指比鼠标粗)
- * · 文字多 → 标题与正文分行,按钮独占一行,不跟文字抢宽度
- */
- @include bp.below-mobile {
- :host {
- top: 0;
- left: 0;
- right: 0;
- /* 用 inset 显式撑满,避免 :host 宽度塌成内容宽 */
- bottom: auto;
- width: 100%;
- box-sizing: border-box;
- transform: none;
- padding-top: max(var(--sp-3), env(safe-area-inset-top));
- padding-inline: var(--sp-2);
- }
- .alert-stack {
- width: 100%;
- max-width: none;
- gap: var(--sp-2);
- }
- .alert {
- display: flex;
- flex-wrap: wrap;
- align-items: flex-start;
- gap: var(--sp-2) var(--sp-2);
- padding: var(--sp-3);
- box-sizing: border-box;
- }
- .alert-body {
- flex: 1 1 0;
- min-width: 0;
- }
- /* 按钮独占一整行,触屏好点 */
- .alert-close {
- flex: 1 1 100%;
- width: 100%;
- min-height: 48px;
- margin-top: var(--sp-1);
- font-size: var(--fs-md);
- font-weight: 600;
- }
- .alert-title {
- font-size: var(--fs-lg);
- }
- }
- `,
- })
- export class AlertHostComponent {
- protected readonly svc = inject(AlertService);
- }
|