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

feat: add secure admin user onboarding

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

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

@@ -16,6 +16,7 @@
 - 动态编辑器对 Object/Array 执行 JSON 类型校验,错误格式会阻止保存;Relation、File、GeoPoint 等无法安全通用写入的 Parse 类型由前后端共同设为只读,避免结构化字段被字符串覆盖。
 - 动态资源表按 `objectId/name/title/username` 等业务字段稳定优先展示,总页数采用向上取整;只读类使用查看抽屉且不显示保存/删除操作。
 - `_User` 详情抽屉已接入专用 `resetPassword` 管理员云函数,执行 8 位最小长度与二次确认;新密码不进入通用资源字段或前端持久化存储。
+- `_User` 新建入口已改为专用 `createUser` 管理员云函数:帐套内校验邀请账号,事务锁原子分配 `legacyUserId`,固定默认普通组 1,并初始化旧版余额/经验数据;请求不接受管理员或角色字段,开户密码也不进入通用资源草稿。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -147,6 +147,56 @@ async function handler(request, response) {
       const resources = [...ALLOWED_CLASSES].sort().map((className) => ({ className, label: CLASS_LABELS[className] || className, writable: !READ_ONLY_CLASSES.has(className), route: '/admin/resources/' + className }));
       return response.json({ success: true, data: { total: resources.length, resources } });
     }
+    if (operation === 'createUser') {
+      const username = String(input.username || '').trim();
+      const password = typeof input.password === 'string' ? input.password : '';
+      const displayName = String(input.displayName || '').trim();
+      const mobile = String(input.mobile || '').trim();
+      const inviteUsername = String(input.inviteUsername || '').trim();
+      if (username.length < 3 || username.length > 64) fail(400, '账号长度应为 3 至 64 位');
+      if (password.length < 8 || password.length > 72) fail(400, '密码长度应为 8 至 72 位');
+      if (displayName.length > 100) fail(400, '显示名不能超过 100 位');
+      if (mobile.length > 32) fail(400, '手机号不能超过 32 位');
+      let companyId = pointerId(context.company);
+      if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
+      if (!companyId) fail(400, '开户必须指定帐套');
+      const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
+      const userFields = await schemaFor('_User');
+      if (!userFields.company) fail(500, '用户表缺少帐套字段');
+      const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND "username"=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1', [companyId, username]);
+      if (duplicate) fail(409, '此用户名已被使用');
+      let parentId = 0;
+      if (inviteUsername) {
+        const inviter = await Psql.oneOrNone('SELECT COALESCE("legacyUserId", CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric END) AS id FROM "_User" WHERE "company"=$1 AND "username"=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1', [companyId, inviteUsername]);
+        parentId = Number(inviter && inviter.id) || 0;
+        if (!parentId) fail(400, '邀请账号不存在');
+      }
+      const created = new Parse.User();
+      created.setUsername(username); created.setPassword(password); created.set('company', company);
+      if (userFields.type) created.set('type', 'user');
+      if (userFields.isDisabled) created.set('isDisabled', false);
+      if (userFields.isDeleted) created.set('isDeleted', false);
+      if (userFields.legacyGroupId) created.set('legacyGroupId', 1);
+      if (userFields.nickname) created.set('nickname', displayName || username);
+      if (mobile && userFields.mobile) created.set('mobile', mobile);
+      if (userFields.isAdmin) created.set('isAdmin', false);
+      if (userFields.roles) created.set('roles', []);
+      try { await created.signUp(null, { useMasterKey: true }); }
+      catch (error) { if (Number(error && error.code) === 202) fail(409, '此用户名已被使用'); throw error; }
+      try {
+        const regTime = new Date().toISOString();
+        const legacyData = { UserID:0,UserName:username,HoneyName:displayName || username,GroupID:1,ParentUserID:parentId,VIP:0,RegTime:regTime,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0,boffExp:0,State:1 };
+        const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-legacy-user-id\'))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("legacyUserId"),0)+1 AS id FROM "_User",lock_row WHERE "company"=$1 AND COALESCE("legacyUserId",0)>0) UPDATE "_User" SET "legacyUserId"=next_id.id,"legacyGroupId"=1,"legacyUserData"=jsonb_set($3::jsonb,\'{UserID}\',to_jsonb(next_id.id),true),"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, created.id, JSON.stringify(legacyData)]);
+        const legacyUserId = Number(rows[0] && rows[0].id) || 0;
+        if (!legacyUserId) throw new Error('无法分配旧系统用户 ID');
+        await created.fetch({ useMasterKey: true });
+        await audit({ ...context, company }, 'create-user', '_User', created.id);
+        return response.json({ success: true, data: { ...serializeObject(created), userId: legacyUserId, groupId: 1 } });
+      } catch (error) {
+        await created.destroy({ useMasterKey: true }).catch(() => undefined);
+        throw error;
+      }
+    }
     const className = String(input.className || '');
     assertClass(className);
     const fields = await schemaFor(className);

+ 16 - 1
scripts/smoke-admin-functions.mjs

@@ -56,6 +56,7 @@ async function sourceActions() {
 }
 
 let userId = '';
+let adminCreatedUserId = '';
 let registeredUserId = '';
 let memberId = '';
 let memberLegacyId = 0;
@@ -165,6 +166,16 @@ try {
   const catalog = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'catalog' });
   if (!Array.isArray(catalog.resources) || catalog.resources.length < 90 || catalog.resources.some((item) => ['_Session', 'Function'].includes(item.className))) throw new Error('后台规范化类目录校验失败');
 
+  const managedUsername = `codex_admin_created_${Date.now()}`;
+  const managedPassword = randomBytes(24).toString('base64url');
+  const managed = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'createUser', username: managedUsername, password: managedPassword, displayName: '后台开户冒烟', mobile: '13800138001', isAdmin: true, roles: ['admin'] });
+  adminCreatedUserId = String(managed.objectId || '');
+  if (!adminCreatedUserId || !Number(managed.userId) || Number(managed.groupId) !== 1 || managed.isAdmin === true || managed.roles?.includes?.('admin') || managed.sessionToken) throw new Error(`管理员开户响应异常:${JSON.stringify(managed)}`);
+  const managedStored = await jsonRequest(`${PARSE_URL}/users/${adminCreatedUserId}`, {}, true);
+  if (managedStored.username !== managedUsername || managedStored.isAdmin === true || managedStored.roles?.includes?.('admin') || Number(managedStored.legacyUserId) !== Number(managed.userId) || Number(managedStored.legacyGroupId) !== 1 || Number(managedStored.legacyUserData?.UserID) !== Number(managed.userId) || Number(managedStored.legacyUserData?.Purse) !== 0) throw new Error('管理员开户持久化约束校验失败');
+  await jsonRequest(`${PARSE_URL}/users/${adminCreatedUserId}`, { method: 'DELETE' }, true);
+  adminCreatedUserId = '';
+
   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 校验失败');
 
@@ -342,7 +353,7 @@ try {
   const productBlocked = await callLegacyFunction('', { action: 'product_list' }, 501);
   if (!String(productBlocked.retmsg).includes('ZL_Commodities')) throw new Error('缺商品主表的读取接口未明确阻塞');
 
-  console.log('Cloud smoke passed: admin auth/tenant/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
+  console.log('Cloud smoke passed: admin auth/tenant/account creation/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);
@@ -352,6 +363,10 @@ try {
     console.error(`临时注册用户清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  if (adminCreatedUserId) await jsonRequest(`${PARSE_URL}/users/${adminCreatedUserId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`管理员开户临时用户清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   if (temporaryGuestbookId) await jsonRequest(`${PARSE_URL}/classes/Guestbook/${temporaryGuestbookId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时留言清理失败:${error.message}`);
     process.exitCode = 1;

+ 29 - 16
src/app/admin/pages/admin-resource.component.html

@@ -23,24 +23,37 @@
   <aside class="editor-drawer">
     <header><div><span>{{ !schema()?.writable ? '查看记录' : 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" [class.invalid]="fieldErrors()[field.name]" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)"></textarea>@if (fieldErrors()[field.name]; as fieldError) { <small class="field-error">{{ fieldError }}</small> } }
-          @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>
-      } }
-      @if (className() === '_User' && draftId()) {
-        <section class="password-reset">
-          <header><lucide-icon [img]="icons.KeyRound" [size]="18" /><div><strong>重置登录密码</strong><span>通过专用云函数写入,不进入通用资料字段</span></div></header>
-          <label><span>新密码 <small>至少 8 位</small></span><input type="password" autocomplete="new-password" [ngModel]="newPassword()" (ngModelChange)="newPassword.set($event)" /></label>
-          <label><span>确认密码</span><input type="password" autocomplete="new-password" [ngModel]="confirmPassword()" (ngModelChange)="confirmPassword.set($event)" /></label>
-          @if (passwordError()) { <div class="password-status error">{{ passwordError() }}</div> }
-          @if (passwordMessage()) { <div class="password-status success">{{ passwordMessage() }}</div> }
-          <button type="button" [disabled]="resettingPassword()" (click)="resetPassword()">{{ resettingPassword() ? '重置中…' : '确认重置密码' }}</button>
+      @if (className() === '_User' && !draftId()) {
+        <section class="account-create">
+          <div class="account-create-note"><strong>开通普通用户</strong><span>自动归入当前帐套与默认组 1,并初始化旧系统用户 ID、余额和经验字段。此入口不能授予管理员权限。</span></div>
+          <label><span>登录账号 <small>3–64 位 · 必填</small></span><input autocomplete="off" [ngModel]="createUsername()" (ngModelChange)="createUsername.set($event)" /></label>
+          <label><span>显示名 <small>选填</small></span><input autocomplete="off" [ngModel]="createDisplayName()" (ngModelChange)="createDisplayName.set($event)" /></label>
+          <label><span>登录密码 <small>8–72 位 · 必填</small></span><input type="password" autocomplete="new-password" [ngModel]="createPassword()" (ngModelChange)="createPassword.set($event)" /></label>
+          <label><span>确认密码 <small>必填</small></span><input type="password" autocomplete="new-password" [ngModel]="createConfirmPassword()" (ngModelChange)="createConfirmPassword.set($event)" /></label>
+          <label><span>手机号 <small>选填</small></span><input inputmode="tel" autocomplete="off" [ngModel]="createMobile()" (ngModelChange)="createMobile.set($event)" /></label>
+          <label><span>邀请账号 <small>选填 · 同帐套</small></span><input autocomplete="off" [ngModel]="createInviteUsername()" (ngModelChange)="createInviteUsername.set($event)" /></label>
+          @if (createUserError()) { <div class="account-create-error">{{ createUserError() }}</div> }
         </section>
+      } @else {
+        @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" [class.invalid]="fieldErrors()[field.name]" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)"></textarea>@if (fieldErrors()[field.name]; as fieldError) { <small class="field-error">{{ fieldError }}</small> } }
+            @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>
+        } }
+        @if (className() === '_User' && draftId()) {
+          <section class="password-reset">
+            <header><lucide-icon [img]="icons.KeyRound" [size]="18" /><div><strong>重置登录密码</strong><span>通过专用云函数写入,不进入通用资料字段</span></div></header>
+            <label><span>新密码 <small>至少 8 位</small></span><input type="password" autocomplete="new-password" [ngModel]="newPassword()" (ngModelChange)="newPassword.set($event)" /></label>
+            <label><span>确认密码</span><input type="password" autocomplete="new-password" [ngModel]="confirmPassword()" (ngModelChange)="confirmPassword.set($event)" /></label>
+            @if (passwordError()) { <div class="password-status error">{{ passwordError() }}</div> }
+            @if (passwordMessage()) { <div class="password-status success">{{ passwordMessage() }}</div> }
+            <button type="button" [disabled]="resettingPassword()" (click)="resetPassword()">{{ resettingPassword() ? '重置中…' : '确认重置密码' }}</button>
+          </section>
+        }
       }
     </div>
-    <footer><button type="button" (click)="closeEditor()">{{ schema()?.writable ? '取消' : '关闭' }}</button>@if (schema()?.writable) { <button class="primary" type="button" [disabled]="saving()" (click)="save()">{{ saving() ? '保存中…' : '保存记录' }}</button> }</footer>
+    <footer><button type="button" (click)="closeEditor()">{{ schema()?.writable ? '取消' : '关闭' }}</button>@if (schema()?.writable) { <button class="primary" type="button" [disabled]="saving()" (click)="submitEditor()">{{ saving() ? '保存中…' : className() === '_User' && !draftId() ? '开通用户' : '保存记录' }}</button> }</footer>
   </aside>
 }

+ 2 - 1
src/app/admin/pages/admin-resource.component.scss

@@ -6,4 +6,5 @@
 .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-fields textarea.invalid { border-color: #cf5961; box-shadow: 0 0 0 3px rgba(207,89,97,.1); }.editor-fields .field-error { color: #b33f48; }.editor-drawer footer { justify-content: flex-end; border-top: 1px solid #e8ecf2; border-bottom: 0; }
 .password-reset { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; gap: 13px; margin-top: 4px; padding: 16px; border: 1px solid #dce8e3; border-radius: 11px; background: #f7fbf9; }.password-reset > header { grid-column: 1 / -1; justify-content: flex-start; padding: 0 0 12px; border-bottom: 1px solid #dfeae5; color: #28795b; }.password-reset > header div { gap: 2px; }.password-reset > header strong { font-size: 13px; }.password-reset > header span { font-size: 10px; }.password-reset > button { grid-column: 1 / -1; justify-self: end; padding: 8px 13px; border: 0; border-radius: 8px; color: white; background: #287e5e; cursor: pointer; }.password-reset > button:disabled { opacity: .55; }.password-status { grid-column: 1 / -1; font-size: 11px; }.password-status.error { color: #b33f48; }.password-status.success { color: #237454; }
-@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; } }
+.account-create { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; gap: 17px; }.account-create-note { grid-column: 1 / -1; display: grid; gap: 5px; padding: 14px 15px; border: 1px solid #dce8e3; border-radius: 10px; color: #28795b; background: #f7fbf9; }.account-create-note strong { font-size: 13px; }.account-create-note span { color: #668175; font-size: 11px; line-height: 1.6; }.account-create-error { grid-column: 1 / -1; padding: 10px 12px; border-radius: 8px; color: #b33f48; background: #fff2f3; font-size: 11px; }
+@media (max-width: 700px) { .resource-header { align-items: start; }.resource-header span { display: none; }.header-actions button:first-child { display: none; }.editor-fields, .account-create { grid-template-columns: 1fr; }.editor-fields label:has(textarea) { grid-column: auto; }.pagination > span { display: none; }.pagination { justify-content: flex-end; } }

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

@@ -64,4 +64,36 @@ describe('AdminResourceComponent', () => {
     expect(functions.admin).toHaveBeenCalledWith('resetPassword', { className: '_User', objectId: 'user-object', newPassword: 'SafePass123' });
     expect(component.passwordMessage()).toContain('已安全重置');
   });
+
+  it('uses the dedicated ordinary-account operation without generic role fields', async () => {
+    component.className.set('_User');
+    component.schema.set({ className: '_User', label: '用户管理', writable: true, supportsSoftDelete: true, fields: [] });
+    component.createUsername.set('student-1001');
+    component.createPassword.set('SafePass123');
+    component.createConfirmPassword.set('SafePass123');
+    component.createDisplayName.set('新学员');
+    component.createMobile.set('13800138000');
+    functions.admin.and.callFake(async (operation: string) => operation === 'createUser'
+      ? { objectId: 'new-user', userId: 1001, groupId: 1 }
+      : { className: '_User', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.createUser();
+
+    expect(functions.admin.calls.first().args).toEqual(['createUser', {
+      username: 'student-1001', password: 'SafePass123', displayName: '新学员', mobile: '13800138000',
+    }]);
+    expect(component.editorOpen()).toBeFalse();
+  });
+
+  it('rejects mismatched account passwords before calling the cloud function', async () => {
+    component.className.set('_User');
+    component.createUsername.set('student-1002');
+    component.createPassword.set('SafePass123');
+    component.createConfirmPassword.set('Different123');
+
+    await component.createUser();
+
+    expect(functions.admin).not.toHaveBeenCalled();
+    expect(component.createUserError()).toContain('不一致');
+  });
 });

+ 37 - 3
src/app/admin/pages/admin-resource.component.ts

@@ -33,6 +33,13 @@ export class AdminResourceComponent implements OnInit {
   readonly confirmPassword = signal('');
   readonly passwordMessage = signal('');
   readonly passwordError = signal('');
+  readonly createUsername = signal('');
+  readonly createPassword = signal('');
+  readonly createConfirmPassword = signal('');
+  readonly createDisplayName = signal('');
+  readonly createMobile = signal('');
+  readonly createInviteUsername = signal('');
+  readonly createUserError = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
@@ -62,16 +69,16 @@ export class AdminResourceComponent implements OnInit {
     finally { this.loading.set(false); }
   }
 
-  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.editorOpen.set(true); }
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); 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.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.editorOpen.set(true);
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); 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(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -107,6 +114,32 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
     finally { this.saving.set(false); }
   }
+  async submitEditor(): Promise<void> {
+    if (this.className() === '_User' && !this.draftId()) await this.createUser();
+    else await this.save();
+  }
+  async createUser(): Promise<void> {
+    const username = this.createUsername().trim();
+    const password = this.createPassword();
+    this.createUserError.set('');
+    if (this.className() !== '_User' || this.draftId()) { this.createUserError.set('只能从用户管理的新建入口开户'); return; }
+    if (username.length < 3 || username.length > 64) { this.createUserError.set('账号长度应为 3 至 64 位'); return; }
+    if (password.length < 8 || password.length > 72) { this.createUserError.set('密码长度应为 8 至 72 位'); return; }
+    if (password !== this.createConfirmPassword()) { this.createUserError.set('两次输入的密码不一致'); return; }
+    const payload: Record<string, unknown> = { username, password };
+    const displayName = this.createDisplayName().trim();
+    const mobile = this.createMobile().trim();
+    const inviteUsername = this.createInviteUsername().trim();
+    if (displayName) payload['displayName'] = displayName;
+    if (mobile) payload['mobile'] = mobile;
+    if (inviteUsername) payload['inviteUsername'] = inviteUsername;
+    this.saving.set(true); this.error.set('');
+    try {
+      await this.functions.admin('createUser', payload);
+      this.closeEditor(); await this.load();
+    } catch (error) { this.createUserError.set(error instanceof Error ? error.message : '用户开户失败'); }
+    finally { this.saving.set(false); }
+  }
   async resetPassword(): Promise<void> {
     this.passwordError.set(''); this.passwordMessage.set('');
     if (this.className() !== '_User' || !this.draftId()) { this.passwordError.set('只能重置现有用户密码'); return; }
@@ -134,4 +167,5 @@ export class AdminResourceComponent implements OnInit {
     return String(value);
   }
   private resetPasswordFields(): void { this.newPassword.set(''); this.confirmPassword.set(''); this.passwordMessage.set(''); this.passwordError.set(''); }
+  private resetCreateUserFields(): void { this.createUsername.set(''); this.createPassword.set(''); this.createConfirmPassword.set(''); this.createDisplayName.set(''); this.createMobile.set(''); this.createInviteUsername.set(''); this.createUserError.set(''); }
 }