| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515 |
- import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
- import { FormsModule } from '@angular/forms';
- import { Router } from '@angular/router';
- import { OrgUser } from '../../../../core/api/models';
- import { OrgUsersService } from '../../../../core/org-users.service';
- import { MAppBarComponent } from '../../../components/m-app-bar/m-app-bar';
- import { MEmptyComponent } from '../../../components/m-empty/m-empty';
- /**
- * 移动·组织成员管理(2026-11-10)
- *
- * ## 为什么不是把 PC 表格搬过来
- *
- * 手机上五列表格必然横向溢出、字小到不可读。移动端改为:
- * - **卡片行**:姓名 + 子信息纵向排布,整行可点
- * - **搜索吸顶**:滚动时仍能改关键词
- * - **操作走底部抽屉**:编辑/新增都是全屏 sheet,而不是居中小弹窗
- * - **分页改为「加载更多」**:手机上点页码很别扭,上一页/下一页语义也更顺
- *
- * 所有触控区 ≥44px,正文 ≥16px(移动端规范)。
- */
- @Component({
- selector: 'app-mb-admin-users-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [FormsModule, MAppBarComponent, MEmptyComponent],
- template: `
- <app-m-app-bar [title]="'成员管理'" [subtitle]="'共 ' + svc.total() + ' 人'" [back]="true" (backTap)="back()">
- <button barRight type="button" class="m-fab" aria-label="添加成员" (click)="openAdd()">
- <i class="fa-solid fa-user-plus" aria-hidden="true"></i>
- </button>
- </app-m-app-bar>
- <!-- 搜索吸顶 -->
- <div class="m-search">
- <input
- class="m-search-input"
- type="search"
- placeholder="搜索昵称 / 手机号 / 用户 ID"
- [(ngModel)]="keywordModel"
- (keydown.enter)="runSearch()"
- aria-label="搜索成员"
- />
- <button type="button" class="m-search-btn" (click)="runSearch()">搜索</button>
- </div>
- <main class="m-body">
- @switch (svc.state()) {
- @case ('loading') {
- <ul class="m-list">
- @for (i of [1, 2, 3, 4]; track i) {
- <li class="m-card m-skel" aria-hidden="true"></li>
- }
- </ul>
- }
- @case ('error') {
- <app-m-empty [title]="'加载失败'" [description]="svc.error() ?? ''" />
- <button type="button" class="m-retry" (click)="reload()">重新加载</button>
- }
- @case ('empty') {
- @if (svc.hasKeyword()) {
- <app-m-empty title="没有找到匹配的成员" description="换个关键词,或清除搜索条件。" />
- } @else {
- <app-m-empty title="组织里还没有成员" description="点右上角加号,输入用户 ID 把人拉进来。" />
- }
- }
- @default {
- <ul class="m-list">
- @for (u of svc.items(); track u.id) {
- <li class="m-card">
- <button type="button" class="m-row" (click)="openEdit(u)">
- <span class="m-avatar" aria-hidden="true">{{ initialOf(u) }}</span>
- <span class="m-main">
- <span class="m-name">{{ u.nickname || u.username || '未命名' }}</span>
- <span class="m-sub">{{ u.mobile || '未填手机号' }}</span>
- <span class="m-id">{{ u.id }}</span>
- </span>
- <i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
- </button>
- <div class="m-actions">
- <button type="button" class="m-act" (click)="openEdit(u)">编辑</button>
- <button type="button" class="m-act danger" (click)="askRemove(u)">移除</button>
- </div>
- </li>
- }
- </ul>
- @if (svc.page() < svc.pageCount()) {
- <button type="button" class="m-more" [disabled]="svc.state() === 'loading'" (click)="loadMore()">
- 加载更多({{ svc.page() }}/{{ svc.pageCount() }})
- </button>
- } @else if (svc.pageCount() > 1) {
- <p class="m-end">已到底</p>
- }
- }
- }
- </main>
- <!-- 编辑 / 新增:全屏 sheet,不用居中小弹窗 -->
- @if (sheetOpen()) {
- <div class="m-sheet-mask" (click)="closeSheet()" aria-hidden="true"></div>
- <section class="m-sheet" role="dialog" aria-modal="true" [attr.aria-label]="sheetTitle()">
- <header class="m-sheet-head">
- <h2>{{ sheetTitle() }}</h2>
- <button type="button" class="m-sheet-x" aria-label="关闭" (click)="closeSheet()">
- <i class="fa-solid fa-xmark" aria-hidden="true"></i>
- </button>
- </header>
- @if (mode() === 'add') {
- <label class="m-field">
- <span>用户 ID</span>
- <input type="text" [(ngModel)]="form.userId" placeholder="粘贴对方「等待加入组织」页上的 ID" />
- </label>
- } @else {
- <label class="m-field">
- <span>昵称</span>
- <input type="text" [(ngModel)]="form.nickname" />
- </label>
- <label class="m-field">
- <span>手机号</span>
- <input type="tel" [(ngModel)]="form.mobile" placeholder="11 位手机号" />
- </label>
- <label class="m-field">
- <span>重置密码</span>
- <input type="password" [(ngModel)]="form.password" placeholder="留空表示不修改" />
- </label>
- }
- @if (formError()) {
- <p class="m-err" role="alert">{{ formError() }}</p>
- }
- <button type="button" class="m-submit" [disabled]="submitting()" (click)="submit()">
- {{ submitting() ? '提交中…' : '确定' }}
- </button>
- </section>
- }
- <!-- 移除确认:也用 sheet,避免居中小弹窗在窄屏被键盘顶飞 -->
- @if (removeTarget(); as target) {
- <div class="m-sheet-mask" (click)="removeTarget.set(null)" aria-hidden="true"></div>
- <section class="m-sheet" role="alertdialog" aria-modal="true" aria-label="移除成员">
- <header class="m-sheet-head">
- <h2>移除成员</h2>
- </header>
- <p class="m-confirm-text">
- 确定把「{{ target.nickname || target.username }}」移出本组织吗?对方将无法继续使用。
- </p>
- @if (formError()) {
- <p class="m-err" role="alert">{{ formError() }}</p>
- }
- <button type="button" class="m-submit danger" [disabled]="submitting()" (click)="confirmRemove()">
- {{ submitting() ? '处理中…' : '确认移除' }}
- </button>
- </section>
- }
- `,
- styles: [
- `
- :host {
- display: flex;
- flex-direction: column;
- height: 100dvh;
- background: var(--canvas);
- }
- .m-fab {
- width: 44px;
- height: 44px;
- border: 0;
- background: transparent;
- color: var(--ink);
- }
- /* 搜索吸顶:滚动列表时仍可改关键词 */
- .m-search {
- flex: none;
- display: flex;
- gap: var(--sp-2);
- padding: var(--sp-3) var(--sp-4);
- background: var(--surface);
- border-bottom: 1px solid var(--edge);
- }
- .m-search-input {
- flex: 1;
- min-height: 44px;
- padding: 0 var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--radius-m);
- background: var(--surface-2);
- color: var(--ink);
- font-size: var(--fs-16); /* iOS 输入框 <16px 会触发自动放大 */
- }
- .m-search-btn {
- min-width: 64px;
- min-height: 44px;
- border: 1px solid var(--primary);
- border-radius: var(--radius-m);
- background: var(--primary);
- color: var(--inverse);
- font-size: var(--fs-14);
- }
- .m-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: var(--sp-3) var(--sp-4) var(--sp-8);
- }
- .m-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: grid;
- gap: var(--sp-3);
- }
- .m-card {
- background: var(--surface);
- border: 1px solid var(--edge);
- border-radius: var(--radius-m);
- overflow: hidden;
- }
- .m-skel {
- height: 96px;
- background: var(--surface-2);
- }
- .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;
- background: transparent;
- text-align: left;
- color: var(--ink-2);
- cursor: pointer;
- }
- .m-avatar {
- flex: none;
- display: grid;
- place-items: center;
- width: 40px;
- height: 40px;
- border-radius: 50%;
- background: var(--surface-2);
- color: var(--ink);
- font-size: var(--fs-16);
- }
- .m-main {
- flex: 1;
- min-width: 0;
- display: grid;
- gap: 2px;
- }
- .m-name {
- color: var(--ink);
- font-size: var(--fs-16);
- font-weight: 600;
- }
- .m-sub {
- color: var(--muted);
- font-size: var(--fs-13);
- }
- .m-id {
- color: var(--faint);
- font-size: var(--fs-12);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .m-actions {
- display: flex;
- border-top: 1px solid var(--edge);
- }
- .m-act {
- flex: 1;
- min-height: 44px;
- border: 0;
- background: transparent;
- color: var(--ink-2);
- font-size: var(--fs-14);
- }
- .m-act.danger {
- color: var(--danger);
- }
- .m-more {
- display: block;
- width: 100%;
- min-height: 44px;
- margin-top: var(--sp-4);
- border: 1px solid var(--edge);
- border-radius: var(--radius-m);
- background: var(--surface);
- color: var(--ink-2);
- font-size: var(--fs-14);
- }
- .m-end {
- text-align: center;
- color: var(--faint);
- font-size: var(--fs-12);
- margin: var(--sp-4) 0 0;
- }
- .m-retry {
- display: block;
- margin: var(--sp-4) auto 0;
- min-height: 44px;
- padding: 0 var(--sp-5);
- border: 1px solid var(--edge);
- border-radius: var(--radius-m);
- background: var(--surface);
- color: var(--ink-2);
- }
- /* 底部抽屉 */
- .m-sheet-mask {
- position: fixed;
- inset: 0;
- background: var(--mask, rgba(0, 0, 0, 0.4));
- z-index: var(--z-mask);
- }
- .m-sheet {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: var(--z-modal);
- max-height: 86dvh;
- overflow-y: auto;
- background: var(--surface);
- border-radius: var(--radius-l) var(--radius-l) 0 0;
- padding: var(--sp-4) var(--sp-4);
- padding-bottom: calc(var(--sp-4) + env(safe-area-inset-bottom, 0px));
- }
- .m-sheet-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: var(--sp-4);
- }
- .m-sheet-head h2 {
- margin: 0;
- font-size: var(--fs-18);
- color: var(--ink);
- }
- .m-sheet-x {
- width: 44px;
- height: 44px;
- border: 0;
- background: transparent;
- color: var(--muted);
- font-size: var(--fs-18);
- }
- .m-field {
- display: grid;
- gap: var(--sp-1);
- margin-bottom: var(--sp-3);
- font-size: var(--fs-13);
- color: var(--muted);
- }
- .m-field input {
- min-height: 44px;
- padding: 0 var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--radius-m);
- background: var(--surface-2);
- color: var(--ink);
- font-size: var(--fs-16);
- }
- .m-confirm-text {
- margin: 0 0 var(--sp-4);
- color: var(--ink-2);
- font-size: var(--fs-16);
- line-height: 1.6;
- }
- .m-err {
- margin: 0 0 var(--sp-3);
- color: var(--danger);
- font-size: var(--fs-13);
- }
- .m-submit {
- width: 100%;
- min-height: 48px;
- border: 1px solid var(--primary);
- border-radius: var(--radius-m);
- background: var(--primary);
- color: var(--inverse);
- font-size: var(--fs-16);
- }
- .m-submit.danger {
- background: var(--danger);
- border-color: var(--danger);
- }
- .m-submit:disabled {
- opacity: 0.6;
- }
- `,
- ],
- })
- export class MbAdminUsersPage implements OnInit {
- private readonly router = inject(Router);
- protected readonly svc = inject(OrgUsersService);
- protected keywordModel = '';
- protected readonly mode = signal<'add' | 'edit'>('add');
- protected readonly sheetOpen = signal(false);
- protected readonly removeTarget = signal<OrgUser | null>(null);
- protected readonly submitting = signal(false);
- protected readonly formError = signal<string | null>(null);
- private editingId = '';
- form: { userId: string; nickname: string; mobile: string; password: string } = {
- userId: '',
- nickname: '',
- mobile: '',
- password: '',
- };
- protected readonly sheetTitle = computed(() => (this.mode() === 'add' ? '添加成员' : '编辑成员'));
- async ngOnInit(): Promise<void> {
- await this.svc.ensureLoaded();
- }
- /**
- * 返回(2026-11-11 改)。
- *
- * 企业管理落地页 `/mobile/admin` 已下线,原来固定跳回它会让用户落到
- * 一个已不存在的中间页。改为**回到来处**:
- * · 从抽屉「成员管理」直接进来 → 回会话列表
- * · 从别处(将来可能有)进来 → 正常后退
- * 与移动端其他详情页(如 `pages/agent`)的返回写法保持同一口径。
- */
- protected back(): void {
- if (history.length > 1) history.back();
- else void this.router.navigateByUrl('/mobile/messages');
- }
- protected async reload(): Promise<void> {
- await this.svc.refresh();
- }
- protected async runSearch(): Promise<void> {
- await this.svc.search(this.keywordModel);
- }
- protected async loadMore(): Promise<void> {
- await this.svc.goTo(this.svc.page() + 1);
- }
- protected initialOf(u: OrgUser): string {
- return (u.nickname || u.username || '?').slice(0, 1).toUpperCase();
- }
- protected openAdd(): void {
- this.mode.set('add');
- this.editingId = '';
- this.form = { userId: '', nickname: '', mobile: '', password: '' };
- this.formError.set(null);
- this.sheetOpen.set(true);
- }
- protected openEdit(u: OrgUser): void {
- this.mode.set('edit');
- this.editingId = u.id;
- this.form = { userId: u.id, nickname: u.nickname, mobile: u.mobile, password: '' };
- this.formError.set(null);
- this.sheetOpen.set(true);
- }
- protected closeSheet(): void {
- if (this.submitting()) return;
- this.sheetOpen.set(false);
- }
- protected async submit(): Promise<void> {
- if (this.submitting()) return;
- this.submitting.set(true);
- this.formError.set(null);
- try {
- if (this.mode() === 'add') {
- await this.svc.add(this.form.userId);
- } else {
- await this.svc.update(this.editingId, {
- nickname: this.form.nickname,
- mobile: this.form.mobile,
- password: this.form.password || undefined,
- });
- }
- this.sheetOpen.set(false);
- } catch (e) {
- this.formError.set(e instanceof Error ? e.message : '提交失败');
- } finally {
- this.submitting.set(false);
- }
- }
- protected askRemove(u: OrgUser): void {
- this.formError.set(null);
- this.removeTarget.set(u);
- }
- protected async confirmRemove(): Promise<void> {
- const u = this.removeTarget();
- if (!u || this.submitting()) return;
- this.submitting.set(true);
- this.formError.set(null);
- try {
- await this.svc.remove(u.id);
- this.removeTarget.set(null);
- } catch (e) {
- this.formError.set(e instanceof Error ? e.message : '移除失败');
- } finally {
- this.submitting.set(false);
- }
- }
- }
|