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

feat: add Angular admin and cloud function gateway

彭峰 1 месяц назад
Родитель
Сommit
525968908b
31 измененных файлов с 1443 добавлено и 1 удалено
  1. 52 0
      docs/migration/admin-migration-status.md
  2. 89 0
      docs/migration/legacy-admin-inventory.generated.md
  3. 43 0
      docs/schema/functions-function.md
  4. 3 0
      package.json
  5. 4 0
      proxy.conf.cjs
  6. 294 0
      scripts/deploy-admin-functions.mjs
  7. 57 0
      scripts/generate-legacy-admin-inventory.mjs
  8. 89 0
      scripts/smoke-admin-functions.mjs
  9. 56 0
      src/app/admin/admin-session.service.ts
  10. 24 0
      src/app/admin/admin.constants.ts
  11. 9 0
      src/app/admin/admin.guard.ts
  12. 70 0
      src/app/admin/admin.models.ts
  13. 29 0
      src/app/admin/admin.routes.ts
  14. 39 0
      src/app/admin/cloud-functions.service.ts
  15. 50 0
      src/app/admin/layout/admin-shell.component.html
  16. 38 0
      src/app/admin/layout/admin-shell.component.scss
  17. 51 0
      src/app/admin/layout/admin-shell.component.ts
  18. 40 0
      src/app/admin/pages/admin-dashboard.component.html
  19. 27 0
      src/app/admin/pages/admin-dashboard.component.scss
  20. 36 0
      src/app/admin/pages/admin-dashboard.component.ts
  21. 36 0
      src/app/admin/pages/admin-login.component.html
  22. 36 0
      src/app/admin/pages/admin-login.component.scss
  23. 51 0
      src/app/admin/pages/admin-login.component.ts
  24. 19 0
      src/app/admin/pages/admin-migration.component.html
  25. 6 0
      src/app/admin/pages/admin-migration.component.scss
  26. 44 0
      src/app/admin/pages/admin-migration.component.ts
  27. 36 0
      src/app/admin/pages/admin-resource.component.html
  28. 8 0
      src/app/admin/pages/admin-resource.component.scss
  29. 104 0
      src/app/admin/pages/admin-resource.component.ts
  30. 2 0
      src/app/app.routes.ts
  31. 1 1
      src/app/core/page-catalog.spec.ts

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

@@ -0,0 +1,52 @@
+# Angular 管理后台与云函数迁移状态
+
+## 已确认的迁移基线
+
+- 旧 Web 交付物是 ASP.NET Core 发布包,没有 `.sln`、`.csproj` 与原始 Razor 管理页面源码。
+- `ZoomLaCMS.dll` 可恢复 79 个后台控制器、至少 1,042 个公开动作;详细清单见 `legacy-admin-inventory.generated.md`。
+- 线上 Parse 应用为 `7pIbDBJmKx_main`,当前可见 109 个 Schema,其中 96 个为 SQL Server 规范化迁移目标类。
+- 核心数据已存在:`CommonModel` 92,435、`VocabularyWord` 89,620、`NodeAuth` 5,935、`PracticeRecord` 816、`CourseAppointment` 345、`DailyStudyRecord` 108、`LessonRecord` 82。
+- 迁移开始前 `Function` 表为空;本阶段已部署 8 个云函数。
+
+## 本阶段已实现
+
+- Angular 18 独立管理端路由 `/admin`,包含独立登录、后台布局、概览、动态 Schema 表格、动态编辑表单、分页、搜索、软删除和迁移状态页。
+- Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
+- 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
+- `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。
+- 8 个线上函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
+
+## SQL 视图 / 函数替代状态
+
+| 旧对象 | 云函数替代 | 状态 | 说明 |
+|---|---|---|---|
+| `ZL_StripeHtmlTag`、`ZL_StripeTrimstr` | `cms.content-normalizer` | 已实现 | 保守转换为纯文本,覆盖脚本、样式、实体和空白 |
+| `ZL_EX_UserView` | `cms.users.extended` | 已实现 | 过滤密码、哈希、会话、Token 与 Secret |
+| `ZL_Exam_ClassView` | `cms.exams.classes` | 已实现基础投影 | 当前目标类缺少可证明的学校/年级 Pointer 时不伪造关联 |
+| `ZL_Guest_BarView` | `cms.guest.bar` | 已实现基础投影 | 支持帐套与分页 |
+| `ZL_SearchView` | `cms.search` | 已实现 | 搜索 `CommonModel` 并清理输出 HTML |
+| `ZL_User_PlatView` | `cms.users.platform` | 已实现 | 使用合并进 `_User` 的 legacy 平台资料 |
+| `ZL_User_WXView` | `cms.users.wechat` | 已实现 | 使用合并进 `_User` 的微信资料并过滤密钥 |
+| `ZL_CartProView` | — | 数据阻塞 | 目标 Schema 无购物车类,且迁移登记没有可用行 |
+| `ZL_Order_PayedView` | — | 数据阻塞 | 目标 Schema 无订单/支付明细类 |
+| `ZL_Order_ProSaleView` | — | 数据阻塞 | 目标 Schema 无订单商品明细类 |
+| `ZL_Order_ShareView` | — | 数据阻塞 | 目标 Schema 无订单分享关系类 |
+| `ZL_Plat_BlogView` | — | 数据阻塞 | 目标 Schema 无博客正文/回复类 |
+
+## 仍需逐页专用迁移
+
+动态 Schema CRUD 覆盖的是规范化业务数据管理能力,不代表旧后台 1,042 个动作已逐页复刻。考试组卷、微信配置、订单支付、模板编辑、数据库备份、站点集群、PowerShell、文件管理等必须逐项判断:
+
+1. 有目标业务类且仍属当前产品范围:新增专用 Angular 页面和专用云函数。
+2. 需要第三方密钥或基础设施权限:取得新系统凭据后再实现,前端不得存储密钥。
+3. 依赖未迁移表、IIS、Windows 文件系统或旧 Zoomla 运行时:列入最终无法迁移清单,不以空成功响应代替。
+
+## 管理员启用前置条件
+
+线上 435 个 `_User` 在核验时没有任何 `isAdmin=true`、`role=admin` 或 `adminRoleKey` 管理员。系统因此默认不可登录管理端。上线前必须由持有 Parse `masterKey` 的部署人员为明确指定的现有用户设置:
+
+- `isAdmin: true`
+- `roles: ["admin"]`
+- `company: Pointer<Company>`
+
+跨帐套管理员才可设置 `adminRoleKey: "super-admin"`;不允许从浏览器执行管理员提权。

+ 89 - 0
docs/migration/legacy-admin-inventory.generated.md

@@ -0,0 +1,89 @@
+# 旧 ASP.NET 管理后台控制器与动作清单
+
+> 本文件由 `scripts/generate-legacy-admin-inventory.mjs` 从 `ZoomLaCMS.dll` 的 ILSpy 反编译结果生成。发布包没有 `.sln`、`.csproj` 和原始 Razor 视图,因此这是后端动作基线,不等同于可恢复的页面像素基线。
+
+- 管理控制器:79
+- 可识别公开动作:1042
+- 生成日期:2026-08-18
+
+| 模块 / 控制器 | 动作数 | 公开动作 |
+|---|---:|---|
+| Extend / AD | 10 | `Default`、`ZoneManage`、`Zone_API`、`ZoneAdd`、`ZoneAdd_Submit`、`InfoManage`、`Info_API`、`InfoAdd`、`InfoAdd_Submit`、`ADPreView` |
+| Content / Addon | 10 | `Comment`、`Commont_API`、`ConAudit_API`、`ConAudit_SJ_API`、`ConAudit`、`ConAuditDetail`、`TagKey`、`TagKey_Submit`、`VerBak`、`VerBak_Del` |
+| Shop / Addon | 4 | `Vendor`、`Vendor_API`、`VendorAdd`、`VendorAdd_Submit` |
+| User / Addon | 11 | `PointGroup`、`PointGroupAdd`、`PointGroup_API`、`PointGroup_Submit`、`VMoneyLog`、`ThirdInfo`、`ThirdInfo_Submit`、`SigninList`、`RealNameAuth`、`RealNameAuthAdd`、`RealNameAuth_API` |
+| Content / Ask | 11 | `Default`、`AskInfo`、`AskAdd`、`AskAdd_Submit`、`Ask_API`、`AnswerList`、`AnswerAdd`、`AnswerAdd_Submit`、`Answer_API`、`Config`、`Config_Submit` |
+| Content / Baike | 8 | `Default`、`BKVersionList`、`Version_API`、`BKList`、`BK_API`、`Config`、`Config_Submit`、`BkCheck` |
+| Content / Bar | 11 | `Default`、`CateList`、`CateAdd`、`CateAdd_Submit`、`Cate_API`、`BarAuthSet`、`BarAuthSet_Submit`、`TieList`、`TieDetail`、`TieMove`、`Tie_API` |
+| BiaoCalc | 5 | `Default`、`BiaoDetail`、`Biao_API`、`UserFiles`、`UserFiles_API` |
+| Office / Chart | 1 | `Index` |
+| Extend / Chat | 11 | `Default`、`UserChatHistory`、`UserFriend`、`Group`、`GroupAdd`、`GroupAdd_Submit`、`GroupUser`、`MessageHistory`、`Config`、`Config_Submit`、`Chat_API` |
+| Design / Class | 4 | `ClassList`、`ClassAdd`、`ClassAdd_Submit`、`Class_API` |
+| Com | 11 | `Logout`、`SPwdCheck`、`BootLayout`、`Import`、`Import_DownTlp`、`Import_Submit`、`ImportForContent`、`ImportForContent_DownTlp`、`ImportForContent_Submit`、`Sort`、`Sort_API` |
+| Common | 3 | `UserBaseField`、`ContentField`、`SelUploadFiles` |
+| Config | 40 | `Index`、`SiteInfo`、`SiteInfo_Submit`、`SiteOption`、`SiteOption_Submit`、`MailConfig`、`MailConfig_Submit`、`UserConfig`、`UserConfig_Submit`、`SetOrderStatus`、`SetOrderStatus_Submit`、`SMSCfg`、`SMSConfig_Submit`、`ThumbConfig`、`ThumbConfig_Submit`、`AppConfig`、`AppConfig_Submit`、`APPConfig_ReInstall`、`Holiday`、`Holiday_API`、`HolidayAdd`、`HolidayAdd_Submit`、`APIInfo`、`APIInfo_Submit`、`APIInfo_SwaggerClose`、`APIInfo_SwaggerOpen`、`ReadFile`、`LicenceFile_API`、`SearchFunc`、`SearchFunc_API`、`SearchFuncAdd`、`SearchFuncAdd_Submit`、`FontIcon`、`close_system`、`Hotkey`、`HotkeyAdd`、`HotkeyAdd_Submit`、`Hotkey_API`、`Prize`、`Prize_Submit` |
+| Content | 32 | `GetMDFile`、`AddContent`、`EditContent`、`ShowContent`、`Content_Add`、`Content_AddToNew`、`Content_Update`、`Content_Draft`、`Content_API`、`ContentMove`、`ContentMove_Submit`、`AddToSpec`、`AddToSpec_Submit`、`ContentManage`、`ContentManage_Html`、`ContentRecycle`、`ContentManage_API`、`ContentManage_Status`、`ContentManage_Del`、`ContentManage_ToExcel`、`ContentManage_Refresh`、`ContentRelease`、`ContentRelease_Submit`、`IHtmlToString`、`CreateHtmlContent`、`Create_Submit`、`CreateHtml`、`ConCatch`、`CatchConfig`、`CatchConfig_Submit`、`MarkDown`、`MarkDown_Del` |
+| Design / Counter | 4 | `VisitList`、`VisitInfoList`、`VisitInfoDetail`、`Counter_API` |
+| Extend / Counter | 10 | `StatisticalCode`、`StatisticalCode_Submit`、`Index`、`Site`、`Month`、`Year`、`Local`、`Browser`、`Os`、`Channel` |
+| Shop / Coupon | 8 | `ArriveManage`、`Arrive_API`、`ArriveAdd`、`ArriveAdd_Submit`、`PromoList`、`Promo_API`、`PromoAdd`、`PromoAdd_Submit` |
+| CRM | 27 | `Index`、`ClientListDiag`、`ClientList`、`ClientView`、`ClientAdd`、`BiServer_Del`、`Client_Add`、`ClientImport`、`Import_DownTlp`、`Import_Client`、`AddServiceRecord`、`AddServiceRecord_Submit`、`ServiceRecord_List`、`CType`、`CTypeAdd`、`CType_Add`、`CType_Del`、`Contact`、`ContactAdd`、`Contact_Add`、`ContactImport`、`ContactImport_Down`、`ContactImport_Upload`、`BecomeCustomerList`、`BecomeCustomerDetail`、`BecomeCustomerDetailUpdate`、`BecomeCustomer_API` |
+| Extend / DBTool | 32 | `Default`、`Default_Result`、`Default_Field`、`ViewList`、`DBBackUP`、`DB_Backup`、`DB_ReStore`、`DB_Down`、`DB_Del`、`SiteBackup`、`EmptyData`、`EmptyData_Submit`、`FSearchConfig`、`FSearchConfig_Submit`、`FileSafe`、`FileSafe_CreateFlag`、`FileSafe_CheckTamper`、`CreateTable`、`CreateTable_API`、`PageConfig`、`PageConfigEdit`、`PageConfigReset`、`PageConfigDelete`、`PageConfigSave`、`Pagelist`、`PageAdd`、`PageAdd_submit`、`PagelistDelete`、`Output`、`DBhelp`、`DBhelp_Submit`、`DBhelp_Formatting` |
+| Dict | 13 | `DicCateList`、`DictCate_API`、`DicCateSel`、`DicItemList`、`DicItem_API`、`GradeCateManage`、`GCateAdd`、`GCateAdd_Submit`、`GCate_API`、`GradeOption`、`GOptionAdd`、`GOptionAdd_Submit`、`GOption_API` |
+| ECharts / ECharts | 6 | `EChartList`、`ChartCite`、`ShowM`、`AddChart`、`AddChart_Submit`、`Default` |
+| Exam | 56 | `Papers_System_Manage`、`Papers_System_Add`、`Paper_QuestionManage`、`ViewPaperCenter`、`QuestList`、`AddEngLishQuestion`、`QuestShow`、`Setting`、`Question_Class_Manage`、`AddQuestion_Class`、`QuestGrade`、`VersionList`、`AddVersion`、`SchoolManage`、`AddSchool`、`ClassRoomManage`、`AddClassRoom`、`StudentList`、`ExTeacherManage`、`AddExTeacher`、`CourseManage`、`AddCourse`、`AddKnowledge`、`KnowledgeManage`、`ExamPointManage`、`order`、`AddExamPoint`、`ToScore`、`SelQuestion`、`Paper_AddLarge`、`Papers_Add`、`Question_Add`、`Question_Class_GetChild`、`Question_Class_Add`、`QuestGrade_Add`、`Version_GetList`、`Version_Add`、`Knowledge_Add`、`Knowledge_GetChild`、`School_Add`、`ClassRoom_Add`、`Teacher_Add`、`Course_Add`、`ExamPoint_Add`、`Setting_Update`、`News`、`NewsAdd`、`NewsAdd_Submit`、`News_API`、`NewsDiv`、`PublishDesc`、`PubNodeAdd`、`PubNodeAdd_Submit`、`PublishAdd`、`PublishAdd_Submit`、`PublishDesign` |
+| Shop / Exp | 20 | `DeliverType`、`DeliverAdd`、`DeliverAdd_Submit`、`Deliver_API`、`OrderSend`、`OrderSend_Submit`、`Factory`、`FactoryAdd`、`FactoryAdd_Submit`、`Factory_API`、`Factory_select`、`Trademark`、`TrademarkAdd`、`Trademark_Submit`、`Trademark_API`、`Trademark_select`、`Currency`、`CurrencyAdd`、`CurrencyAdd_Submit`、`Currency_API` |
+| Files | 7 | `Default`、`GetFile`、`FileExtend`、`FileExtend_Submit`、`FileCloud`、`FileCloud_Submit`、`Filea` |
+| FontManager | 7 | `Index`、`Detail`、`Upgrade`、`Delete`、`DeleteRange`、`DeleteFile`、`DeleteFiles` |
+| FontPic | 16 | `Index`、`FontPicInfo`、`FontPicInfo_Submit`、`FontPic_API`、`Shape`、`ShapeInfo`、`ShapeInfo_Submit`、`Shape_API`、`ShapeType`、`ShapeTypeInfo`、`ShapeTypeInfo_Submit`、`ShapeType_API`、`Draft`、`DraftInfo`、`DraftInfo_Submit`、`Draft_API` |
+| Content / Guest | 11 | `Default`、`CateList`、`CateAdd`、`CateAdd_Submit`、`Cate_API`、`MsgList`、`ReplyGuest`、`EBtnSubmit`、`MsgShow`、`MsgShow_Submit`、`Msg_API` |
+| Helper | 2 | `Index`、`HelpInfo` |
+| Hms | 10 | `UList`、`VipAdd`、`VipAddJump`、`VipUpdate`、`VipUpdate_Submit`、`VipRenewal`、`VipRenewal_Submit`、`VipOverdue`、`VipOverdue_Submit`、`OList` |
+| Index | 16 | `Default`、`Main`、`WorkTable`、`Scence`、`FuncList`、`Lockin`、`SiteMap`、`Lockin_Submit`、`ASCXLoad`、`Login`、`Login_AJAX`、`Logout`、`API`、`AccountForm`、`AccountForm_API`、`AccountForm_Submit` |
+| Design / Interactive | 2 | `InteractiveList`、`Interactive_API` |
+| Office / iServer | 11 | `Default`、`AddQuestionRecord`、`AddQuestionRecord_Submit`、`BiServer`、`BiServer_Del`、`BiServerInfo`、`DelIServer`、`UpdateIServer`、`BselectiServer`、`ISReplyAdd`、`ISReplyAdd_Submit` |
+| Office / Licence | 10 | `LicenceFiles`、`LicenceFiles_API`、`LicenceFilesAdd`、`LicenceFilesAdd_Submit`、`LicenceSignApplyList`、`LicenceSignApplyDetail`、`LicenceSignApply_API`、`LicenceSignList`、`LicenceSignDetail`、`LicenceSign_API` |
+| Extend / Log | 10 | `TxtLog`、`TxtLogContent`、`TxtLog_Down`、`SysLog`、`TaskList`、`TaskAdd`、`TaskAdd_API`、`TaskAdd_Submit`、`Task_API`、`TaskCenter` |
+| User / Mail | 19 | `Index`、`SafeMobileSms`、`SafeMobileSms_API`、`SMSSend`、`SMSSend_Submit`、`SMSSendLog`、`DeleteSMSLog`、`DeleteSMSLogs`、`Mail_API`、`MailList`、`MailTlp`、`MailTlpAdd`、`MailTlpAdd_Submit`、`MailSysTlp`、`MailSysTlpEdit`、`MailSysTlpEdit_Submit`、`MailSend`、`MailSend_Submit`、`MailSendLog` |
+| Manager | 2 | `ModifyPassword`、`ModifyPassword_Submit` |
+| Content / MarkDown | 5 | `Index`、`MD`、`Index_submit`、`Dels`、`Preview` |
+| User / Message | 5 | `Message`、`MessageSend`、`Message_Add`、`MessageRead`、`Message_API` |
+| Mobile | 2 | `Index`、`MobileBrower` |
+| Model | 11 | `Index`、`ModelManage`、`ModelAdd`、`ModelAdd_Submit`、`Model_API`、`ModelData`、`Field`、`Order_Submit`、`FieldAdd`、`FieldAdd_Submit`、`Field_API` |
+| Extend / MyCss | 7 | `Default`、`Default_API`、`CSSEditor`、`CSSEditor2`、`CSSContent`、`CSSContent_Submit`、`CSSDown` |
+| Content / Node | 21 | `ZhiDing`、`NodeManage`、`NodeSearch`、`NodeRecycle`、`NodeAdd`、`SPageAdd`、`OutLinkAdd`、`NodeAdd_Submit`、`Node_API`、`BatchNode`、`BatchNode_Submit`、`UnionNode`、`UnionNode_Merge`、`UnionNode_Move`、`Special_API`、`Special`、`SpecialAdd`、`SpecialAdd_Save`、`UnionSpecial`、`UnionSpecial_Move`、`UnionSpecial_Merge` |
+| Office / OA | 27 | `Default`、`FlowInfo`、`FlowInfo_Submit`、`Flow_API`、`SelFlowModel`、`FlowItem`、`FlowItemInfo`、`FlowItemInfo_Submit`、`FlowItem_API`、`SelUserModel`、`AddMisModel`、`AddMisModel_Submit`、`AddSign`、`AddSign_Submit`、`AddSign_Delete`、`AddWorkFlowType`、`AddWorkFlowType_Submit`、`AddWorkFlowType_Delete`、`ApplyManage`、`ApplyManage_Delete`、`FlowTypeList`、`MailManage`、`MisModelManage`、`MisModelManage_Delete`、`OAConfig`、`SelModelFieds`、`SignManage` |
+| Order | 38 | `OrderList_font`、`OrderSettle_font`、`OrderSettle_font_detail`、`OrderSettleAdd_font`、`OrderSettleAdd_font_Submit`、`OrderSettleHistory_font`、`OrderSettle_API`、`FontAuthorizeList`、`FontAuthorizeDetail`、`FontAuthorize_API`、`FontOrder_Output`、`OrderListInfo_font`、`OrderListInfo_font_licence`、`OrderList_fontDown`、`OrderList`、`Order_Down`、`Order_API`、`OrderListInfo`、`OrderRepairAudit`、`Audit_Btn_Click`、`UnAudit_Btn_Click`、`Del_Btn_Click`、`RepairDeailt`、`ChangeMoney_Btn_Click`、`CartManage`、`OrderAdd`、`OrderAdd_Submit`、`OrderPay`、`OrderPay_Submit`、`OrderBack`、`OrderBack_Submit`、`OrderPrint`、`OrderExpPrint`、`Output`、`OrderCar`、`API`、`Supple`、`Supple_Submit` |
+| Page | 18 | `ApplyAudit`、`ApplyInfo`、`PageStyle`、`PageStyleAdd`、`PageContent`、`EditContent`、`Content_Edit`、`PageConfig`、`PageTemplate`、`PageTemplateAdd`、`SetPageOrder`、`PageConfig_Update`、`PageTemplate_Add`、`SetPageOrder_Batch`、`SetPageOrder_UpMove`、`SetPageOrder_DownMove`、`Style_Add`、`Apply_Update` |
+| Pay | 29 | `PayPlat`、`PayPlat_API`、`PayPlatAdd`、`PayPlatAdd_Submit`、`PayPalPlat`、`WechatPlat`、`WechatPlat_Submit`、`AliH5Plat`、`AliBankPlat`、`PaymentList`、`PaymentList_Output`、`PaymentList_API`、`PaymentInfo`、`Payment_Success`、`Regular`、`Regular_API`、`RegularAdd`、`RegularAdd_Submit`、`OCR`、`OCRList`、`OCRView`、`OCRDelete`、`OFR`、`OFRAdd`、`OFRDelete`、`VMoneyTurnover`、`VMoneyStatistics`、`VMoneyStatisticsAdd`、`VMoneyStatisticsDetail` |
+| Office / Plat | 22 | `Default`、`AddComp`、`AddComp_Submit`、`AuditApply`、`AuditApply_Agree`、`AuditApply_Reject`、`CompList`、`CompInfo`、`CreateComp`、`MemberList`、`Member_Add`、`Member_API`、`PlatInfoDeail`、`PlatInfoDetail_Submit`、`PlatInfoManage`、`TopicList`、`WordTlp`、`API`、`Crud`、`CrudAdd`、`CrudAdd_Submit`、`GroupAdmin_API` |
+| Label / PowerShell | 6 | `Index`、`Cm_information`、`RunScript`、`AddScriptConsole`、`Add`、`Submit` |
+| Printer | 1 | `Index` |
+| User / PrivacyPolicy | 5 | `LicenceList`、`LicenceAdd`、`LicenceAdd_Submit`、`Licence_API`、`LicenceUserSign` |
+| Product | 23 | `ProductManage`、`ProductRecycle`、`Product_API`、`AddProduct`、`GetProducer`、`GetBrand`、`ProductShow`、`Product_Add`、`Product_AddToNew`、`StockList`、`StockAdd`、`Stock_Add`、`Addon_VerBak`、`RegionPrice`、`RegionPrice_Submit`、`RegionPriceEdit`、`RegionPriceEdit_Submit`、`ShareEdit`、`ShareEdit_Submit`、`CommentManage`、`CommentManage_API`、`CommentAdd`、`CommentAdd_Submit` |
+| User / Promo | 11 | `Index`、`WD`、`WD_API`、`WDAudit`、`WDAudit_Accept`、`WDAudit_Reject`、`PromoList`、`UserBank`、`UserBankAdd`、`UserBankAdd_Submit`、`UserBank_API` |
+| Pub | 12 | `PubManage`、`Pub_API`、`Pubinfo`、`PubAdd`、`PubAdd_Submit`、`Pubsinfo`、`PubsinfoAdd`、`PubsinfoAdd_Submit`、`PubInfo_API`、`PubInfo2_API`、`PubsinfoReply`、`PubsinfoReply_Submit` |
+| Push | 12 | `Index`、`APIList`、`API_del`、`AddAPI`、`AddAPI_Submit`、`PushMsg`、`PushMsg_Submit`、`PushTlp`、`AddPushTlp`、`AddPushTlp_Submit`、`PushTlp_API`、`MsgList` |
+| Extend / PWA | 6 | `Default`、`Default_Submit`、`Mobile`、`Mobile_Submit`、`Offline`、`Offline_Submit` |
+| Extend / QuartzTask | 11 | `TaskList`、`ViewLog`、`TaskAdd`、`TaskAdd`、`TaskEdit`、`TaskUpdate`、`ExecuteNow`、`TaskDelete`、`TaskDeleteRange`、`TaskPause`、`TaskResume` |
+| Shop / Refund | 4 | `ShowOrder`、`WxRefund`、`WxRefundInfo`、`WxRefund_API` |
+| Design / Resource | 4 | `ResList`、`ResourceAdd`、`ResourceAdd_Submit`、`Resource_API` |
+| User / Role | 11 | `UserRole`、`UserRoleAdd`、`UserRoleAdd_Submit`、`UserRoleAuth`、`UserRole_API`、`ARole`、`ARole_API`、`ARoleAdd`、`ARoleAdd_Submit`、`ARoleAuth`、`ARoleAuth_Submit` |
+| Extend / Safe | 15 | `Default`、`API`、`SiteSwitch`、`SiteIP`、`SiteIPAdd`、`SiteIPAdd_Submit`、`ContentProtect`、`SiteAssess`、`SiteProtect`、`SiteProtect_Submit`、`ContentCheck`、`ContentCheckConfig`、`ContentCheckConfig_Submit`、`ContentFilter`、`SiteCheck` |
+| Sale | 9 | `Index`、`SaleByDay`、`SaleByMonth`、`SaleByProduct`、`SaleByProductDetail`、`SaleByClass`、`SaleByUser`、`SaleByUserDetail`、`PayList` |
+| Design / Scene | 5 | `SceneList`、`SceneListRecycle`、`SceneAdd`、`SceneAdd_Submit`、`Scene_API` |
+| Office / Service | 11 | `Default`、`Seat`、`SeatAdd`、`SeatAdd_Submit`、`CodeAdd`、`CodeAdd_Submit`、`Seat_API`、`CodeList`、`MsgEx`、`MsgInfo`、`MsgList` |
+| Site | 18 | `API`、`Default`、`SiteList`、`FileManage`、`SiteAdd`、`SiteAdd_Submit`、`Config`、`Config_Submit`、`SitePool`、`ServerClusterConfig`、`ServerAdd`、`SiteCloudSetup`、`SiteDataCenter`、`VirtualDirectory`、`DBManage`、`DBAdd`、`HostList`、`HostAdd` |
+| Label / SiteDeploy | 12 | `SiteList`、`SiteAdd`、`SiteAdd`、`SiteDetail`、`ExecuteCommand`、`SiteEdit`、`SetDefaultApi`、`SiteUpdate`、`SiteDelete`、`SiteDeleteRange`、`SitePublish`、`SiteDownload` |
+| Shop / Store | 9 | `StoreManage`、`Store_API`、`StoreStyle`、`Style_API`、`StoreStyleAdd`、`StoreStyleAdd_Submit`、`StoreUpdate`、`StoreUpdate_Submit`、`Product` |
+| User / Struct | 14 | `Default`、`StructList`、`order`、`order_submit`、`Struct_API`、`StructByTree`、`Struct_ListByTree`、`StructAdd`、`StructAdd_Submit`、`StructMember`、`Structure_update`、`Structure_Merge`、`Structure_Move`、`Structure_List` |
+| Extend / Survey | 10 | `SurveyManage`、`Survey_API`、`SurveyAdd`、`SurveyAdd_Submit`、`SurveyItemList`、`SurveyItemAdd`、`SurveyItemAdd_Submit`、`SurveyItem_API`、`SurveyResultChart`、`SurveyResultList` |
+| Template | 34 | `LabelManage`、`LabelHtml`、`LabelHtml_Save`、`LabelSql`、`LabelSql_Save`、`LabelCallTab`、`LabelCall_API`、`LabelInsert`、`Label_API`、`LabelSql_API`、`LabelUpdate`、`LabelUpdate_Submit`、`LabelUpdate_Submit2`、`LabelSql_Field`、`LabelPage`、`LabelPage_Submit`、`TemplateManage`、`TemplateEdit`、`TemplateList`、`TemplateEdit_Submit`、`CSSManage`、`CSSEdit`、`CSSEdit_Submit`、`TemplateSetOfficial`、`TemplateSet`、`TemplateSetDown`、`TemplateSet_API`、`TemplateSetAdd`、`TemplateSetAdd_Submit`、`DSList`、`DBList_API`、`DSAdd`、`DSAdd_Submit`、`SelFont` |
+| Design / Template | 6 | `GoDesign`、`TlpList`、`TlpListRecycle`、`TemlateAdd`、`TemlateAdd_Submit`、`Temlate_API` |
+| User | 38 | `UserManage`、`Output`、`dthelp`、`user`、`GetMd`、`UserAdd`、`UserAdd_Submit`、`UserInfo`、`UserOrder`、`DelUserPost_Btn_Click`、`UPClient`、`UserInfo_Submit`、`UserInfo_PlatSubmit`、`UserBlontoAdmin`、`UserBlontoAdmin_Submit`、`User_API`、`UserLogin`、`UserExp`、`UserExp_Submit`、`AdminManage`、`AdminAdd`、`AdminAdd_Submit`、`AdminSuperEdit_Submit`、`Admin_API`、`GroupManage`、`Group_API`、`GroupAdd`、`GroupAdd_Submit`、`PermissionInfo`、`SendMailList`、`SubscriptListManage`、`Jobsconfig`、`ZoneConfig`、`WSApi`、`Department`、`Department_Submit`、`WSApi_Submit`、`UserGraph` |
+| Content / VBook | 6 | `Default`、`Add`、`Add_Submit`、`List`、`API`、`Best` |
+| Vdesign | 19 | `Default`、`SiteAdd`、`SiteAdd_Submit`、`SiteArticle`、`SiteShop`、`SiteMessage`、`Site_API`、`SiteBoard`、`SiteBoardAdd`、`SiteBoardAdd_Submit`、`Board_API`、`SiteBoardType`、`SiteBoardTypeAdd`、`SiteBoardTypeAdd_Submit`、`BoardType_API`、`SiteChangeLog`、`SiteChangeLog_API`、`TemplateBuyLog`、`TemplateBuyLog_API` |
+| Extend / VDesign | 15 | `SPage`、`SPageAdd`、`SPageAdd_Submit`、`SPage_API`、`SPageEdit`、`SPageEdit_Submit`、`SPage_AddComponent`、`SPage_AddLayout`、`SPageDesign`、`SPageDesign_API`、`SPageDesign_Nav`、`SPageDesign_Label`、`SPageDesign_Content`、`SPageDesign_Carousel`、`SPagePreview` |
+| Label / Vue | 5 | `Index`、`TlpView`、`VUE_API`、`Config`、`Config_Submit` |
+| Mobile / WeiXin | 43 | `Index`、`WXAPPManage`、`WXAPP_API`、`WxConfig`、`WXConfig_Submit`、`WelPage`、`WelPage_Submit`、`WxPayConfig`、`WxPayConfig_Submit`、`WxUserList`、`WXUser_API`、`EditWxMenu`、`PayLog`、`API`、`MsgTlp`、`MsgTlpAdd`、`MsgTlpAdd_Submit`、`MsgMultiAdd`、`MsgMultiAdd_Submit`、`MsgSend`、`MsgSend_Submit`、`MsgPreView`、`ReplyList`、`Reply_API`、`ReplyAdd`、`ReplyAdd_Submit`、`Config`、`Config_Submit`、`RedPackCfg`、`RedPackCfgAdd`、`RedPackCfgAdd_Submit`、`RedPackCfg_API`、`RedPackLog_Detail`、`RedPackLog_API`、`Popularize`、`Popularize_Submit`、`MiniprogramCode`、`MiniprogramCode_Submit`、`ServiceNotice`、`ServiceNoticeSendLog`、`ServiceNoticeSendAgian`、`ServiceNoticeSend`、`ServiceNoticeSend_Submit` |
+| Workload | 1 | `Default` |
+| WritePearl | 10 | `Default`、`CopybookAdd`、`CopybookAdd_Submit`、`Copybook_API`、`Manuscripts`、`ManuscriptsAdd`、`ManuscriptsAdd_Submit`、`Manuscripts_API`、`UserFiles`、`UserFiles_API` |

+ 43 - 0
docs/schema/functions-function.md

@@ -0,0 +1,43 @@
+# 云函数 Function Schema
+
+线上运行时使用 Parse `Function` 类保存云函数代码与元数据。Angular 前端不保存也不接触 Parse `masterKey`,只把当前 Parse `sessionToken` 发送给 `/api/functions/*`。
+
+```plantuml
+@startuml
+class Function {
+  objectId: String
+  name: String
+  desc: String
+  type: String
+  path: String
+  code: String
+  params: Array
+  paramList: Array
+  respType: String
+  respJson: Object
+  enabled: Boolean
+  version: String
+  project: Pointer<Project>
+  createdAt: Date
+  updatedAt: Date
+  ACL: ACL
+}
+@enduml
+```
+
+当前执行器版本 `2.0.0` 的全局通配路由按不带前导 `/` 的字符串精确匹配 `Function.path`。因此记录保存为 `xiaoshu/admin/gateway`,HTTP 调用仍使用 `/api/functions/xiaoshu/admin/gateway`。这是对线上执行器行为的兼容,部署脚本会校验这一约束。
+
+已部署函数:
+
+| Function.name | path | 用途 |
+|---|---|---|
+| `xiaoshu.admin.gateway` | `xiaoshu/admin/gateway` | 后台权限、帐套隔离、Schema、分页 CRUD、统计与软删除 |
+| `cms.content-normalizer` | `xiaoshu/cms/content-normalizer` | 替代两个 SQL HTML 清理函数 |
+| `cms.users.extended` | `xiaoshu/cms/users/extended` | 用户扩展资料安全投影 |
+| `cms.users.platform` | `xiaoshu/cms/users/platform` | 用户平台资料投影 |
+| `cms.users.wechat` | `xiaoshu/cms/users/wechat` | 用户微信资料投影并过滤密钥 |
+| `cms.exams.classes` | `xiaoshu/cms/exams/classes` | 考试班级分页投影 |
+| `cms.guest.bar` | `xiaoshu/cms/guest/bar` | 互动社区分页投影 |
+| `cms.search` | `xiaoshu/cms/search` | 规范化内容搜索 |
+
+部署使用 `XIAOSHU_MASTER_KEY` 进程环境变量;仓库和浏览器构建产物不得保存真实值。

+ 3 - 0
package.json

@@ -7,6 +7,9 @@
     "build": "ng build",
     "build": "ng build",
     "parity:generate": "node scripts/generate-source-parity.mjs",
     "parity:generate": "node scripts/generate-source-parity.mjs",
     "parity:check": "node scripts/generate-source-parity.mjs --check",
     "parity:check": "node scripts/generate-source-parity.mjs --check",
+    "cloud:validate": "node scripts/deploy-admin-functions.mjs --validate",
+    "cloud:deploy": "node scripts/deploy-admin-functions.mjs",
+    "cloud:smoke": "node scripts/smoke-admin-functions.mjs",
     "watch": "ng build --watch --configuration development",
     "watch": "ng build --watch --configuration development",
     "test": "ng test",
     "test": "ng test",
     "test:ci": "ng test --no-watch --no-progress --browsers=ChromeHeadless"
     "test:ci": "ng test --no-watch --no-progress --browsers=ChromeHeadless"

+ 4 - 0
proxy.conf.cjs

@@ -17,6 +17,10 @@ module.exports = {
     target: 'https://server.xiaoshu.pro', secure: true, changeOrigin: true, logLevel: 'warn',
     target: 'https://server.xiaoshu.pro', secure: true, changeOrigin: true, logLevel: 'warn',
     pathRewrite: { '^/ncloud': '/parse' },
     pathRewrite: { '^/ncloud': '/parse' },
   },
   },
+  '/cloud-functions': {
+    target: 'https://server.xiaoshu.pro', secure: true, changeOrigin: true, logLevel: 'warn',
+    pathRewrite: { '^/cloud-functions': '/api/functions' },
+  },
   '/fmode-api': {
   '/fmode-api': {
     target: 'https://api.fmode.cn', secure: true, changeOrigin: true, logLevel: 'warn',
     target: 'https://api.fmode.cn', secure: true, changeOrigin: true, logLevel: 'warn',
     pathRewrite: { '^/fmode-api': '' },
     pathRewrite: { '^/fmode-api': '' },

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

@@ -0,0 +1,294 @@
+#!/usr/bin/env node
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const validateOnly = process.argv.includes('--validate');
+
+const adminGatewayCode = String.raw`
+const CLASS_LABELS = {
+  _User: '用户管理', Company: '帐套管理', Profile: '员工档案', Group: '用户组', Role: '角色', Permission: '权限', Department: '组织部门',
+  CourseBinding: '课程绑定', CourseAppointment: '课程预约', LessonRecord: '上课记录', DailyStudyRecord: '每日学习记录', PracticeRecord: '练习记录', MemoryPracticeRecord: '抗遗忘记录',
+  VocabularyWord: '词库', CommonModel: '通用内容', ContentArticle: '文章内容', ContentPublish: '内容发布', Node: '栏目节点', NodeAuth: '节点权限', Model: '内容模型', ModelField: '模型字段',
+  ExamClass: '考试班级', ExamSysQuestions: '考试题库', ExamSysPapers: '试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷题目',
+  Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
+  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '搜索记录', Agency: '代理机构', DeliveryCenter: '交付中心'
+};
+const ALLOWED_CLASSES = new Set([
+  'PageTemplate','PaperQuestions','PayPlat','Permission','PlatComp','App','DesignAnswer','DesignAsk','Attachment','DesignPage','DesignQuestion','DesignRes','Feedback','DesignScence','StudentAchieve','ContentArticle','DesignSiteInfo','Profile','AdInfo','AdZone','ARoleAuth','Baike','ExamSysQuestions','ContactInfo','VocabularyWord','AssessmentProfile','Agency','MemoryPracticeRecord','DeliveryCenter','CourseBinding','PracticeRecord','CourseAppointment','Account','SurveyItem','SurveyLog','LessonRecord','DailyStudyRecord','CommonModel','ContentPublish','Company','_Role','_User','CRMSAttr','Currency','Datadic','Datadiccategory','DesignTlp','DocModel','DocPermission','ExamClass','ExamSysPapers','ExamType','ExamPoint','ExTeacher','FontPicShape','FontPicShapeType','Grade','GradeCate','Group','GroupModel','GuestBar','Guestbook','Guestcate','MailTemp','Manager','MisProcedure','MisProLevel','MisSign','MisType','PageStyle','Model','ModelField','Node','NodeAuth','NodeModelTemplate','Product','PlatUserRole','Pub','PubTw','PubWTHD','PubZXDC','PublishNode','QuestionsKnowledge','Role','StoreProduct','SafeMobile','Search','SenTask','ServiceSeat','ShopFareTlp','ShopMoneyRegular','Special','StoreApplication','StoreStyle','SysCSSManage','SysHoliday','SysLog','Temp','ThirdPlatInfo','UserCredit','UserDummyPoint','UserFriendGroup','UserLevel','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis'
+]);
+const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo']);
+const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role']);
+const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
+
+function inputOf(request) {
+  const body = request.body || {};
+  return body.params && typeof body.params === 'object' ? body.params : body;
+}
+function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
+function pointerId(value) { return value && (value.id || value.objectId || (value.__type === 'Pointer' && value.objectId)); }
+function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
+function safeValue(value, depth = 0) {
+  if (value == null || depth > 4) return value;
+  if (Array.isArray(value)) return value.map((item) => safeValue(item, depth + 1));
+  if (value instanceof Date) return value.toISOString();
+  if (value && typeof value.toJSON === 'function') return safeValue(value.toJSON(), depth + 1);
+  if (typeof value === 'object') {
+    const output = {};
+    for (const [key, item] of Object.entries(value)) {
+      if (HIDDEN_FIELDS.has(key) || /(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(key)) continue;
+      output[key] = safeValue(item, depth + 1);
+    }
+    return output;
+  }
+  return value;
+}
+async function requireAdmin(request) {
+  const current = request.user || (typeof user !== 'undefined' ? user : null);
+  if (!current) fail(401, '管理员会话已失效');
+  await current.fetch({ useMasterKey: true });
+  const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
+  const role = String(current.get('role') || '');
+  const roleKey = String(current.get('adminRoleKey') || '');
+  const isSuperAdmin = roleKey === 'super-admin' || roles.includes('super-admin');
+  const isAdmin = current.get('isAdmin') === true || role === 'admin' || roles.includes('admin') || isSuperAdmin;
+  if (!isAdmin) fail(403, '当前账号未被授权为后台管理员');
+  const company = request.company || current.get('company') || null;
+  if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套');
+  return { current, roles, isSuperAdmin, company };
+}
+function assertClass(className) {
+  if (!ALLOWED_CLASSES.has(className)) fail(400, '不允许访问该数据类');
+}
+async function schemaFor(className) {
+  assertClass(className);
+  const schema = await new Parse.Schema(className).get({ useMasterKey: true });
+  return schema && schema.fields ? schema.fields : {};
+}
+function applyTenant(query, fields, context, requestedCompanyId) {
+  if (!fields.company) return;
+  if (context.isSuperAdmin && requestedCompanyId) {
+    query.equalTo('company', Parse.Object.createWithoutData('Company', String(requestedCompanyId)));
+  } else if (context.company) {
+    query.equalTo('company', context.company);
+  }
+}
+function serializeObject(object) { return safeValue(object.toJSON()); }
+function toParseValue(field, value) {
+  if (value == null || value === '') return value;
+  if (field.type === 'Pointer') {
+    const objectId = pointerId(value) || value;
+    return Parse.Object.createWithoutData(field.targetClass, String(objectId));
+  }
+  if (field.type === 'Date') {
+    const date = new Date(value.iso || value);
+    if (Number.isNaN(date.getTime())) fail(400, '日期字段格式无效');
+    return date;
+  }
+  if (field.type === 'Number') {
+    const number = Number(value);
+    if (!Number.isFinite(number)) fail(400, '数字字段格式无效');
+    return number;
+  }
+  if (field.type === 'Boolean') return value === true || value === 'true';
+  return value;
+}
+async function countClass(className, context) {
+  const fields = await schemaFor(className);
+  const query = new Parse.Query(className);
+  applyTenant(query, fields, context);
+  if (fields.isDeleted) query.notEqualTo('isDeleted', true);
+  return query.count({ useMasterKey: true });
+}
+async function audit(context, action, className, objectId) {
+  try {
+    const log = new Parse.Object('SysLog');
+    const schema = await new Parse.Schema('SysLog').get({ useMasterKey: true });
+    const fields = schema.fields || {};
+    if (fields.company && context.company) log.set('company', context.company);
+    if (fields.userId) log.set('userId', context.current.id);
+    if (fields.userName) log.set('userName', String(context.current.get('username') || ''));
+    if (fields.remind) log.set('remind', '[AngularAdmin] ' + action + ' ' + className + '/' + objectId);
+    if (fields.logType) log.set('logType', 'angular-admin');
+    await log.save(null, { useMasterKey: true });
+  } catch (_) { /* 审计表字段来自旧系统,失败不能覆盖主操作结果。 */ }
+}
+
+async function handler(request, response) {
+  try {
+    const input = inputOf(request);
+    const operation = String(input.operation || 'meta');
+    const context = await requireAdmin(request);
+    if (operation === 'meta') {
+      const functionQuery = new Parse.Query('Function');
+      functionQuery.notEqualTo('enabled', false);
+      const cloudFunctions = await functionQuery.count({ useMasterKey: true });
+      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('realname') || context.current.get('nickname') || context.current.get('username') || ''), company: safeValue(context.company), roles: context.roles, isSuperAdmin: context.isSuperAdmin },
+        classes: ALLOWED_CLASSES.size, cloudFunctions
+      }});
+    }
+    if (operation === 'dashboard') {
+      const metrics = [
+        ['_User','用户总数'],['CourseBinding','课程绑定'],['CourseAppointment','课程预约'],['PracticeRecord','练习记录'],['LessonRecord','上课记录'],['DailyStudyRecord','每日学习'],['VocabularyWord','词库单词'],['CommonModel','内容记录']
+      ];
+      const counts = await Promise.all(metrics.map(async ([className, label]) => ({ className, label, count: await countClass(className, context), route: '/admin/resources/' + className })));
+      const functionQuery = new Parse.Query('Function'); functionQuery.notEqualTo('enabled', false);
+      let registeredRecords = 195452;
+      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 }) } } });
+    }
+    const className = String(input.className || '');
+    assertClass(className);
+    const fields = await schemaFor(className);
+    const classWritable = !READ_ONLY_CLASSES.has(className);
+    if (operation === 'schema') {
+      const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !SYSTEM_FIELDS.has(name) }));
+      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, supportsSoftDelete: Boolean(fields.isDeleted) } });
+    }
+    if (operation === 'list') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
+      let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); if (fields.isDeleted) query.notEqualTo('isDeleted', true);
+      const search = String(input.search || '').trim();
+      if (search) { const searchField = ['name','title','username','realName','mobile','sourceKey'].find((name) => fields[name] && fields[name].type === 'String'); if (searchField) query.matches(searchField, escapeRegex(search), 'i'); }
+      const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
+      const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
+      const results = await query.find({ useMasterKey: true });
+      return response.json({ success: true, data: { className, page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    const objectId = String(input.objectId || '');
+    if (operation === 'get') {
+      if (!objectId) fail(400, '缺少 objectId'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true });
+      return response.json({ success: true, data: serializeObject(object) });
+    }
+    if (operation === 'save') {
+      if (!classWritable) fail(403, '该系统类不允许通用编辑');
+      if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
+      let object;
+      if (objectId) { const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); object = await query.get(objectId, { useMasterKey: true }); } else object = new Parse.Object(className);
+      const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      for (const [name, value] of Object.entries(payload)) { if (!fields[name] || SYSTEM_FIELDS.has(name)) continue; if (value === null) object.unset(name); else object.set(name, toParseValue(fields[name], value)); }
+      if (fields.company && context.company) object.set('company', context.company); if (fields.isDeleted && !objectId) object.set('isDeleted', false);
+      await object.save(null, { useMasterKey: true }); await audit(context, objectId ? 'update' : 'create', className, object.id);
+      return response.json({ success: true, data: serializeObject(object) });
+    }
+    if (operation === 'delete') {
+      if (!classWritable || !objectId) fail(400, '该记录不允许删除'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true });
+      if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
+      await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
+    }
+    if (operation === 'resetPassword') {
+      if (className !== '_User' || !objectId || typeof input.newPassword !== 'string' || input.newPassword.length < 8) fail(400, '密码至少 8 位');
+      const query = new Parse.Query('_User'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); target.setPassword(input.newPassword); if (fields.passwordResetRequired) target.set('passwordResetRequired', false); await target.save(null, { useMasterKey: true }); await audit(context, 'reset-password', '_User', objectId); return response.json({ success: true, data: { objectId } });
+    }
+    fail(400, '不支持的后台操作');
+  } catch (error) {
+    const status = Number(error.status || (error.code === 101 ? 404 : 500));
+    return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' });
+  }
+}
+`;
+
+function cmsReadCode(mode) {
+  return String.raw`
+const MODE = ${JSON.stringify(mode)};
+const HIDDEN = /(?:password|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword)/i;
+function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
+function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
+function safe(value, depth = 0) { if (value == null || depth > 4) return value; if (Array.isArray(value)) return value.map((item) => safe(item, depth + 1)); if (value && typeof value.toJSON === 'function') return safe(value.toJSON(), depth + 1); if (typeof value === 'object') { const output = {}; for (const [key,item] of Object.entries(value)) if (!HIDDEN.test(key)) output[key] = safe(item, depth + 1); return output; } return value; }
+async function auth(request) { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) fail(401, '需要登录'); await current.fetch({ useMasterKey: true }); const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : []; const superAdmin = current.get('adminRoleKey') === 'super-admin' || roles.includes('super-admin'); if (!(current.get('isAdmin') === true || current.get('role') === 'admin' || roles.includes('admin') || superAdmin)) fail(403, '需要管理员权限'); const company = request.company || current.get('company') || null; if (!company && !superAdmin) fail(403, '管理员未分配帐套'); return { current, company, superAdmin }; }
+async function schema(name) { try { return (await new Parse.Schema(name).get({ useMasterKey: true })).fields || {}; } catch (_) { return {}; } }
+function tenant(query, fields, context) { if (fields.company && context.company) query.equalTo('company', context.company); if (fields.isDeleted) query.notEqualTo('isDeleted', true); }
+function stripHtml(content) { return String(content == null ? '' : content).replace(/<!--[\s\S]*?-->/g, ' ').replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ').replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'").replace(/\s+/g, ' ').trim(); }
+function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
+async function handler(request, response) {
+  try {
+    const input = inputOf(request); const context = await auth(request); const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
+    if (MODE === 'content-normalizer') return response.json({ success: true, data: { content: stripHtml(input.content), mode: String(input.mode || 'strip') } });
+    if (MODE.startsWith('user-')) {
+      const userId = String(input.userId || ''); if (!userId) fail(400, '缺少 userId'); const fields = await schema('_User'); const query = new Parse.Query('_User'); tenant(query, fields, context); const target = await query.get(userId, { useMasterKey: true }); const data = safe(target);
+      if (MODE === 'user-extended') { delete data.legacyUserPlat; delete data.legacyWxUser; delete data.wechat; delete data.wxapp; return response.json({ success: true, data }); }
+      if (MODE === 'user-platform') return response.json({ success: true, data: { objectId: target.id, platform: safe(target.get('legacyUserPlat') || {}), company: safe(target.get('company')) } });
+      return response.json({ success: true, data: { objectId: target.id, wechat: safe(target.get('legacyWxUser') || target.get('wechat') || target.get('wxapp') || {}) } });
+    }
+    if (MODE === 'exam-classes' || MODE === 'guest-bar') {
+      const className = MODE === 'exam-classes' ? 'ExamClass' : 'GuestBar'; const fields = await schema(className); const query = new Parse.Query(className); tenant(query, fields, context); query.descending(fields.updatedAt ? 'updatedAt' : 'createdAt'); const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize); const rows = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { page, pageSize, total, results: rows.map(safe) } });
+    }
+    if (MODE === 'search') {
+      const keyword = stripHtml(input.keyword); if (!keyword) fail(400, '请输入搜索关键词'); const fields = await schema('CommonModel'); const candidates = ['title','inputer','sourceKey','synopsis','content'].filter((name) => fields[name] && fields[name].type === 'String'); if (!candidates.length) return response.json({ success: true, data: { page, pageSize, total: 0, results: [] } });
+      const pattern = escapeRegex(keyword); const queries = candidates.slice(0, 3).map((field) => { const query = new Parse.Query('CommonModel'); tenant(query, fields, context); query.matches(field, pattern, 'i'); return query; }); const query = queries.length === 1 ? queries[0] : Parse.Query.or(...queries); query.descending('updatedAt'); query.skip((page - 1) * pageSize); query.limit(pageSize); const rows = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { page, pageSize, total: rows.length < pageSize ? (page - 1) * pageSize + rows.length : null, results: rows.map((row) => { const json = safe(row); if (json.content) json.content = stripHtml(json.content).slice(0, 260); return json; }) } });
+    }
+    fail(400, '不支持的 CMS 查询');
+  } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' }); }
+}
+`;
+}
+
+const definitions = [
+  { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
+  { name: 'cms.content-normalizer', desc: '替代 ZL_StripeHtmlTag/ZL_StripeTrimstr,保守输出纯文本', path: 'xiaoshu/cms/content-normalizer', code: cmsReadCode('content-normalizer'), params: [{ name: 'content', type: 'String', required: false }] },
+  { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },
+  { name: 'cms.users.platform', desc: '替代 ZL_User_PlatView', path: 'xiaoshu/cms/users/platform', code: cmsReadCode('user-platform'), params: [{ name: 'userId', type: 'String', required: true }] },
+  { name: 'cms.users.wechat', desc: '替代 ZL_User_WXView,过滤令牌与密钥', path: 'xiaoshu/cms/users/wechat', code: cmsReadCode('user-wechat'), params: [{ name: 'userId', type: 'String', required: true }] },
+  { name: 'cms.exams.classes', desc: '替代 ZL_Exam_ClassView', path: 'xiaoshu/cms/exams/classes', code: cmsReadCode('exam-classes'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] },
+  { name: 'cms.guest.bar', desc: '替代 ZL_Guest_BarView', path: 'xiaoshu/cms/guest/bar', code: cmsReadCode('guest-bar'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] },
+  { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] },
+];
+
+function validateDefinition(definition) {
+  if (definition.path.startsWith('/')) throw new Error(`${definition.name}: 当前执行器的全局 path 不接受前导 /`);
+  const factory = new Function(`${definition.code}\nreturn typeof handler;`);
+  if (factory() !== 'function') throw new Error(`${definition.name}: 未定义 handler`);
+}
+
+async function parseRequest(path, init = {}) {
+  const response = await fetch(`${PARSE_URL}${path}`, {
+    ...init,
+    headers: {
+      'X-Parse-Application-Id': APP_ID,
+      'X-Parse-Master-Key': MASTER_KEY,
+      'Content-Type': 'application/json',
+      ...(init.headers || {}),
+    },
+  });
+  const payload = await response.json();
+  if (!response.ok || payload.error) throw new Error(payload.error || `Parse 请求失败:${response.status}`);
+  return payload;
+}
+
+async function upsert(definition) {
+  const where = encodeURIComponent(JSON.stringify({ name: definition.name }));
+  const existing = await parseRequest(`/classes/Function?where=${where}&limit=1&keys=objectId`);
+  const body = {
+    name: definition.name,
+    desc: definition.desc,
+    type: 'standalone',
+    path: definition.path,
+    code: definition.code.trim(),
+    params: definition.params,
+    paramList: definition.params,
+    respType: 'json',
+    respJson: { success: true, data: {} },
+    enabled: true,
+    version: '1.0.0',
+  };
+  const objectId = existing.results?.[0]?.objectId;
+  if (objectId) {
+    await parseRequest(`/classes/Function/${objectId}`, { method: 'PUT', body: JSON.stringify(body) });
+    return { action: 'updated', objectId };
+  }
+  const created = await parseRequest('/classes/Function', { method: 'POST', body: JSON.stringify(body) });
+  return { action: 'created', objectId: created.objectId };
+}
+
+for (const definition of definitions) validateDefinition(definition);
+
+if (validateOnly) {
+  console.log(`Validated ${definitions.length} cloud function definitions.`);
+} else {
+  if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY;不会把 masterKey 写入项目文件。');
+  for (const definition of definitions) {
+    const result = await upsert(definition);
+    console.log(`${result.action.padEnd(7)} ${definition.path} (${result.objectId})`);
+  }
+}

+ 57 - 0
scripts/generate-legacy-admin-inventory.mjs

@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+
+import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join, relative } from 'node:path';
+
+function argument(name) {
+  const index = process.argv.indexOf(name);
+  return index >= 0 ? process.argv[index + 1] : '';
+}
+
+const source = argument('--source');
+const output = argument('--output');
+if (!source || !output) throw new Error('用法:node scripts/generate-legacy-admin-inventory.mjs --source <ILSpy输出目录> --output <md>');
+
+const files = [];
+function walk(directory) {
+  for (const entry of readdirSync(directory, { withFileTypes: true })) {
+    const path = join(directory, entry.name);
+    if (entry.isDirectory()) walk(path);
+    else if (entry.name.endsWith('.cs') && path.includes('Areas.Admin.Controllers')) files.push(path);
+  }
+}
+walk(source);
+
+const returnTypes = 'Task<[^>]+>|IActionResult|ActionResult|JsonResult|ContentResult|ViewResult|string|void|object|DataTable|PartialViewResult';
+const methodPattern = new RegExp(`public\\s+(?:async\\s+)?(?:${returnTypes})\\s+([A-Za-z0-9_]+)\\s*\\(`, 'g');
+const rows = [];
+for (const file of files) {
+  const code = readFileSync(file, 'utf8');
+  const controller = code.match(/public class ([A-Za-z0-9_]+)Controller/)?.[1];
+  if (!controller) continue;
+  const actions = [...code.matchAll(methodPattern)].map((match) => match[1]).filter((name) => !['OnActionExecuting', 'OnActionExecutionAsync'].includes(name));
+  const sourcePath = relative(source, file);
+  const moduleName = sourcePath.replace(/^ZoomLaCMS\.Areas\.Admin\.Controllers(?:\.|\/)?/, '').replace(/Controller\.cs$/, '').replaceAll('/', ' / ') || controller;
+  rows.push({ controller, moduleName, actions, source: sourcePath });
+}
+rows.sort((left, right) => left.controller.localeCompare(right.controller, 'en'));
+
+const totalActions = rows.reduce((sum, row) => sum + row.actions.length, 0);
+const lines = [
+  '# 旧 ASP.NET 管理后台控制器与动作清单',
+  '',
+  '> 本文件由 `scripts/generate-legacy-admin-inventory.mjs` 从 `ZoomLaCMS.dll` 的 ILSpy 反编译结果生成。发布包没有 `.sln`、`.csproj` 和原始 Razor 视图,因此这是后端动作基线,不等同于可恢复的页面像素基线。',
+  '',
+  `- 管理控制器:${rows.length}`,
+  `- 可识别公开动作:${totalActions}`,
+  '- 生成日期:2026-08-18',
+  '',
+  '| 模块 / 控制器 | 动作数 | 公开动作 |',
+  '|---|---:|---|',
+  ...rows.map((row) => `| ${row.moduleName} | ${row.actions.length} | ${row.actions.map((action) => `\`${action}\``).join('、')} |`),
+  '',
+];
+
+mkdirSync(dirname(output), { recursive: true });
+writeFileSync(output, lines.join('\n'));
+console.log(`Generated ${output}: ${rows.length} controllers, ${totalActions} actions.`);

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

@@ -0,0 +1,89 @@
+#!/usr/bin/env node
+
+import { randomBytes } from 'node:crypto';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
+
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+async function jsonRequest(url, init = {}, master = false) {
+  const response = await fetch(url, {
+    ...init,
+    headers: {
+      'X-Parse-Application-Id': APP_ID,
+      ...(master ? { 'X-Parse-Master-Key': MASTER_KEY } : {}),
+      'Content-Type': 'application/json',
+      ...(init.headers || {}),
+    },
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok) {
+    const detail = payload.message || payload.error || payload;
+    throw new Error(`${response.status}: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`);
+  }
+  return payload;
+}
+
+async function callFunction(path, token, params) {
+  const payload = await jsonRequest(`${FUNCTION_URL}/${path}`, {
+    method: 'POST',
+    body: JSON.stringify({ token, params }),
+  });
+  if (!payload.success) throw new Error(payload.message || `${path} 返回失败`);
+  return payload.data;
+}
+
+let userId = '';
+try {
+  const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
+  const companyId = sample.results?.[0]?.objectId;
+  if (!companyId) throw new Error('没有可用于帐套隔离测试的 Company');
+  const company = { __type: 'Pointer', className: 'Company', objectId: companyId };
+
+  const username = `codex_admin_smoke_${Date.now()}`;
+  const password = randomBytes(24).toString('base64url');
+  const created = await jsonRequest(`${PARSE_URL}/users`, {
+    method: 'POST',
+    body: JSON.stringify({ username, password, isAdmin: true, roles: ['admin'], company }),
+  }, true);
+  userId = created.objectId;
+
+  const login = await jsonRequest(`${PARSE_URL}/login`, {
+    method: 'POST',
+    body: JSON.stringify({ username, password }),
+  });
+  if (!login.sessionToken) throw new Error('临时管理员登录未返回 sessionToken');
+
+  const meta = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'meta' });
+  if (meta.identity?.objectId !== userId || !meta.cloudFunctions) throw new Error('管理员 meta 校验失败');
+
+  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 schema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'CourseAppointment' });
+  if (schema.className !== 'CourseAppointment' || !Array.isArray(schema.fields)) throw new Error('schema 校验失败');
+
+  const page = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'list', className: 'CourseAppointment', page: 1, pageSize: 2 });
+  if (!Array.isArray(page.results) || page.pageSize !== 2) throw new Error('资源分页校验失败');
+
+  const normalized = await callFunction('xiaoshu/cms/content-normalizer', login.sessionToken, { content: '<p>中文&nbsp;<strong>内容</strong></p><script>alert(1)</script>' });
+  if (normalized.content !== '中文 内容') throw new Error(`内容清理结果异常:${normalized.content}`);
+
+  const exams = await callFunction('xiaoshu/cms/exams/classes', login.sessionToken, { page: 1, pageSize: 2 });
+  if (!Array.isArray(exams.results)) throw new Error('考试班级投影校验失败');
+
+  const guest = await callFunction('xiaoshu/cms/guest/bar', login.sessionToken, { page: 1, pageSize: 2 });
+  if (!Array.isArray(guest.results)) throw new Error('互动社区投影校验失败');
+
+  console.log('Cloud smoke passed: auth, tenant scope, dashboard, schema, list, normalizer, exams, guest.');
+} finally {
+  if (userId) {
+    await jsonRequest(`${PARSE_URL}/users/${userId}`, { method: 'DELETE' }, true).catch((error) => {
+      console.error(`临时管理员清理失败:${error.message}`);
+      process.exitCode = 1;
+    });
+  }
+}

+ 56 - 0
src/app/admin/admin-session.service.ts

@@ -0,0 +1,56 @@
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { computed, inject, Injectable, signal } from '@angular/core';
+import { Router } from '@angular/router';
+import { firstValueFrom } from 'rxjs';
+import { ADMIN_SESSION_KEY, PARSE_APP_ID, PARSE_BASE_URL } from './admin.constants';
+import { AdminSession } from './admin.models';
+
+@Injectable({ providedIn: 'root' })
+export class AdminSessionService {
+  private readonly http = inject(HttpClient);
+  private readonly router = inject(Router);
+  private readonly state = signal<AdminSession | null>(this.restore());
+
+  readonly session = this.state.asReadonly();
+  readonly isAuthenticated = computed(() => Boolean(this.state()?.sessionToken));
+
+  async login(username: string, password: string): Promise<AdminSession> {
+    const headers = new HttpHeaders({
+      'X-Parse-Application-Id': PARSE_APP_ID,
+      'Content-Type': 'application/json',
+    });
+    const result = await firstValueFrom(this.http.post<Record<string, unknown>>(
+      `${PARSE_BASE_URL}/login`,
+      { username: username.trim(), password },
+      { headers },
+    ));
+    const session: AdminSession = {
+      objectId: String(result['objectId'] ?? ''),
+      username: String(result['username'] ?? username),
+      sessionToken: String(result['sessionToken'] ?? ''),
+      company: result['company'] as AdminSession['company'],
+    };
+    if (!session.objectId || !session.sessionToken) throw new Error('登录响应缺少有效会话');
+    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
+    this.state.set(session);
+    return session;
+  }
+
+  logout(redirect = true): void {
+    sessionStorage.removeItem(ADMIN_SESSION_KEY);
+    this.state.set(null);
+    if (redirect) void this.router.navigate(['/admin/login']);
+  }
+
+  private restore(): AdminSession | null {
+    try {
+      const value = sessionStorage.getItem(ADMIN_SESSION_KEY);
+      if (!value) return null;
+      const session = JSON.parse(value) as AdminSession;
+      return session.sessionToken ? session : null;
+    } catch {
+      sessionStorage.removeItem(ADMIN_SESSION_KEY);
+      return null;
+    }
+  }
+}

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

@@ -0,0 +1,24 @@
+export const PARSE_APP_ID = '7pIbDBJmKx_main';
+
+const isLocalDevelopment = ['localhost', '127.0.0.1'].includes(globalThis.location?.hostname ?? '');
+
+export const PARSE_BASE_URL = isLocalDevelopment ? '/ncloud' : 'https://server.xiaoshu.pro/parse';
+export const CLOUD_FUNCTION_BASE_URL = isLocalDevelopment
+  ? '/cloud-functions'
+  : 'https://server.xiaoshu.pro/api/functions';
+
+export const ADMIN_SESSION_KEY = 'xiaoshu_admin_session_v1';
+
+export const ADMIN_NAVIGATION = [
+  { label: '管理概览', icon: 'layout-dashboard', route: '/admin/dashboard' },
+  { label: '用户与组织', icon: 'users', route: '/admin/resources/_User' },
+  { label: '课程绑定', icon: 'book-open', route: '/admin/resources/CourseBinding' },
+  { label: '预约管理', icon: 'calendar-days', route: '/admin/resources/CourseAppointment' },
+  { label: '练习记录', icon: 'clipboard-check', route: '/admin/resources/PracticeRecord' },
+  { label: '上课记录', icon: 'graduation-cap', route: '/admin/resources/LessonRecord' },
+  { label: '每日学习', icon: 'chart-no-axes-combined', route: '/admin/resources/DailyStudyRecord' },
+  { label: '词库管理', icon: 'languages', route: '/admin/resources/VocabularyWord' },
+  { label: '内容管理', icon: 'files', route: '/admin/resources/CommonModel' },
+  { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
+  { label: '迁移状态', icon: 'route', route: '/admin/migration' },
+] as const;

+ 9 - 0
src/app/admin/admin.guard.ts

@@ -0,0 +1,9 @@
+import { inject } from '@angular/core';
+import { CanActivateFn, Router } from '@angular/router';
+import { AdminSessionService } from './admin-session.service';
+
+export const adminGuard: CanActivateFn = (_route, state) => {
+  const sessions = inject(AdminSessionService);
+  if (sessions.isAuthenticated()) return true;
+  return inject(Router).createUrlTree(['/admin/login'], { queryParams: { returnUrl: state.url } });
+};

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

@@ -0,0 +1,70 @@
+export interface ParsePointer {
+  __type: 'Pointer';
+  className: string;
+  objectId: string;
+}
+
+export interface AdminIdentity {
+  objectId: string;
+  username: string;
+  displayName: string;
+  company?: ParsePointer;
+  roles: string[];
+  isSuperAdmin: boolean;
+}
+
+export interface AdminSession {
+  objectId: string;
+  username: string;
+  sessionToken: string;
+  company?: ParsePointer;
+}
+
+export interface CloudEnvelope<T> {
+  success: boolean;
+  data: T;
+  error?: string;
+  message?: string;
+}
+
+export interface AdminFieldSchema {
+  name: string;
+  type: string;
+  targetClass?: string;
+  required?: boolean;
+  writable: boolean;
+}
+
+export interface AdminClassSchema {
+  className: string;
+  label: string;
+  fields: AdminFieldSchema[];
+  writable: boolean;
+  supportsSoftDelete: boolean;
+}
+
+export interface ResourcePage {
+  className: string;
+  page: number;
+  pageSize: number;
+  total: number;
+  results: Record<string, unknown>[];
+}
+
+export interface DashboardMetric {
+  className: string;
+  label: string;
+  count: number;
+  route: string;
+}
+
+export interface DashboardData {
+  identity: AdminIdentity;
+  metrics: DashboardMetric[];
+  migration: {
+    sourceObjects: number;
+    migratedClasses: number;
+    registeredRecords: number;
+    cloudFunctions: number;
+  };
+}

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

@@ -0,0 +1,29 @@
+import { Routes } from '@angular/router';
+import { adminGuard } from './admin.guard';
+
+export const adminRoutes: Routes = [
+  {
+    path: 'admin/login',
+    loadComponent: () => import('./pages/admin-login.component').then((module) => module.AdminLoginComponent),
+  },
+  {
+    path: 'admin',
+    canActivate: [adminGuard],
+    loadComponent: () => import('./layout/admin-shell.component').then((module) => module.AdminShellComponent),
+    children: [
+      { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
+      {
+        path: 'dashboard',
+        loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent),
+      },
+      {
+        path: 'resources/:className',
+        loadComponent: () => import('./pages/admin-resource.component').then((module) => module.AdminResourceComponent),
+      },
+      {
+        path: 'migration',
+        loadComponent: () => import('./pages/admin-migration.component').then((module) => module.AdminMigrationComponent),
+      },
+    ],
+  },
+];

+ 39 - 0
src/app/admin/cloud-functions.service.ts

@@ -0,0 +1,39 @@
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { inject, Injectable } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
+import { AdminSessionService } from './admin-session.service';
+import { CloudEnvelope } from './admin.models';
+
+export class CloudFunctionError extends Error {
+  constructor(message: string, readonly status = 0, readonly payload?: unknown) {
+    super(message);
+  }
+}
+
+@Injectable({ providedIn: 'root' })
+export class CloudFunctionsService {
+  private readonly http = inject(HttpClient);
+  private readonly sessions = inject(AdminSessionService);
+
+  async admin<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
+    const token = this.sessions.session()?.sessionToken;
+    if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
+    try {
+      const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
+        `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`,
+        { token, params: { operation, ...params } },
+      ));
+      if (!envelope.success) throw new CloudFunctionError(envelope.message || envelope.error || '云函数执行失败');
+      return envelope.data;
+    } catch (error) {
+      if (error instanceof CloudFunctionError) throw error;
+      if (error instanceof HttpErrorResponse) {
+        const payload = error.error as { error?: string; message?: string } | null;
+        if (error.status === 401 || error.status === 403) this.sessions.logout();
+        throw new CloudFunctionError(payload?.message || payload?.error || error.message, error.status, payload);
+      }
+      throw new CloudFunctionError(error instanceof Error ? error.message : '未知云函数错误');
+    }
+  }
+}

+ 50 - 0
src/app/admin/layout/admin-shell.component.html

@@ -0,0 +1,50 @@
+<div class="admin-frame" [class.sidebar-collapsed]="collapsed()">
+  <aside class="admin-sidebar" [class.mobile-open]="mobileOpen()">
+    <a class="brand" routerLink="/admin/dashboard" aria-label="小树陪练管理后台">
+      <img src="/static/images/logo.png" alt="小树陪练" />
+      <span>小树陪练<small>管理后台</small></span>
+    </a>
+
+    <nav aria-label="后台主导航">
+      @for (item of navigation; track item.route) {
+        <a [routerLink]="item.route" routerLinkActive="active" (click)="mobileOpen.set(false)">
+          <lucide-icon [img]="iconFor(item.icon)" [size]="19" />
+          <span>{{ item.label }}</span>
+        </a>
+      }
+    </nav>
+
+    <div class="sidebar-foot">
+      <button type="button" (click)="sessions.logout()">
+        <lucide-icon [img]="icons.LogOut" [size]="18" /><span>退出登录</span>
+      </button>
+    </div>
+  </aside>
+
+  <button class="sidebar-mask" type="button" aria-label="关闭菜单" (click)="mobileOpen.set(false)"></button>
+
+  <section class="admin-workspace">
+    <header class="admin-topbar">
+      <div class="topbar-leading">
+        <button class="mobile-menu" type="button" aria-label="打开菜单" (click)="mobileOpen.set(true)">
+          <lucide-icon [img]="icons.Menu" [size]="22" />
+        </button>
+        <button class="collapse-button" type="button" aria-label="切换侧栏" (click)="collapsed.set(!collapsed())">
+          <lucide-icon [img]="collapsed() ? icons.ChevronRight : icons.ChevronLeft" [size]="18" />
+        </button>
+        <div class="global-search">
+          <lucide-icon [img]="icons.Search" [size]="18" />
+          <span>搜索用户、课程或业务数据</span>
+          <kbd>⌘ K</kbd>
+        </div>
+      </div>
+      <div class="topbar-account">
+        <button class="notification" type="button" aria-label="通知"><lucide-icon [img]="icons.Bell" [size]="19" /></button>
+        <div class="avatar">{{ (sessions.session()?.username || '管').slice(0, 1).toUpperCase() }}</div>
+        <span>{{ sessions.session()?.username || '管理员' }}</span>
+      </div>
+    </header>
+
+    <main class="admin-content"><router-outlet /></main>
+  </section>
+</div>

+ 38 - 0
src/app/admin/layout/admin-shell.component.scss

@@ -0,0 +1,38 @@
+:host { display: block; min-height: 100vh; color: #172033; background: #f5f7fb; }
+.admin-frame { --sidebar: 256px; display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); min-height: 100vh; transition: grid-template-columns .22s ease; }
+.admin-frame.sidebar-collapsed { --sidebar: 78px; }
+.admin-sidebar { position: sticky; top: 0; z-index: 30; display: flex; flex-direction: column; height: 100vh; overflow: hidden; color: #d7e2f4; background: linear-gradient(180deg, #10233f 0%, #0b1930 100%); box-shadow: 10px 0 32px rgba(12, 30, 55, .08); }
+.brand { display: flex; align-items: center; gap: 12px; min-height: 76px; padding: 14px 20px; color: white; text-decoration: none; border-bottom: 1px solid rgba(255,255,255,.08); }
+.brand img { width: 38px; height: 38px; object-fit: contain; border-radius: 11px; background: white; }
+.brand span { display: grid; min-width: 135px; font-size: 16px; font-weight: 700; line-height: 1.15; }
+.brand small { margin-top: 5px; color: #8ca4c8; font-size: 11px; font-weight: 500; letter-spacing: .18em; }
+nav { display: grid; gap: 5px; padding: 18px 12px; overflow: auto; }
+nav a, .sidebar-foot button { display: flex; align-items: center; gap: 13px; min-height: 44px; padding: 0 14px; overflow: hidden; border: 0; border-radius: 10px; color: #aebdd3; background: transparent; font: inherit; white-space: nowrap; text-decoration: none; cursor: pointer; transition: .18s ease; }
+nav a:hover, .sidebar-foot button:hover { color: white; background: rgba(255,255,255,.07); }
+nav a.active { color: white; background: linear-gradient(135deg, #2f80ed, #236bd1); box-shadow: 0 8px 20px rgba(47,128,237,.28); }
+.sidebar-foot { margin-top: auto; padding: 14px 12px 20px; border-top: 1px solid rgba(255,255,255,.08); }
+.sidebar-foot button { width: 100%; }
+.sidebar-collapsed .brand span, .sidebar-collapsed nav span, .sidebar-collapsed .sidebar-foot span { opacity: 0; pointer-events: none; }
+.admin-workspace { min-width: 0; }
+.admin-topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 0 28px; border-bottom: 1px solid #e5eaf1; background: rgba(255,255,255,.94); backdrop-filter: blur(12px); }
+.topbar-leading, .topbar-account { display: flex; align-items: center; gap: 12px; }
+.collapse-button, .mobile-menu, .notification { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid #e1e7ef; border-radius: 9px; color: #55647a; background: white; cursor: pointer; }
+.mobile-menu { display: none; }
+.global-search { display: flex; align-items: center; gap: 9px; width: min(360px, 34vw); height: 39px; padding: 0 12px; border: 1px solid #e1e7ef; border-radius: 10px; color: #8a96a8; background: #f8fafc; font-size: 13px; }
+.global-search kbd { margin-left: auto; padding: 2px 6px; border: 1px solid #dfe5ed; border-radius: 5px; background: white; font: inherit; }
+.topbar-account { font-size: 13px; font-weight: 600; }
+.avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: white; background: linear-gradient(135deg, #43b37d, #208d5c); }
+.admin-content { padding: 28px; }
+.sidebar-mask { display: none; }
+@media (max-width: 900px) {
+  .admin-frame, .admin-frame.sidebar-collapsed { display: block; }
+  .admin-sidebar { position: fixed; left: 0; transform: translateX(-102%); width: 256px; transition: transform .2s ease; }
+  .admin-sidebar.mobile-open { transform: translateX(0); }
+  .sidebar-mask { position: fixed; inset: 0; z-index: 25; border: 0; background: rgba(7,18,35,.5); }
+  .admin-sidebar.mobile-open + .sidebar-mask { display: block; }
+  .collapse-button { display: none; }
+  .mobile-menu { display: grid; }
+  .global-search { display: none; }
+  .admin-topbar { padding: 0 16px; }
+  .admin-content { padding: 18px 14px; }
+}

+ 51 - 0
src/app/admin/layout/admin-shell.component.ts

@@ -0,0 +1,51 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
+import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
+import {
+  Bell,
+  BookOpen,
+  CalendarDays,
+  ChartNoAxesCombined,
+  ChevronLeft,
+  ChevronRight,
+  ClipboardCheck,
+  Files,
+  GraduationCap,
+  Languages,
+  LayoutDashboard,
+  ListChecks,
+  LogOut,
+  LucideAngularModule,
+  Menu,
+  Route,
+  Search,
+  Users,
+} from 'lucide-angular';
+import { ADMIN_NAVIGATION } from '../admin.constants';
+import { AdminSessionService } from '../admin-session.service';
+
+@Component({
+  selector: 'app-admin-shell',
+  standalone: true,
+  imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet, LucideAngularModule],
+  templateUrl: './admin-shell.component.html',
+  styleUrl: './admin-shell.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminShellComponent {
+  readonly navigation = ADMIN_NAVIGATION;
+  readonly collapsed = signal(false);
+  readonly mobileOpen = signal(false);
+  readonly icons = {
+    Bell, BookOpen, CalendarDays, ChartNoAxesCombined, ChevronLeft, ChevronRight,
+    ClipboardCheck, Files, GraduationCap, Languages, LayoutDashboard, ListChecks,
+    LogOut, Menu, Route, Search, Users,
+  };
+
+  constructor(readonly sessions: AdminSessionService) {}
+
+  iconFor(name: string) {
+    const key = Object.keys(this.icons).find((value) => value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`).replace(/^-/, '') === name);
+    return this.icons[key as keyof typeof this.icons] ?? this.icons.Files;
+  }
+}

+ 40 - 0
src/app/admin/pages/admin-dashboard.component.html

@@ -0,0 +1,40 @@
+<header class="page-header">
+  <div><p>ADMIN OVERVIEW</p><h1>管理概览</h1><span>查看核心业务数据与迁移运行状态</span></div>
+  <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新数据</button>
+</header>
+
+@if (loading() && !data()) {
+  <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在通过云函数汇总数据…</div>
+} @else if (error()) {
+  <div class="state error"><strong>暂时无法载入概览</strong><span>{{ error() }}</span><button type="button" (click)="load()">重新加载</button></div>
+} @else {
+  @if (data(); as dashboard) {
+  <section class="welcome">
+    <div><span>欢迎回来</span><h2>{{ dashboard.identity.displayName || dashboard.identity.username }}</h2><p>当前数据范围:{{ dashboard.identity.company ? '所属帐套' : (dashboard.identity.isSuperAdmin ? '全部帐套' : '未分配帐套') }}</p></div>
+    <div class="migration-progress"><span>规范化迁移登记</span><strong>{{ dashboard.migration.registeredRecords | number }}</strong><small>SQL Server 来源记录</small></div>
+  </section>
+
+  <section class="metric-grid">
+    @for (metric of dashboard.metrics; track metric.className) {
+      <a [routerLink]="metric.route" class="metric-card">
+        <span>{{ metric.label }}</span><strong>{{ metric.count | number }}</strong><small>{{ metric.className }}</small><lucide-icon [img]="icons.ArrowRight" [size]="18" />
+      </a>
+    }
+  </section>
+
+  <section class="status-grid">
+    <article>
+      <div class="status-icon blue"><lucide-icon [img]="icons.Database" [size]="22" /></div>
+      <div><span>来源对象</span><strong>{{ dashboard.migration.sourceObjects }}</strong><small>表、视图与函数审计登记</small></div>
+    </article>
+    <article>
+      <div class="status-icon green"><lucide-icon [img]="icons.TableProperties" [size]="22" /></div>
+      <div><span>已迁移业务类</span><strong>{{ dashboard.migration.migratedClasses }}</strong><small>Parse 规范化类</small></div>
+    </article>
+    <article>
+      <div class="status-icon amber"><lucide-icon [img]="icons.FunctionSquare" [size]="22" /></div>
+      <div><span>已部署云函数</span><strong>{{ dashboard.migration.cloudFunctions }}</strong><small>Function 表有效记录</small></div>
+    </article>
+  </section>
+  }
+}

+ 27 - 0
src/app/admin/pages/admin-dashboard.component.scss

@@ -0,0 +1,27 @@
+.page-header { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
+.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; letter-spacing: -.025em; }
+.page-header span { display: block; margin-top: 8px; color: #7c899b; font-size: 13px; }
+.page-header button, .state button { display: flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 14px; border: 1px solid #dfe5ed; border-radius: 9px; color: #445166; background: white; font: inherit; cursor: pointer; }
+.welcome { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 28px 32px; border-radius: 16px; color: white; background: linear-gradient(120deg, #12365b, #17675a); box-shadow: 0 14px 32px rgba(17,54,90,.16); }
+.welcome span { color: #a9d4c6; font-size: 13px; }
+.welcome h2 { margin: 6px 0; font-size: 27px; }
+.welcome p, .welcome small { margin: 0; color: #c5d7df; font-size: 12px; }
+.migration-progress { display: grid; min-width: 180px; padding-left: 28px; border-left: 1px solid rgba(255,255,255,.18); }
+.migration-progress strong { margin: 4px 0; font-size: 29px; }
+.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-top: 20px; }
+.metric-card { position: relative; display: grid; min-height: 138px; padding: 20px; border: 1px solid #e5eaf1; border-radius: 14px; color: #172033; background: white; text-decoration: none; box-shadow: 0 7px 20px rgba(25,48,79,.04); transition: .18s ease; }
+.metric-card:hover { transform: translateY(-2px); border-color: #bad9cd; box-shadow: 0 10px 26px rgba(25,48,79,.09); }
+.metric-card span { color: #69778a; font-size: 13px; }
+.metric-card strong { align-self: end; font-size: 29px; }
+.metric-card small { color: #9aa5b4; }
+.metric-card lucide-icon { position: absolute; right: 18px; bottom: 18px; color: #2d9b71; }
+.status-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 20px; }
+.status-grid article { display: flex; align-items: center; gap: 15px; padding: 19px; border: 1px solid #e5eaf1; border-radius: 14px; background: white; }
+.status-icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; }
+.status-icon.blue { color: #3379ce; background: #edf5ff; }.status-icon.green { color: #258a65; background: #eaf8f2; }.status-icon.amber { color: #ad761a; background: #fff6df; }
+.status-grid article div:last-child { display: grid; gap: 2px; }.status-grid span { color: #687589; font-size: 12px; }.status-grid strong { font-size: 21px; }.status-grid small { color: #9ba5b2; font-size: 11px; }
+.state { display: grid; place-items: center; gap: 10px; min-height: 340px; color: #778498; }.state.error strong { color: #b73d45; }.state.error span { max-width: 560px; text-align: center; }
+.spin { animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
+@media (max-width: 1100px) { .metric-grid { grid-template-columns: repeat(2, 1fr); } }
+@media (max-width: 700px) { .page-header { align-items: start; }.page-header span { display: none; }.welcome { align-items: start; padding: 22px; }.migration-progress { display: none; }.metric-grid, .status-grid { grid-template-columns: 1fr; } }

+ 36 - 0
src/app/admin/pages/admin-dashboard.component.ts

@@ -0,0 +1,36 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { ArrowRight, Database, FunctionSquare, LoaderCircle, LucideAngularModule, RefreshCw, TableProperties } from 'lucide-angular';
+import { DashboardData } from '../admin.models';
+import { CloudFunctionsService } from '../cloud-functions.service';
+
+@Component({
+  selector: 'app-admin-dashboard',
+  standalone: true,
+  imports: [CommonModule, RouterLink, LucideAngularModule],
+  templateUrl: './admin-dashboard.component.html',
+  styleUrl: './admin-dashboard.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminDashboardComponent implements OnInit {
+  readonly data = signal<DashboardData | null>(null);
+  readonly loading = signal(true);
+  readonly error = signal('');
+  readonly icons = { ArrowRight, Database, FunctionSquare, LoaderCircle, RefreshCw, TableProperties };
+  private readonly functions = inject(CloudFunctionsService);
+
+  ngOnInit(): void { void this.load(); }
+
+  async load(): Promise<void> {
+    this.loading.set(true);
+    this.error.set('');
+    try {
+      this.data.set(await this.functions.admin<DashboardData>('dashboard'));
+    } catch (error) {
+      this.error.set(error instanceof Error ? error.message : '概览加载失败');
+    } finally {
+      this.loading.set(false);
+    }
+  }
+}

+ 36 - 0
src/app/admin/pages/admin-login.component.html

@@ -0,0 +1,36 @@
+<main class="login-page">
+  <section class="login-story">
+    <div class="story-inner">
+      <div class="story-brand"><img src="/static/images/logo.png" alt="" /><span>小树陪练</span></div>
+      <p class="eyebrow">XIAOSHU PRACTICE</p>
+      <h1>让每一次管理,<br />都服务于真实成长。</h1>
+      <p class="story-copy">统一管理用户、课程、预约、练习、内容与学习数据。所有业务操作均通过云函数执行,并遵循帐套隔离。</p>
+      <div class="security-note"><lucide-icon [img]="icons.ShieldCheck" [size]="21" /><span>Parse 会话认证 · 云函数权限校验 · 多租户数据隔离</span></div>
+    </div>
+  </section>
+
+  <section class="login-panel">
+    <form (ngSubmit)="submit()">
+      <div class="mobile-brand"><img src="/static/images/logo.png" alt="" /><span>小树陪练管理后台</span></div>
+      <p class="eyebrow">ADMIN CONSOLE</p>
+      <h2>欢迎回来</h2>
+      <p class="form-intro">使用已授权的管理员账号登录系统</p>
+
+      <label>
+        <span>管理员账号</span>
+        <div class="control"><lucide-icon [img]="icons.UserRound" [size]="19" /><input name="username" [(ngModel)]="username" autocomplete="username" placeholder="请输入账号" /></div>
+      </label>
+      <label>
+        <span>登录密码</span>
+        <div class="control"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input name="password" [(ngModel)]="password" [type]="showPassword() ? 'text' : 'password'" autocomplete="current-password" placeholder="请输入密码" /><button type="button" (click)="showPassword.set(!showPassword())"><lucide-icon [img]="showPassword() ? icons.EyeOff : icons.Eye" [size]="18" /></button></div>
+      </label>
+
+      @if (error()) { <p class="error" role="alert">{{ error() }}</p> }
+      <button class="submit" type="submit" [disabled]="loading()">
+        @if (loading()) { <lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="19" /> }
+        {{ loading() ? '正在验证权限…' : '登录管理后台' }}
+      </button>
+      <p class="help">旧系统迁移账号如被标记为“需重置密码”,须先完成 Parse 密码重置。</p>
+    </form>
+  </section>
+</main>

+ 36 - 0
src/app/admin/pages/admin-login.component.scss

@@ -0,0 +1,36 @@
+:host { display: block; min-height: 100vh; }
+.login-page { display: grid; grid-template-columns: minmax(420px, 1.08fr) minmax(420px, .92fr); min-height: 100vh; color: #172033; background: #f7f9fc; }
+.login-story { position: relative; display: grid; place-items: center; overflow: hidden; padding: 64px; color: white; background: linear-gradient(145deg, rgba(14,44,78,.96), rgba(19,88,76,.93)), url('/static/images/ai-reading-journey.png') center/cover; }
+.login-story::before, .login-story::after { content: ''; position: absolute; border-radius: 50%; border: 1px solid rgba(255,255,255,.12); }
+.login-story::before { width: 540px; height: 540px; right: -220px; top: -180px; }
+.login-story::after { width: 300px; height: 300px; left: -120px; bottom: -100px; }
+.story-inner { position: relative; z-index: 1; max-width: 570px; }
+.story-brand, .mobile-brand { display: flex; align-items: center; gap: 12px; font-weight: 700; }
+.story-brand img, .mobile-brand img { width: 44px; height: 44px; padding: 4px; object-fit: contain; border-radius: 12px; background: white; }
+.eyebrow { margin: 72px 0 15px; color: #7ed8b5; font-size: 12px; font-weight: 800; letter-spacing: .22em; }
+h1 { margin: 0; font-size: clamp(38px, 4.3vw, 64px); line-height: 1.16; letter-spacing: -.035em; }
+.story-copy { max-width: 520px; margin: 28px 0; color: #c8d9e5; font-size: 16px; line-height: 1.9; }
+.security-note { display: flex; align-items: center; gap: 10px; margin-top: 50px; color: #b8d7cd; font-size: 13px; }
+.login-panel { display: grid; place-items: center; padding: 48px; }
+form { width: min(100%, 420px); }
+.mobile-brand { display: none; }
+form .eyebrow { margin: 0 0 14px; color: #258d68; }
+h2 { margin: 0; font-size: 36px; letter-spacing: -.03em; }
+.form-intro { margin: 10px 0 38px; color: #7e8a9c; }
+label { display: grid; gap: 9px; margin: 0 0 20px; color: #3d495b; font-size: 13px; font-weight: 650; }
+.control { display: flex; align-items: center; gap: 10px; height: 50px; padding: 0 14px; border: 1px solid #dce3ec; border-radius: 11px; color: #8a96a7; background: white; transition: .18s ease; }
+.control:focus-within { border-color: #2f9c72; box-shadow: 0 0 0 3px rgba(47,156,114,.12); }
+input { min-width: 0; flex: 1; border: 0; outline: 0; color: #172033; background: transparent; font: inherit; }
+.control button { display: grid; place-items: center; padding: 5px; border: 0; color: #78869a; background: transparent; cursor: pointer; }
+.submit { display: flex; align-items: center; justify-content: center; gap: 9px; width: 100%; height: 52px; margin-top: 8px; border: 0; border-radius: 11px; color: white; background: linear-gradient(135deg, #2a9b70, #187754); box-shadow: 0 10px 24px rgba(32,135,94,.22); font: inherit; font-weight: 700; cursor: pointer; }
+.submit:disabled { opacity: .65; cursor: wait; }
+.error { margin: -4px 0 14px; color: #c23d45; font-size: 13px; }
+.help { margin: 22px 0 0; color: #8b96a5; font-size: 12px; line-height: 1.7; text-align: center; }
+.spin { animation: spin .8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+@media (max-width: 860px) {
+  .login-page { display: block; }
+  .login-story { display: none; }
+  .login-panel { min-height: 100vh; padding: 28px 22px; }
+  .mobile-brand { display: flex; margin-bottom: 58px; }
+}

+ 51 - 0
src/app/admin/pages/admin-login.component.ts

@@ -0,0 +1,51 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { Eye, EyeOff, LoaderCircle, LockKeyhole, LucideAngularModule, ShieldCheck, UserRound } from 'lucide-angular';
+import { AdminSessionService } from '../admin-session.service';
+import { CloudFunctionsService } from '../cloud-functions.service';
+
+@Component({
+  selector: 'app-admin-login',
+  standalone: true,
+  imports: [CommonModule, FormsModule, LucideAngularModule],
+  templateUrl: './admin-login.component.html',
+  styleUrl: './admin-login.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminLoginComponent {
+  username = '';
+  password = '';
+  readonly showPassword = signal(false);
+  readonly loading = signal(false);
+  readonly error = signal('');
+  readonly icons = { Eye, EyeOff, LoaderCircle, LockKeyhole, ShieldCheck, UserRound };
+
+  private readonly sessions = inject(AdminSessionService);
+  private readonly functions = inject(CloudFunctionsService);
+  private readonly router = inject(Router);
+  private readonly route = inject(ActivatedRoute);
+
+  constructor() { document.title = '后台登录 · 小树陪练'; }
+
+  async submit(): Promise<void> {
+    if (!this.username.trim() || !this.password) {
+      this.error.set('请输入管理员账号和密码');
+      return;
+    }
+    this.loading.set(true);
+    this.error.set('');
+    try {
+      await this.sessions.login(this.username, this.password);
+      await this.functions.admin('meta');
+      const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '/admin/dashboard';
+      await this.router.navigateByUrl(returnUrl);
+    } catch (error) {
+      this.sessions.logout(false);
+      this.error.set(error instanceof Error ? error.message : '登录失败');
+    } finally {
+      this.loading.set(false);
+    }
+  }
+}

+ 19 - 0
src/app/admin/pages/admin-migration.component.html

@@ -0,0 +1,19 @@
+<header class="page-header"><div><p>MIGRATION CONTROL</p><h1>迁移状态</h1><span>SQL Server → PostgreSQL / Parse → Cloud Functions</span></div></header>
+
+<section class="summary">
+  <article><div class="icon green"><lucide-icon [img]="icons.Database" [size]="22" /></div><span>规范化数据迁移</span><strong>195,452</strong><small>来源记录已登记,96 个目标业务类</small></article>
+  <article><div class="icon blue"><lucide-icon [img]="icons.CloudCog" [size]="22" /></div><span>线上云函数</span><strong>{{ meta()?.cloudFunctions ?? '—' }}</strong><small>Function 表有效记录</small></article>
+  <article><div class="icon amber"><lucide-icon [img]="icons.CircleAlert" [size]="22" /></div><span>阻塞接口</span><strong>4</strong><small>源订单、购物车、博客表未迁入目标类</small></article>
+</section>
+
+@if (error()) { <div class="notice error">{{ error() }}</div> }
+<div class="notice"><lucide-icon [img]="icons.CircleAlert" [size]="19" /><span>旧数据库视图不在 PostgreSQL 中复建;对应读取逻辑必须通过云函数组合新 Parse 类。状态为“数据阻塞”的项目不会伪造成功结果。</span></div>
+
+<section class="matrix">
+  <header><div><h2>SQL 逻辑替代矩阵</h2><p>依据 2026-08-13 接口重写登记逐项跟踪</p></div><span>{{ rewrites.length }} 项</span></header>
+  <table><thead><tr><th>迁移项</th><th>云函数路径 / 模块</th><th>当前状态</th></tr></thead><tbody>
+    @for (item of rewrites; track item[1]) {
+      <tr><td>{{ item[0] }}</td><td><code>{{ item[1] }}</code></td><td>@if (item[2] === 'implemented') { <span class="badge ok"><lucide-icon [img]="icons.CheckCircle2" [size]="14" />已实现</span> } @else { <span class="badge blocked"><lucide-icon [img]="icons.CircleAlert" [size]="14" />数据阻塞</span> }</td></tr>
+    }
+  </tbody></table>
+</section>

+ 6 - 0
src/app/admin/pages/admin-migration.component.scss

@@ -0,0 +1,6 @@
+.page-header { margin-bottom: 22px; }.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; }
+.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }.summary article { display: grid; grid-template-columns: auto 1fr; column-gap: 13px; padding: 19px; border: 1px solid #e4e9f0; border-radius: 14px; background: white; }.icon { grid-row: 1 / 4; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; }.icon.green { color: #258a65; background: #eaf8f2; }.icon.blue { color: #3379ce; background: #edf5ff; }.icon.amber { color: #ad761a; background: #fff6df; }.summary span { color: #6e7a8c; font-size: 12px; }.summary strong { margin: 2px 0; font-size: 24px; }.summary small { color: #98a3b2; font-size: 11px; }
+.notice { display: flex; align-items: flex-start; gap: 10px; margin-top: 16px; padding: 13px 15px; border: 1px solid #d8e8f4; border-radius: 10px; color: #476278; background: #f1f8fd; font-size: 12px; line-height: 1.7; }.notice.error { border-color: #f1d6d8; color: #a43b43; background: #fff3f4; }
+.matrix { overflow: hidden; margin-top: 17px; border: 1px solid #e4e9f0; border-radius: 14px; background: white; }.matrix > header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid #e9edf2; }.matrix h2 { margin: 0; font-size: 17px; }.matrix p { margin: 5px 0 0; color: #8b96a5; font-size: 11px; }.matrix > header > span { padding: 4px 9px; border-radius: 14px; color: #43705f; background: #eaf6f1; font-size: 11px; }
+table { width: 100%; border-collapse: collapse; }th, td { padding: 12px 20px; border-bottom: 1px solid #edf0f4; font-size: 12px; text-align: left; }th { color: #758194; background: #fafbfd; font-size: 11px; }code { padding: 3px 6px; border-radius: 5px; color: #4e6578; background: #f1f4f7; }.badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 14px; font-size: 11px; }.badge.ok { color: #217756; background: #eaf7f1; }.badge.blocked { color: #9e6912; background: #fff4d9; }
+@media (max-width: 760px) { .summary { grid-template-columns: 1fr; }.matrix { overflow-x: auto; }.matrix > header, th, td { padding-left: 14px; padding-right: 14px; }table { min-width: 680px; } }

+ 44 - 0
src/app/admin/pages/admin-migration.component.ts

@@ -0,0 +1,44 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { CheckCircle2, CircleAlert, CloudCog, Database, LoaderCircle, LucideAngularModule } from 'lucide-angular';
+import { CloudFunctionsService } from '../cloud-functions.service';
+
+interface MigrationMeta {
+  classes: number;
+  cloudFunctions: number;
+  identity: { company?: unknown; isSuperAdmin: boolean };
+}
+
+@Component({
+  selector: 'app-admin-migration',
+  standalone: true,
+  imports: [CommonModule, LucideAngularModule],
+  templateUrl: './admin-migration.component.html',
+  styleUrl: './admin-migration.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminMigrationComponent implements OnInit {
+  readonly meta = signal<MigrationMeta | null>(null);
+  readonly error = signal('');
+  readonly icons = { CheckCircle2, CircleAlert, CloudCog, Database, LoaderCircle };
+  readonly rewrites = [
+    ['内容 HTML 规范化', 'cms.content-normalizer', 'implemented'],
+    ['购物车投影', '/xiaoshu/cms/cart/items', 'blocked'],
+    ['用户扩展资料', '/xiaoshu/cms/users/extended', 'implemented'],
+    ['考试班级投影', '/xiaoshu/cms/exams/classes', 'implemented'],
+    ['互动社区投影', '/xiaoshu/cms/guest/bar', 'implemented'],
+    ['已支付订单', '/xiaoshu/cms/orders/paid', 'blocked'],
+    ['商品销售投影', '/xiaoshu/cms/orders/product-sales', 'blocked'],
+    ['订单分享投影', '/xiaoshu/cms/orders/share', 'blocked'],
+    ['博客列表', '/xiaoshu/cms/blogs', 'blocked'],
+    ['统一内容搜索', '/xiaoshu/cms/search', 'implemented'],
+    ['用户平台资料', '/xiaoshu/cms/users/platform', 'implemented'],
+    ['用户微信资料', '/xiaoshu/cms/users/wechat', 'implemented'],
+  ] as const;
+  private readonly functions = inject(CloudFunctionsService);
+
+  async ngOnInit(): Promise<void> {
+    try { this.meta.set(await this.functions.admin<MigrationMeta>('meta')); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '迁移状态加载失败'); }
+  }
+}

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

@@ -0,0 +1,36 @@
+<header class="resource-header">
+  <div><p>DATA RESOURCE</p><h1>{{ schema()?.label || className() }}</h1><span>{{ className() }} · {{ page()?.total || 0 | number }} 条记录</span></div>
+  <div class="header-actions"><button type="button" (click)="load(1, true)"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button>@if (schema()?.writable) { <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="18" />新建记录</button> }</div>
+</header>
+
+<section class="resource-card">
+  <div class="toolbar"><label><lucide-icon [img]="icons.Search" [size]="18" /><input [ngModel]="search()" (ngModelChange)="search.set($event)" (keyup.enter)="load(1)" placeholder="按名称、标题、账号或来源键搜索" /></label><button type="button" (click)="load(1)">查询</button></div>
+  @if (error()) { <div class="inline-error">{{ error() }}</div> }
+  @if (loading() && !page()) {
+    <div class="empty"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在加载数据…</div>
+  } @else {
+    <div class="table-scroll"><table><thead><tr>@for (field of columns(); track field.name) { <th>{{ field.name }}</th> }<th class="actions">操作</th></tr></thead><tbody>
+      @for (row of page()?.results || []; track row['objectId']) {
+        <tr>@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" aria-label="编辑" (click)="openEdit(row)"><lucide-icon [img]="icons.Pencil" [size]="16" /></button>@if (schema()?.writable) { <button class="danger" type="button" aria-label="删除" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
+      } @empty { <tr><td class="empty-cell" [attr.colspan]="columns().length + 1">没有符合条件的数据</td></tr> }
+    </tbody></table></div>
+    <footer class="pagination"><span>第 {{ page()?.page || 1 }} 页,共 {{ ((page()?.total || 0) / (page()?.pageSize || 20)) | number:'1.0-0' }} 页</span><div><button type="button" [disabled]="(page()?.page || 1) <= 1 || loading()" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" />上一页</button><button type="button" [disabled]="(page()?.page || 1) * (page()?.pageSize || 20) >= (page()?.total || 0) || loading()" (click)="load((page()?.page || 1) + 1)">下一页<lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></div></footer>
+  }
+</section>
+
+@if (editorOpen()) {
+  <button class="drawer-mask" type="button" aria-label="关闭编辑器" (click)="closeEditor()"></button>
+  <aside class="editor-drawer">
+    <header><div><span>{{ draftId() ? '编辑记录' : '新建记录' }}</span><strong>{{ schema()?.label }}</strong></div><button type="button" (click)="closeEditor()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
+    <div class="editor-fields">
+      @for (field of schema()?.fields || []; track field.name) { @if (field.writable) {
+        <label><span>{{ field.name }} <small>{{ field.type }}{{ field.required ? ' · 必填' : '' }}</small></span>
+          @if (field.type === 'Boolean') { <input type="checkbox" [ngModel]="draft()[field.name] === true" (ngModelChange)="setField(field, $event)" /> }
+          @else if (field.type === 'Object' || field.type === 'Array') { <textarea rows="5" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)"></textarea> }
+          @else { <input [type]="field.type === 'Number' ? 'number' : field.type === 'Date' ? 'datetime-local' : 'text'" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)" [placeholder]="field.type === 'Pointer' ? (field.targetClass + ' objectId') : field.name" /> }
+        </label>
+      } }
+    </div>
+    <footer><button type="button" (click)="closeEditor()">取消</button><button class="primary" type="button" [disabled]="saving()" (click)="save()">{{ saving() ? '保存中…' : '保存记录' }}</button></footer>
+  </aside>
+}

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

@@ -0,0 +1,8 @@
+.resource-header { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 22px; }.resource-header p { margin: 0 0 7px; color: #2e9a71; font-size: 11px; font-weight: 800; letter-spacing: .18em; }.resource-header h1 { margin: 0; font-size: 27px; }.resource-header span { display: block; margin-top: 7px; color: #7d899a; font-size: 12px; }
+.header-actions { display: flex; gap: 9px; }.header-actions button, .toolbar button, .pagination button, .editor-drawer footer button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 38px; padding: 0 14px; border: 1px solid #dfe5ed; border-radius: 9px; color: #4e5b6e; background: white; font: inherit; cursor: pointer; }.primary { border-color: #258c67 !important; color: white !important; background: #258c67 !important; }
+.resource-card { overflow: hidden; border: 1px solid #e4e9f0; border-radius: 14px; background: white; box-shadow: 0 7px 24px rgba(24,46,76,.04); }.toolbar { display: flex; gap: 10px; padding: 17px; border-bottom: 1px solid #edf0f4; }.toolbar label { display: flex; align-items: center; gap: 9px; width: min(430px, 100%); height: 39px; padding: 0 12px; border: 1px solid #dfe5ed; border-radius: 9px; color: #8490a1; background: #f9fafc; }.toolbar input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font: inherit; }
+.table-scroll { overflow: auto; }table { width: 100%; border-collapse: collapse; table-layout: fixed; }th { padding: 12px 15px; color: #6c7889; background: #f8fafc; font-size: 11px; font-weight: 700; text-align: left; text-transform: uppercase; }td { max-width: 220px; padding: 13px 15px; overflow: hidden; border-top: 1px solid #edf0f4; color: #3a4658; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }tbody tr:hover { background: #fbfdfc; }.actions { width: 92px; text-align: right; }.actions button { display: inline-grid; place-items: center; width: 31px; height: 31px; margin-left: 5px; border: 1px solid #dfe5ed; border-radius: 8px; color: #557085; background: white; cursor: pointer; }.actions button.danger { color: #b44951; }.empty-cell { height: 180px; color: #8a95a5; text-align: center; }
+.pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 17px; border-top: 1px solid #edf0f4; color: #7a8798; font-size: 12px; }.pagination div { display: flex; gap: 8px; }.pagination button { min-height: 34px; }.pagination button:disabled { opacity: .45; cursor: default; }
+.empty { display: grid; place-items: center; gap: 10px; min-height: 300px; color: #7f8b9c; }.inline-error { padding: 10px 17px; color: #b33f48; background: #fff2f3; font-size: 12px; }.spin { animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
+.drawer-mask { position: fixed; inset: 0; z-index: 50; border: 0; background: rgba(11,24,42,.42); }.editor-drawer { position: fixed; top: 0; right: 0; z-index: 60; display: grid; grid-template-rows: auto 1fr auto; width: min(560px, 94vw); height: 100vh; background: white; box-shadow: -18px 0 50px rgba(17,36,62,.18); }.editor-drawer header, .editor-drawer footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px; border-bottom: 1px solid #e8ecf2; }.editor-drawer header div { display: grid; gap: 3px; }.editor-drawer header span { color: #8290a3; font-size: 11px; }.editor-drawer header strong { font-size: 18px; }.editor-drawer header button { display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 8px; background: #f2f5f8; cursor: pointer; }.editor-fields { display: grid; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; overflow: auto; padding: 22px; }.editor-fields label { display: grid; align-content: start; gap: 7px; color: #445165; font-size: 12px; }.editor-fields label:has(textarea) { grid-column: 1 / -1; }.editor-fields small { color: #95a0af; font-weight: 400; }.editor-fields input:not([type=checkbox]), .editor-fields textarea { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid #dce3eb; border-radius: 8px; outline: 0; background: #fbfcfd; font: inherit; }.editor-fields input:focus, .editor-fields textarea:focus { border-color: #44a681; box-shadow: 0 0 0 3px rgba(68,166,129,.1); }.editor-fields textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }.editor-drawer footer { justify-content: flex-end; border-top: 1px solid #e8ecf2; border-bottom: 0; }
+@media (max-width: 700px) { .resource-header { align-items: start; }.resource-header span { display: none; }.header-actions button:first-child { display: none; }.editor-fields { grid-template-columns: 1fr; }.editor-fields label:has(textarea) { grid-column: auto; }.pagination > span { display: none; }.pagination { justify-content: flex-end; } }

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

@@ -0,0 +1,104 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { ChevronLeft, ChevronRight, LoaderCircle, LucideAngularModule, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-angular';
+import { AdminClassSchema, AdminFieldSchema, ResourcePage } from '../admin.models';
+import { CloudFunctionsService } from '../cloud-functions.service';
+
+@Component({
+  selector: 'app-admin-resource',
+  standalone: true,
+  imports: [CommonModule, FormsModule, LucideAngularModule],
+  templateUrl: './admin-resource.component.html',
+  styleUrl: './admin-resource.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminResourceComponent implements OnInit {
+  readonly className = signal('');
+  readonly schema = signal<AdminClassSchema | null>(null);
+  readonly page = signal<ResourcePage | null>(null);
+  readonly loading = signal(true);
+  readonly saving = signal(false);
+  readonly error = signal('');
+  readonly search = signal('');
+  readonly editorOpen = signal(false);
+  readonly draftId = signal('');
+  readonly draft = signal<Record<string, unknown>>({});
+  readonly icons = { ChevronLeft, ChevronRight, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
+  readonly columns = computed(() => {
+    const fields = this.schema()?.fields ?? [];
+    const preferred = ['objectId', 'name', 'title', 'username', 'realName', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    return [...fields].sort((a, b) => preferred.indexOf(a.name) - preferred.indexOf(b.name)).slice(0, 7);
+  });
+  private readonly functions = inject(CloudFunctionsService);
+  private readonly route = inject(ActivatedRoute);
+  private readonly destroyRef = inject(DestroyRef);
+
+  ngOnInit(): void {
+    this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
+      this.className.set(params.get('className') ?? '');
+      this.search.set('');
+      void this.load(1, true);
+    });
+  }
+
+  async load(pageNumber = this.page()?.page ?? 1, reloadSchema = false): Promise<void> {
+    this.loading.set(true); this.error.set('');
+    try {
+      if (reloadSchema || !this.schema()) this.schema.set(await this.functions.admin<AdminClassSchema>('schema', { className: this.className() }));
+      this.page.set(await this.functions.admin<ResourcePage>('list', { className: this.className(), page: pageNumber, pageSize: 20, search: this.search().trim() }));
+    } catch (error) { this.error.set(error instanceof Error ? error.message : '数据加载失败'); }
+    finally { this.loading.set(false); }
+  }
+
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.editorOpen.set(true); }
+  async openEdit(row: Record<string, unknown>): Promise<void> {
+    const objectId = String(row['objectId'] ?? '');
+    this.error.set('');
+    try {
+      const detail = await this.functions.admin<Record<string, unknown>>('get', { className: this.className(), objectId });
+      this.draftId.set(objectId); this.draft.set(detail); this.editorOpen.set(true);
+    } catch (error) { this.error.set(error instanceof Error ? error.message : '详情加载失败'); }
+  }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); }
+  setField(field: AdminFieldSchema, raw: unknown): void {
+    let value = raw;
+    if (field.type === 'Number') value = raw === '' ? null : Number(raw);
+    if (field.type === 'Pointer') value = raw ? { __type: 'Pointer', className: field.targetClass, objectId: String(raw) } : null;
+    if ((field.type === 'Object' || field.type === 'Array') && typeof raw === 'string') {
+      try { value = raw.trim() ? JSON.parse(raw) : field.type === 'Array' ? [] : {}; } catch { value = raw; }
+    }
+    this.draft.update((current) => ({ ...current, [field.name]: value }));
+  }
+  inputValue(field: AdminFieldSchema): string | number {
+    const value = this.draft()[field.name];
+    if (field.type === 'Pointer') return String((value as { objectId?: string } | null)?.objectId ?? '');
+    if (field.type === 'Date') return String((value as { iso?: string } | string | null)?.hasOwnProperty?.('iso') ? (value as { iso: string }).iso.slice(0, 16) : String(value ?? '').slice(0, 16));
+    if (field.type === 'Object' || field.type === 'Array') return value == null ? '' : JSON.stringify(value, null, 2);
+    return typeof value === 'number' ? value : String(value ?? '');
+  }
+  async save(): Promise<void> {
+    this.saving.set(true); this.error.set('');
+    try {
+      await this.functions.admin('save', { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() });
+      this.closeEditor(); await this.load();
+    } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
+    finally { this.saving.set(false); }
+  }
+  async remove(row: Record<string, unknown>): Promise<void> {
+    const objectId = String(row['objectId'] ?? '');
+    if (!objectId || !confirm(`确认删除 ${this.className()} / ${objectId}?`)) return;
+    try { await this.functions.admin('delete', { className: this.className(), objectId }); await this.load(); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '删除失败'); }
+  }
+  display(value: unknown): string {
+    if (value == null || value === '') return '—';
+    if (typeof value === 'object') {
+      const data = value as Record<string, unknown>;
+      return String(data['name'] ?? data['title'] ?? data['username'] ?? data['objectId'] ?? JSON.stringify(value));
+    }
+    return String(value);
+  }
+}

+ 2 - 0
src/app/app.routes.ts

@@ -3,6 +3,7 @@ import { authGuard } from './core/auth.guard';
 import { PageKind } from './core/models';
 import { PageKind } from './core/models';
 import { PAGE_CATALOG } from './core/page-catalog';
 import { PAGE_CATALOG } from './core/page-catalog';
 import { AppShellComponent } from './layout/app-shell.component';
 import { AppShellComponent } from './layout/app-shell.component';
+import { adminRoutes } from './admin/admin.routes';
 
 
 type ComponentLoader = Exclude<Route['loadComponent'], undefined>;
 type ComponentLoader = Exclude<Route['loadComponent'], undefined>;
 
 
@@ -40,6 +41,7 @@ const migratedRoutes: Routes = PAGE_CATALOG.map((meta): Route => ({
 }));
 }));
 
 
 export const routes: Routes = [
 export const routes: Routes = [
+  ...adminRoutes,
   {
   {
     path: '',
     path: '',
     component: AppShellComponent,
     component: AppShellComponent,

+ 1 - 1
src/app/core/page-catalog.spec.ts

@@ -8,7 +8,7 @@ describe('migration route catalog', () => {
   });
   });
 
 
   it('registers every catalog entry in Angular Router', () => {
   it('registers every catalog entry in Angular Router', () => {
-    const children = routes[0].children ?? [];
+    const children = routes.find((route) => route.children?.some((child) => child.path === 'pages/home/index'))?.children ?? [];
     const routePaths = new Set(children.map((route) => route.path));
     const routePaths = new Set(children.map((route) => route.path));
     for (const page of PAGE_CATALOG) expect(routePaths.has(page.path)).withContext(page.path).toBeTrue();
     for (const page of PAGE_CATALOG) expect(routePaths.has(page.path)).withContext(page.path).toBeTrue();
   });
   });