| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 |
- import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
- import { UiMButton, UiMSegmented } from '../../../ui';
- import { Router } from '@angular/router';
- import { PaletteService, PALETTES } from '../../../core/palette.service';
- import { SessionService } from '../../../core/session.service';
- import { DeviceService } from '../../../core/device.service';
- /**
- * 移动·我的(SPEC FR-10 移动形态)
- * 单列账号与分组设置,大触达区;退出二次确认用居中弹窗(非原生 confirm)。
- */
- @Component({
- selector: 'app-mb-mine-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [UiMButton, UiMSegmented],
- template: `
- <div class="m-page">
- <header class="m-head">
- <h1>我的</h1>
- <p>管理账号、界面外观与使用偏好。</p>
- </header>
- <!-- 账号卡 -->
- <section class="card identity-card">
- <span class="avatar-lg" aria-hidden="true">{{ initial() }}</span>
- <div class="identity-body">
- <h2>{{ me()?.name || '未登录' }}</h2>
- <p>{{ me()?.mobile || '—' }}</p>
- </div>
- </section>
- <dl class="facts">
- <div><dt>所属组织</dt><dd>{{ orgName() }}</dd></div>
- <!-- 「工作空间」原为编造项(无数据源),已移除(黄金规则 1:不编造) -->
- <div><dt>当前端</dt><dd>{{ mode() === 'mobile' ? '手机端' : '桌面端' }}</dd></div>
- </dl>
- <!-- 界面外观 -->
- <section class="card">
- <h3 class="section-heading"><i class="fa-solid fa-palette" aria-hidden="true"></i>界面外观</h3>
- <div class="setting-row">
- <div class="copy">
- <h4>显示模式</h4>
- <p>浅色或深色。</p>
- </div>
- <ui-m-segmented [items]="themeItems" [active]="themeMode() === 'light' ? 0 : 1" (activeChange)="onThemeIndex($event)" />
- </div>
- <div class="palette-block">
- <div class="copy">
- <h4>整体配色</h4>
- <p>导航、内容与控件统一切换。</p>
- </div>
- <div class="palette-options" role="group" aria-label="整体配色方案">
- @for (p of palettes; track p.id) {
- <button
- type="button"
- class="palette-choice"
- [class.selected]="palette.palette() === p.id"
- [attr.aria-pressed]="palette.palette() === p.id"
- (click)="palette.set(p.id)"
- >
- <span
- class="palette-thumb"
- [attr.data-preview-palette]="p.id"
- [attr.data-preview-theme]="themeMode()"
- aria-hidden="true"
- >
- <span class="thumb-nav"><i></i><i></i></span>
- <span class="thumb-content"><i></i><i></i></span>
- </span>
- <span class="palette-label">
- {{ p.name }}
- @if (palette.palette() === p.id) {
- <i class="fa-solid fa-circle-check" aria-hidden="true"></i>
- }
- </span>
- </button>
- }
- </div>
- </div>
- </section>
- <!-- 凭证(2026-11-01:可查看登录 Token;重新登录改为真正清态重登) -->
- <section class="card">
- <h3 class="section-heading"><i class="fa-solid fa-lock" aria-hidden="true"></i>登录凭证</h3>
- <div class="token-block">
- <p class="token-hint">可用于其他设备直接登录,等同于账号密码,请勿分享。</p>
- <code class="token-value" [class.masked]="!tokenShown()">{{ tokenText() }}</code>
- <div class="token-btns">
- <ui-m-button variant="secondary" (click)="tokenShown.set(!tokenShown())">
- {{ tokenShown() ? '隐藏' : '显示' }}
- </ui-m-button>
- <ui-m-button variant="secondary" [disabled]="!tokenShown()" (click)="copyToken()">
- {{ copied() ? '已复制' : '复制' }}
- </ui-m-button>
- </div>
- </div>
- </section>
- <button type="button" class="logout-action" (click)="confirming.set(true)">
- <i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>
- <span>退出登录</span>
- </button>
- <footer class="about">
- <span>Fmode</span>
- </footer>
- </div>
- <!-- 退出二次确认 -->
- @if (confirming()) {
- <div class="logout-backdrop" role="presentation" (click)="confirming.set(false)">
- <section
- class="logout-dialog"
- role="alertdialog"
- aria-modal="true"
- aria-labelledby="logout-title"
- (click)="$event.stopPropagation()"
- >
- <span class="logout-dialog-icon" aria-hidden="true"><i class="fa-solid fa-arrow-right-from-bracket"></i></span>
- <h2 id="logout-title">确认退出当前账号?</h2>
- <p>退出后,本设备上的会话数据会被清除。重新进入工作台需要再次登录。</p>
- <div class="dialog-actions">
- <ui-m-button variant="secondary" [stretch]="true" (click)="confirming.set(false)">暂不退出</ui-m-button>
- <button type="button" class="dialog-danger" (click)="doLogout()">
- <i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>退出登录
- </button>
- </div>
- </section>
- </div>
- }
- `,
- styles: [
- `
- .m-page {
- padding: var(--sp-5) var(--mob-gutter) calc(var(--sp-8) + var(--mob-safe-bottom));
- }
- .m-head {
- margin-bottom: var(--sp-4);
- }
- .m-head h1 {
- margin: 0;
- font-size: var(--fs-26);
- color: var(--ink);
- }
- .m-head p {
- margin: var(--sp-1) 0 0;
- font-size: var(--fs-13);
- color: var(--muted);
- }
- .card {
- padding: var(--sp-4);
- margin-bottom: var(--sp-4);
- border: 1px solid var(--edge);
- border-radius: var(--r-lg);
- box-shadow: var(--shadow-soft);
- background: var(--surface);
- }
- .identity-card {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- }
- .avatar-lg {
- display: grid;
- place-items: center;
- width: 64px;
- height: 64px;
- border-radius: var(--r-tag);
- background: var(--primary-subtle);
- color: var(--primary);
- font-size: var(--fs-26);
- font-weight: 600;
- flex-shrink: 0;
- }
- .identity-body {
- flex: 1;
- min-width: 0;
- }
- .identity-body h2 {
- margin: 0;
- font-size: var(--fs-20);
- color: var(--ink);
- }
- .identity-body p {
- margin: 0;
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .badge {
- padding: 0 var(--sp-2);
- border-radius: var(--r-sm);
- background: var(--surface-hover);
- color: var(--ink-2);
- font-size: var(--fs-11);
- line-height: 22px;
- flex-shrink: 0;
- }
- .facts {
- display: grid;
- gap: var(--sp-2);
- margin: 0 0 var(--sp-4);
- border: 1px solid var(--edge);
- padding: var(--sp-4);
- border-radius: var(--r-md);
- background: var(--surface-2);
- }
- .facts > div {
- display: flex;
- justify-content: space-between;
- gap: var(--sp-3);
- font-size: var(--fs-13);
- }
- .facts dt {
- color: var(--muted);
- }
- .facts dd {
- margin: 0;
- color: var(--ink);
- }
- .section-heading {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin: 0 0 var(--sp-3);
- font-size: var(--fs-15);
- color: var(--ink);
- }
- .setting-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--sp-3);
- padding: var(--sp-3) 0;
- border-bottom: 1px solid var(--edge);
- flex-wrap: wrap;
- }
- .setting-row:last-of-type {
- border-bottom: 0;
- padding-bottom: 0;
- }
- .copy h4 {
- margin: 0;
- font-size: var(--fs-13);
- color: var(--ink);
- }
- .copy p {
- margin: 2px 0 0;
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .segmented {
- display: flex;
- gap: var(--sp-1);
- padding: var(--sp-1);
- border-radius: var(--r-md);
- background: var(--surface-2);
- }
- .seg-btn {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-1);
- min-height: var(--touch-min);
- padding: 0 var(--sp-3);
- border: 0;
- border-radius: var(--r-sm);
- background: transparent;
- color: var(--ink-2);
- font-size: var(--fs-13);
- cursor: pointer;
- }
- .seg-btn.active {
- background: var(--surface);
- color: var(--ink);
- font-weight: 600;
- box-shadow: var(--shadow-soft);
- }
- .btn-outline,
- .btn-danger {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: var(--touch-min);
- padding: 0 var(--sp-4);
- border-radius: var(--r-sm);
- font-size: var(--fs-13);
- cursor: pointer;
- }
- .btn-outline {
- border: 1px solid var(--edge-strong);
- background: var(--surface);
- color: var(--ink);
- }
- .btn-danger {
- border: 0;
- background: var(--danger);
- color: var(--on-danger);
- }
- .logout-action {
- width: 100%;
- margin-bottom: var(--sp-4);
- min-height: 48px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: var(--sp-2);
- border: 1px solid color-mix(in srgb, var(--danger) 52%, var(--edge));
- border-radius: var(--r-md);
- background: var(--danger-subtle);
- color: var(--danger);
- font-size: var(--fs-13);
- font-weight: 600;
- cursor: pointer;
- transition: background-color var(--motion-fast) var(--ease-standard), transform var(--motion-fast) var(--ease-standard);
- }
- .logout-action:active, .dialog-danger:active { transform: scale(.98); }
- .logout-action:focus-visible, .dialog-danger:focus-visible {
- outline: 2px solid var(--primary-ring);
- outline-offset: 2px;
- }
- .logout-action:hover { background: var(--danger-subtle); }
- .dialog-danger {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: var(--sp-2);
- flex: 1;
- min-height: var(--touch-min);
- border: 1px solid color-mix(in srgb, var(--danger) 52%, var(--edge));
- border-radius: var(--r-sm);
- background: var(--danger);
- color: var(--on-danger);
- font: inherit;
- font-size: var(--fs-13);
- font-weight: 600;
- cursor: pointer;
- }
- .palette-block {
- margin-top: var(--sp-3);
- }
- .palette-options {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: var(--sp-2);
- margin-top: var(--sp-3);
- }
- .palette-choice {
- display: grid;
- gap: var(--sp-1);
- padding: var(--sp-2);
- border: 1px solid var(--edge-strong);
- border-radius: var(--r-sm);
- background: var(--surface);
- text-align: left;
- cursor: pointer;
- min-width: 0;
- min-height: 74px;
- }
- .palette-choice.selected {
- border-color: var(--primary);
- box-shadow: 0 0 0 1px var(--primary);
- }
- .palette-thumb {
- display: grid;
- grid-template-columns: 30% 1fr;
- height: 36px;
- border: 1px solid var(--edge-strong);
- border-radius: var(--r-sm);
- overflow: hidden;
- }
- .thumb-nav {
- display: grid;
- align-content: start;
- gap: 3px;
- padding: 6px 4px;
- background: var(--nav-surface);
- }
- .thumb-nav i {
- display: block;
- height: 2px;
- border-radius: var(--r-tag);
- background: var(--nav-text);
- opacity: 0.6;
- }
- .thumb-content {
- display: grid;
- align-content: center;
- gap: 4px;
- padding: 6px;
- background: var(--surface);
- }
- .thumb-content i {
- display: block;
- height: 2px;
- border-radius: var(--r-tag);
- background: var(--edge-strong);
- }
- .thumb-content i:last-child {
- width: 45%;
- height: 5px;
- background: var(--primary);
- }
- .palette-label {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- font-size: var(--fs-12);
- font-weight: 500;
- color: var(--ink);
- }
- .about {
- display: flex;
- align-items: center;
- justify-content: space-between;
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .logout-backdrop {
- position: fixed;
- inset: 0;
- z-index: var(--z-modal);
- background: var(--overlay);
- display: flex;
- align-items: center;
- justify-content: center;
- padding: var(--sp-6);
- }
- .logout-dialog {
- width: min(340px, 100%);
- display: grid;
- justify-items: center;
- gap: var(--sp-3);
- padding: var(--sp-5);
- border-radius: var(--r-lg);
- background: var(--surface);
- box-shadow: var(--shadow-overlay);
- text-align: center;
- animation: logout-dialog-in var(--motion-normal) var(--ease-standard) both;
- }
- @media (prefers-reduced-motion: reduce) {
- .logout-dialog {
- animation: none;
- }
- }
- .logout-dialog h2 {
- margin: 0;
- font-size: var(--fs-17);
- color: var(--ink);
- }
- .logout-dialog p {
- margin: 0;
- max-width: 28ch;
- font-size: var(--fs-13);
- color: var(--muted);
- line-height: 1.65;
- }
- .dialog-actions {
- display: flex;
- gap: var(--sp-2);
- width: 100%;
- }
- .logout-dialog-icon {
- display: grid;
- place-items: center;
- width: 48px;
- height: 48px;
- border-radius: var(--r-tag);
- background: var(--danger-subtle);
- color: var(--danger);
- font-size: var(--fs-17);
- }
- @keyframes logout-dialog-in {
- from { opacity: 0; transform: translateY(var(--sp-2)) scale(0.98); }
- to { opacity: 1; transform: translateY(0) scale(1); }
- }
- `,
- ],
- })
- export class MbMinePage {
- readonly palette = inject(PaletteService);
- private readonly session = inject(SessionService);
- private readonly device = inject(DeviceService);
- private readonly router = inject(Router);
- readonly palettes = PALETTES;
- readonly themeItems = ['浅色', '深色'];
- onThemeIndex(i: number): void {
- this.setTheme(i === 0 ? 'light' : 'dark');
- }
- readonly confirming = signal(false);
- readonly me = computed(() => this.session.me());
- readonly mode = computed(() => 'mobile' as const);
- readonly initial = computed(() => (this.me()?.name ?? '?').slice(0, 1));
- /** 组织名:真实实现由服务端 Company 返回;未就绪时显示中性占位 */
- /**
- * 所属组织名 —— 取真实的 `Me.companyName`(实测 Company 表可拿到,如「飞码AI」)。
- * 🔴 此前是硬编码「企业空间」(假文案,用户实测反馈「没加载正确信息」)。
- */
- readonly orgName = computed(() => {
- const me = this.me();
- return me?.companyName ?? (me?.companyId ? '(名称未取到)' : '未绑定组织');
- });
- readonly themeMode = signal<'light' | 'dark'>(
- typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
- ? 'dark'
- : 'light',
- );
- setTheme(mode: 'light' | 'dark'): void {
- this.themeMode.set(mode);
- if (typeof document === 'undefined') return;
- document.documentElement.classList.toggle('dark', mode === 'dark');
- try {
- localStorage.setItem('ui-mode', mode);
- } catch {
- /* 忽略 */
- }
- const meta = document.querySelector('meta[name="theme-color"]');
- const canvas = getComputedStyle(document.documentElement).getPropertyValue('--canvas').trim();
- if (meta && canvas) meta.setAttribute('content', canvas);
- }
- switchMode(): void {
- const { path } = this.device.switchMode();
- void this.router.navigateByUrl(path, { replaceUrl: true });
- }
- /** 是否已点「显示」(默认打码,避免旁人一眼看到) */
- readonly tokenShown = signal(false);
- /** 复制后的短暂反馈 */
- readonly copied = signal(false);
- private readonly rawToken = computed(() => this.session.token() ?? '');
- /** 展示用 Token(默认打码:保留前 4 + 后 4) */
- readonly tokenText = computed(() => {
- const t = this.rawToken();
- if (!t) return '未登录';
- if (!this.tokenShown()) return `${t.slice(0, 4)}${'•'.repeat(12)}${t.slice(-4)}`;
- return t;
- });
- copyToken(): void {
- const t = this.rawToken();
- if (!t) return;
- void navigator.clipboard
- ?.writeText(t)
- .then(() => {
- this.copied.set(true);
- setTimeout(() => this.copied.set(false), 1600);
- })
- .catch(() => {
- /* 剪贴板不可用:静默失败,可手动选中复制 */
- });
- }
- /** 「重新登录」已移除(2026-11-01):与「退出登录」功能重叠,留一个入口即可 */
- async doLogout(): Promise<void> {
- this.session.logout();
- this.confirming.set(false);
- const navigated = await this.router.navigateByUrl('/login', { replaceUrl: true });
- if (!navigated && typeof location !== 'undefined') location.replace('/login');
- }
- }
|