Pārlūkot izejas kodu

feat: migrate admin node lifecycle

彭峰 1 mēnesi atpakaļ
vecāks
revīzija
338f5ad40e

+ 3 - 2
docs/migration/admin-migration-status.md

@@ -22,8 +22,9 @@
 - 当前线上 `Group` 表为空;用户组移动只接受当前帐套中真实存在的 `Group.groupId`,界面会明确提示先在“全部数据类”建立用户组,不会把不存在的默认编号伪装成可选组。
 - 旧 `GroupAdd/GroupAdd_Submit` 已迁为专用 `saveGroup`:新增组使用 PostgreSQL 事务锁在帐套内原子分配只读 `groupId`,校验同名组、父组归属与层级循环;通用保存不能绕过。删除组前会拒绝仍被用户或下级组引用的记录。
 - 旧 `ContentManage_Status`、`ContentManage_Del`、回收站恢复与 `ContentMove_Submit` 已迁为专用 `contentBatch`:支持每批 1–100 条内容、限定旧状态 `-3/-2/-1/0/99`、校验目标节点帐套并保留 addon 关联。`CommonModel` 的 `generalId/modelId/nodeId/itemId/tableName/status/sourceKey/orderId` 改为通用只读;新增按钮关闭,避免只写主表却遗漏模型附表。
-- `_User` 与 `CommonModel` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,以及审核、回收、恢复、移动内容节点;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 9 项已实现、12 项部分实现、111 项数据阻塞、185 项基础设施阻塞、725 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 旧 `NodeAdd_Submit`、`Node_API` 与 `UnionNode_Move` 的核心栏目生命周期已迁为专用 `saveNode/nodeBatch`:新增栏目以临时来源键完成 Parse 初始写入,再使用 PostgreSQL 事务锁原子分配 `nodeId/orderId` 并替换为旧 `sourceKey`;保存会校验同级名称/目录重复、父栏目帐套和回收状态,移动会阻断自身/下级循环并递归修正所有后代深度。回收、恢复和每批 1–100 个栏目迁移已接入 Angular;永久删除仅在没有内容、下级栏目、节点权限和模型模板引用时开放,通用保存与删除不能绕过。
+- `_User`、`CommonModel` 与 `Node` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,以及回收、恢复、移动栏目;翻页或重新查询会清空选择,避免误操作隐藏页记录。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 14 项已实现、19 项部分实现、111 项数据阻塞、185 项基础设施阻塞、713 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 110 - 3
scripts/deploy-admin-functions.mjs

@@ -21,7 +21,10 @@ 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','isDisabled','legacyUserId','legacyGroupId','legacyUserData','groupId']);
 const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
 const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
-const CLASS_SYSTEM_FIELDS = { CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']) };
+const CLASS_SYSTEM_FIELDS = {
+  CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']),
+  Node: new Set(['nodeId','parentId','depth','child','orderId','zstatus','sourceKey','cuser','cuname','editDate'])
+};
 
 function inputOf(request) {
   const body = request.body || {};
@@ -311,6 +314,108 @@ async function handler(request, response) {
       await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-group' : 'create-group', 'Group', target.id);
       return response.json({ success: true, data: serializeObject(target) });
     }
+    if (operation === 'saveNode') {
+      const objectId = String(input.objectId || '');
+      const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      const nodeName = String(payload.nodeName || '').trim();
+      const nodeDir = String(payload.nodeDir || '').trim();
+      const parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId);
+      if (!nodeName || nodeName.length > 100) fail(400, '栏目名称长度应为 1 至 100 位');
+      if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父节点编号无效');
+      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 fields = await schemaFor('Node');
+      let target;
+      let currentNodeId = 0;
+      if (objectId) {
+        const query = new Parse.Query('Node'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
+        companyId = pointerId(target.get('company')) || companyId;
+        currentNodeId = Number(target.get('nodeId')) || 0;
+      } else target = new Parse.Object('Node');
+      let parentDepth = 0;
+      if (parentId > 0) {
+        const parent = await Psql.oneOrNone('SELECT "nodeId","depth" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1', [companyId, parentId]);
+        if (!parent) fail(404, '父节点不存在、已回收或不属于当前帐套');
+        parentDepth = Number(parent.depth) || 0;
+        if (currentNodeId) {
+          const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "nodeId","parentId",ARRAY["nodeId"::text] AS path FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 UNION ALL SELECT n."nodeId",n."parentId",chain.path||n."nodeId"::text FROM "Node" n JOIN chain ON n."nodeId"=chain."parentId" WHERE n."company"=$1 AND NOT n."nodeId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "nodeId"=$3 LIMIT 1', [companyId, parentId, currentNodeId]);
+          if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
+        }
+      }
+      const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Node" WHERE "company"=$1 AND COALESCE("parentId",0)=$2 AND "objectId"<>$3 AND COALESCE("zstatus",99)<>-2 AND (LOWER(TRIM(COALESCE("nodeName",\'\')))=LOWER($4) OR ($5<>\'\' AND LOWER(TRIM(COALESCE("nodeDir",\'\')))=LOWER($5))) LIMIT 1', [companyId, parentId, objectId, nodeName, nodeDir]);
+      if (duplicate) fail(409, '同一父栏目下的栏目名称或目录名不能重复');
+      for (const [name, value] of Object.entries(payload)) {
+        if (!fields[name] || isSystemField('Node', name)) continue;
+        if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许编辑: ' + name);
+        if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
+      }
+      target.set('nodeName', nodeName); target.set('nodeDir', nodeDir); target.set('parentId', parentId); target.set('depth', parentDepth + 1); target.set('company', company);
+      if (!objectId) {
+        target.set('sourceKey', 'cloud:admin-node:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
+        target.set('zstatus', 99); target.set('child', 0); target.set('cdate', new Date());
+        if (fields.cuser) target.set('cuser', Number(context.current.get('legacyUserId')) || 0);
+        if (fields.cuname) target.set('cuname', String(context.current.get('username') || ''));
+      }
+      if (fields.editDate) target.set('editDate', new Date());
+      try { await target.save(null, { useMasterKey: true }); }
+      catch (error) { fail(422, '栏目初始写入失败: ' + String(error && error.message || error)); }
+      try {
+        if (!objectId) {
+          const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-node-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("nodeId"),0)+1 AS id FROM "Node",lock_row WHERE "company"=$1 AND COALESCE("nodeId",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "Node",lock_row WHERE "company"=$1 AND COALESCE("parentId",0)=$3 AND "objectId"<>$2) UPDATE "Node" SET "nodeId"=next_id.id,"orderId"=next_order.id,"sourceKey"=\'[["NodeID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, parentId]);
+          currentNodeId = Number(rows[0] && rows[0].id) || 0;
+          if (!currentNodeId) throw new Error('无法分配栏目编号');
+        }
+        await Psql.query('WITH RECURSIVE tree AS (SELECT "objectId","nodeId",$3::numeric AS depth FROM "Node" WHERE "company"=$1 AND "objectId"=$2 UNION ALL SELECT n."objectId",n."nodeId",tree.depth+1 FROM "Node" n JOIN tree ON n."parentId"=tree."nodeId" WHERE n."company"=$1) UPDATE "Node" n SET "depth"=tree.depth,"updatedAt"=NOW() FROM tree WHERE n."objectId"=tree."objectId"', [companyId, target.id, parentDepth + 1]);
+      } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '栏目结构写入失败: ' + String(error && error.message || error)); }
+      await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-node' : 'create-node', 'Node', target.id);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'nodeBatch') {
+      const action = String(input.action || '');
+      if (!['recycle','recover','move','purge'].includes(action)) fail(400, '不支持的栏目批量操作');
+      const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
+      const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
+      if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个栏目');
+      const fields = await schemaFor('Node');
+      const query = new Parse.Query('Node'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
+      const targets = await query.find({ useMasterKey: true });
+      if (targets.length !== objectIds.length) fail(404, '部分栏目不存在或不属于当前帐套');
+      const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
+      if (companyIds.length !== 1) fail(400, '批量操作的栏目必须属于同一帐套');
+      const companyId = companyIds[0];
+      const nodeIds = targets.map((target) => Number(target.get('nodeId')) || 0);
+      if (nodeIds.some((nodeId) => nodeId < 1)) fail(409, '栏目缺少有效旧系统编号');
+      let parentId = null;
+      let parentDepth = 0;
+      if (action === 'move') {
+        parentId = Number(input.parentId);
+        if (!Number.isInteger(parentId) || parentId < 0) fail(400, '目标父节点编号无效');
+        if (parentId > 0) {
+          const parent = await Psql.oneOrNone('SELECT "nodeId","depth" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1', [companyId, parentId]);
+          if (!parent) fail(404, '目标父节点不存在、已回收或不属于当前帐套');
+          parentDepth = Number(parent.depth) || 0;
+          for (const nodeId of nodeIds) {
+            const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "nodeId","parentId",ARRAY["nodeId"::text] AS path FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 UNION ALL SELECT n."nodeId",n."parentId",chain.path||n."nodeId"::text FROM "Node" n JOIN chain ON n."nodeId"=chain."parentId" WHERE n."company"=$1 AND NOT n."nodeId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "nodeId"=$3 LIMIT 1', [companyId, parentId, nodeId]);
+            if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
+          }
+        }
+        for (const target of targets) { target.set('parentId', parentId); target.set('depth', parentDepth + 1); if (fields.editDate) target.set('editDate', new Date()); }
+        await Parse.Object.saveAll(targets, { useMasterKey: true });
+        for (const target of targets) await Psql.query('WITH RECURSIVE tree AS (SELECT "objectId","nodeId",$3::numeric AS depth FROM "Node" WHERE "company"=$1 AND "objectId"=$2 UNION ALL SELECT n."objectId",n."nodeId",tree.depth+1 FROM "Node" n JOIN tree ON n."parentId"=tree."nodeId" WHERE n."company"=$1) UPDATE "Node" n SET "depth"=tree.depth,"updatedAt"=NOW() FROM tree WHERE n."objectId"=tree."objectId"', [companyId, target.id, parentDepth + 1]);
+      } else if (action === 'purge') {
+        const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "CommonModel" WHERE "company"=$1 AND "nodeId"=ANY($2::numeric[])) AS contents,(SELECT COUNT(*)::int FROM "Node" WHERE "company"=$1 AND "parentId"=ANY($2::numeric[]) AND NOT ("nodeId"=ANY($2::numeric[]))) AS children,(SELECT COUNT(*)::int FROM "NodeAuth" WHERE "nodeId"=ANY($2::numeric[])) AS auth,(SELECT COUNT(*)::int FROM "NodeModelTemplate" WHERE "nodeId"=ANY($2::numeric[])) AS templates', [companyId, nodeIds]);
+        if (Number(refs.contents) || Number(refs.children) || Number(refs.auth) || Number(refs.templates)) fail(409, '栏目仍被内容、下级栏目、权限或模板引用,不能永久删除');
+        await Parse.Object.destroyAll(targets, { useMasterKey: true });
+      } else {
+        const status = action === 'recycle' ? -2 : 99;
+        for (const target of targets) { target.set('zstatus', status); if (fields.editDate) target.set('editDate', new Date()); }
+        await Parse.Object.saveAll(targets, { useMasterKey: true });
+      }
+      for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'node-' + action, 'Node', target.id);
+      return response.json({ success: true, data: { action, updated: targets.length, parentId, results: action === 'purge' ? [] : targets.map(serializeObject) } });
+    }
     if (operation === 'contentBatch') {
       const action = String(input.action || '');
       if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');
@@ -333,7 +438,7 @@ async function handler(request, response) {
         if (!Number.isInteger(nodeId) || nodeId < 1) fail(400, '请选择有效目标节点');
         const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
         if (companyIds.length !== 1) fail(400, '批量移动的内容必须属于同一帐套');
-        const node = await Psql.oneOrNone('SELECT "objectId" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 LIMIT 1', [companyIds[0], nodeId]);
+        const node = await Psql.oneOrNone('SELECT "objectId" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1', [companyIds[0], nodeId]);
         if (!node) fail(404, '目标节点不存在或不属于当前帐套');
       }
       for (const target of targets) {
@@ -356,7 +461,7 @@ async function handler(request, response) {
       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);
       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'); }
+      if (search) { const searchField = ['name','title','nodeName','groupName','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 })).filter(isVisible);
@@ -371,6 +476,7 @@ async function handler(request, response) {
       if (!classWritable) fail(403, '该系统类不允许通用编辑');
       if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
       if (className === 'Group') fail(400, '用户组必须走专用保存流程');
+      if (className === 'Node') fail(400, '栏目必须走专用保存流程');
       if (className === 'CommonModel' && !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);
@@ -385,6 +491,7 @@ async function handler(request, response) {
       if (className === '_User') assertCanManageUser(context, object, 'lock');
       if (className === 'Group') { const groupId = Number(object.get('groupId')) || 0; const companyId = pointerId(object.get('company')); const users = await Psql.one('SELECT COUNT(*)::int AS count FROM "_User" WHERE "company"=$1 AND COALESCE("legacyGroupId",0)=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)', [companyId, groupId]); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "Group" WHERE "company"=$1 AND COALESCE("parentGroupId",0)=$2', [companyId, groupId]); if (Number(users.count) > 0) fail(409, '该用户组仍有用户,不能删除'); if (Number(children.count) > 0) fail(409, '该用户组仍有下级组,不能删除'); }
       if (className === 'CommonModel') { object.set('status', -2); await object.save(null, { useMasterKey: true }); await audit(context, 'content-recycle', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: true, status: -2 } }); }
+      if (className === 'Node') { object.set('zstatus', -2); if (fields.editDate) object.set('editDate', new Date()); await object.save(null, { useMasterKey: true }); await audit(context, 'node-recycle', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: true, zstatus: -2 } }); }
       if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
       if (className === '_User') await revokeSessions([object]);
       await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });

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

@@ -30,6 +30,18 @@ const explicit = new Map([
   ['Content/Content_Update', ['partial', '可更新安全主表字段;模型附表更新仅覆盖已迁移学习模型']],
   ['Content/ContentRecycle', ['partial', '可回收和恢复内容;独立回收站筛选页尚未提供']],
   ['Content/ContentManage_API', ['partial', '已覆盖恢复;永久删除、清空、专题移除和排序交换尚未迁移']],
+  ['Content / Node/NodeManage', ['implemented', '已由 Node 资源列表、分页与专用结构操作替代']],
+  ['Content / Node/NodeSearch', ['implemented', '已覆盖按栏目名称搜索并保持帐套隔离']],
+  ['Content / Node/NodeAdd', ['implemented', '已由 Node 动态表单与父节点结构控件替代']],
+  ['Content / Node/NodeAdd_Submit', ['implemented', '已覆盖原子栏目编号、同级防重、父节点与层级校验']],
+  ['Content / Node/UnionNode_Move', ['implemented', '已由 nodeBatch 批量移动与递归深度同步替代']],
+  ['Content / Node/NodeRecycle', ['partial', '已覆盖回收和恢复;独立回收站筛选页尚未提供']],
+  ['Content / Node/SPageAdd', ['partial', '节点类型与外链字段可编辑;旧单页专用布局尚未逐字段复刻']],
+  ['Content / Node/OutLinkAdd', ['partial', '节点类型与 URL 字段可编辑;旧外链专用布局尚未逐字段复刻']],
+  ['Content / Node/Node_API', ['partial', '已覆盖回收、恢复与安全永久删除;清空全部节点被明确禁止']],
+  ['Content / Node/BatchNode', ['partial', '已提供栏目批量回收、恢复和结构迁移']],
+  ['Content / Node/BatchNode_Submit', ['partial', '已覆盖结构批处理;旧批量模板、权限与计费字段仍待专用实现']],
+  ['Content / Node/UnionNode', ['partial', '已提供栏目批量迁移;栏目合并仍待实现']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -69,6 +69,7 @@ async function sourceActions() {
 let userId = '';
 let adminCreatedUserId = '';
 let temporaryGroupObjectId = '';
+const temporaryNodeObjectIds = [];
 let registeredUserId = '';
 let memberId = '';
 let memberLegacyId = 0;
@@ -216,6 +217,33 @@ try {
   const contentSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'CommonModel' });
   const contentFieldMap = Object.fromEntries((contentSchema.fields || []).map((field) => [field.name, field]));
   if (contentSchema.creatable !== false || contentFieldMap.status?.writable !== false || contentFieldMap.modelId?.writable !== false || contentFieldMap.itemId?.writable !== false || contentFieldMap.nodeId?.writable !== false) throw new Error('内容结构字段未从通用创建/编辑中隔离');
+  const nodeSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'Node' });
+  const nodeFieldMap = Object.fromEntries((nodeSchema.fields || []).map((field) => [field.name, field]));
+  if (nodeFieldMap.nodeId?.writable !== false || nodeFieldMap.parentId?.writable !== false || nodeFieldMap.depth?.writable !== false || nodeFieldMap.zstatus?.writable !== false || nodeFieldMap.sourceKey?.writable !== false) throw new Error('栏目结构字段未从通用编辑中隔离');
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'save', className: 'Node', fields: { nodeName: '禁止通用新增' } }, 400);
+  const nodeSuffix = Date.now().toString(36);
+  const rootNode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveNode', className: 'Node', parentId: 0, fields: { nodeName: `云函数冒烟栏目-${nodeSuffix}`, nodeDir: `smoke-${nodeSuffix}`, nodeType: 1, contentModel: '54' } });
+  temporaryNodeObjectIds.push(String(rootNode.objectId || ''));
+  const rootNodeId = Number(rootNode.nodeId);
+  if (!temporaryNodeObjectIds[0] || !rootNodeId || Number(rootNode.parentId) !== 0 || Number(rootNode.depth) !== 1 || rootNode.sourceKey !== `[["NodeID",${rootNodeId}]]`) throw new Error(`栏目原子创建异常:${JSON.stringify(rootNode)}`);
+  const childNode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveNode', className: 'Node', parentId: rootNodeId, fields: { nodeName: `云函数冒烟子栏目-${nodeSuffix}`, nodeDir: `smoke-child-${nodeSuffix}`, nodeType: 1, contentModel: '54' } });
+  temporaryNodeObjectIds.push(String(childNode.objectId || ''));
+  const childNodeId = Number(childNode.nodeId);
+  if (!temporaryNodeObjectIds[1] || !childNodeId || Number(childNode.parentId) !== rootNodeId || Number(childNode.depth) !== 2) throw new Error(`子栏目层级创建异常:${JSON.stringify(childNode)}`);
+  const duplicateNode = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveNode', className: 'Node', parentId: 0, fields: { nodeName: `云函数冒烟栏目-${nodeSuffix}`, nodeDir: `another-${nodeSuffix}` } }, 409);
+  if (!String(duplicateNode.message).includes('不能重复')) throw new Error('栏目同级重名保护未返回明确原因');
+  const cycleNode = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveNode', className: 'Node', objectId: rootNode.objectId, parentId: childNodeId, fields: { ...rootNode, nodeName: rootNode.nodeName, nodeDir: rootNode.nodeDir } }, 409);
+  if (!String(cycleNode.message).includes('自身或其下级')) throw new Error('栏目层级循环保护未返回明确原因');
+  const movedNode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'nodeBatch', className: 'Node', action: 'move', objectIds: [childNode.objectId], parentId: 0 });
+  if (movedNode.updated !== 1 || Number(movedNode.results?.[0]?.parentId) !== 0) throw new Error('栏目批量迁移失败');
+  const recycledNode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'nodeBatch', className: 'Node', action: 'recycle', objectIds: [rootNode.objectId] });
+  if (Number(recycledNode.results?.[0]?.zstatus) !== -2) throw new Error('栏目回收站状态更新失败');
+  const recoveredNode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'nodeBatch', className: 'Node', action: 'recover', objectIds: [rootNode.objectId] });
+  if (Number(recoveredNode.results?.[0]?.zstatus) !== 99) throw new Error('栏目回收站恢复失败');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'nodeBatch', className: 'Node', action: 'purge', objectIds: [childNode.objectId] });
+  temporaryNodeObjectIds.pop();
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'nodeBatch', className: 'Node', action: 'purge', objectIds: [rootNode.objectId] });
+  temporaryNodeObjectIds.pop();
 
   const contentPending = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'status', objectIds: [temporaryAppointmentCommonObjectId], status: 0 });
   if (contentPending.updated !== 1 || Number(contentPending.results?.[0]?.status) !== 0) throw new Error('内容待审核状态更新失败');
@@ -401,7 +429,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/account lifecycle/group sync/content workflow/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 lifecycle/group sync/node lifecycle/content workflow/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}`);
@@ -419,6 +447,10 @@ try {
     console.error(`临时用户组清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  for (const nodeId of temporaryNodeObjectIds) if (nodeId) await jsonRequest(`${PARSE_URL}/classes/Node/${nodeId}`, { 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;

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

@@ -10,6 +10,8 @@ describe('legacy admin coverage inventory', () => {
   it('keeps verified mappings and blockers explicit', () => {
     const byKey = new Map(LEGACY_ADMIN_COVERAGE.map((entry) => [`${entry.module}/${entry.action}`, entry]));
     expect(byKey.get('Content/ContentManage_Status')?.status).toBe('implemented');
+    expect(byKey.get('Content / Node/NodeAdd_Submit')?.status).toBe('implemented');
+    expect(byKey.get('Content / Node/Node_API')?.status).toBe('partial');
     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');

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

@@ -3520,78 +3520,78 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Node",
     "action": "NodeManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Node 资源列表、分页与专用结构操作替代"
   },
   {
     "module": "Content / Node",
     "action": "NodeSearch",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已覆盖按栏目名称搜索并保持帐套隔离"
   },
   {
     "module": "Content / Node",
     "action": "NodeRecycle",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖回收和恢复;独立回收站筛选页尚未提供"
   },
   {
     "module": "Content / Node",
     "action": "NodeAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Node 动态表单与父节点结构控件替代"
   },
   {
     "module": "Content / Node",
     "action": "SPageAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "节点类型与外链字段可编辑;旧单页专用布局尚未逐字段复刻"
   },
   {
     "module": "Content / Node",
     "action": "OutLinkAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "节点类型与 URL 字段可编辑;旧外链专用布局尚未逐字段复刻"
   },
   {
     "module": "Content / Node",
     "action": "NodeAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已覆盖原子栏目编号、同级防重、父节点与层级校验"
   },
   {
     "module": "Content / Node",
     "action": "Node_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖回收、恢复与安全永久删除;清空全部节点被明确禁止"
   },
   {
     "module": "Content / Node",
     "action": "BatchNode",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已提供栏目批量回收、恢复和结构迁移"
   },
   {
     "module": "Content / Node",
     "action": "BatchNode_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖结构批处理;旧批量模板、权限与计费字段仍待专用实现"
   },
   {
     "module": "Content / Node",
     "action": "UnionNode",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已提供栏目批量迁移;栏目合并仍待实现"
   },
   {
     "module": "Content / Node",
@@ -3604,8 +3604,8 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Node",
     "action": "UnionNode_Move",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 nodeBatch 批量移动与递归深度同步替代"
   },
   {
     "module": "Content / Node",

+ 17 - 2
src/app/admin/pages/admin-resource.component.html

@@ -11,9 +11,12 @@
         <input class="bulk-reason" maxlength="200" [ngModel]="userActionReason()" (ngModelChange)="userActionReason.set($event)" placeholder="状态原因(选填)" />
         <button class="danger-action" type="button" [disabled]="updatingUser()" (click)="applyBulkUserAction('lock')">停用</button><button type="button" [disabled]="updatingUser()" (click)="applyBulkUserAction('unlock')">解锁</button>
         <select [ngModel]="selectedUserGroupId()" (ngModelChange)="selectedUserGroupId.set($event)"><option [ngValue]="0">选择用户组</option>@for (group of userGroups(); track group['objectId']) { <option [ngValue]="groupIdOf(group)">{{ groupLabel(group) }}</option> }</select><button type="button" [disabled]="updatingUser() || !selectedUserGroupId()" (click)="applyBulkUserAction('move')">移动分组</button>
-      } @else {
+      } @else if (className() === 'CommonModel') {
         <select [ngModel]="selectedContentStatus()" (ngModelChange)="selectedContentStatus.set($event)"><option [ngValue]="99">已审核</option><option [ngValue]="0">待审核</option><option [ngValue]="-1">未通过</option><option [ngValue]="-3">草稿</option></select><button type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('status')">更新状态</button><button class="danger-action" type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('recycle')">回收</button><button type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('recover')">恢复</button>
         <input class="bulk-node" type="number" min="1" [ngModel]="targetContentNodeId()" (ngModelChange)="targetContentNodeId.set(+$event)" placeholder="节点 ID" /><button type="button" [disabled]="updatingContent() || !targetContentNodeId()" (click)="applyBulkContentAction('move')">移动节点</button>
+      } @else {
+        <button class="danger-action" type="button" [disabled]="updatingNode()" (click)="applyBulkNodeAction('recycle')">回收</button><button type="button" [disabled]="updatingNode()" (click)="applyBulkNodeAction('recover')">恢复</button>
+        <input class="bulk-node" type="number" min="0" [ngModel]="targetNodeParentId()" (ngModelChange)="targetNodeParentId.set(+$event)" placeholder="父节点 ID" /><button type="button" [disabled]="updatingNode()" (click)="applyBulkNodeAction('move')">移动栏目</button>
       }
     </div>
   }
@@ -25,7 +28,7 @@
   } @else {
     <div class="table-scroll"><table><thead><tr>@if (bulkEnabled()) { <th class="select-col"><input type="checkbox" aria-label="全选当前页" [checked]="allPageSelected()" (change)="togglePageSelection($any($event.target).checked)" /></th> }@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>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable) { <button class="danger" type="button" [attr.aria-label]="className() === 'CommonModel' ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
+        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable) { <button class="danger" type="button" [attr.aria-label]="['CommonModel', 'Node'].includes(className()) ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
       } @empty { <tr><td class="empty-cell" [attr.colspan]="columns().length + (bulkEnabled() ? 2 : 1)">没有符合条件的数据</td></tr> }
     </tbody></table></div>
     <footer class="pagination"><span>第 {{ page()?.page || 1 }} 页,共 {{ pageCount() }} 页</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>
@@ -68,6 +71,18 @@
             @if (contentActionMessage()) { <div class="content-action-status success">{{ contentActionMessage() }}</div> }
           </section>
         }
+        @if (className() === 'Node') {
+          <section class="content-controls node-controls">
+            <header><div><strong>栏目结构与回收站</strong><span>专用云函数维护栏目编号、父子层级、同级防重与循环保护</span></div>@if (draftId()) { <b [class.recycled]="nodeStatusValue() === -2">{{ nodeStatusValue() === -2 ? '回收站' : '正常' }} · ID {{ draft()['nodeId'] }}</b> }</header>
+            <label><span>父节点 ID <small>顶级栏目填 0</small></span><input type="number" min="0" [ngModel]="targetNodeParentId()" (ngModelChange)="targetNodeParentId.set(+$event)" /></label>
+            @if (draftId()) {
+              <button type="button" [disabled]="updatingNode()" (click)="applyNodeAction('move')">移动栏目</button>
+              <div class="content-recycle">@if (nodeStatusValue() === -2) { <button type="button" [disabled]="updatingNode()" (click)="applyNodeAction('recover')">从回收站恢复</button> } @else { <button class="danger-action" type="button" [disabled]="updatingNode()" (click)="applyNodeAction('recycle')">移入回收站</button> }</div>
+              @if (nodeActionError()) { <div class="content-action-status error">{{ nodeActionError() }}</div> }
+              @if (nodeActionMessage()) { <div class="content-action-status success">{{ nodeActionMessage() }}</div> }
+            }
+          </section>
+        }
         @if (className() === '_User' && draftId()) {
           <section class="user-controls">
             <header><div><strong>账号状态与用户组</strong><span>专用云函数会同步旧版 State / GroupID;停用账号会立即撤销会话</span></div><b [class.disabled]="draft()['isDisabled'] === true">{{ draft()['isDisabled'] === true ? '已停用' : '正常' }}</b></header>

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

@@ -145,6 +145,39 @@ describe('AdminResourceComponent', () => {
     }]);
   });
 
+  it('routes node creation through the structural cloud operation', async () => {
+    component.className.set('Node');
+    component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ nodeName: '新栏目', nodeDir: 'new-node', parentId: 999, nodeId: 999 });
+    component.targetNodeParentId.set(16);
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveNode'
+      ? { objectId: 'node-object', nodeId: 401, nodeName: '新栏目', parentId: 16 }
+      : { className: 'Node', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveNode', {
+      className: 'Node', objectId: undefined, fields: { nodeName: '新栏目', nodeDir: 'new-node', parentId: 999, nodeId: 999 }, parentId: 16,
+    }]);
+  });
+
+  it('moves a node through the cycle-protected batch operation', async () => {
+    component.className.set('Node');
+    component.draftId.set('node-object');
+    component.draft.set({ objectId: 'node-object', nodeId: 40, parentId: 16, zstatus: 99 });
+    component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] });
+    component.targetNodeParentId.set(29);
+    functions.admin.and.callFake(async (operation: string) => operation === 'nodeBatch'
+      ? { action: 'move', updated: 1, results: [{ objectId: 'node-object', nodeId: 40, parentId: 29, zstatus: 99 }] }
+      : { className: 'Node', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.applyNodeAction('move');
+
+    expect(functions.admin.calls.first().args).toEqual(['nodeBatch', { className: 'Node', action: 'move', objectIds: ['node-object'], parentId: 29 }]);
+    expect(component.draft()['parentId']).toBe(29);
+    expect(component.nodeActionMessage()).toContain('29');
+  });
+
   it('updates content workflow state through the dedicated batch operation', async () => {
     component.className.set('CommonModel');
     component.draftId.set('content-object');
@@ -188,4 +221,18 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('2 条内容');
     expect(component.selectedIds()).toEqual([]);
   });
+
+  it('recovers all selected nodes through the dedicated batch operation', async () => {
+    component.className.set('Node');
+    component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] });
+    component.selectedIds.set(['node-a', 'node-b']);
+    functions.admin.and.callFake(async (operation: string) => operation === 'nodeBatch'
+      ? { action: 'recover', updated: 2, results: [] }
+      : { className: 'Node', page: 1, pageSize: 20, total: 2, results: [] });
+
+    await component.applyBulkNodeAction('recover');
+
+    expect(functions.admin.calls.first().args).toEqual(['nodeBatch', { className: 'Node', action: 'recover', objectIds: ['node-a', 'node-b'] }]);
+    expect(component.bulkMessage()).toContain('2 个栏目');
+  });
 });

+ 51 - 7
src/app/admin/pages/admin-resource.component.ts

@@ -51,6 +51,10 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingContent = signal(false);
   readonly contentActionError = signal('');
   readonly contentActionMessage = signal('');
+  readonly targetNodeParentId = signal(0);
+  readonly updatingNode = signal(false);
+  readonly nodeActionError = signal('');
+  readonly nodeActionMessage = signal('');
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
@@ -62,7 +66,7 @@ export class AdminResourceComponent implements OnInit {
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node'].includes(this.className()));
   readonly allPageSelected = computed(() => {
     const ids = (this.page()?.results || []).map((row) => String(row['objectId'] || '')).filter(Boolean);
     return Boolean(ids.length) && ids.every((id) => this.selectedIds().includes(id));
@@ -89,17 +93,17 @@ 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.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.editorOpen.set(true); }
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); 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.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.editorOpen.set(true);
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.editorOpen.set(true);
       if (this.className() === '_User') await this.loadUserGroups();
     } 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(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -130,8 +134,10 @@ export class AdminResourceComponent implements OnInit {
     if (invalidFields.length) { this.error.set(`请先修正字段格式:${invalidFields.join('、')}`); return; }
     this.saving.set(true); this.error.set('');
     try {
-      const operation = this.className() === 'Group' ? 'saveGroup' : 'save';
-      await this.functions.admin(operation, { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() });
+      const operation = this.className() === 'Group' ? 'saveGroup' : this.className() === 'Node' ? 'saveNode' : 'save';
+      const payload: Record<string, unknown> = { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() };
+      if (this.className() === 'Node') payload['parentId'] = this.targetNodeParentId();
+      await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
     finally { this.saving.set(false); }
@@ -279,9 +285,45 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量内容操作失败'); }
     finally { this.updatingContent.set(false); }
   }
+  async applyNodeAction(action: 'recycle' | 'recover' | 'move'): Promise<void> {
+    this.nodeActionError.set(''); this.nodeActionMessage.set('');
+    if (this.className() !== 'Node' || !this.draftId()) { this.nodeActionError.set('只能管理现有栏目'); return; }
+    if (action === 'recycle' && !confirm('确认将该栏目移入回收站?')) return;
+    const payload: Record<string, unknown> = { className: 'Node', action, objectIds: [this.draftId()] };
+    if (action === 'move') {
+      const parentId = this.targetNodeParentId();
+      if (!Number.isInteger(parentId) || parentId < 0) { this.nodeActionError.set('请输入有效父节点 ID,顶级栏目填 0'); return; }
+      payload['parentId'] = parentId;
+    }
+    this.updatingNode.set(true);
+    try {
+      const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('nodeBatch', payload);
+      if (result.results?.[0]) { this.draft.set(result.results[0]); this.targetNodeParentId.set(Number(result.results[0]['parentId']) || 0); }
+      this.nodeActionMessage.set(action === 'recycle' ? '栏目已移入回收站' : action === 'recover' ? '栏目已恢复' : `栏目已移动到父节点 ${this.targetNodeParentId()}`);
+      await this.load();
+    } catch (error) { this.nodeActionError.set(error instanceof Error ? error.message : '栏目操作失败'); }
+    finally { this.updatingNode.set(false); }
+  }
+  async applyBulkNodeAction(action: 'recycle' | 'recover' | 'move'): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'Node' || !objectIds.length) { this.bulkError.set('请先选择栏目'); return; }
+    if (action === 'recycle' && !confirm(`确认将选中的 ${objectIds.length} 个栏目移入回收站?`)) return;
+    const payload: Record<string, unknown> = { className: 'Node', action, objectIds };
+    if (action === 'move') {
+      const parentId = this.targetNodeParentId();
+      if (!Number.isInteger(parentId) || parentId < 0) { this.bulkError.set('请输入有效父节点 ID,顶级栏目填 0'); return; }
+      payload['parentId'] = parentId;
+    }
+    this.updatingNode.set(true);
+    try {
+      const result = await this.functions.admin<{ updated: number }>('nodeBatch', payload);
+      await this.load(); this.bulkMessage.set(`已处理 ${result.updated} 个栏目`);
+    } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量栏目操作失败'); }
+    finally { this.updatingNode.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
-    const verb = this.className() === 'CommonModel' ? '移入回收站' : '删除';
+    const verb = ['CommonModel', 'Node'].includes(this.className()) ? '移入回收站' : '删除';
     if (!objectId || !confirm(`确认${verb} ${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 : '删除失败'); }
@@ -298,6 +340,7 @@ export class AdminResourceComponent implements OnInit {
   groupLabel(group: Record<string, unknown>): string { return `${String(group['groupName'] || group['name'] || '未命名组')}(${this.groupIdOf(group)})`; }
   contentStatusValue(): number { return Number(this.draft()['status']) || 0; }
   contentStatusLabel(status: number): string { return ({ [-3]: '草稿', [-2]: '回收站', [-1]: '未通过', 0: '待审核', 99: '已审核' } as Record<number, string>)[status] || String(status); }
+  nodeStatusValue(): number { return Number(this.draft()['zstatus'] ?? 99); }
   private async loadUserGroups(): Promise<void> {
     try {
       const groups = await this.functions.admin<ResourcePage>('list', { className: 'Group', page: 1, pageSize: 100, sort: 'orderId', order: 'asc' });
@@ -308,4 +351,5 @@ export class AdminResourceComponent implements OnInit {
   private resetCreateUserFields(): void { this.createUsername.set(''); this.createPassword.set(''); this.createConfirmPassword.set(''); this.createDisplayName.set(''); this.createMobile.set(''); this.createInviteUsername.set(''); this.createUserError.set(''); }
   private resetUserActions(detail: Record<string, unknown> = {}): void { this.userGroups.set([]); this.selectedUserGroupId.set(Number(detail['legacyGroupId'] ?? (detail['legacyUserData'] as Record<string, unknown> | undefined)?.['GroupID']) || 0); this.userActionReason.set(''); this.userActionError.set(''); this.userActionMessage.set(''); }
   private resetContentActions(detail: Record<string, unknown> = {}): void { this.selectedContentStatus.set(Number(detail['status']) || 0); this.targetContentNodeId.set(Number(detail['nodeId']) || 0); this.contentActionError.set(''); this.contentActionMessage.set(''); }
+  private resetNodeActions(detail: Record<string, unknown> = {}): void { this.targetNodeParentId.set(Number(detail['parentId']) || 0); this.nodeActionError.set(''); this.nodeActionMessage.set(''); }
 }