Просмотр исходного кода

feat: expose migrated admin resource catalog

彭峰 1 месяц назад
Родитель
Сommit
29430aaf60

+ 1 - 0
docs/migration/admin-migration-status.md

@@ -12,6 +12,7 @@
 
 - Angular 18 独立管理端路由 `/admin`,包含独立登录、后台布局、概览、动态 Schema 表格、动态编辑表单、分页、搜索、软删除和迁移状态页。
 - 后台迁移状态页直接读取线上 `xiaoshu.app.gateway/migration_status`,动态显示已实现 action 数量及完整阻塞原因清单,不再使用前端硬编码阻塞计数。
+- 后台“全部数据类”目录由管理员云函数实时返回白名单,所有已开放规范化类均可从界面进入动态列表/编辑页;`_Session`、`Function` 等敏感系统类不进入目录。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 4 - 0
scripts/deploy-admin-functions.mjs

@@ -139,6 +139,10 @@ async function handler(request, response) {
       try { const row = await Psql.oneOrNone('SELECT COUNT(*)::int AS count FROM cms.record_registry'); if (row) registeredRecords = Number(row.count); } catch (_) {}
       return response.json({ success: true, data: { identity: { objectId: context.current.id, username: String(context.current.get('username') || ''), displayName: String(context.current.get('realName') || context.current.get('nickname') || context.current.get('username') || ''), company: safeValue(context.company), roles: context.roles, isSuperAdmin: context.isSuperAdmin }, metrics: counts, migration: { sourceObjects: 338, migratedClasses: 96, registeredRecords, cloudFunctions: await functionQuery.count({ useMasterKey: true }) } } });
     }
+    if (operation === 'catalog') {
+      const resources = [...ALLOWED_CLASSES].sort().map((className) => ({ className, label: CLASS_LABELS[className] || className, writable: !READ_ONLY_CLASSES.has(className), route: '/admin/resources/' + className }));
+      return response.json({ success: true, data: { total: resources.length, resources } });
+    }
     const className = String(input.className || '');
     assertClass(className);
     const fields = await schemaFor(className);

+ 3 - 0
scripts/smoke-admin-functions.mjs

@@ -162,6 +162,9 @@ try {
   const dashboard = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'dashboard' });
   if (!Array.isArray(dashboard.metrics) || dashboard.metrics.length !== 8) throw new Error('dashboard 指标校验失败');
 
+  const catalog = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'catalog' });
+  if (!Array.isArray(catalog.resources) || catalog.resources.length < 90 || catalog.resources.some((item) => ['_Session', 'Function'].includes(item.className))) throw new Error('后台规范化类目录校验失败');
+
   const schema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'CourseAppointment' });
   if (schema.className !== 'CourseAppointment' || !Array.isArray(schema.fields)) throw new Error('schema 校验失败');
 

+ 1 - 0
src/app/admin/admin.constants.ts

@@ -11,6 +11,7 @@ export const ADMIN_SESSION_KEY = 'xiaoshu_admin_session_v1';
 
 export const ADMIN_NAVIGATION = [
   { label: '管理概览', icon: 'layout-dashboard', route: '/admin/dashboard' },
+  { label: '全部数据类', icon: 'table-properties', route: '/admin/resources' },
   { label: '用户与组织', icon: 'users', route: '/admin/resources/_User' },
   { label: '课程绑定', icon: 'book-open', route: '/admin/resources/CourseBinding' },
   { label: '预约管理', icon: 'calendar-days', route: '/admin/resources/CourseAppointment' },

+ 12 - 0
src/app/admin/admin.models.ts

@@ -38,6 +38,18 @@ export interface AppMigrationStatus {
   blocked: Record<string, string>;
 }
 
+export interface AdminResourceCatalogItem {
+  className: string;
+  label: string;
+  writable: boolean;
+  route: string;
+}
+
+export interface AdminResourceCatalog {
+  total: number;
+  resources: AdminResourceCatalogItem[];
+}
+
 export interface AdminFieldSchema {
   name: string;
   type: string;

+ 4 - 0
src/app/admin/admin.routes.ts

@@ -16,6 +16,10 @@ export const adminRoutes: Routes = [
         path: 'dashboard',
         loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent),
       },
+      {
+        path: 'resources',
+        loadComponent: () => import('./pages/admin-resource-catalog.component').then((module) => module.AdminResourceCatalogComponent),
+      },
       {
         path: 'resources/:className',
         loadComponent: () => import('./pages/admin-resource.component').then((module) => module.AdminResourceComponent),

+ 2 - 1
src/app/admin/layout/admin-shell.component.ts

@@ -19,6 +19,7 @@ import {
   Menu,
   Route,
   Search,
+  TableProperties,
   Users,
 } from 'lucide-angular';
 import { ADMIN_NAVIGATION } from '../admin.constants';
@@ -39,7 +40,7 @@ export class AdminShellComponent {
   readonly icons = {
     Bell, BookOpen, CalendarDays, ChartNoAxesCombined, ChevronLeft, ChevronRight,
     ClipboardCheck, Files, GraduationCap, Languages, LayoutDashboard, ListChecks,
-    LogOut, Menu, Route, Search, Users,
+    LogOut, Menu, Route, Search, TableProperties, Users,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

+ 22 - 0
src/app/admin/pages/admin-resource-catalog.component.html

@@ -0,0 +1,22 @@
+<header class="page-header"><div><p>DATA DIRECTORY</p><h1>全部数据类</h1><span>只展示管理员云函数白名单内的规范化业务类</span></div><strong>{{ catalog()?.total ?? '—' }} 类</strong></header>
+
+<label class="search-box"><lucide-icon [img]="icons.Search" [size]="18" /><input [ngModel]="query()" (ngModelChange)="query.set($event)" placeholder="搜索中文名称或 Parse Class" /></label>
+
+@if (loading()) {
+  <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="25" />正在通过云函数读取目录…</div>
+} @else if (error()) {
+  <div class="state error"><strong>目录加载失败</strong><span>{{ error() }}</span><button type="button" (click)="load()">重新加载</button></div>
+} @else {
+  <section class="resource-grid">
+    @for (item of resources(); track item.className) {
+      <a [routerLink]="item.route">
+        <div class="resource-icon"><lucide-icon [img]="icons.Database" [size]="20" /></div>
+        <div><strong>{{ item.label }}</strong><code>{{ item.className }}</code></div>
+        <span [class.readonly]="!item.writable">{{ item.writable ? '可管理' : '只读' }}</span>
+        <lucide-icon class="arrow" [img]="icons.ArrowRight" [size]="18" />
+      </a>
+    } @empty {
+      <div class="state">没有匹配的数据类</div>
+    }
+  </section>
+}

+ 5 - 0
src/app/admin/pages/admin-resource-catalog.component.scss

@@ -0,0 +1,5 @@
+:host { display: block; }.page-header { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 20px; }.page-header p { margin: 0 0 7px; color: #2e9a71; font-size: 11px; font-weight: 800; letter-spacing: .18em; }.page-header h1 { margin: 0; font-size: 28px; }.page-header span { display: block; margin-top: 7px; color: #7e8999; font-size: 12px; }.page-header > strong { color: #2c6f55; font-size: 15px; }
+.search-box { display: flex; align-items: center; gap: 9px; max-width: 520px; height: 42px; padding: 0 13px; border: 1px solid #dfe5ed; border-radius: 11px; color: #7d8999; background: white; }.search-box input { flex: 1; min-width: 0; border: 0; outline: 0; color: #28364a; background: transparent; font: inherit; }
+.resource-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 13px; margin-top: 18px; }.resource-grid a { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 76px; padding: 14px 42px 14px 14px; border: 1px solid #e2e7ee; border-radius: 13px; color: #28364a; background: white; text-decoration: none; transition: .18s ease; }.resource-grid a:hover { border-color: #9cc9b7; box-shadow: 0 8px 24px rgba(31, 86, 64, .09); transform: translateY(-1px); }.resource-icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: #2e906c; background: #eaf7f2; }.resource-grid a > div:nth-child(2) { display: grid; gap: 5px; min-width: 0; }.resource-grid strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.resource-grid code { overflow: hidden; color: #8390a1; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }.resource-grid span { padding: 3px 7px; border-radius: 12px; color: #247556; background: #eaf7f1; font-size: 10px; white-space: nowrap; }.resource-grid span.readonly { color: #8a641d; background: #fff3d9; }.arrow { position: absolute; right: 14px; color: #a1adba; }
+.state { display: flex; align-items: center; justify-content: center; gap: 9px; min-height: 180px; color: #7f8b9b; font-size: 13px; }.state.error { flex-direction: column; color: #a43b43; }.state button { padding: 7px 12px; border: 0; border-radius: 7px; color: white; background: #2d8f6a; cursor: pointer; }.spin { animation: spin .9s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }
+@media (max-width: 1100px) { .resource-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (max-width: 680px) { .resource-grid { grid-template-columns: 1fr; }.page-header { align-items: flex-start; }.page-header > strong { display: none; } }

+ 39 - 0
src/app/admin/pages/admin-resource-catalog.component.ts

@@ -0,0 +1,39 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { RouterLink } from '@angular/router';
+import { ArrowRight, Database, LoaderCircle, LucideAngularModule, Search } from 'lucide-angular';
+import { AdminResourceCatalog } from '../admin.models';
+import { CloudFunctionsService } from '../cloud-functions.service';
+
+@Component({
+  selector: 'app-admin-resource-catalog',
+  standalone: true,
+  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  templateUrl: './admin-resource-catalog.component.html',
+  styleUrl: './admin-resource-catalog.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminResourceCatalogComponent implements OnInit {
+  readonly catalog = signal<AdminResourceCatalog | null>(null);
+  readonly query = signal('');
+  readonly loading = signal(true);
+  readonly error = signal('');
+  readonly resources = computed(() => {
+    const keyword = this.query().trim().toLowerCase();
+    const resources = this.catalog()?.resources ?? [];
+    return keyword ? resources.filter((item) => `${item.label} ${item.className}`.toLowerCase().includes(keyword)) : resources;
+  });
+  readonly icons = { ArrowRight, Database, LoaderCircle, Search };
+  private readonly functions = inject(CloudFunctionsService);
+
+  ngOnInit(): void { void this.load(); }
+
+  async load(): Promise<void> {
+    this.loading.set(true);
+    this.error.set('');
+    try { this.catalog.set(await this.functions.admin<AdminResourceCatalog>('catalog')); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '数据类目录加载失败'); }
+    finally { this.loading.set(false); }
+  }
+}