| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
- import { Router } from '@angular/router';
- import { FmodeApi } from '../../../core/api/fmode-api';
- import { DeviceService } from '../../../core/device.service';
- import { SessionService } from '../../../core/session.service';
- import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
- /**
- * 「等待组织邀请」页(移动端,2026-11-10)
- *
- * ## 与桌面版的差别(不是简单缩小)
- *
- * | | 桌面 | 移动 |
- * |---|---|---|
- * | 骨架 | 居中浮卡 | 顶栏固定 + 内容纵向滚动 + 底部主操作**吸底** |
- * | 信息 | 两列栅格 | 单列纵向,标签在上、值在下 |
- * | 操作 | 并排两个按钮 | 主按钮吸底(拇指区),退出登录弱化为文字 |
- * | 复制 | 小图标 | 整行可点(触控区 ≥44px) |
- *
- * 移动端用户通常是单手操作:最重要的「立即检查」必须落在拇指够得到的位置。
- */
- @Component({
- selector: 'app-m-pending',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [MAppBarComponent],
- template: `
- <app-m-app-bar title="等待加入组织" subtitle="需要管理员邀请" />
- <main class="m-body">
- <section class="m-hero">
- <span class="m-mark" aria-hidden="true"><i class="fa-solid fa-hourglass-half"></i></span>
- <h1>还没加入组织</h1>
- <p class="m-lead">
- 把下面的用户 ID 发给管理员,由管理员把你拉进组织后即可使用。
- </p>
- </section>
- <section class="m-card">
- <button type="button" class="m-row" (click)="copyId()" aria-label="复制用户 ID">
- <span class="m-row-body">
- <span class="m-row-label">用户 ID</span>
- <span class="m-row-value mono">{{ me()?.id || '—' }}</span>
- </span>
- <i class="fa-solid fa-copy" aria-hidden="true"></i>
- </button>
- <div class="m-row static">
- <span class="m-row-body">
- <span class="m-row-label">昵称</span>
- <span class="m-row-value">{{ me()?.name || '—' }}</span>
- </span>
- </div>
- <div class="m-row static">
- <span class="m-row-body">
- <span class="m-row-label">手机号</span>
- <span class="m-row-value">{{ maskedMobile() }}</span>
- </span>
- </div>
- </section>
- <p class="m-status" [attr.data-tone]="copyTone()" role="status" aria-live="polite">
- {{ statusText() }}
- </p>
- <button type="button" class="m-text-btn" (click)="logout()">退出登录</button>
- </main>
- <footer class="m-foot">
- <button type="button" class="m-primary" [disabled]="checking()" (click)="checkNow()">
- {{ checking() ? '正在检查…' : '我已被邀请,立即检查' }}
- </button>
- </footer>
- `,
- styles: [
- `
- :host {
- display: flex;
- flex-direction: column;
- height: 100dvh;
- background: var(--canvas);
- }
- .m-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: var(--sp-4) var(--sp-4) var(--sp-6);
- }
- .m-hero {
- text-align: center;
- padding: var(--sp-6) 0 var(--sp-5);
- }
- .m-mark {
- display: inline-grid;
- place-items: center;
- width: 64px;
- height: 64px;
- border-radius: 50%;
- background: var(--surface-2);
- color: var(--primary);
- font-size: var(--fs-24);
- margin-bottom: var(--sp-3);
- }
- h1 {
- margin: 0 0 var(--sp-2);
- font-size: var(--fs-20);
- color: var(--ink);
- }
- .m-lead {
- margin: 0;
- color: var(--muted);
- font-size: var(--fs-14);
- line-height: 1.6;
- }
- .m-card {
- background: var(--surface);
- border-radius: var(--radius-m);
- border: 1px solid var(--edge);
- overflow: hidden;
- }
- .m-row {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: 64px;
- padding: var(--sp-3) var(--sp-4);
- border: 0;
- border-bottom: 1px solid var(--edge);
- background: var(--surface);
- text-align: left;
- color: var(--ink-2);
- cursor: pointer;
- }
- .m-row:last-child {
- border-bottom: 0;
- }
- .m-row.static {
- cursor: default;
- }
- .m-row-body {
- flex: 1;
- min-width: 0;
- display: grid;
- gap: 2px;
- }
- .m-row-label {
- color: var(--muted);
- font-size: var(--fs-12);
- }
- .m-row-value {
- color: var(--ink);
- font-size: var(--fs-16);
- word-break: break-all;
- }
- .mono {
- font-family: var(--font-mono, ui-monospace, monospace);
- }
- .m-status {
- margin: var(--sp-4) 0 0;
- font-size: var(--fs-13);
- color: var(--muted);
- text-align: center;
- line-height: 1.6;
- }
- .m-status[data-tone='ok'] {
- color: var(--success);
- }
- .m-status[data-tone='warn'] {
- color: var(--warning);
- }
- .m-text-btn {
- display: block;
- margin: var(--sp-5) auto 0;
- min-height: 44px;
- padding: 0 var(--sp-4);
- border: 0;
- background: transparent;
- color: var(--muted);
- font-size: var(--fs-14);
- }
- /* 主操作吸底:拇指区,不随内容滚走 */
- .m-foot {
- flex: none;
- padding: var(--sp-3) var(--sp-4);
- padding-bottom: calc(var(--sp-3) + env(safe-area-inset-bottom, 0px));
- background: var(--surface);
- border-top: 1px solid var(--edge);
- }
- .m-primary {
- width: 100%;
- min-height: 48px;
- border-radius: var(--radius-m);
- border: 1px solid var(--primary);
- background: var(--primary);
- color: var(--inverse);
- font-size: var(--fs-16);
- }
- .m-primary:disabled {
- opacity: 0.6;
- }
- `,
- ],
- })
- export class MbPendingPage implements OnInit, OnDestroy {
- private readonly session = inject(SessionService);
- private readonly api = inject(FmodeApi);
- private readonly router = inject(Router);
- private readonly device = inject(DeviceService);
- private readonly _checking = signal(false);
- private readonly _copyTone = signal<'none' | 'ok' | 'warn'>('none');
- private readonly _status = signal('正在等待管理员邀请…');
- readonly me = this.session.me;
- protected readonly checking = this._checking.asReadonly();
- protected readonly copyTone = this._copyTone.asReadonly();
- protected readonly statusText = computed(() => this._status());
- private timer: ReturnType<typeof setInterval> | null = null;
- ngOnInit(): void {
- this.timer = setInterval(() => void this.poll(), 15_000);
- }
- protected maskedMobile(): string {
- const m = this.me()?.mobile ?? '';
- if (!m) return '—';
- return m.length >= 7 ? `${m.slice(0, 3)}****${m.slice(-4)}` : m;
- }
- protected async copyId(): Promise<void> {
- const id = this.me()?.id;
- if (!id) return;
- try {
- await navigator.clipboard.writeText(id);
- this._copyTone.set('ok');
- this._status.set('用户 ID 已复制,发给管理员即可');
- } catch {
- this._copyTone.set('warn');
- this._status.set('复制失败,请长按上面的 ID 手动复制');
- }
- }
- protected async checkNow(): Promise<void> {
- if (this._checking()) return;
- this._checking.set(true);
- this._status.set('正在检查…');
- try {
- await this.poll();
- } finally {
- this._checking.set(false);
- }
- }
- protected logout(): void {
- this.session.logout();
- void this.router.navigateByUrl('/login', { replaceUrl: true });
- }
- private async poll(): Promise<void> {
- const token = this.session.token();
- if (!token) return;
- try {
- /* 用服务端判定(个人试用组织也算无组织),细节见 org.guard.ts 注释 */
- const m = await this.api.fetchOrgMembership();
- if (m.hasOrg) {
- this._copyTone.set('ok');
- this._status.set('已加入组织,正在进入…');
- this.session.setMe(await this.api.fetchMe(token));
- void this.router.navigateByUrl(this.device.entryPath(), { replaceUrl: true });
- return;
- }
- this._status.set('仍在等待,管理员邀请后会自动进入');
- } catch {
- this._status.set('检查失败,请稍后再试');
- }
- }
- ngOnDestroy(): void {
- if (this.timer) {
- clearInterval(this.timer);
- this.timer = null;
- }
- }
- }
|