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: `
@if (!canManage()) { } @else {
{{ roleLabel() }}
{{ me()?.companyName || '未归属公司' }}
{{ me()?.name || '—' }} · {{ me()?.mobile || '—' }}

组织架构

@for (g of groups(); track g.companyId ?? 'none') {
{{ g.companyName }} {{ countPeople(g) }} 位同事 · {{ countAgents(g) }} 个容器
@for (d of g.departments; track d.name) {
{{ d.name }} @if (d.name === '未分组') { 部门维度待接入 }
@for (p of d.people; track p.id) {
{{ p.name }} {{ p.mobile || '—' }} · {{ p.agents?.length || 0 }} 个容器
} @for (a of d.agents; track a.id) {
{{ a.name }} {{ a.specialty || '数字生命' }}
} @empty { @if (d.people.length === 0) {

该部门暂无成员

} }
}
} @empty { }

成员

管理能力

    @for (cap of capabilities; track cap.key) {
  • {{ cap.label }} {{ cap.desc }} 尚未开放
  • }
}
`, 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'); } }