users.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
  2. import { FormsModule } from '@angular/forms';
  3. import { Router } from '@angular/router';
  4. import { OrgUser } from '../../../../core/api/models';
  5. import { OrgUsersService } from '../../../../core/org-users.service';
  6. import { MAppBarComponent } from '../../../components/m-app-bar/m-app-bar';
  7. import { MEmptyComponent } from '../../../components/m-empty/m-empty';
  8. /**
  9. * 移动·组织成员管理(2026-11-10)
  10. *
  11. * ## 为什么不是把 PC 表格搬过来
  12. *
  13. * 手机上五列表格必然横向溢出、字小到不可读。移动端改为:
  14. * - **卡片行**:姓名 + 子信息纵向排布,整行可点
  15. * - **搜索吸顶**:滚动时仍能改关键词
  16. * - **操作走底部抽屉**:编辑/新增都是全屏 sheet,而不是居中小弹窗
  17. * - **分页改为「加载更多」**:手机上点页码很别扭,上一页/下一页语义也更顺
  18. *
  19. * 所有触控区 ≥44px,正文 ≥16px(移动端规范)。
  20. */
  21. @Component({
  22. selector: 'app-mb-admin-users-page',
  23. changeDetection: ChangeDetectionStrategy.OnPush,
  24. imports: [FormsModule, MAppBarComponent, MEmptyComponent],
  25. template: `
  26. <app-m-app-bar [title]="'成员管理'" [subtitle]="'共 ' + svc.total() + ' 人'" [back]="true" (backTap)="back()">
  27. <button barRight type="button" class="m-fab" aria-label="添加成员" (click)="openAdd()">
  28. <i class="fa-solid fa-user-plus" aria-hidden="true"></i>
  29. </button>
  30. </app-m-app-bar>
  31. <!-- 搜索吸顶 -->
  32. <div class="m-search">
  33. <input
  34. class="m-search-input"
  35. type="search"
  36. placeholder="搜索昵称 / 手机号 / 用户 ID"
  37. [(ngModel)]="keywordModel"
  38. (keydown.enter)="runSearch()"
  39. aria-label="搜索成员"
  40. />
  41. <button type="button" class="m-search-btn" (click)="runSearch()">搜索</button>
  42. </div>
  43. <main class="m-body">
  44. @switch (svc.state()) {
  45. @case ('loading') {
  46. <ul class="m-list">
  47. @for (i of [1, 2, 3, 4]; track i) {
  48. <li class="m-card m-skel" aria-hidden="true"></li>
  49. }
  50. </ul>
  51. }
  52. @case ('error') {
  53. <app-m-empty [title]="'加载失败'" [description]="svc.error() ?? ''" />
  54. <button type="button" class="m-retry" (click)="reload()">重新加载</button>
  55. }
  56. @case ('empty') {
  57. @if (svc.hasKeyword()) {
  58. <app-m-empty title="没有找到匹配的成员" description="换个关键词,或清除搜索条件。" />
  59. } @else {
  60. <app-m-empty title="组织里还没有成员" description="点右上角加号,输入用户 ID 把人拉进来。" />
  61. }
  62. }
  63. @default {
  64. <ul class="m-list">
  65. @for (u of svc.items(); track u.id) {
  66. <li class="m-card">
  67. <button type="button" class="m-row" (click)="openEdit(u)">
  68. <span class="m-avatar" aria-hidden="true">{{ initialOf(u) }}</span>
  69. <span class="m-main">
  70. <span class="m-name">{{ u.nickname || u.username || '未命名' }}</span>
  71. <span class="m-sub">{{ u.mobile || '未填手机号' }}</span>
  72. <span class="m-id">{{ u.id }}</span>
  73. </span>
  74. <i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
  75. </button>
  76. <div class="m-actions">
  77. <button type="button" class="m-act" (click)="openEdit(u)">编辑</button>
  78. <button type="button" class="m-act danger" (click)="askRemove(u)">移除</button>
  79. </div>
  80. </li>
  81. }
  82. </ul>
  83. @if (svc.page() < svc.pageCount()) {
  84. <button type="button" class="m-more" [disabled]="svc.state() === 'loading'" (click)="loadMore()">
  85. 加载更多({{ svc.page() }}/{{ svc.pageCount() }})
  86. </button>
  87. } @else if (svc.pageCount() > 1) {
  88. <p class="m-end">已到底</p>
  89. }
  90. }
  91. }
  92. </main>
  93. <!-- 编辑 / 新增:全屏 sheet,不用居中小弹窗 -->
  94. @if (sheetOpen()) {
  95. <div class="m-sheet-mask" (click)="closeSheet()" aria-hidden="true"></div>
  96. <section class="m-sheet" role="dialog" aria-modal="true" [attr.aria-label]="sheetTitle()">
  97. <header class="m-sheet-head">
  98. <h2>{{ sheetTitle() }}</h2>
  99. <button type="button" class="m-sheet-x" aria-label="关闭" (click)="closeSheet()">
  100. <i class="fa-solid fa-xmark" aria-hidden="true"></i>
  101. </button>
  102. </header>
  103. @if (mode() === 'add') {
  104. <label class="m-field">
  105. <span>用户 ID</span>
  106. <input type="text" [(ngModel)]="form.userId" placeholder="粘贴对方「等待加入组织」页上的 ID" />
  107. </label>
  108. } @else {
  109. <label class="m-field">
  110. <span>昵称</span>
  111. <input type="text" [(ngModel)]="form.nickname" />
  112. </label>
  113. <label class="m-field">
  114. <span>手机号</span>
  115. <input type="tel" [(ngModel)]="form.mobile" placeholder="11 位手机号" />
  116. </label>
  117. <label class="m-field">
  118. <span>重置密码</span>
  119. <input type="password" [(ngModel)]="form.password" placeholder="留空表示不修改" />
  120. </label>
  121. }
  122. @if (formError()) {
  123. <p class="m-err" role="alert">{{ formError() }}</p>
  124. }
  125. <button type="button" class="m-submit" [disabled]="submitting()" (click)="submit()">
  126. {{ submitting() ? '提交中…' : '确定' }}
  127. </button>
  128. </section>
  129. }
  130. <!-- 移除确认:也用 sheet,避免居中小弹窗在窄屏被键盘顶飞 -->
  131. @if (removeTarget(); as target) {
  132. <div class="m-sheet-mask" (click)="removeTarget.set(null)" aria-hidden="true"></div>
  133. <section class="m-sheet" role="alertdialog" aria-modal="true" aria-label="移除成员">
  134. <header class="m-sheet-head">
  135. <h2>移除成员</h2>
  136. </header>
  137. <p class="m-confirm-text">
  138. 确定把「{{ target.nickname || target.username }}」移出本组织吗?对方将无法继续使用。
  139. </p>
  140. @if (formError()) {
  141. <p class="m-err" role="alert">{{ formError() }}</p>
  142. }
  143. <button type="button" class="m-submit danger" [disabled]="submitting()" (click)="confirmRemove()">
  144. {{ submitting() ? '处理中…' : '确认移除' }}
  145. </button>
  146. </section>
  147. }
  148. `,
  149. styles: [
  150. `
  151. :host {
  152. display: flex;
  153. flex-direction: column;
  154. height: 100dvh;
  155. background: var(--canvas);
  156. }
  157. .m-fab {
  158. width: 44px;
  159. height: 44px;
  160. border: 0;
  161. background: transparent;
  162. color: var(--ink);
  163. }
  164. /* 搜索吸顶:滚动列表时仍可改关键词 */
  165. .m-search {
  166. flex: none;
  167. display: flex;
  168. gap: var(--sp-2);
  169. padding: var(--sp-3) var(--sp-4);
  170. background: var(--surface);
  171. border-bottom: 1px solid var(--edge);
  172. }
  173. .m-search-input {
  174. flex: 1;
  175. min-height: 44px;
  176. padding: 0 var(--sp-3);
  177. border: 1px solid var(--edge);
  178. border-radius: var(--radius-m);
  179. background: var(--surface-2);
  180. color: var(--ink);
  181. font-size: var(--fs-16); /* iOS 输入框 <16px 会触发自动放大 */
  182. }
  183. .m-search-btn {
  184. min-width: 64px;
  185. min-height: 44px;
  186. border: 1px solid var(--primary);
  187. border-radius: var(--radius-m);
  188. background: var(--primary);
  189. color: var(--inverse);
  190. font-size: var(--fs-14);
  191. }
  192. .m-body {
  193. flex: 1;
  194. min-height: 0;
  195. overflow-y: auto;
  196. padding: var(--sp-3) var(--sp-4) var(--sp-8);
  197. }
  198. .m-list {
  199. list-style: none;
  200. margin: 0;
  201. padding: 0;
  202. display: grid;
  203. gap: var(--sp-3);
  204. }
  205. .m-card {
  206. background: var(--surface);
  207. border: 1px solid var(--edge);
  208. border-radius: var(--radius-m);
  209. overflow: hidden;
  210. }
  211. .m-skel {
  212. height: 96px;
  213. background: var(--surface-2);
  214. }
  215. .m-row {
  216. display: flex;
  217. align-items: center;
  218. gap: var(--sp-3);
  219. width: 100%;
  220. min-height: 64px;
  221. padding: var(--sp-3) var(--sp-4);
  222. border: 0;
  223. background: transparent;
  224. text-align: left;
  225. color: var(--ink-2);
  226. cursor: pointer;
  227. }
  228. .m-avatar {
  229. flex: none;
  230. display: grid;
  231. place-items: center;
  232. width: 40px;
  233. height: 40px;
  234. border-radius: 50%;
  235. background: var(--surface-2);
  236. color: var(--ink);
  237. font-size: var(--fs-16);
  238. }
  239. .m-main {
  240. flex: 1;
  241. min-width: 0;
  242. display: grid;
  243. gap: 2px;
  244. }
  245. .m-name {
  246. color: var(--ink);
  247. font-size: var(--fs-16);
  248. font-weight: 600;
  249. }
  250. .m-sub {
  251. color: var(--muted);
  252. font-size: var(--fs-13);
  253. }
  254. .m-id {
  255. color: var(--faint);
  256. font-size: var(--fs-12);
  257. overflow: hidden;
  258. text-overflow: ellipsis;
  259. white-space: nowrap;
  260. }
  261. .m-actions {
  262. display: flex;
  263. border-top: 1px solid var(--edge);
  264. }
  265. .m-act {
  266. flex: 1;
  267. min-height: 44px;
  268. border: 0;
  269. background: transparent;
  270. color: var(--ink-2);
  271. font-size: var(--fs-14);
  272. }
  273. .m-act.danger {
  274. color: var(--danger);
  275. }
  276. .m-more {
  277. display: block;
  278. width: 100%;
  279. min-height: 44px;
  280. margin-top: var(--sp-4);
  281. border: 1px solid var(--edge);
  282. border-radius: var(--radius-m);
  283. background: var(--surface);
  284. color: var(--ink-2);
  285. font-size: var(--fs-14);
  286. }
  287. .m-end {
  288. text-align: center;
  289. color: var(--faint);
  290. font-size: var(--fs-12);
  291. margin: var(--sp-4) 0 0;
  292. }
  293. .m-retry {
  294. display: block;
  295. margin: var(--sp-4) auto 0;
  296. min-height: 44px;
  297. padding: 0 var(--sp-5);
  298. border: 1px solid var(--edge);
  299. border-radius: var(--radius-m);
  300. background: var(--surface);
  301. color: var(--ink-2);
  302. }
  303. /* 底部抽屉 */
  304. .m-sheet-mask {
  305. position: fixed;
  306. inset: 0;
  307. background: var(--mask, rgba(0, 0, 0, 0.4));
  308. z-index: var(--z-mask);
  309. }
  310. .m-sheet {
  311. position: fixed;
  312. left: 0;
  313. right: 0;
  314. bottom: 0;
  315. z-index: var(--z-modal);
  316. max-height: 86dvh;
  317. overflow-y: auto;
  318. background: var(--surface);
  319. border-radius: var(--radius-l) var(--radius-l) 0 0;
  320. padding: var(--sp-4) var(--sp-4);
  321. padding-bottom: calc(var(--sp-4) + env(safe-area-inset-bottom, 0px));
  322. }
  323. .m-sheet-head {
  324. display: flex;
  325. align-items: center;
  326. justify-content: space-between;
  327. margin-bottom: var(--sp-4);
  328. }
  329. .m-sheet-head h2 {
  330. margin: 0;
  331. font-size: var(--fs-18);
  332. color: var(--ink);
  333. }
  334. .m-sheet-x {
  335. width: 44px;
  336. height: 44px;
  337. border: 0;
  338. background: transparent;
  339. color: var(--muted);
  340. font-size: var(--fs-18);
  341. }
  342. .m-field {
  343. display: grid;
  344. gap: var(--sp-1);
  345. margin-bottom: var(--sp-3);
  346. font-size: var(--fs-13);
  347. color: var(--muted);
  348. }
  349. .m-field input {
  350. min-height: 44px;
  351. padding: 0 var(--sp-3);
  352. border: 1px solid var(--edge);
  353. border-radius: var(--radius-m);
  354. background: var(--surface-2);
  355. color: var(--ink);
  356. font-size: var(--fs-16);
  357. }
  358. .m-confirm-text {
  359. margin: 0 0 var(--sp-4);
  360. color: var(--ink-2);
  361. font-size: var(--fs-16);
  362. line-height: 1.6;
  363. }
  364. .m-err {
  365. margin: 0 0 var(--sp-3);
  366. color: var(--danger);
  367. font-size: var(--fs-13);
  368. }
  369. .m-submit {
  370. width: 100%;
  371. min-height: 48px;
  372. border: 1px solid var(--primary);
  373. border-radius: var(--radius-m);
  374. background: var(--primary);
  375. color: var(--inverse);
  376. font-size: var(--fs-16);
  377. }
  378. .m-submit.danger {
  379. background: var(--danger);
  380. border-color: var(--danger);
  381. }
  382. .m-submit:disabled {
  383. opacity: 0.6;
  384. }
  385. `,
  386. ],
  387. })
  388. export class MbAdminUsersPage implements OnInit {
  389. private readonly router = inject(Router);
  390. protected readonly svc = inject(OrgUsersService);
  391. protected keywordModel = '';
  392. protected readonly mode = signal<'add' | 'edit'>('add');
  393. protected readonly sheetOpen = signal(false);
  394. protected readonly removeTarget = signal<OrgUser | null>(null);
  395. protected readonly submitting = signal(false);
  396. protected readonly formError = signal<string | null>(null);
  397. private editingId = '';
  398. form: { userId: string; nickname: string; mobile: string; password: string } = {
  399. userId: '',
  400. nickname: '',
  401. mobile: '',
  402. password: '',
  403. };
  404. protected readonly sheetTitle = computed(() => (this.mode() === 'add' ? '添加成员' : '编辑成员'));
  405. async ngOnInit(): Promise<void> {
  406. await this.svc.ensureLoaded();
  407. }
  408. /**
  409. * 返回(2026-11-11 改)。
  410. *
  411. * 企业管理落地页 `/mobile/admin` 已下线,原来固定跳回它会让用户落到
  412. * 一个已不存在的中间页。改为**回到来处**:
  413. * · 从抽屉「成员管理」直接进来 → 回会话列表
  414. * · 从别处(将来可能有)进来 → 正常后退
  415. * 与移动端其他详情页(如 `pages/agent`)的返回写法保持同一口径。
  416. */
  417. protected back(): void {
  418. if (history.length > 1) history.back();
  419. else void this.router.navigateByUrl('/mobile/messages');
  420. }
  421. protected async reload(): Promise<void> {
  422. await this.svc.refresh();
  423. }
  424. protected async runSearch(): Promise<void> {
  425. await this.svc.search(this.keywordModel);
  426. }
  427. protected async loadMore(): Promise<void> {
  428. await this.svc.goTo(this.svc.page() + 1);
  429. }
  430. protected initialOf(u: OrgUser): string {
  431. return (u.nickname || u.username || '?').slice(0, 1).toUpperCase();
  432. }
  433. protected openAdd(): void {
  434. this.mode.set('add');
  435. this.editingId = '';
  436. this.form = { userId: '', nickname: '', mobile: '', password: '' };
  437. this.formError.set(null);
  438. this.sheetOpen.set(true);
  439. }
  440. protected openEdit(u: OrgUser): void {
  441. this.mode.set('edit');
  442. this.editingId = u.id;
  443. this.form = { userId: u.id, nickname: u.nickname, mobile: u.mobile, password: '' };
  444. this.formError.set(null);
  445. this.sheetOpen.set(true);
  446. }
  447. protected closeSheet(): void {
  448. if (this.submitting()) return;
  449. this.sheetOpen.set(false);
  450. }
  451. protected async submit(): Promise<void> {
  452. if (this.submitting()) return;
  453. this.submitting.set(true);
  454. this.formError.set(null);
  455. try {
  456. if (this.mode() === 'add') {
  457. await this.svc.add(this.form.userId);
  458. } else {
  459. await this.svc.update(this.editingId, {
  460. nickname: this.form.nickname,
  461. mobile: this.form.mobile,
  462. password: this.form.password || undefined,
  463. });
  464. }
  465. this.sheetOpen.set(false);
  466. } catch (e) {
  467. this.formError.set(e instanceof Error ? e.message : '提交失败');
  468. } finally {
  469. this.submitting.set(false);
  470. }
  471. }
  472. protected askRemove(u: OrgUser): void {
  473. this.formError.set(null);
  474. this.removeTarget.set(u);
  475. }
  476. protected async confirmRemove(): Promise<void> {
  477. const u = this.removeTarget();
  478. if (!u || this.submitting()) return;
  479. this.submitting.set(true);
  480. this.formError.set(null);
  481. try {
  482. await this.svc.remove(u.id);
  483. this.removeTarget.set(null);
  484. } catch (e) {
  485. this.formError.set(e instanceof Error ? e.message : '移除失败');
  486. } finally {
  487. this.submitting.set(false);
  488. }
  489. }
  490. }