admin.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
  2. import { Router } from '@angular/router';
  3. import { canManageCompany, resolveScope } from '../../../core/acl';
  4. import { SessionService } from '../../../core/session.service';
  5. import { ContactsService } from '../../../core/contacts.service';
  6. import { Agent, Person } from '../../../core/api/models';
  7. import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
  8. import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
  9. import { MEmptyComponent } from '../../components/m-empty/m-empty';
  10. import { AgentRenameService } from '../../../core/agent-rename.service';
  11. import { PromptModalService } from '../../../components/domain/prompt-modal/prompt-modal.service';
  12. /**
  13. * 企业管理(**预留页**,2026-11-04)
  14. *
  15. * ## 现在做什么
  16. *
  17. * 只搭结构、不实现功能:
  18. * · 入口只对公司管理员 / 系统超管可见(`canManageCompany`)
  19. * · 页面按「公司 → 部门」展示组织架构骨架(部门维度暂为「未分组」)
  20. * · 各项管理能力以**明确的「尚未开放」**呈现,不做假开关
  21. *
  22. * ## 为什么不做成"能点但没用"
  23. *
  24. * 容器管理(配额、权限、部门归属、跨用户授权)依赖容器侧的写入能力,
  25. * 目前只有本地对象层面的改动、不会真正影响容器。
  26. * 做成可点但无效的开关,会让管理员以为自己改了实际配置 —— 比不提供更糟。
  27. *
  28. * ## 后续接入点
  29. *
  30. * 1. 部门维度:`Person.department` / `Agent.department` 有值即自动分组(模板已就绪)
  31. * 2. 容器管理:在「容器清单」行内加操作菜单,调服务端接口
  32. * 3. 成员管理:在「同事」行内加角色/部门调整
  33. */
  34. @Component({
  35. selector: 'app-mb-admin-page',
  36. changeDetection: ChangeDetectionStrategy.OnPush,
  37. imports: [MAppBarComponent, MAvatarComponent, MEmptyComponent],
  38. template: `
  39. <div class="m-page">
  40. <app-m-app-bar title="企业管理" [back]="true" (backTap)="back()" />
  41. <div class="m-body">
  42. @if (!canManage()) {
  43. <app-m-empty
  44. icon="fa-solid fa-lock"
  45. title="无访问权限"
  46. description="仅企业管理员可进入此页面"
  47. />
  48. } @else {
  49. <section class="m-section">
  50. <div class="m-card m-profile">
  51. <span class="m-role">{{ roleLabel() }}</span>
  52. <div class="m-profile-body">
  53. <div class="m-profile-name">{{ me()?.companyName || '未归属公司' }}</div>
  54. <div class="m-profile-sub">{{ me()?.name || '—' }} · {{ me()?.mobile || '—' }}</div>
  55. </div>
  56. </div>
  57. </section>
  58. <section class="m-section">
  59. <h2 class="m-section-title">组织架构</h2>
  60. @for (g of groups(); track g.companyId ?? 'none') {
  61. <div class="m-card m-org">
  62. <div class="m-org-head">
  63. <i class="fa-solid fa-building" aria-hidden="true"></i>
  64. <b>{{ g.companyName }}</b>
  65. <span class="m-org-count">
  66. {{ countPeople(g) }} 位同事 · {{ countAgents(g) }} 个容器
  67. </span>
  68. </div>
  69. @for (d of g.departments; track d.name) {
  70. <div class="m-dept">
  71. <div class="m-dept-head">
  72. <i class="fa-solid fa-sitemap" aria-hidden="true"></i>
  73. {{ d.name }}
  74. @if (d.name === '未分组') {
  75. <span class="m-dept-hint">部门维度待接入</span>
  76. }
  77. </div>
  78. @for (p of d.people; track p.id) {
  79. <div class="m-row">
  80. <app-m-avatar [name]="p.name" tone="person" size="sm" />
  81. <span class="m-row-body">
  82. <span class="m-row-t1">{{ p.name }}</span>
  83. <span class="m-row-t2">
  84. {{ p.mobile || '—' }} · {{ p.agents?.length || 0 }} 个容器
  85. </span>
  86. </span>
  87. </div>
  88. }
  89. @for (a of d.agents; track a.id) {
  90. <div class="m-row">
  91. <app-m-avatar [name]="a.name" size="sm" />
  92. <span class="m-row-body">
  93. <span class="m-row-t1">{{ a.name }}</span>
  94. <span class="m-row-t2">{{ a.specialty || '数字生命' }}</span>
  95. </span>
  96. <!-- 重命名:仅管理员可见(普通成员看不到这个按钮) -->
  97. <button
  98. type="button"
  99. class="m-rename"
  100. [attr.aria-label]="'重命名 ' + a.name"
  101. [disabled]="renameSvc.busy() === a.id"
  102. (click)="rename(a)"
  103. >
  104. <i class="fa-solid fa-pen" aria-hidden="true"></i>
  105. </button>
  106. </div>
  107. } @empty {
  108. @if (d.people.length === 0) {
  109. <p class="m-dept-empty">该部门暂无成员</p>
  110. }
  111. }
  112. </div>
  113. }
  114. </div>
  115. } @empty {
  116. <app-m-empty title="暂无组织数据" icon="fa-solid fa-building" />
  117. }
  118. </section>
  119. <!-- 成员管理(2026-11-10 已开放):其余能力仍标「尚未开放」,不做假开关 -->
  120. <section class="m-section">
  121. <h2 class="m-section-title">成员</h2>
  122. <ul class="m-list">
  123. <li>
  124. <button type="button" class="m-row" (click)="goMembers()">
  125. <span class="m-row-body">
  126. <span class="m-row-t1">成员管理</span>
  127. <span class="m-row-t2">新增、改资料、重置密码、移除组织成员</span>
  128. </span>
  129. <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
  130. </button>
  131. </li>
  132. </ul>
  133. </section>
  134. <!-- 管理能力:明确标注未开放,不做假开关 -->
  135. <section class="m-section">
  136. <h2 class="m-section-title">管理能力</h2>
  137. <ul class="m-list">
  138. @for (cap of capabilities; track cap.key) {
  139. <li class="m-row m-row--cap">
  140. <span class="m-row-body">
  141. <span class="m-row-t1">{{ cap.label }}</span>
  142. <span class="m-row-t2">{{ cap.desc }}</span>
  143. </span>
  144. <span class="m-badge-soon">尚未开放</span>
  145. </li>
  146. }
  147. </ul>
  148. </section>
  149. }
  150. </div>
  151. </div>
  152. `,
  153. styles: [
  154. `
  155. :host {
  156. display: block;
  157. background: var(--canvas);
  158. }
  159. .m-page {
  160. display: flex;
  161. flex-direction: column;
  162. height: 100dvh;
  163. box-sizing: border-box;
  164. }
  165. .m-body {
  166. flex: 1;
  167. min-height: 0;
  168. overflow-y: auto;
  169. -webkit-overflow-scrolling: touch;
  170. padding-bottom: calc(var(--mob-safe-bottom) + var(--sp-6));
  171. }
  172. .m-section {
  173. padding: 0 var(--mob-gutter);
  174. }
  175. .m-section + .m-section {
  176. margin-top: var(--sp-6);
  177. }
  178. .m-section:first-of-type {
  179. margin-top: var(--sp-3);
  180. }
  181. .m-section-title {
  182. margin: 0 0 var(--sp-3);
  183. color: var(--muted);
  184. font-size: var(--fs-13);
  185. font-weight: 400;
  186. }
  187. .m-card {
  188. padding: var(--sp-4);
  189. border: 1px solid var(--edge);
  190. border-radius: var(--r-lg);
  191. background: var(--surface);
  192. box-shadow: var(--shadow-soft);
  193. }
  194. .m-profile {
  195. display: flex;
  196. align-items: center;
  197. gap: var(--sp-3);
  198. }
  199. .m-role {
  200. flex: none;
  201. padding: var(--sp-1) var(--sp-2);
  202. border-radius: var(--r-sm);
  203. background: var(--primary-subtle);
  204. color: var(--primary);
  205. font-size: var(--fs-11);
  206. font-weight: 600;
  207. }
  208. .m-profile-body {
  209. min-width: 0;
  210. }
  211. .m-profile-name {
  212. color: var(--ink);
  213. font-size: var(--fs-17);
  214. font-weight: 600;
  215. }
  216. .m-profile-sub {
  217. color: var(--muted);
  218. font-size: var(--fs-13);
  219. }
  220. .m-org + .m-org {
  221. margin-top: var(--sp-3);
  222. }
  223. .m-org-head {
  224. display: flex;
  225. align-items: center;
  226. gap: var(--sp-2);
  227. padding-bottom: var(--sp-3);
  228. border-bottom: 1px solid var(--edge);
  229. }
  230. .m-org-head > i {
  231. color: var(--muted);
  232. }
  233. .m-org-head b {
  234. flex: 1;
  235. min-width: 0;
  236. overflow: hidden;
  237. text-overflow: ellipsis;
  238. white-space: nowrap;
  239. font-size: var(--fs-15);
  240. }
  241. .m-org-count {
  242. flex: none;
  243. color: var(--muted);
  244. font-size: var(--fs-11);
  245. }
  246. .m-dept {
  247. margin-top: var(--sp-3);
  248. }
  249. .m-dept-head {
  250. display: flex;
  251. align-items: center;
  252. gap: var(--sp-2);
  253. margin-bottom: var(--sp-1);
  254. color: var(--ink-2);
  255. font-size: var(--fs-13);
  256. font-weight: 600;
  257. }
  258. .m-dept-head > i {
  259. color: var(--muted);
  260. font-size: var(--fs-11);
  261. }
  262. .m-dept-hint {
  263. margin-left: auto;
  264. color: var(--muted);
  265. font-size: var(--fs-11);
  266. font-weight: 400;
  267. }
  268. .m-dept-empty {
  269. margin: 0;
  270. padding: var(--sp-2) 0 var(--sp-2) var(--sp-5);
  271. color: var(--muted);
  272. font-size: var(--fs-12);
  273. }
  274. .m-row {
  275. display: flex;
  276. align-items: center;
  277. gap: var(--sp-3);
  278. min-height: 48px;
  279. padding: var(--sp-2) 0;
  280. }
  281. .m-row--cap {
  282. padding: var(--sp-3) var(--sp-4);
  283. min-height: 56px;
  284. }
  285. .m-row-body {
  286. flex: 1;
  287. min-width: 0;
  288. }
  289. .m-row-t1 {
  290. display: block;
  291. color: var(--ink);
  292. font-size: var(--fs-15);
  293. }
  294. .m-row-t2 {
  295. display: block;
  296. color: var(--muted);
  297. font-size: var(--fs-13);
  298. }
  299. .m-list {
  300. margin: 0;
  301. padding: 0;
  302. list-style: none;
  303. border: 1px solid var(--edge);
  304. border-radius: var(--r-lg);
  305. background: var(--surface);
  306. overflow: hidden;
  307. }
  308. .m-list > li + li {
  309. border-top: 1px solid var(--edge);
  310. }
  311. .m-rename {
  312. display: grid;
  313. place-items: center;
  314. flex: none;
  315. width: 32px;
  316. height: 32px;
  317. border: 0;
  318. border-radius: var(--r-sm);
  319. background: none;
  320. color: var(--muted);
  321. font-size: var(--fs-12);
  322. cursor: pointer;
  323. }
  324. .m-rename:active {
  325. background: var(--surface-hover);
  326. }
  327. .m-rename:disabled {
  328. opacity: 0.4;
  329. }
  330. .m-toast {
  331. position: fixed;
  332. left: 50%;
  333. bottom: calc(var(--mob-tabbar-h) + var(--sp-6));
  334. transform: translateX(-50%);
  335. z-index: var(--z-toast);
  336. max-width: 80vw;
  337. padding: var(--sp-2) var(--sp-4);
  338. border-radius: var(--r-md);
  339. background: var(--ink);
  340. color: var(--canvas);
  341. font-size: var(--fs-13);
  342. text-align: center;
  343. box-shadow: var(--shadow-pop);
  344. }
  345. .m-badge-soon {
  346. flex: none;
  347. padding: var(--sp-1) var(--sp-2);
  348. border-radius: var(--r-sm);
  349. background: var(--neutral-soft);
  350. color: var(--neutral);
  351. font-size: var(--fs-11);
  352. }
  353. `,
  354. ],
  355. })
  356. export class MbAdminPage {
  357. private readonly router = inject(Router);
  358. private readonly session = inject(SessionService);
  359. private readonly contacts = inject(ContactsService);
  360. /** 重命名服务(模板要访问 busy,故为 protected) */
  361. protected readonly renameSvc = inject(AgentRenameService);
  362. private readonly prompt = inject(PromptModalService);
  363. protected readonly me = this.session.me;
  364. protected readonly canManage = computed(() => canManageCompany(this.session.me()));
  365. protected readonly roleLabel = computed(() => resolveScope(this.session.me()).label);
  366. protected readonly groups = computed(() => this.contacts.companyGroups());
  367. /** 管理能力清单(预留,均标注「尚未开放」) */
  368. protected readonly capabilities = [
  369. { key: 'quota', label: '容器配额与计费', desc: '统一设置部门容器的资源与用量上限' },
  370. { key: 'dept', label: '部门归属', desc: '把容器划到具体部门,供部门内共享' },
  371. { key: 'perm', label: '容器权限', desc: '配置谁可以与该容器对话、谁可以管理' },
  372. {
  373. key: 'member',
  374. label: '成员细粒度权限',
  375. desc: '按成员或部门单独授权:可读 / 可对话 / 可管理,逐容器生效',
  376. },
  377. { key: 'audit', label: '使用审计', desc: '查看容器使用记录与异常告警' },
  378. ];
  379. /**
  380. * 重命名容器(2026-11-04)。
  381. *
  382. * 结果写回**服务端**:容器名是同公司跨用户可见的,
  383. * 存本地会导致「我这里改了、同事看到的还是旧名」。
  384. * 服务端接口就绪前调用会失败,界面如实提示 —— 不做本地假象。
  385. */
  386. /** 进入成员管理页(2026-11-10) */
  387. protected goMembers(): void {
  388. void this.router.navigateByUrl('/mobile/admin/users');
  389. }
  390. protected rename(agent: Agent): void {
  391. this.prompt.open({
  392. title: '重命名容器',
  393. value: agent.name,
  394. placeholder: '新的容器名称',
  395. allowEmpty: false,
  396. confirmText: '保存',
  397. validate: (v) => {
  398. const n = v.trim();
  399. if (!n) return '名称不能为空';
  400. if (n.length > 24) return '名称不能超过 24 个字符';
  401. if (n === agent.name) return '名称没有变化';
  402. return null;
  403. },
  404. onConfirm: (v) => {
  405. void this.renameSvc
  406. .rename(agent.id, v.trim())
  407. .catch((e: unknown) => {
  408. this.toast(e instanceof Error ? e.message : '重命名失败');
  409. });
  410. },
  411. });
  412. }
  413. /** 轻量错误提示(复用现有 toast,不额外引入依赖) */
  414. private toast(msg: string): void {
  415. const el = document.createElement('div');
  416. el.className = 'm-toast';
  417. el.textContent = msg;
  418. document.body.appendChild(el);
  419. setTimeout(() => el.remove(), 2400);
  420. }
  421. protected countPeople(g: { departments: { people: Person[] }[] }): number {
  422. return g.departments.reduce((n, d) => n + d.people.length, 0);
  423. }
  424. protected countAgents(g: { departments: { agents: Agent[] }[] }): number {
  425. return g.departments.reduce((n, d) => n + d.agents.length, 0);
  426. }
  427. protected back(): void {
  428. if (history.length > 1) history.back();
  429. else void this.router.navigateByUrl('/mobile/mine');
  430. }
  431. }