pending.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
  2. import { Router } from '@angular/router';
  3. import { FmodeApi } from '../../../core/api/fmode-api';
  4. import { DeviceService } from '../../../core/device.service';
  5. import { SessionService } from '../../../core/session.service';
  6. import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
  7. /**
  8. * 「等待组织邀请」页(移动端,2026-11-10)
  9. *
  10. * ## 与桌面版的差别(不是简单缩小)
  11. *
  12. * | | 桌面 | 移动 |
  13. * |---|---|---|
  14. * | 骨架 | 居中浮卡 | 顶栏固定 + 内容纵向滚动 + 底部主操作**吸底** |
  15. * | 信息 | 两列栅格 | 单列纵向,标签在上、值在下 |
  16. * | 操作 | 并排两个按钮 | 主按钮吸底(拇指区),退出登录弱化为文字 |
  17. * | 复制 | 小图标 | 整行可点(触控区 ≥44px) |
  18. *
  19. * 移动端用户通常是单手操作:最重要的「立即检查」必须落在拇指够得到的位置。
  20. */
  21. @Component({
  22. selector: 'app-m-pending',
  23. changeDetection: ChangeDetectionStrategy.OnPush,
  24. imports: [MAppBarComponent],
  25. template: `
  26. <app-m-app-bar title="等待加入组织" subtitle="需要管理员邀请" />
  27. <main class="m-body">
  28. <section class="m-hero">
  29. <span class="m-mark" aria-hidden="true"><i class="fa-solid fa-hourglass-half"></i></span>
  30. <h1>还没加入组织</h1>
  31. <p class="m-lead">
  32. 把下面的用户 ID 发给管理员,由管理员把你拉进组织后即可使用。
  33. </p>
  34. </section>
  35. <section class="m-card">
  36. <button type="button" class="m-row" (click)="copyId()" aria-label="复制用户 ID">
  37. <span class="m-row-body">
  38. <span class="m-row-label">用户 ID</span>
  39. <span class="m-row-value mono">{{ me()?.id || '—' }}</span>
  40. </span>
  41. <i class="fa-solid fa-copy" aria-hidden="true"></i>
  42. </button>
  43. <div class="m-row static">
  44. <span class="m-row-body">
  45. <span class="m-row-label">昵称</span>
  46. <span class="m-row-value">{{ me()?.name || '—' }}</span>
  47. </span>
  48. </div>
  49. <div class="m-row static">
  50. <span class="m-row-body">
  51. <span class="m-row-label">手机号</span>
  52. <span class="m-row-value">{{ maskedMobile() }}</span>
  53. </span>
  54. </div>
  55. </section>
  56. <p class="m-status" [attr.data-tone]="copyTone()" role="status" aria-live="polite">
  57. {{ statusText() }}
  58. </p>
  59. <button type="button" class="m-text-btn" (click)="logout()">退出登录</button>
  60. </main>
  61. <footer class="m-foot">
  62. <button type="button" class="m-primary" [disabled]="checking()" (click)="checkNow()">
  63. {{ checking() ? '正在检查…' : '我已被邀请,立即检查' }}
  64. </button>
  65. </footer>
  66. `,
  67. styles: [
  68. `
  69. :host {
  70. display: flex;
  71. flex-direction: column;
  72. height: 100dvh;
  73. background: var(--canvas);
  74. }
  75. .m-body {
  76. flex: 1;
  77. min-height: 0;
  78. overflow-y: auto;
  79. padding: var(--sp-4) var(--sp-4) var(--sp-6);
  80. }
  81. .m-hero {
  82. text-align: center;
  83. padding: var(--sp-6) 0 var(--sp-5);
  84. }
  85. .m-mark {
  86. display: inline-grid;
  87. place-items: center;
  88. width: 64px;
  89. height: 64px;
  90. border-radius: 50%;
  91. background: var(--surface-2);
  92. color: var(--primary);
  93. font-size: var(--fs-24);
  94. margin-bottom: var(--sp-3);
  95. }
  96. h1 {
  97. margin: 0 0 var(--sp-2);
  98. font-size: var(--fs-20);
  99. color: var(--ink);
  100. }
  101. .m-lead {
  102. margin: 0;
  103. color: var(--muted);
  104. font-size: var(--fs-14);
  105. line-height: 1.6;
  106. }
  107. .m-card {
  108. background: var(--surface);
  109. border-radius: var(--radius-m);
  110. border: 1px solid var(--edge);
  111. overflow: hidden;
  112. }
  113. .m-row {
  114. display: flex;
  115. align-items: center;
  116. gap: var(--sp-3);
  117. width: 100%;
  118. min-height: 64px;
  119. padding: var(--sp-3) var(--sp-4);
  120. border: 0;
  121. border-bottom: 1px solid var(--edge);
  122. background: var(--surface);
  123. text-align: left;
  124. color: var(--ink-2);
  125. cursor: pointer;
  126. }
  127. .m-row:last-child {
  128. border-bottom: 0;
  129. }
  130. .m-row.static {
  131. cursor: default;
  132. }
  133. .m-row-body {
  134. flex: 1;
  135. min-width: 0;
  136. display: grid;
  137. gap: 2px;
  138. }
  139. .m-row-label {
  140. color: var(--muted);
  141. font-size: var(--fs-12);
  142. }
  143. .m-row-value {
  144. color: var(--ink);
  145. font-size: var(--fs-16);
  146. word-break: break-all;
  147. }
  148. .mono {
  149. font-family: var(--font-mono, ui-monospace, monospace);
  150. }
  151. .m-status {
  152. margin: var(--sp-4) 0 0;
  153. font-size: var(--fs-13);
  154. color: var(--muted);
  155. text-align: center;
  156. line-height: 1.6;
  157. }
  158. .m-status[data-tone='ok'] {
  159. color: var(--success);
  160. }
  161. .m-status[data-tone='warn'] {
  162. color: var(--warning);
  163. }
  164. .m-text-btn {
  165. display: block;
  166. margin: var(--sp-5) auto 0;
  167. min-height: 44px;
  168. padding: 0 var(--sp-4);
  169. border: 0;
  170. background: transparent;
  171. color: var(--muted);
  172. font-size: var(--fs-14);
  173. }
  174. /* 主操作吸底:拇指区,不随内容滚走 */
  175. .m-foot {
  176. flex: none;
  177. padding: var(--sp-3) var(--sp-4);
  178. padding-bottom: calc(var(--sp-3) + env(safe-area-inset-bottom, 0px));
  179. background: var(--surface);
  180. border-top: 1px solid var(--edge);
  181. }
  182. .m-primary {
  183. width: 100%;
  184. min-height: 48px;
  185. border-radius: var(--radius-m);
  186. border: 1px solid var(--primary);
  187. background: var(--primary);
  188. color: var(--inverse);
  189. font-size: var(--fs-16);
  190. }
  191. .m-primary:disabled {
  192. opacity: 0.6;
  193. }
  194. `,
  195. ],
  196. })
  197. export class MbPendingPage implements OnInit, OnDestroy {
  198. private readonly session = inject(SessionService);
  199. private readonly api = inject(FmodeApi);
  200. private readonly router = inject(Router);
  201. private readonly device = inject(DeviceService);
  202. private readonly _checking = signal(false);
  203. private readonly _copyTone = signal<'none' | 'ok' | 'warn'>('none');
  204. private readonly _status = signal('正在等待管理员邀请…');
  205. readonly me = this.session.me;
  206. protected readonly checking = this._checking.asReadonly();
  207. protected readonly copyTone = this._copyTone.asReadonly();
  208. protected readonly statusText = computed(() => this._status());
  209. private timer: ReturnType<typeof setInterval> | null = null;
  210. ngOnInit(): void {
  211. this.timer = setInterval(() => void this.poll(), 15_000);
  212. }
  213. protected maskedMobile(): string {
  214. const m = this.me()?.mobile ?? '';
  215. if (!m) return '—';
  216. return m.length >= 7 ? `${m.slice(0, 3)}****${m.slice(-4)}` : m;
  217. }
  218. protected async copyId(): Promise<void> {
  219. const id = this.me()?.id;
  220. if (!id) return;
  221. try {
  222. await navigator.clipboard.writeText(id);
  223. this._copyTone.set('ok');
  224. this._status.set('用户 ID 已复制,发给管理员即可');
  225. } catch {
  226. this._copyTone.set('warn');
  227. this._status.set('复制失败,请长按上面的 ID 手动复制');
  228. }
  229. }
  230. protected async checkNow(): Promise<void> {
  231. if (this._checking()) return;
  232. this._checking.set(true);
  233. this._status.set('正在检查…');
  234. try {
  235. await this.poll();
  236. } finally {
  237. this._checking.set(false);
  238. }
  239. }
  240. protected logout(): void {
  241. this.session.logout();
  242. void this.router.navigateByUrl('/login', { replaceUrl: true });
  243. }
  244. private async poll(): Promise<void> {
  245. const token = this.session.token();
  246. if (!token) return;
  247. try {
  248. /* 用服务端判定(个人试用组织也算无组织),细节见 org.guard.ts 注释 */
  249. const m = await this.api.fetchOrgMembership();
  250. if (m.hasOrg) {
  251. this._copyTone.set('ok');
  252. this._status.set('已加入组织,正在进入…');
  253. this.session.setMe(await this.api.fetchMe(token));
  254. void this.router.navigateByUrl(this.device.entryPath(), { replaceUrl: true });
  255. return;
  256. }
  257. this._status.set('仍在等待,管理员邀请后会自动进入');
  258. } catch {
  259. this._status.set('检查失败,请稍后再试');
  260. }
  261. }
  262. ngOnDestroy(): void {
  263. if (this.timer) {
  264. clearInterval(this.timer);
  265. this.timer = null;
  266. }
  267. }
  268. }