Переглянути джерело

feat: migrate administrator password changes

彭峰 2 місяців тому
батько
коміт
3d42fdd569

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

@@ -31,6 +31,7 @@
 - Helper 的数据库自初始化与帮助精灵依赖服务器 `DBConfig.json`、本地 SQL 文件、旧 Notification 数据和 SignalR 分组协议,目标均不存在,两个动作由 `legacyHelperBlocker` 明确阻塞。Hms 的 10 个动作管理的是另一个产品“逐浪主题大师”的七档会员卡,依赖 `ZL_UserBase.cardType/ICQ` 及 `ZL_Orderinfo/ZL_CartPro`;迁移数据没有这些字段或表,不能误用小树现有 `VIP` 合伙人等级,已由 `legacyHmsBlocker` 逐项说明。
 - `Index` 后台入口控制器的 Default/Main/WorkTable、Login/Login_AJAX、Logout 已由 Angular 后台壳、管理概览、Parse 登录与会话撤销等价替代;FuncList/SiteMap 由侧栏、资源目录和后台快捷入口部分覆盖。旧主题桌面、ASP.NET Session 锁屏、动态 ASCX/Razor 加载、全局动画开关及公开管理员申请缺少服务器配置或安全审批链,8 个动作由 `legacyIndexBlocker` 明确阻塞,绝不复刻随机密码加固定高权限角色的不安全流程。
 - `Extend/Log.SysLog` 已用线上 470 条 `SysLog` 实际记录迁为只读 `systemLogs` 云函数和 Angular“系统日志”页,支持帐套隔离、分页、一级/二级类型、关键词与日期筛选,并禁止通用写入。旧文本日志目录和 Hangfire 任务中心缺少文件系统、`Content_ScheTask` 数据及调度控制面;`SenTask` 为空且语义不等价。Design/Interactive、Office/iServer、Office/Licence 也分别缺少 `Design_Pub`、服务工单/回复、协议/申请/签约主表及 PDF 文件运行时,合计 32 个动作由专用阻塞契约逐项返回原因。
+- Manager 修改密码已迁为 Angular 专用页面和 `changeOwnPassword`:验证当前口令、限制新密码 8–72 位、拒绝复用原密码,保存后撤销该管理员全部 Parse 会话并强制重新登录。旧 MarkDown 五个动作依赖未迁入的 MarkDown 主表、`wwwroot/UploadFiles/MarkDown` 本地目录和 `/PreView/md` 渲染页,均由 `legacyMarkdownBlocker` 返回数据或运行时阻塞。
 - 旧 `NodeAdd_Submit`、`Node_API` 与 `UnionNode_Move` 的核心栏目生命周期已迁为专用 `saveNode/nodeBatch`:新增栏目以临时来源键完成 Parse 初始写入,再使用 PostgreSQL 事务锁原子分配 `nodeId/orderId` 并替换为旧 `sourceKey`;保存会校验同级名称/目录重复、父栏目帐套和回收状态,移动会阻断自身/下级循环并递归修正所有后代深度。回收、恢复和每批 1–100 个栏目迁移已接入 Angular;永久删除仅在没有内容、下级栏目、节点权限和模型模板引用时开放,通用保存与删除不能绕过。
 - 旧 `SpecialAdd_Save` 与 `UnionSpecial_Move` 已迁为专用 `saveSpecial/specialBatch`:新增专题使用临时来源键和 PostgreSQL 事务锁原子分配 `specId/orderId`,保存时校验全帐套名称/目录唯一、父专题归属与层级循环;删除会拒绝仍有下级专题的记录。旧 `UnionSpecial_Merge` 被明确列为数据阻塞:`CommonModel.specialId` 只存在于 Parse Schema、PostgreSQL 物理列缺失,商品主表也未迁移,无法安全改写内容/商品专题关系。
 - 旧 `ModelManage/Field/Order_Submit` 的安全部分已迁为 `saveModelMetadata/saveModelFieldMetadata/modelFieldOrder`:现有模型可编辑名称、条目文案、图标、说明和内容模板元数据,现有字段可编辑别名、提示、显示/搜索配置,并可在同模型内批量排序;`modelId/modelType/tableName/fieldId/modelId/fieldName/fieldType/sourceKey` 等结构字段通用只读。新增、复制、删除模型或字段会显式返回 501,因为旧实现同时执行 PostgreSQL 建表/改列、标签生成和模板文件写入,目标托管环境没有该 DDL/旧模板运行时。

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

@@ -214,6 +214,12 @@ async function handler(request, response) {
       if (session) await session.destroy({ useMasterKey: true });
       return response.json({ success: true, data: { revoked: Boolean(session) } });
     }
+    if (operation === 'changeOwnPassword') {
+      const oldPassword = typeof input.oldPassword === 'string' ? input.oldPassword : ''; const newPassword = typeof input.newPassword === 'string' ? input.newPassword : '';
+      if (!oldPassword || newPassword.length < 8 || newPassword.length > 72) fail(400, '原密码不能为空,新密码长度应为 8 至 72 位'); if (oldPassword === newPassword) fail(400, '新密码不能与原密码相同');
+      let verified; try { verified = await Parse.User.logIn(String(context.current.get('username') || ''), oldPassword); } catch (_) { fail(400, '原密码错误'); } if (!verified || verified.id !== context.current.id) fail(400, '原密码验证失败');
+      const activeSessionQuery = new Parse.Query('_Session'); activeSessionQuery.equalTo('user', context.current); const activeSessions = await activeSessionQuery.count({ useMasterKey: true }); context.current.setPassword(newPassword); await context.current.save(null, { useMasterKey: true }); const explicitlyRevoked = await revokeSessions([context.current]); await audit(context, 'change-own-password', '_User', context.current.id); return response.json({ success: true, data: { objectId: context.current.id, revokedSessions: Math.max(activeSessions, explicitlyRevoked), reauthenticationRequired: true } });
+    }
     if (operation === 'dashboard') {
       const metrics = [
         ['_User','用户总数'],['CourseBinding','课程绑定'],['CourseAppointment','课程预约'],['PracticeRecord','练习记录'],['LessonRecord','上课记录'],['DailyStudyRecord','每日学习'],['VocabularyWord','词库单词'],['CommonModel','内容记录']
@@ -1378,6 +1384,11 @@ async function handler(request, response) {
         AccountForm_Submit: '旧动作生成随机密码并直接授予固定角色 2/4/6/7/8;目标权限模型不等价且没有审批链,不能安全创建管理员申请'
       }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧后台入口动作'); fail(501, 'migration_blocked: ' + blockers[action]);
     }
+    if (operation === 'legacyMarkdownBlocker') {
+      const action = String(input.action || ''); const blockers = {
+        Index: '目标 Schema 缺少旧 MarkDown 主表、MDName/MDPath/MDStatus/MDPosition 等历史记录', MD: '目标没有可新建或编辑的 MarkDown 记录', Index_submit: '目标缺少 MarkDown 持久层,当前 Parse 文件适配器也不能替代旧 wwwroot/UploadFiles/MarkDown 本地上传目录', Dels: '目标没有 Markdown 记录与对应服务器文件可同步删除', Preview: '目标缺少 MarkDown 文件、SiteInfo.SiteUrl 与旧 /PreView/md 渲染运行时'
+      }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Markdown 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
+    }
     if (operation === 'legacyDesignInteractiveBlocker') {
       const action = String(input.action || ''); const blockers = {
         InteractiveList: '目标 Schema 缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer 和 Pub 语义不等价',

+ 7 - 0
scripts/generate-admin-coverage.mjs

@@ -169,6 +169,13 @@ const explicit = new Map([
   ['Index/AccountForm', ['blocked-infrastructure', '目标没有 SiteOption.RegManager、管理员申请模型和审批链,不能开放高权限自助申请']],
   ['Index/AccountForm_API', ['blocked-infrastructure', '目标没有管理员申请开关与待审批模型,不能提供公开账号/邮箱探测接口']],
   ['Index/AccountForm_Submit', ['blocked-infrastructure', '旧动作随机生成密码并直接授予固定高权限角色,目标权限模型不等价且没有审批链']],
+  ['Manager/ModifyPassword', ['implemented', '已由 Angular 修改密码页要求原密码、新密码和确认密码替代']],
+  ['Manager/ModifyPassword_Submit', ['implemented', 'changeOwnPassword 验证原密码、更新当前管理员并撤销该账号全部会话后强制重登']],
+  ['Content / MarkDown/Index', ['blocked-data', '目标 Schema 缺少旧 MarkDown 主表及历史记录']],
+  ['Content / MarkDown/MD', ['blocked-data', '目标没有可新建或编辑的 MarkDown 记录']],
+  ['Content / MarkDown/Index_submit', ['blocked-infrastructure', '目标缺少 MarkDown 持久层且 Parse 文件适配器不能替代旧本地上传目录']],
+  ['Content / MarkDown/Dels', ['blocked-data', '目标没有 Markdown 记录与对应服务器文件可同步删除']],
+  ['Content / MarkDown/Preview', ['blocked-infrastructure', '目标缺少 Markdown 文件、SiteInfo.SiteUrl 与旧 /PreView/md 渲染运行时']],
   ['Design / Interactive/InteractiveList', ['blocked-data', '目标缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer/Pub 不等价']],
   ['Design / Interactive/Interactive_API', ['blocked-data', '目标没有可按旧 Design_Pub ID 删除的交互提交记录']],
   ['Office / iServer/Default', ['blocked-data', '目标 Schema 缺少旧 IServer 服务工单主表与回复表']],

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

@@ -663,6 +663,9 @@ try {
   for (const action of ['Scence','Lockin','Lockin_Submit','ASCXLoad','API','AccountForm','AccountForm_API','AccountForm_Submit']) {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyIndexBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧后台入口动作 ${action} 未返回明确迁移阻塞`);
   }
+  for (const action of ['Index','MD','Index_submit','Dels','Preview']) {
+    const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyMarkdownBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧 Markdown 动作 ${action} 未返回明确迁移阻塞`);
+  }
   for (const action of ['InteractiveList','Interactive_API']) {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyDesignInteractiveBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧互动提交动作 ${action} 未返回明确迁移阻塞`);
   }
@@ -859,6 +862,13 @@ try {
   const productBlocked = await callLegacyFunction('', { action: 'product_list' }, 501);
   if (!String(productBlocked.retmsg).includes('ZL_Commodities')) throw new Error('缺商品主表的读取接口未明确阻塞');
 
+  const changedPassword = randomBytes(24).toString('base64url');
+  const wrongPassword = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'changeOwnPassword', oldPassword: 'definitely-wrong-password', newPassword: changedPassword }, 400); if (!String(wrongPassword.message).includes('原密码')) throw new Error('管理员修改密码未校验原密码');
+  const passwordChange = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'changeOwnPassword', oldPassword: password, newPassword: changedPassword }); if (passwordChange.objectId !== userId || passwordChange.reauthenticationRequired !== true || Number(passwordChange.revokedSessions) < 1) throw new Error(`管理员修改密码与会话撤销异常:${JSON.stringify(passwordChange)}`);
+  const oldSessionResponse = await fetch(`${FUNCTION_URL}/xiaoshu/admin/gateway`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token: login.sessionToken, params: { operation: 'meta' } }) }); if (oldSessionResponse.status !== 401) throw new Error(`管理员改密后旧会话仍可使用:${oldSessionResponse.status}`);
+  const oldPasswordResponse = await fetch(`${PARSE_URL}/login`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (oldPasswordResponse.ok) throw new Error('管理员旧密码在修改后仍可登录');
+  const changedLogin = await jsonRequest(`${PARSE_URL}/login`, { method: 'POST', body: JSON.stringify({ username, password: changedPassword }) }); if (!changedLogin.sessionToken) throw new Error('管理员修改密码后无法用新密码登录'); login.sessionToken = changedLogin.sessionToken;
+
   const logout = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'logout', sessionToken: login.sessionToken });
   if (logout.revoked !== true) throw new Error('管理员退出未销毁当前 Parse 会话');
   const revokedResponse = await fetch(`${FUNCTION_URL}/xiaoshu/admin/gateway`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token: login.sessionToken, params: { operation: 'meta' } }) });

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

@@ -11,6 +11,7 @@ export const ADMIN_SESSION_KEY = 'xiaoshu_admin_session_v1';
 
 export const ADMIN_NAVIGATION = [
   { label: '管理概览', icon: 'layout-dashboard', route: '/admin/dashboard' },
+  { label: '修改密码', icon: 'key-round', route: '/admin/change-password' },
   { label: '云函数接口', icon: 'braces', route: '/admin/api-catalog' },
   { label: '图标选择器', icon: 'shapes', route: '/admin/icon-catalog' },
   { label: 'Bootstrap 布局', icon: 'layout-template', route: '/admin/boot-layout' },

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

@@ -16,6 +16,10 @@ export const adminRoutes: Routes = [
         path: 'dashboard',
         loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent),
       },
+      {
+        path: 'change-password',
+        loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent),
+      },
       {
         path: 'api-catalog',
         loadComponent: () => import('./pages/admin-api-catalog.component').then((module) => module.AdminApiCatalogComponent),

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

@@ -18,6 +18,7 @@ import {
   GalleryHorizontalEnd,
   GraduationCap,
   Headphones,
+  KeyRound,
   Languages,
   LayoutDashboard,
   LayoutTemplate,
@@ -56,7 +57,7 @@ export class AdminShellComponent {
   readonly mobileOpen = signal(false);
   readonly icons = {
     BadgeCent, Bell, BookMarked, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
-    ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, Languages, LayoutDashboard, ListChecks, ListTodo, ScrollText,
+    ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, KeyRound, Languages, LayoutDashboard, ListChecks, ListTodo, ScrollText,
     LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Palette, Route, Search, Shapes, Store, TableProperties, Tags, Users, WalletCards,
   };
 

+ 1 - 1
src/app/admin/legacy-admin-coverage.generated.spec.ts

@@ -144,6 +144,6 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('User/User_API')?.status).toBe('partial');
     expect(byKey.get('Order/OrderList')?.status).toBe('blocked-data');
     expect(byKey.get('Label / PowerShell/RunScript')?.status).toBe('blocked-infrastructure');
-    expect(byKey.get('Exam/Papers_System_Manage')?.status).toBe('pending');
+    expect(byKey.get('Exam/Papers_System_Manage')?.status).toBe('partial');
   });
 });

+ 14 - 14
src/app/admin/legacy-admin-coverage.generated.ts

@@ -3289,50 +3289,50 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Manager",
     "action": "ModifyPassword",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 修改密码页要求原密码、新密码和确认密码替代"
   },
   {
     "module": "Manager",
     "action": "ModifyPassword_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "changeOwnPassword 验证原密码、更新当前管理员并撤销该账号全部会话后强制重登"
   },
   {
     "module": "Content / MarkDown",
     "action": "Index",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 MarkDown 主表及历史记录"
   },
   {
     "module": "Content / MarkDown",
     "action": "MD",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可新建或编辑的 MarkDown 记录"
   },
   {
     "module": "Content / MarkDown",
     "action": "Index_submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少 MarkDown 持久层且 Parse 文件适配器不能替代旧本地上传目录"
   },
   {
     "module": "Content / MarkDown",
     "action": "Dels",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有 Markdown 记录与对应服务器文件可同步删除"
   },
   {
     "module": "Content / MarkDown",
     "action": "Preview",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少 Markdown 文件、SiteInfo.SiteUrl 与旧 /PreView/md 渲染运行时"
   },
   {
     "module": "User / Message",

+ 12 - 0
src/app/admin/pages/admin-change-password.component.html

@@ -0,0 +1,12 @@
+<header class="page-header"><div><p>ACCOUNT SECURITY</p><h1>修改密码</h1><span>验证当前密码后更新管理员凭据,并注销该账号的全部会话</span></div></header>
+
+<section class="password-card">
+  <div class="security-note"><lucide-icon [img]="icons.ShieldCheck" [size]="23" /><div><strong>修改后需要重新登录</strong><span>所有浏览器和设备上的现有管理员会话都会立即失效。</span></div></div>
+  <form (ngSubmit)="submit()">
+    <label><span>原密码</span><input type="password" autocomplete="current-password" [ngModel]="oldPassword()" (ngModelChange)="oldPassword.set($event)" name="oldPassword" /></label>
+    <label><span>新密码</span><input type="password" autocomplete="new-password" minlength="8" maxlength="72" [ngModel]="newPassword()" (ngModelChange)="newPassword.set($event)" name="newPassword" /></label>
+    <label><span>确认新密码</span><input type="password" autocomplete="new-password" minlength="8" maxlength="72" [ngModel]="confirmPassword()" (ngModelChange)="confirmPassword.set($event)" name="confirmPassword" /></label>
+    @if (error()) { <div class="form-error">{{ error() }}</div> }
+    <button type="submit" [disabled]="loading()"><lucide-icon [img]="loading() ? icons.LoaderCircle : icons.KeyRound" [size]="17" [class.spin]="loading()" />{{ loading() ? '正在安全更新…' : '修改密码并退出' }}</button>
+  </form>
+</section>

+ 3 - 0
src/app/admin/pages/admin-change-password.component.scss

@@ -0,0 +1,3 @@
+.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: 27px; }.page-header span { display: block; margin-top: 7px; color: #7d899a; font-size: 12px; }
+.password-card { width: min(560px, 100%); overflow: hidden; border: 1px solid #e4e9f0; border-radius: 14px; background: white; box-shadow: 0 7px 24px rgba(24,46,76,.04); }.security-note { display: flex; gap: 12px; padding: 17px 20px; color: #28795b; background: #f2faf6; }.security-note div { display: grid; gap: 4px; }.security-note strong { font-size: 13px; }.security-note span { color: #668175; font-size: 11px; line-height: 1.5; }
+form { display: grid; gap: 17px; padding: 22px; }label { display: grid; gap: 7px; color: #445165; font-size: 12px; }input { width: 100%; box-sizing: border-box; padding: 11px 12px; border: 1px solid #dce3eb; border-radius: 8px; outline: 0; background: #fbfcfd; font: inherit; }input:focus { border-color: #44a681; box-shadow: 0 0 0 3px rgba(68,166,129,.1); }.form-error { padding: 10px 12px; border-radius: 8px; color: #b33f48; background: #fff2f3; font-size: 11px; }button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 42px; border: 0; border-radius: 9px; color: white; background: #258c67; font: inherit; cursor: pointer; }button:disabled { opacity: .55; cursor: default; }.spin { animation: spin .8s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }

+ 37 - 0
src/app/admin/pages/admin-change-password.component.spec.ts

@@ -0,0 +1,37 @@
+import { TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { AdminSessionService } from '../admin-session.service';
+import { CloudFunctionsService } from '../cloud-functions.service';
+import { AdminChangePasswordComponent } from './admin-change-password.component';
+
+describe('AdminChangePasswordComponent', () => {
+  const functions = { admin: jasmine.createSpy('admin') };
+  const sessions = { logout: jasmine.createSpy('logout') };
+  const router = { navigate: jasmine.createSpy('navigate') };
+  let component: AdminChangePasswordComponent;
+
+  beforeEach(async () => {
+    functions.admin.calls.reset(); sessions.logout.calls.reset(); router.navigate.calls.reset(); router.navigate.and.resolveTo(true);
+    await TestBed.configureTestingModule({
+      imports: [AdminChangePasswordComponent],
+      providers: [
+        { provide: CloudFunctionsService, useValue: functions },
+        { provide: AdminSessionService, useValue: sessions },
+        { provide: Router, useValue: router },
+      ],
+    }).compileComponents();
+    component = TestBed.createComponent(AdminChangePasswordComponent).componentInstance;
+  });
+
+  it('rejects a mismatched confirmation before calling the cloud function', async () => {
+    component.oldPassword.set('CurrentPass123'); component.newPassword.set('NewPassword123'); component.confirmPassword.set('Different123');
+    await component.submit();
+    expect(component.error()).toContain('不一致'); expect(functions.admin).not.toHaveBeenCalled();
+  });
+
+  it('changes the current password and forces a fresh login', async () => {
+    functions.admin.and.resolveTo({ objectId: 'admin-user', revokedSessions: 2, reauthenticationRequired: true }); component.oldPassword.set('CurrentPass123'); component.newPassword.set('NewPassword123'); component.confirmPassword.set('NewPassword123');
+    await component.submit();
+    expect(functions.admin).toHaveBeenCalledWith('changeOwnPassword', { oldPassword: 'CurrentPass123', newPassword: 'NewPassword123' }); expect(sessions.logout).toHaveBeenCalledWith(false); expect(router.navigate).toHaveBeenCalledWith(['/admin/login'], { queryParams: { passwordChanged: '1' } });
+  });
+});

+ 46 - 0
src/app/admin/pages/admin-change-password.component.ts

@@ -0,0 +1,46 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { KeyRound, LoaderCircle, LucideAngularModule, ShieldCheck } from 'lucide-angular';
+import { AdminSessionService } from '../admin-session.service';
+import { CloudFunctionsService } from '../cloud-functions.service';
+
+@Component({
+  selector: 'app-admin-change-password',
+  standalone: true,
+  imports: [CommonModule, FormsModule, LucideAngularModule],
+  templateUrl: './admin-change-password.component.html',
+  styleUrl: './admin-change-password.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminChangePasswordComponent {
+  readonly oldPassword = signal('');
+  readonly newPassword = signal('');
+  readonly confirmPassword = signal('');
+  readonly loading = signal(false);
+  readonly error = signal('');
+  readonly icons = { KeyRound, LoaderCircle, ShieldCheck };
+  private readonly functions = inject(CloudFunctionsService);
+  private readonly sessions = inject(AdminSessionService);
+  private readonly router = inject(Router);
+
+  constructor() { document.title = '修改密码 · 小树陪练管理后台'; }
+
+  async submit(): Promise<void> {
+    this.error.set('');
+    if (!this.oldPassword()) { this.error.set('请输入原密码'); return; }
+    if (this.newPassword().length < 8 || this.newPassword().length > 72) { this.error.set('新密码长度应为 8 至 72 位'); return; }
+    if (this.newPassword() !== this.confirmPassword()) { this.error.set('两次输入的新密码不一致'); return; }
+    if (this.newPassword() === this.oldPassword()) { this.error.set('新密码不能与原密码相同'); return; }
+    this.loading.set(true);
+    try {
+      await this.functions.admin('changeOwnPassword', { oldPassword: this.oldPassword(), newPassword: this.newPassword() });
+      this.oldPassword.set(''); this.newPassword.set(''); this.confirmPassword.set(''); this.sessions.logout(false);
+      await this.router.navigate(['/admin/login'], { queryParams: { passwordChanged: '1' } });
+    } catch (error) {
+      this.oldPassword.set('');
+      this.error.set(error instanceof Error ? error.message : '密码修改失败');
+    } finally { this.loading.set(false); }
+  }
+}

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

@@ -25,6 +25,7 @@
         <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 (notice()) { <p class="notice" role="status">{{ notice() }}</p> }
       @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" /> }

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

@@ -25,6 +25,7 @@ input { min-width: 0; flex: 1; border: 0; outline: 0; color: #172033; background
 .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; }
+.notice { margin: -4px 0 14px; padding: 10px 12px; border-radius: 8px; color: #237454; background: #eaf7f1; font-size: 12px; line-height: 1.5; }
 .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); } }

+ 2 - 1
src/app/admin/pages/admin-login.component.ts

@@ -20,6 +20,7 @@ export class AdminLoginComponent {
   readonly showPassword = signal(false);
   readonly loading = signal(false);
   readonly error = signal('');
+  readonly notice = signal('');
   readonly icons = { Eye, EyeOff, LoaderCircle, LockKeyhole, ShieldCheck, UserRound };
 
   private readonly sessions = inject(AdminSessionService);
@@ -27,7 +28,7 @@ export class AdminLoginComponent {
   private readonly router = inject(Router);
   private readonly route = inject(ActivatedRoute);
 
-  constructor() { document.title = '后台登录 · 小树陪练'; }
+  constructor() { document.title = '后台登录 · 小树陪练'; if (this.route.snapshot.queryParamMap.get('passwordChanged') === '1') this.notice.set('密码已修改,所有旧会话均已注销,请使用新密码登录。'); }
 
   async submit(): Promise<void> {
     if (!this.username.trim() || !this.password) {