| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444 |
- import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
- import { Router } from '@angular/router';
- import { canManageCompany, resolveScope } from '../../../core/acl';
- import { SessionService } from '../../../core/session.service';
- import { ContactsService } from '../../../core/contacts.service';
- import { Agent, Person } from '../../../core/api/models';
- import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
- import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
- import { MEmptyComponent } from '../../components/m-empty/m-empty';
- import { AgentRenameService } from '../../../core/agent-rename.service';
- import { PromptModalService } from '../../../components/domain/prompt-modal/prompt-modal.service';
- /**
- * 企业管理(**预留页**,2026-11-04)
- *
- * ## 现在做什么
- *
- * 只搭结构、不实现功能:
- * · 入口只对公司管理员 / 系统超管可见(`canManageCompany`)
- * · 页面按「公司 → 部门」展示组织架构骨架(部门维度暂为「未分组」)
- * · 各项管理能力以**明确的「尚未开放」**呈现,不做假开关
- *
- * ## 为什么不做成"能点但没用"
- *
- * 容器管理(配额、权限、部门归属、跨用户授权)依赖容器侧的写入能力,
- * 目前只有本地对象层面的改动、不会真正影响容器。
- * 做成可点但无效的开关,会让管理员以为自己改了实际配置 —— 比不提供更糟。
- *
- * ## 后续接入点
- *
- * 1. 部门维度:`Person.department` / `Agent.department` 有值即自动分组(模板已就绪)
- * 2. 容器管理:在「容器清单」行内加操作菜单,调服务端接口
- * 3. 成员管理:在「同事」行内加角色/部门调整
- */
- @Component({
- selector: 'app-mb-admin-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [MAppBarComponent, MAvatarComponent, MEmptyComponent],
- template: `
- <div class="m-page">
- <app-m-app-bar title="企业管理" [back]="true" (backTap)="back()" />
- <div class="m-body">
- @if (!canManage()) {
- <app-m-empty
- icon="fa-solid fa-lock"
- title="无访问权限"
- description="仅企业管理员可进入此页面"
- />
- } @else {
- <section class="m-section">
- <div class="m-card m-profile">
- <span class="m-role">{{ roleLabel() }}</span>
- <div class="m-profile-body">
- <div class="m-profile-name">{{ me()?.companyName || '未归属公司' }}</div>
- <div class="m-profile-sub">{{ me()?.name || '—' }} · {{ me()?.mobile || '—' }}</div>
- </div>
- </div>
- </section>
- <section class="m-section">
- <h2 class="m-section-title">组织架构</h2>
- @for (g of groups(); track g.companyId ?? 'none') {
- <div class="m-card m-org">
- <div class="m-org-head">
- <i class="fa-solid fa-building" aria-hidden="true"></i>
- <b>{{ g.companyName }}</b>
- <span class="m-org-count">
- {{ countPeople(g) }} 位同事 · {{ countAgents(g) }} 个容器
- </span>
- </div>
- @for (d of g.departments; track d.name) {
- <div class="m-dept">
- <div class="m-dept-head">
- <i class="fa-solid fa-sitemap" aria-hidden="true"></i>
- {{ d.name }}
- @if (d.name === '未分组') {
- <span class="m-dept-hint">部门维度待接入</span>
- }
- </div>
- @for (p of d.people; track p.id) {
- <div class="m-row">
- <app-m-avatar [name]="p.name" tone="person" size="sm" />
- <span class="m-row-body">
- <span class="m-row-t1">{{ p.name }}</span>
- <span class="m-row-t2">
- {{ p.mobile || '—' }} · {{ p.agents?.length || 0 }} 个容器
- </span>
- </span>
- </div>
- }
- @for (a of d.agents; track a.id) {
- <div class="m-row">
- <app-m-avatar [name]="a.name" size="sm" />
- <span class="m-row-body">
- <span class="m-row-t1">{{ a.name }}</span>
- <span class="m-row-t2">{{ a.specialty || '数字生命' }}</span>
- </span>
- <!-- 重命名:仅管理员可见(普通成员看不到这个按钮) -->
- <button
- type="button"
- class="m-rename"
- [attr.aria-label]="'重命名 ' + a.name"
- [disabled]="renameSvc.busy() === a.id"
- (click)="rename(a)"
- >
- <i class="fa-solid fa-pen" aria-hidden="true"></i>
- </button>
- </div>
- } @empty {
- @if (d.people.length === 0) {
- <p class="m-dept-empty">该部门暂无成员</p>
- }
- }
- </div>
- }
- </div>
- } @empty {
- <app-m-empty title="暂无组织数据" icon="fa-solid fa-building" />
- }
- </section>
- <!-- 成员管理(2026-11-10 已开放):其余能力仍标「尚未开放」,不做假开关 -->
- <section class="m-section">
- <h2 class="m-section-title">成员</h2>
- <ul class="m-list">
- <li>
- <button type="button" class="m-row" (click)="goMembers()">
- <span class="m-row-body">
- <span class="m-row-t1">成员管理</span>
- <span class="m-row-t2">新增、改资料、重置密码、移除组织成员</span>
- </span>
- <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
- </button>
- </li>
- </ul>
- </section>
- <!-- 管理能力:明确标注未开放,不做假开关 -->
- <section class="m-section">
- <h2 class="m-section-title">管理能力</h2>
- <ul class="m-list">
- @for (cap of capabilities; track cap.key) {
- <li class="m-row m-row--cap">
- <span class="m-row-body">
- <span class="m-row-t1">{{ cap.label }}</span>
- <span class="m-row-t2">{{ cap.desc }}</span>
- </span>
- <span class="m-badge-soon">尚未开放</span>
- </li>
- }
- </ul>
- </section>
- }
- </div>
- </div>
- `,
- styles: [
- `
- :host {
- display: block;
- background: var(--canvas);
- }
- .m-page {
- display: flex;
- flex-direction: column;
- height: 100dvh;
- box-sizing: border-box;
- }
- .m-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- padding-bottom: calc(var(--mob-safe-bottom) + var(--sp-6));
- }
- .m-section {
- padding: 0 var(--mob-gutter);
- }
- .m-section + .m-section {
- margin-top: var(--sp-6);
- }
- .m-section:first-of-type {
- margin-top: var(--sp-3);
- }
- .m-section-title {
- margin: 0 0 var(--sp-3);
- color: var(--muted);
- font-size: var(--fs-13);
- font-weight: 400;
- }
- .m-card {
- padding: var(--sp-4);
- border: 1px solid var(--edge);
- border-radius: var(--r-lg);
- background: var(--surface);
- box-shadow: var(--shadow-soft);
- }
- .m-profile {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- }
- .m-role {
- flex: none;
- padding: var(--sp-1) var(--sp-2);
- border-radius: var(--r-sm);
- background: var(--primary-subtle);
- color: var(--primary);
- font-size: var(--fs-11);
- font-weight: 600;
- }
- .m-profile-body {
- min-width: 0;
- }
- .m-profile-name {
- color: var(--ink);
- font-size: var(--fs-17);
- font-weight: 600;
- }
- .m-profile-sub {
- color: var(--muted);
- font-size: var(--fs-13);
- }
- .m-org + .m-org {
- margin-top: var(--sp-3);
- }
- .m-org-head {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- padding-bottom: var(--sp-3);
- border-bottom: 1px solid var(--edge);
- }
- .m-org-head > i {
- color: var(--muted);
- }
- .m-org-head b {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: var(--fs-15);
- }
- .m-org-count {
- flex: none;
- color: var(--muted);
- font-size: var(--fs-11);
- }
- .m-dept {
- margin-top: var(--sp-3);
- }
- .m-dept-head {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin-bottom: var(--sp-1);
- color: var(--ink-2);
- font-size: var(--fs-13);
- font-weight: 600;
- }
- .m-dept-head > i {
- color: var(--muted);
- font-size: var(--fs-11);
- }
- .m-dept-hint {
- margin-left: auto;
- color: var(--muted);
- font-size: var(--fs-11);
- font-weight: 400;
- }
- .m-dept-empty {
- margin: 0;
- padding: var(--sp-2) 0 var(--sp-2) var(--sp-5);
- color: var(--muted);
- font-size: var(--fs-12);
- }
- .m-row {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- min-height: 48px;
- padding: var(--sp-2) 0;
- }
- .m-row--cap {
- padding: var(--sp-3) var(--sp-4);
- min-height: 56px;
- }
- .m-row-body {
- flex: 1;
- min-width: 0;
- }
- .m-row-t1 {
- display: block;
- color: var(--ink);
- font-size: var(--fs-15);
- }
- .m-row-t2 {
- display: block;
- color: var(--muted);
- font-size: var(--fs-13);
- }
- .m-list {
- margin: 0;
- padding: 0;
- list-style: none;
- border: 1px solid var(--edge);
- border-radius: var(--r-lg);
- background: var(--surface);
- overflow: hidden;
- }
- .m-list > li + li {
- border-top: 1px solid var(--edge);
- }
- .m-rename {
- display: grid;
- place-items: center;
- flex: none;
- width: 32px;
- height: 32px;
- border: 0;
- border-radius: var(--r-sm);
- background: none;
- color: var(--muted);
- font-size: var(--fs-12);
- cursor: pointer;
- }
- .m-rename:active {
- background: var(--surface-hover);
- }
- .m-rename:disabled {
- opacity: 0.4;
- }
- .m-toast {
- position: fixed;
- left: 50%;
- bottom: calc(var(--mob-tabbar-h) + var(--sp-6));
- transform: translateX(-50%);
- z-index: var(--z-toast);
- max-width: 80vw;
- padding: var(--sp-2) var(--sp-4);
- border-radius: var(--r-md);
- background: var(--ink);
- color: var(--canvas);
- font-size: var(--fs-13);
- text-align: center;
- box-shadow: var(--shadow-pop);
- }
- .m-badge-soon {
- flex: none;
- padding: var(--sp-1) var(--sp-2);
- border-radius: var(--r-sm);
- background: var(--neutral-soft);
- color: var(--neutral);
- font-size: var(--fs-11);
- }
- `,
- ],
- })
- export class MbAdminPage {
- private readonly router = inject(Router);
- private readonly session = inject(SessionService);
- private readonly contacts = inject(ContactsService);
- /** 重命名服务(模板要访问 busy,故为 protected) */
- protected readonly renameSvc = inject(AgentRenameService);
- private readonly prompt = inject(PromptModalService);
- protected readonly me = this.session.me;
- protected readonly canManage = computed(() => canManageCompany(this.session.me()));
- protected readonly roleLabel = computed(() => resolveScope(this.session.me()).label);
- protected readonly groups = computed(() => this.contacts.companyGroups());
- /** 管理能力清单(预留,均标注「尚未开放」) */
- protected readonly capabilities = [
- { key: 'quota', label: '容器配额与计费', desc: '统一设置部门容器的资源与用量上限' },
- { key: 'dept', label: '部门归属', desc: '把容器划到具体部门,供部门内共享' },
- { key: 'perm', label: '容器权限', desc: '配置谁可以与该容器对话、谁可以管理' },
- {
- key: 'member',
- label: '成员细粒度权限',
- desc: '按成员或部门单独授权:可读 / 可对话 / 可管理,逐容器生效',
- },
- { key: 'audit', label: '使用审计', desc: '查看容器使用记录与异常告警' },
- ];
- /**
- * 重命名容器(2026-11-04)。
- *
- * 结果写回**服务端**:容器名是同公司跨用户可见的,
- * 存本地会导致「我这里改了、同事看到的还是旧名」。
- * 服务端接口就绪前调用会失败,界面如实提示 —— 不做本地假象。
- */
- /** 进入成员管理页(2026-11-10) */
- protected goMembers(): void {
- void this.router.navigateByUrl('/mobile/admin/users');
- }
- protected rename(agent: Agent): void {
- this.prompt.open({
- title: '重命名容器',
- value: agent.name,
- placeholder: '新的容器名称',
- allowEmpty: false,
- confirmText: '保存',
- validate: (v) => {
- const n = v.trim();
- if (!n) return '名称不能为空';
- if (n.length > 24) return '名称不能超过 24 个字符';
- if (n === agent.name) return '名称没有变化';
- return null;
- },
- onConfirm: (v) => {
- void this.renameSvc
- .rename(agent.id, v.trim())
- .catch((e: unknown) => {
- this.toast(e instanceof Error ? e.message : '重命名失败');
- });
- },
- });
- }
- /** 轻量错误提示(复用现有 toast,不额外引入依赖) */
- private toast(msg: string): void {
- const el = document.createElement('div');
- el.className = 'm-toast';
- el.textContent = msg;
- document.body.appendChild(el);
- setTimeout(() => el.remove(), 2400);
- }
- protected countPeople(g: { departments: { people: Person[] }[] }): number {
- return g.departments.reduce((n, d) => n + d.people.length, 0);
- }
- protected countAgents(g: { departments: { agents: Agent[] }[] }): number {
- return g.departments.reduce((n, d) => n + d.agents.length, 0);
- }
- protected back(): void {
- if (history.length > 1) history.back();
- else void this.router.navigateByUrl('/mobile/mine');
- }
- }
|