mine.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
  2. import { UiMButton, UiMSegmented } from '../../../ui';
  3. import { Router } from '@angular/router';
  4. import { PaletteService, PALETTES } from '../../../core/palette.service';
  5. import { SessionService } from '../../../core/session.service';
  6. import { DeviceService } from '../../../core/device.service';
  7. /**
  8. * 移动·我的(SPEC FR-10 移动形态)
  9. * 单列账号与分组设置,大触达区;退出二次确认用居中弹窗(非原生 confirm)。
  10. */
  11. @Component({
  12. selector: 'app-mb-mine-page',
  13. changeDetection: ChangeDetectionStrategy.OnPush,
  14. imports: [UiMButton, UiMSegmented],
  15. template: `
  16. <div class="m-page">
  17. <header class="m-head">
  18. <h1>我的</h1>
  19. <p>管理账号、界面外观与使用偏好。</p>
  20. </header>
  21. <!-- 账号卡 -->
  22. <section class="card identity-card">
  23. <span class="avatar-lg" aria-hidden="true">{{ initial() }}</span>
  24. <div class="identity-body">
  25. <h2>{{ me()?.name || '未登录' }}</h2>
  26. <p>{{ me()?.mobile || '—' }}</p>
  27. </div>
  28. </section>
  29. <dl class="facts">
  30. <div><dt>所属组织</dt><dd>{{ orgName() }}</dd></div>
  31. <!-- 「工作空间」原为编造项(无数据源),已移除(黄金规则 1:不编造) -->
  32. <div><dt>当前端</dt><dd>{{ mode() === 'mobile' ? '手机端' : '桌面端' }}</dd></div>
  33. </dl>
  34. <!-- 界面外观 -->
  35. <section class="card">
  36. <h3 class="section-heading"><i class="fa-solid fa-palette" aria-hidden="true"></i>界面外观</h3>
  37. <div class="setting-row">
  38. <div class="copy">
  39. <h4>显示模式</h4>
  40. <p>浅色或深色。</p>
  41. </div>
  42. <ui-m-segmented [items]="themeItems" [active]="themeMode() === 'light' ? 0 : 1" (activeChange)="onThemeIndex($event)" />
  43. </div>
  44. <div class="palette-block">
  45. <div class="copy">
  46. <h4>整体配色</h4>
  47. <p>导航、内容与控件统一切换。</p>
  48. </div>
  49. <div class="palette-options" role="group" aria-label="整体配色方案">
  50. @for (p of palettes; track p.id) {
  51. <button
  52. type="button"
  53. class="palette-choice"
  54. [class.selected]="palette.palette() === p.id"
  55. [attr.aria-pressed]="palette.palette() === p.id"
  56. (click)="palette.set(p.id)"
  57. >
  58. <span
  59. class="palette-thumb"
  60. [attr.data-preview-palette]="p.id"
  61. [attr.data-preview-theme]="themeMode()"
  62. aria-hidden="true"
  63. >
  64. <span class="thumb-nav"><i></i><i></i></span>
  65. <span class="thumb-content"><i></i><i></i></span>
  66. </span>
  67. <span class="palette-label">
  68. {{ p.name }}
  69. @if (palette.palette() === p.id) {
  70. <i class="fa-solid fa-circle-check" aria-hidden="true"></i>
  71. }
  72. </span>
  73. </button>
  74. }
  75. </div>
  76. </div>
  77. </section>
  78. <!-- 凭证(2026-11-01:可查看登录 Token;重新登录改为真正清态重登) -->
  79. <section class="card">
  80. <h3 class="section-heading"><i class="fa-solid fa-lock" aria-hidden="true"></i>登录凭证</h3>
  81. <div class="token-block">
  82. <p class="token-hint">可用于其他设备直接登录,等同于账号密码,请勿分享。</p>
  83. <code class="token-value" [class.masked]="!tokenShown()">{{ tokenText() }}</code>
  84. <div class="token-btns">
  85. <ui-m-button variant="secondary" (click)="tokenShown.set(!tokenShown())">
  86. {{ tokenShown() ? '隐藏' : '显示' }}
  87. </ui-m-button>
  88. <ui-m-button variant="secondary" [disabled]="!tokenShown()" (click)="copyToken()">
  89. {{ copied() ? '已复制' : '复制' }}
  90. </ui-m-button>
  91. </div>
  92. </div>
  93. </section>
  94. <button type="button" class="logout-action" (click)="confirming.set(true)">
  95. <i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>
  96. <span>退出登录</span>
  97. </button>
  98. <footer class="about">
  99. <span>Fmode</span>
  100. </footer>
  101. </div>
  102. <!-- 退出二次确认 -->
  103. @if (confirming()) {
  104. <div class="logout-backdrop" role="presentation" (click)="confirming.set(false)">
  105. <section
  106. class="logout-dialog"
  107. role="alertdialog"
  108. aria-modal="true"
  109. aria-labelledby="logout-title"
  110. (click)="$event.stopPropagation()"
  111. >
  112. <span class="logout-dialog-icon" aria-hidden="true"><i class="fa-solid fa-arrow-right-from-bracket"></i></span>
  113. <h2 id="logout-title">确认退出当前账号?</h2>
  114. <p>退出后,本设备上的会话数据会被清除。重新进入工作台需要再次登录。</p>
  115. <div class="dialog-actions">
  116. <ui-m-button variant="secondary" [stretch]="true" (click)="confirming.set(false)">暂不退出</ui-m-button>
  117. <button type="button" class="dialog-danger" (click)="doLogout()">
  118. <i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>退出登录
  119. </button>
  120. </div>
  121. </section>
  122. </div>
  123. }
  124. `,
  125. styles: [
  126. `
  127. .m-page {
  128. padding: var(--sp-5) var(--mob-gutter) calc(var(--sp-8) + var(--mob-safe-bottom));
  129. }
  130. .m-head {
  131. margin-bottom: var(--sp-4);
  132. }
  133. .m-head h1 {
  134. margin: 0;
  135. font-size: var(--fs-26);
  136. color: var(--ink);
  137. }
  138. .m-head p {
  139. margin: var(--sp-1) 0 0;
  140. font-size: var(--fs-13);
  141. color: var(--muted);
  142. }
  143. .card {
  144. padding: var(--sp-4);
  145. margin-bottom: var(--sp-4);
  146. border: 1px solid var(--edge);
  147. border-radius: var(--r-lg);
  148. box-shadow: var(--shadow-soft);
  149. background: var(--surface);
  150. }
  151. .identity-card {
  152. display: flex;
  153. align-items: center;
  154. gap: var(--sp-3);
  155. }
  156. .avatar-lg {
  157. display: grid;
  158. place-items: center;
  159. width: 64px;
  160. height: 64px;
  161. border-radius: var(--r-tag);
  162. background: var(--primary-subtle);
  163. color: var(--primary);
  164. font-size: var(--fs-26);
  165. font-weight: 600;
  166. flex-shrink: 0;
  167. }
  168. .identity-body {
  169. flex: 1;
  170. min-width: 0;
  171. }
  172. .identity-body h2 {
  173. margin: 0;
  174. font-size: var(--fs-20);
  175. color: var(--ink);
  176. }
  177. .identity-body p {
  178. margin: 0;
  179. font-size: var(--fs-12);
  180. color: var(--muted);
  181. }
  182. .badge {
  183. padding: 0 var(--sp-2);
  184. border-radius: var(--r-sm);
  185. background: var(--surface-hover);
  186. color: var(--ink-2);
  187. font-size: var(--fs-11);
  188. line-height: 22px;
  189. flex-shrink: 0;
  190. }
  191. .facts {
  192. display: grid;
  193. gap: var(--sp-2);
  194. margin: 0 0 var(--sp-4);
  195. border: 1px solid var(--edge);
  196. padding: var(--sp-4);
  197. border-radius: var(--r-md);
  198. background: var(--surface-2);
  199. }
  200. .facts > div {
  201. display: flex;
  202. justify-content: space-between;
  203. gap: var(--sp-3);
  204. font-size: var(--fs-13);
  205. }
  206. .facts dt {
  207. color: var(--muted);
  208. }
  209. .facts dd {
  210. margin: 0;
  211. color: var(--ink);
  212. }
  213. .section-heading {
  214. display: flex;
  215. align-items: center;
  216. gap: var(--sp-2);
  217. margin: 0 0 var(--sp-3);
  218. font-size: var(--fs-15);
  219. color: var(--ink);
  220. }
  221. .setting-row {
  222. display: flex;
  223. align-items: center;
  224. justify-content: space-between;
  225. gap: var(--sp-3);
  226. padding: var(--sp-3) 0;
  227. border-bottom: 1px solid var(--edge);
  228. flex-wrap: wrap;
  229. }
  230. .setting-row:last-of-type {
  231. border-bottom: 0;
  232. padding-bottom: 0;
  233. }
  234. .copy h4 {
  235. margin: 0;
  236. font-size: var(--fs-13);
  237. color: var(--ink);
  238. }
  239. .copy p {
  240. margin: 2px 0 0;
  241. font-size: var(--fs-11);
  242. color: var(--muted);
  243. }
  244. .segmented {
  245. display: flex;
  246. gap: var(--sp-1);
  247. padding: var(--sp-1);
  248. border-radius: var(--r-md);
  249. background: var(--surface-2);
  250. }
  251. .seg-btn {
  252. display: inline-flex;
  253. align-items: center;
  254. gap: var(--sp-1);
  255. min-height: var(--touch-min);
  256. padding: 0 var(--sp-3);
  257. border: 0;
  258. border-radius: var(--r-sm);
  259. background: transparent;
  260. color: var(--ink-2);
  261. font-size: var(--fs-13);
  262. cursor: pointer;
  263. }
  264. .seg-btn.active {
  265. background: var(--surface);
  266. color: var(--ink);
  267. font-weight: 600;
  268. box-shadow: var(--shadow-soft);
  269. }
  270. .btn-outline,
  271. .btn-danger {
  272. display: inline-flex;
  273. align-items: center;
  274. justify-content: center;
  275. min-height: var(--touch-min);
  276. padding: 0 var(--sp-4);
  277. border-radius: var(--r-sm);
  278. font-size: var(--fs-13);
  279. cursor: pointer;
  280. }
  281. .btn-outline {
  282. border: 1px solid var(--edge-strong);
  283. background: var(--surface);
  284. color: var(--ink);
  285. }
  286. .btn-danger {
  287. border: 0;
  288. background: var(--danger);
  289. color: var(--on-danger);
  290. }
  291. .logout-action {
  292. width: 100%;
  293. margin-bottom: var(--sp-4);
  294. min-height: 48px;
  295. display: inline-flex;
  296. align-items: center;
  297. justify-content: center;
  298. gap: var(--sp-2);
  299. border: 1px solid color-mix(in srgb, var(--danger) 52%, var(--edge));
  300. border-radius: var(--r-md);
  301. background: var(--danger-subtle);
  302. color: var(--danger);
  303. font-size: var(--fs-13);
  304. font-weight: 600;
  305. cursor: pointer;
  306. transition: background-color var(--motion-fast) var(--ease-standard), transform var(--motion-fast) var(--ease-standard);
  307. }
  308. .logout-action:active, .dialog-danger:active { transform: scale(.98); }
  309. .logout-action:focus-visible, .dialog-danger:focus-visible {
  310. outline: 2px solid var(--primary-ring);
  311. outline-offset: 2px;
  312. }
  313. .logout-action:hover { background: var(--danger-subtle); }
  314. .dialog-danger {
  315. display: inline-flex;
  316. align-items: center;
  317. justify-content: center;
  318. gap: var(--sp-2);
  319. flex: 1;
  320. min-height: var(--touch-min);
  321. border: 1px solid color-mix(in srgb, var(--danger) 52%, var(--edge));
  322. border-radius: var(--r-sm);
  323. background: var(--danger);
  324. color: var(--on-danger);
  325. font: inherit;
  326. font-size: var(--fs-13);
  327. font-weight: 600;
  328. cursor: pointer;
  329. }
  330. .palette-block {
  331. margin-top: var(--sp-3);
  332. }
  333. .palette-options {
  334. display: grid;
  335. grid-template-columns: repeat(2, minmax(0, 1fr));
  336. gap: var(--sp-2);
  337. margin-top: var(--sp-3);
  338. }
  339. .palette-choice {
  340. display: grid;
  341. gap: var(--sp-1);
  342. padding: var(--sp-2);
  343. border: 1px solid var(--edge-strong);
  344. border-radius: var(--r-sm);
  345. background: var(--surface);
  346. text-align: left;
  347. cursor: pointer;
  348. min-width: 0;
  349. min-height: 74px;
  350. }
  351. .palette-choice.selected {
  352. border-color: var(--primary);
  353. box-shadow: 0 0 0 1px var(--primary);
  354. }
  355. .palette-thumb {
  356. display: grid;
  357. grid-template-columns: 30% 1fr;
  358. height: 36px;
  359. border: 1px solid var(--edge-strong);
  360. border-radius: var(--r-sm);
  361. overflow: hidden;
  362. }
  363. .thumb-nav {
  364. display: grid;
  365. align-content: start;
  366. gap: 3px;
  367. padding: 6px 4px;
  368. background: var(--nav-surface);
  369. }
  370. .thumb-nav i {
  371. display: block;
  372. height: 2px;
  373. border-radius: var(--r-tag);
  374. background: var(--nav-text);
  375. opacity: 0.6;
  376. }
  377. .thumb-content {
  378. display: grid;
  379. align-content: center;
  380. gap: 4px;
  381. padding: 6px;
  382. background: var(--surface);
  383. }
  384. .thumb-content i {
  385. display: block;
  386. height: 2px;
  387. border-radius: var(--r-tag);
  388. background: var(--edge-strong);
  389. }
  390. .thumb-content i:last-child {
  391. width: 45%;
  392. height: 5px;
  393. background: var(--primary);
  394. }
  395. .palette-label {
  396. display: flex;
  397. align-items: center;
  398. gap: var(--sp-1);
  399. font-size: var(--fs-12);
  400. font-weight: 500;
  401. color: var(--ink);
  402. }
  403. .about {
  404. display: flex;
  405. align-items: center;
  406. justify-content: space-between;
  407. font-size: var(--fs-11);
  408. color: var(--muted);
  409. }
  410. .logout-backdrop {
  411. position: fixed;
  412. inset: 0;
  413. z-index: var(--z-modal);
  414. background: var(--overlay);
  415. display: flex;
  416. align-items: center;
  417. justify-content: center;
  418. padding: var(--sp-6);
  419. }
  420. .logout-dialog {
  421. width: min(340px, 100%);
  422. display: grid;
  423. justify-items: center;
  424. gap: var(--sp-3);
  425. padding: var(--sp-5);
  426. border-radius: var(--r-lg);
  427. background: var(--surface);
  428. box-shadow: var(--shadow-overlay);
  429. text-align: center;
  430. animation: logout-dialog-in var(--motion-normal) var(--ease-standard) both;
  431. }
  432. @media (prefers-reduced-motion: reduce) {
  433. .logout-dialog {
  434. animation: none;
  435. }
  436. }
  437. .logout-dialog h2 {
  438. margin: 0;
  439. font-size: var(--fs-17);
  440. color: var(--ink);
  441. }
  442. .logout-dialog p {
  443. margin: 0;
  444. max-width: 28ch;
  445. font-size: var(--fs-13);
  446. color: var(--muted);
  447. line-height: 1.65;
  448. }
  449. .dialog-actions {
  450. display: flex;
  451. gap: var(--sp-2);
  452. width: 100%;
  453. }
  454. .logout-dialog-icon {
  455. display: grid;
  456. place-items: center;
  457. width: 48px;
  458. height: 48px;
  459. border-radius: var(--r-tag);
  460. background: var(--danger-subtle);
  461. color: var(--danger);
  462. font-size: var(--fs-17);
  463. }
  464. @keyframes logout-dialog-in {
  465. from { opacity: 0; transform: translateY(var(--sp-2)) scale(0.98); }
  466. to { opacity: 1; transform: translateY(0) scale(1); }
  467. }
  468. `,
  469. ],
  470. })
  471. export class MbMinePage {
  472. readonly palette = inject(PaletteService);
  473. private readonly session = inject(SessionService);
  474. private readonly device = inject(DeviceService);
  475. private readonly router = inject(Router);
  476. readonly palettes = PALETTES;
  477. readonly themeItems = ['浅色', '深色'];
  478. onThemeIndex(i: number): void {
  479. this.setTheme(i === 0 ? 'light' : 'dark');
  480. }
  481. readonly confirming = signal(false);
  482. readonly me = computed(() => this.session.me());
  483. readonly mode = computed(() => 'mobile' as const);
  484. readonly initial = computed(() => (this.me()?.name ?? '?').slice(0, 1));
  485. /** 组织名:真实实现由服务端 Company 返回;未就绪时显示中性占位 */
  486. /**
  487. * 所属组织名 —— 取真实的 `Me.companyName`(实测 Company 表可拿到,如「飞码AI」)。
  488. * 🔴 此前是硬编码「企业空间」(假文案,用户实测反馈「没加载正确信息」)。
  489. */
  490. readonly orgName = computed(() => {
  491. const me = this.me();
  492. return me?.companyName ?? (me?.companyId ? '(名称未取到)' : '未绑定组织');
  493. });
  494. readonly themeMode = signal<'light' | 'dark'>(
  495. typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
  496. ? 'dark'
  497. : 'light',
  498. );
  499. setTheme(mode: 'light' | 'dark'): void {
  500. this.themeMode.set(mode);
  501. if (typeof document === 'undefined') return;
  502. document.documentElement.classList.toggle('dark', mode === 'dark');
  503. try {
  504. localStorage.setItem('ui-mode', mode);
  505. } catch {
  506. /* 忽略 */
  507. }
  508. const meta = document.querySelector('meta[name="theme-color"]');
  509. const canvas = getComputedStyle(document.documentElement).getPropertyValue('--canvas').trim();
  510. if (meta && canvas) meta.setAttribute('content', canvas);
  511. }
  512. switchMode(): void {
  513. const { path } = this.device.switchMode();
  514. void this.router.navigateByUrl(path, { replaceUrl: true });
  515. }
  516. /** 是否已点「显示」(默认打码,避免旁人一眼看到) */
  517. readonly tokenShown = signal(false);
  518. /** 复制后的短暂反馈 */
  519. readonly copied = signal(false);
  520. private readonly rawToken = computed(() => this.session.token() ?? '');
  521. /** 展示用 Token(默认打码:保留前 4 + 后 4) */
  522. readonly tokenText = computed(() => {
  523. const t = this.rawToken();
  524. if (!t) return '未登录';
  525. if (!this.tokenShown()) return `${t.slice(0, 4)}${'•'.repeat(12)}${t.slice(-4)}`;
  526. return t;
  527. });
  528. copyToken(): void {
  529. const t = this.rawToken();
  530. if (!t) return;
  531. void navigator.clipboard
  532. ?.writeText(t)
  533. .then(() => {
  534. this.copied.set(true);
  535. setTimeout(() => this.copied.set(false), 1600);
  536. })
  537. .catch(() => {
  538. /* 剪贴板不可用:静默失败,可手动选中复制 */
  539. });
  540. }
  541. /** 「重新登录」已移除(2026-11-01):与「退出登录」功能重叠,留一个入口即可 */
  542. async doLogout(): Promise<void> {
  543. this.session.logout();
  544. this.confirming.set(false);
  545. const navigated = await this.router.navigateByUrl('/login', { replaceUrl: true });
  546. if (!navigated && typeof location !== 'undefined') location.replace('/login');
  547. }
  548. }