Parcourir la source

feat: 新增服务评价管理功能

主要功能:
- 新增服务评价管理页面,支持系统-服务两级树形导航
- 实现评价项目和选项的增删改查功能
- 支持评价项目和选项的拖拽排序
- 集成编辑选项得分、删除评价项、删除选项等API接口
- 添加收起所有展开行的快捷按钮

技术优化:
- 优化KCTable组件,支持禁用特定行的拖拽功能(disabledDragKeys)
- 实现展开行禁止拖拽的交互逻辑,提供用户友好的提示
- 统一拖拽图标样式(使用iconliebiao图标)
- 优化嵌套表格的缩进和视觉层次
- 移除调试日志,提升代码质量

UI细节:
- 优化按钮样式和自适应宽度
- 调整评价类型显示样式
- 完善拖拽手柄列和展开列的布局
- 增加嵌套表格缩进,提升可读性
code4eat il y a 1 mois
Parent
commit
ad27f2e8c5

+ 165 - 42
src/components/kcTable/index.tsx

@@ -7,22 +7,30 @@
  * @FilePath: /KC-MiddlePlatform/src/components/kcTable/index.tsx
  */
 
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useMemo } from 'react';
 import ProTable, { ActionType, ProColumns } from '@ant-design/pro-table';
+import { message } from 'antd';
 import { KCTableType } from './typing';
 import './style.less';
+import { useDrag, useDrop } from 'react-dnd';
 
-const KCTable: React.FC<KCTableType.KCTableProps<any, {}>> = ({
+const KCTable: React.FC<KCTableType.KCTableProps<any, { current?: number; pageSize?: number; [key: string]: any }>> = ({
   columns,
   reload,
   newVer,
   params,
   request,
   onChange,
+  dragSort,
+  dragSortKey,
+  dragType,
+  disabledDragKeys,
+  onDragSortEnd,
   ...restProps
 }) => {
   const [tableColumns, setTableColumns] = useState<ProColumns[]>([]);
   const actionRef = useRef<ActionType>();
+  const type = dragType || 'KC_TABLE_ROW'; // 使用自定义类型或默认类型
 
   useEffect(() => {
     setTableColumns(
@@ -40,55 +48,170 @@ const KCTable: React.FC<KCTableType.KCTableProps<any, {}>> = ({
   }, [reload]);
 
   // 处理分页变化
-  const handleTableChange = (pagination: any, filters: any, sorter: any) => {
+  const handleTableChange = (pagination: any, filters: any, sorter: any, extra: any) => {
     if (onChange) {
-      onChange(pagination, filters, sorter);
+      onChange(pagination, filters, sorter, extra);
     }
   };
 
+  const moveRow = React.useCallback((dragIndex: number, hoverIndex: number) => {
+    if (!onDragSortEnd) {
+      return;
+    }
+    const raw = (restProps as any).dataSource || [];
+    // 仅当存在有效数组时才排序,防止非对象触发下游渲染问题
+    if (!Array.isArray(raw) || raw.length === 0) {
+      return;
+    }
+    const data = raw.map((x: any) => (x && typeof x === 'object' ? { ...x } : x));
+    const newData = data.slice();
+    const dragRow = newData[dragIndex];
+    if (typeof dragRow !== 'object') {
+      return;
+    }
+    newData.splice(dragIndex, 1);
+    newData.splice(hoverIndex, 0, dragRow);
+    // 避免在 rc-table 渲染周期内立即变更数据导致内部校验异常,异步派发
+    setTimeout(() => onDragSortEnd(newData), 0);
+  }, [onDragSortEnd, restProps, dragSort]);
+
+  const components = useMemo(() => {
+    if (!dragSort) {
+      return undefined;
+    }
+    // 行组件:支持拖拽排序
+    const DragRow: React.FC<any> = ({ className, style, index, record, children, moveRow: moveRowProp, ...rest }) => {
+      const ref = useRef<HTMLTableRowElement>(null);
+      
+      // 获取行的 key,判断是否禁用拖拽
+      const rowKeyValue = dragSortKey ? record?.[dragSortKey] : record?.id;
+      const isDisabled = disabledDragKeys && disabledDragKeys.includes(rowKeyValue);
+
+      const [, drop] = useDrop({
+        accept: type,
+        hover: (item: any, monitor: any) => {
+          if (!ref.current) return;
+          if (isDisabled) return; // 禁用行不响应 hover
+          const dragIndex = item.index;
+          const hoverIndex = index;
+          if (dragIndex === hoverIndex) return;
+          const clientOffset = monitor.getClientOffset();
+          const hoverBoundingRect = ref.current.getBoundingClientRect();
+          const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+          const hoverClientY = clientOffset ? clientOffset.y - hoverBoundingRect.top : 0;
+          if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return;
+          if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return;
+          // 使用从 props 传入的 moveRow
+          if (moveRowProp) {
+            moveRowProp(dragIndex, hoverIndex);
+          }
+          item.index = hoverIndex;
+        },
+        collect: (monitor) => ({
+          isOver: monitor.isOver(),
+        }),
+      });
+
+      const [{ isDragging }, drag] = useDrag({
+        type,
+        item: { index },
+        canDrag: () => {
+          if (isDisabled) {
+            message.warning('请先收起展开的行,再进行排序操作');
+            return false;
+          }
+          return true;
+        },
+        collect: (monitor) => ({ isDragging: monitor.isDragging() }),
+      });
+
+      // 所有行都应用拖拽,这样才能触发 canDrag 回调显示提示
+      drag(drop(ref));
+
+      return (
+        <tr 
+          ref={ref} 
+          className={className} 
+          style={{ 
+            opacity: isDragging ? 0.5 : 1, 
+            cursor: isDisabled ? 'not-allowed' : 'move',
+            ...style 
+          }} 
+          {...rest}
+        >
+          {children}
+        </tr>
+      );
+    };
+
+    return {
+      body: {
+        row: (props: any) => <DragRow {...props} />,
+      },
+    } as any;
+  }, [dragSort, type, disabledDragKeys, dragSortKey]);
+
   if(newVer){
     return (
-      <ProTable
-        tableStyle={{
-          border: '1px solid #DAE2F2',
-          borderRadius:4
-        }}
-        toolBarRender={false}
-        search={false}
-        className="KCTable"
-        actionRef={actionRef}
-        columns={tableColumns}
-        pagination={{ 
-          defaultPageSize: 10,
-          current: params?.current,
-          pageSize: params?.pageSize
-        }}
-        onChange={handleTableChange}
-        params={params}
-        request={request}
-        {...restProps}
-      />
+        <ProTable
+          tableStyle={{
+            border: '1px solid #DAE2F2',
+            borderRadius:4
+          }}
+          toolBarRender={false}
+          search={false}
+          className="KCTable"
+          actionRef={actionRef}
+          columns={tableColumns}
+          pagination={{
+            defaultPageSize: 10,
+            current: params?.current,
+            pageSize: params?.pageSize
+          }}
+          onChange={handleTableChange}
+          params={params}
+          request={request}
+          components={components}
+          onRow={(record: any, index) => {
+            if (!dragSort) return {} as any;
+            return {
+              index,
+              record,
+              moveRow,
+            } as any;
+          }}
+          {...restProps}
+        />
     );
   }else{
     return (
-      <ProTable
-        tableStyle={{
-          border: '1px solid #DAE2F2',
-          borderRadius:4
-        }}
-        className="KCTable"
-        actionRef={actionRef}
-        columns={tableColumns}
-        pagination={{ 
-          defaultPageSize: 10,
-          current: params?.current,
-          pageSize: params?.pageSize
-        }}
-        onChange={handleTableChange}
-        params={params}
-        request={request}
-        {...restProps}
-      />
+        <ProTable
+          tableStyle={{
+            border: '1px solid #DAE2F2',
+            borderRadius:4
+          }}
+          className="KCTable"
+          actionRef={actionRef}
+          columns={tableColumns}
+          pagination={{ 
+            defaultPageSize: 10,
+            current: params?.current,
+            pageSize: params?.pageSize
+          }}
+          onChange={handleTableChange}
+          params={params}
+          request={request}
+          components={components}
+          onRow={(record: any, index) => {
+            if (!dragSort) return {} as any;
+            return {
+              index,
+              record,
+              moveRow,
+            } as any;
+          }}
+          {...restProps}
+        />
     );
   }
 };

+ 10 - 0
src/components/kcTable/typing.d.ts

@@ -18,5 +18,15 @@ declare namespace KCTableType {
     columns: ProColumns<any, 'text'>[];
     reload?: boolean;
     newVer?:boolean,
+    /** 是否开启行拖拽排序(左侧拖拽,整行作为拖拽源/目标) */
+    dragSort?: boolean;
+    /** 拖拽行的唯一键,默认使用 rowKey */
+    dragSortKey?: string;
+    /** 拖拽类型标识,用于区分不同层级的表格拖拽(避免嵌套表格拖拽冲突) */
+    dragType?: string;
+    /** 禁止拖拽的行键列表(例如已展开的行) */
+    disabledDragKeys?: React.Key[];
+    /** 拖拽结束回调,返回新顺序的数据(dataSource 模式下有效) */
+    onDragSortEnd?: (sortedRows: T[]) => void;
   }
 }

+ 2 - 2
src/pages/platform/_layout.tsx

@@ -1,7 +1,7 @@
 /*
  * @Author: your name
  * @Date: 2022-01-06 15:25:39
- * @LastEditTime: 2025-05-15 14:14:09
+ * @LastEditTime: 2025-05-22 14:30:28
  * @LastEditors: code4eat awesomedema@gmail.com
  * @Description: 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
  * @FilePath: /KC-MiddlePlatform/src/pages/platform/_layout.tsx
@@ -631,7 +631,7 @@ export default function Layout({ children, location, route, history, match, ...r
       {isThirdPartySystem && (
         <iframe 
           id={'bi_iframe'} 
-          style={{ width: '100%', height: '100%', border: 'none' }} 
+          style={{ width: '100%', height: 'calc(100vh - 48px)', border: 'none' }} 
           src={addTokenToUrl(url as string, token)} 
           onLoad={() => adjustIframe()}
         />

+ 9 - 1
src/pages/platform/setting/hospManage/index.tsx

@@ -1,7 +1,7 @@
 /*
  * @Author: your name
  * @Date: 2022-01-13 15:22:48
- * @LastEditTime: 2025-02-18 15:09:49
+ * @LastEditTime: 2025-05-22 15:53:18
  * @LastEditors: code4eat awesomedema@gmail.com
  * @Description: 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
  * @FilePath: /KC-MiddlePlatform/src/pages/platform/setting/hospManage/index.tsx
@@ -1022,6 +1022,14 @@ const HospManage: FC<PageProps> = ({ hospManageModel: state, dispatch }) => {
     });
   }
 
+  const onCheckedHandle = async (data: NavSelecterItemType[]) => {
+    dispatch &&
+      dispatch({
+        type: 'hospManageModel/postEditData',
+        payload: { bindMenuIds: data.map((a) => a.menuId) },
+      });
+  };
+
   return (
     <div className="HospManage">
       <ActModal {...state} dispatch={dispatch} />

+ 1052 - 0
src/pages/platform/setting/serviceEvaluate/index.tsx

@@ -0,0 +1,1052 @@
+/*
+ * @Author: code4eat awesomedema@gmail.com
+ * @Date: 2025-09-30 00:00:00
+ * @LastEditors: code4eat awesomedema@gmail.com
+ * @LastEditTime: 2025-10-17 11:57:04
+ * @FilePath: /KC-MiddlePlatform/src/pages/platform/setting/serviceEvaluate/index.tsx
+ * @Description: 服务评价管理 - 左树右表 + 可展开选项子表
+ */
+
+import React, { useEffect, useMemo, useState } from 'react';
+import './style.less';
+
+import { Button, Input, InputNumber, message, Modal, Popconfirm, Tooltip, Transfer, Tree } from 'antd';
+import { ProColumns } from '@ant-design/pro-table';
+import { ModalForm, ProFormDigit, ProFormText } from '@ant-design/pro-form';
+import KCTable from '@/components/kcTable';
+import DirectoryTree from 'antd/es/tree/DirectoryTree';
+import { DataNode } from 'antd/es/tree';
+import type { TransferDirection } from 'antd/es/transfer';
+import { createFromIconfontCN } from '@ant-design/icons';
+import expandedIcon from '../../../../../public/images/treenode_open.png';
+import closeIcon from '../../../../../public/images/treenode_collapse.png';
+import { KCInput } from '@/components/KCInput';
+import { getDictByDictTypeAndSysid } from '@/service/dictionary';
+
+import {
+  getCategoryTree,
+  addCategory,
+  getProjectList,
+  getOptionList,
+  addProject,
+  delProject,
+  addOption,
+  delOption,
+  updateProjectWeight,
+  updateOptionScore,
+  CategoryNode,
+  ProjectItem,
+  OptionItem,
+  SystemTreeNode,
+  getSystemListForModal,
+  addSystems,
+  getSelectedSystemList,
+  addServiceTypes,
+  getServiceEvaluationItem,
+  getEvaluationSelect,
+  addEvaluationItem,
+  addEvaluationSelect,
+  editEvaluationSelect,
+  deleteEvaluationItem,
+  deleteEvaluationSelect,
+} from './service';
+
+const IconFont = createFromIconfontCN({
+  scriptUrl: '/zhongtaiC.js',
+});
+
+// 子表格:选项列表
+const OptionsList: React.FC<{
+  project: ProjectItem;
+  options: OptionItem[];
+  onAdd: (data: { optionCode: string; optionName: string; score: number }) => Promise<void>;
+  onDelete: (optionId: string) => Promise<void>;
+  onScoreChange: (optionId: string, score: number) => Promise<void>;
+}> = ({ project, options, onAdd, onDelete, onScoreChange }) => {
+  return (
+    <div className="SE-OptionsWrap">
+      <div className="SE-OptionsHeader">
+        <span className="SE-SubTitle">{project.projectName} - 选项</span>
+        <ModalForm
+          title="新增选项"
+          width={520}
+          trigger={<Button type="link">新增选项</Button>}
+          modalProps={{ destroyOnClose: true }}
+          onFinish={async (values) => {
+            await onAdd(values as any);
+            return true;
+          }}
+        >
+          <ProFormText name="optionCode" label="选项代码" placeholder="请输入" rules={[{ required: true, message: '必填' }]} />
+          <ProFormText name="optionName" label="选项名称" placeholder="请输入" rules={[{ required: true, message: '必填' }]} />
+          <ProFormDigit name="score" label="选项得分" placeholder="请输入" min={0} max={9999} rules={[{ required: true, message: '必填' }]} />
+        </ModalForm>
+      </div>
+
+      <div className="SE-OptionsTable">
+        <div className="SE-OptionsRow SE-OptionsHead">
+          <div className="col idx">序号</div>
+          <div className="col code">选项代码</div>
+          <div className="col name">选项名称</div>
+          <div className="col score">选项得分</div>
+          <div className="col act">操作</div>
+        </div>
+        {options && options.length > 0 ? (
+          options.map((opt, index) => (
+            <div className="SE-OptionsRow" key={opt.optionId}>
+              <div className="col idx">{index + 1}</div>
+              <div className="col code">{opt.optionCode}</div>
+              <div className="col name">{opt.optionName}</div>
+              <div className="col score">
+                <InputNumber
+                  min={0}
+                  max={9999}
+                  value={opt.score}
+                  onChange={(val) => {
+                    const v = typeof val === 'number' ? val : Number(val);
+                    if (!Number.isNaN(v)) onScoreChange(opt.optionId, v);
+                  }}
+                />
+              </div>
+              <div className="col act">
+                <Popconfirm title="是否确认删除?" onConfirm={() => onDelete(opt.optionId)}>
+                  <a>删除</a>
+                </Popconfirm>
+              </div>
+            </div>
+          ))
+        ) : (
+          <div className="SE-Empty">暂无选项</div>
+        )}
+      </div>
+    </div>
+  );
+};
+
+const ServiceEvaluatePage: React.FC = () => {
+  // 左侧分类树
+  const [categoryTree, set_categoryTree] = useState<CategoryNode[]>([]);
+  const [currentCategory, set_currentCategory] = useState<CategoryNode | undefined>();
+  const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
+  const [autoExpandParent, setAutoExpandParent] = useState(true);
+  const [searchValue, setSearchValue] = useState('');
+
+  // 主表相关
+  const [reload, set_reload] = useState(false);
+  const [tableParams, set_tableParams] = useState<{ current: number; pageSize: number; categoryId?: string }>({ current: 1, pageSize: 10 });
+  const [tableKey, set_tableKey] = useState(0); // 用于强制刷新表格
+  const [expandedRowKeys, set_expandedRowKeys] = useState<React.Key[]>([]); // 追踪展开的行
+
+  // 展开区缓存选项数据,避免频繁请求
+  const [projectOptionsMap, set_projectOptionsMap] = useState<Record<string, OptionItem[]>>({});
+  // 项目权重编辑态(保持原交互:编辑/勾选)
+  const [editingProjectId, set_editingProjectId] = useState<string | null>(null);
+  const [editingProjectWeight, set_editingProjectWeight] = useState<number>(0);
+
+  // 添加系统弹窗
+  const [sysModalOpen, set_sysModalOpen] = useState(false);
+  const [systemTree, set_systemTree] = useState<SystemTreeNode[]>([]);
+  const [sysTargetKeys, set_sysTargetKeys] = useState<string[]>([]);
+  const [sysSelectedKeys, set_sysSelectedKeys] = useState<string[]>([]);
+  const [sysExpandedKeys, set_sysExpandedKeys] = useState<string[]>([]);
+  // 服务项弹窗
+  const [serviceModalOpen, set_serviceModalOpen] = useState(false);
+  const [serviceDict, set_serviceDict] = useState<{ key: string; title: string }[]>([]);
+  const [serviceSelected, set_serviceSelected] = useState<string[]>([]);
+  const [serviceBindSystemId, set_serviceBindSystemId] = useState<string>('');
+  const [serviceSaving, set_serviceSaving] = useState(false);
+  // 服务节点展开行:缓存“评价项 -> 选项”数据(与 /centerSys/evaluation/getEvaluationSelect 返回结构一致)
+  const [serviceOptionsMap, set_serviceOptionsMap] = useState<
+    Record<string, { id: number | string; hospId?: string; itemCode: string; code: string; name: string; value: number; sort: number }[]>
+  >({});
+
+  // 评价项目弹窗(右侧“添加”)
+  const [evalItemModalOpen, set_evalItemModalOpen] = useState(false);
+  const [evalItemDict, set_evalItemDict] = useState<{ key: string; title: string }[]>([]);
+  const [evalItemTargetKeys, set_evalItemTargetKeys] = useState<string[]>([]);
+  const [evalItemSelectedKeys, set_evalItemSelectedKeys] = useState<string[]>([]);
+  // 评价项权重编辑态
+  const [editingEvalCode, set_editingEvalCode] = useState<string | null>(null);
+  const [editingEvalValue, set_editingEvalValue] = useState<number>(0);
+  // 选项弹窗(评价项 -> 选项)
+  const [selectModalOpen, set_selectModalOpen] = useState(false);
+  const [selectDict, set_selectDict] = useState<{ key: string; title: string }[]>([]);
+  const [selectTargetKeys, set_selectTargetKeys] = useState<string[]>([]);
+  const [selectSelectedKeys, set_selectSelectedKeys] = useState<string[]>([]);
+  const [bindItemCode, set_bindItemCode] = useState<string>('');
+  // 服务节点主表数据缓存(用于拖拽排序后保持展示顺序)
+  const [evalItemRows, set_evalItemRows] = useState<any[]>([]);
+  const [draggingMainKey, set_draggingMainKey] = useState<string | null>(null);
+  const [draggingOverMainKey, set_draggingOverMainKey] = useState<string | null>(null);
+  const [useLocalEvalOrder, set_useLocalEvalOrder] = useState<boolean>(false);
+  // 嵌套选项拖拽的中间态
+  const [draggingOption, set_draggingOption] = useState<{ parent: string; key: string } | null>(null);
+  const [draggingOverOption, set_draggingOverOption] = useState<{ parent: string; key: string } | null>(null);
+
+  // 工具:移动数组元素
+  const moveItem = <T,>(list: T[], from: number, to: number): T[] => {
+    const next = [...list];
+    const [m] = next.splice(from, 1);
+    next.splice(to, 0, m);
+    return next;
+  };
+
+  // 获取左侧“已选系统”并渲染为单层树
+  const fetchTree = async () => {
+    const list = await getSelectedSystemList();
+    const tree: any[] = (list || []).map((item: any) => ({
+      id: String(item.systemId),
+      name: String(item.systemName),
+      nodeType: 'system',
+      selectable: false,
+      // 将 serviceList 映射为子节点
+      children: (item.serviceList || []).map((s: any) => ({
+        id: String(s.id),
+        name: String(s.name),
+        code: String(s.code || ''),
+        nodeType: 'service',
+        selectable: true,
+      })),
+    }));
+    set_categoryTree(tree as any);
+
+    // 默认选中第一个服务;若无服务则只展开系统,不触发右侧
+    if (tree.length > 0) {
+      const sys = tree[0];
+      if (sys.children && sys.children.length > 0) {
+        const firstChild = sys.children[0];
+        set_currentCategory(firstChild as any);
+        set_tableParams((p) => ({ ...p, current: 1, categoryId: firstChild.id }));
+        set_reload(true);
+        set_editingEvalCode(null);
+        // 服务节点:首次加载右侧评价项
+        if ((firstChild as any).nodeType === 'service') {
+          await loadServiceEvalItems(String((firstChild as any).code || (firstChild as any).name));
+        }
+      }
+      if (sys.children && sys.children.length > 0) {
+        setExpandedKeys([sys.id]);
+          setAutoExpandParent(true);
+      }
+    }
+  };
+
+  // 获取系统树(弹窗数据源)
+  const fetchSystemTree = async () => {
+    const { systemList, selectSystemList } = await getSystemListForModal();
+    set_systemTree(systemList || []);
+    // 默认已选
+    set_sysTargetKeys((selectSystemList || []).map((v)=> String(v)));
+    // 默认全部展开
+    const allKeys: string[] = [];
+    const walk = (list: SystemTreeNode[]) => { list.forEach(n=>{ allKeys.push(String(n.code)); if (n.children && n.children.length) walk(n.children); }); };
+    walk(systemList || []);
+    set_sysExpandedKeys(allKeys);
+  };
+
+  // 获取服务项字典
+  const fetchServiceDict = async () => {
+    // 使用中心系统ID:1547394914533380096 获取指定字典类型
+    const resp: any = await getDictByDictTypeAndSysid('1547394914533380096', 'EVALUATION_SERVICE');
+    const list = (resp?.dataVoList || []).map((x: any) => ({ key: String(x.code || x.value), title: String(x.name) }));
+    set_serviceDict(list);
+  };
+
+  // 获取评价项目字典
+  const fetchEvaluationItemsDict = async () => {
+    const resp: any = await getDictByDictTypeAndSysid('1547394914533380096', 'EVALUATION_ITEM');
+    const list = (resp?.dataVoList || []).map((x: any) => ({ key: String(x.code || x.value), title: String(x.name) }));
+    set_evalItemDict(list);
+  };
+
+  // 获取“选项”字典
+  const fetchEvaluationSelectDict = async () => {
+    const resp: any = await getDictByDictTypeAndSysid('1547394914533380096', 'EVALUATION_SELECT');
+    const list = (resp?.dataVoList || []).map((x: any) => ({ key: String(x.code || x.value), title: String(x.name) }));
+    set_selectDict(list);
+  };
+
+  // 拉取服务类型下的评价项(用于 dataSource 渲染 & 拖拽)
+  const loadServiceEvalItems = async (serviceCode: string) => {
+    const list = (await getServiceEvaluationItem(serviceCode)) || [];
+    const normalizePercent = (v: any): number => {
+      const n = Number(v);
+      return Number.isFinite(n) && n >= 0 ? n : 0;
+    };
+    const mapped = (list || []).map((it: any, idx: number) => ({
+      id: it.id,  // 保留 id 字段,用于删除等操作
+      itemCode: it.itemCode,
+      itemName: it.itemName,
+      percent: normalizePercent(it.percent),
+      sort: it.sort ?? idx + 1,
+    }));
+    set_evalItemRows(mapped);
+    set_useLocalEvalOrder(true);
+  };
+
+  // 获取表格数据:服务节点 → 评价项;系统/分类节点 → 项目列表
+  const getTableData = async (params: any) => {
+    set_reload(false);
+    if (!currentCategory) return { data: [], success: true } as any;
+    const node: any = currentCategory as any;
+    if (node.nodeType === 'service') {
+      // 使用 dataSource 渲染时,这里返回本地 rows,避免与 KCTable 拖拽冲突
+      if (evalItemRows && evalItemRows.length > 0) {
+        return { data: evalItemRows, success: true } as any;
+      }
+      await loadServiceEvalItems(String(node.code || node.name));
+      return { data: evalItemRows, success: true } as any;
+    }
+    const resp = await getProjectList({ categoryId: currentCategory.id, ...params });
+    return { data: resp.list || [], success: true } as any;
+  };
+
+  // 主表列定义 - 项目
+  const projectColumns: ProColumns<ProjectItem>[] = useMemo(
+    () => [
+      { title: '', dataIndex: 'drag', width: 32, render: () => (<span className="SE-DragHandle" style={{ cursor:'move', display:'flex', alignItems:'center', justifyContent:'center', height:'100%' }}><IconFont type="iconliebiao" style={{ fontSize: 16, color: '#99A6BF' }} /></span>) },
+      {
+        title: '序号',
+        dataIndex: 'index',
+        width: 60,
+        render: (_, __, index) => index + 1,
+      },
+      {
+        title: '评价项目代码',
+        dataIndex: 'projectCode',
+        width: 100,
+      },
+      {
+        title: '评价项目名称',
+        dataIndex: 'projectName',
+        ellipsis: true,
+      },
+      {
+        title: '权重',
+        dataIndex: 'weight',
+        width: 120,
+        render: (_, record) => (
+          <InputNumber
+            min={0}
+            max={100}
+            formatter={(value) => `${value}%`}
+            parser={(value) => Number(String(value).replace('%', ''))}
+            value={record.weight}
+            onChange={async (val) => {
+              const weight = typeof val === 'number' ? val : Number(val);
+              if (Number.isNaN(weight)) return;
+              const ok = await updateProjectWeight(record.projectId, weight);
+              if (ok) set_reload(true);
+            }}
+          />
+        ),
+      },
+      {
+        title: '操作',
+        key: 'option',
+        width: 160,
+        valueType: 'option',
+        render: (_, record) => [
+          <ModalForm
+            key="add"
+            title={`新增“${record.projectName}”的选项`}
+            trigger={<a>选项</a>}
+            width={520}
+            onFinish={async (values) => {
+              const ok = await addOption({ projectId: record.projectId, ...(values as any) });
+              if (ok) {
+              const opts = await getOptionList({ projectId: record.projectId });
+              set_projectOptionsMap((old) => ({ ...old, [record.projectId]: opts || [] }));
+              }
+              return !!ok;
+            }}
+          >
+            <ProFormText name="optionCode" label="选项代码" rules={[{ required: true, message: '必填' }]} />
+            <ProFormText name="optionName" label="选项名称" rules={[{ required: true, message: '必填' }]} />
+            <ProFormDigit name="score" label="选项得分" min={0} max={9999} rules={[{ required: true, message: '必填' }]} />
+          </ModalForm>,
+          <Popconfirm key="del" title="是否确认删除?" onConfirm={async () => { const ok = await delProject(record.projectId); if (ok) set_reload(true); }}>
+            <a>删除</a>
+          </Popconfirm>,
+        ],
+      },
+    ],
+    [projectOptionsMap]
+  );
+
+  // 评价项表格列
+  const evalItemColumns: ProColumns<any>[] = useMemo(
+    () => [
+      { title: '', dataIndex: 'drag', width: 32, render: () => (<span className="SE-DragHandle" style={{ cursor:'move', display:'flex', alignItems:'center', justifyContent:'center', height:'100%' }}><IconFont type="iconliebiao" style={{ fontSize: 16, color: '#99A6BF' }} /></span>) },
+      { title: '序号', dataIndex: 'index', width: 60, render: (_, __, index) => index + 1 },
+      { title: '评价项目代码', dataIndex: 'itemCode', width: 120 },
+      { title: '评价项目名称', dataIndex: 'itemName', ellipsis: true },
+      {
+        title: '权重',
+        dataIndex: 'percent',
+        width: 120,
+        render: (v: any) => `${(Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : 0)}%`,
+      },
+      // 移除不需要的排序列
+      {
+        title: '操作',
+        valueType: 'option',
+        width: 120,
+        render: (_, record) => [
+          <a key="opt" onClick={async () => { 
+            set_bindItemCode(record.itemCode); 
+            // 获取该评价项已有的选项
+            const existingOptions = await getEvaluationSelect(record.itemCode);
+            const existingKeys = (existingOptions || []).map((item: any) => String(item.code));
+            set_selectTargetKeys(existingKeys); 
+            set_selectSelectedKeys([]); 
+            await fetchEvaluationSelectDict(); 
+            set_selectModalOpen(true); 
+          }}>选项</a>,
+          <Popconfirm key="del" title="是否确认删除?" onConfirm={async () => { 
+            const res = await deleteEvaluationItem(record.id);
+            if (res !== false) {
+              // 删除成功后,如果该项是展开状态,从展开列表中移除
+              set_expandedRowKeys((prev) => prev.filter(k => k !== record.itemCode));
+              // 重新加载评价项列表
+              const current: any = currentCategory as any;
+              if (current && current.nodeType === 'service') {
+                await loadServiceEvalItems(String(current.code || current.name));
+              }
+            }
+          }}>
+            <a>删除</a>
+          </Popconfirm>,
+        ],
+      },
+    ],
+    [editingEvalCode, editingEvalValue]
+  );
+
+  // 展开行渲染(分类/系统场景使用)
+  const expandedRowRender = (record: ProjectItem) => {
+    const options = projectOptionsMap[record.projectId] || [];
+    return (
+      <OptionsList
+        project={record}
+        options={options}
+        onAdd={async (data) => {
+          const ok = await addOption({ projectId: record.projectId, ...data });
+          if (ok) {
+          const opts = await getOptionList({ projectId: record.projectId });
+          set_projectOptionsMap((old) => ({ ...old, [record.projectId]: opts || [] }));
+          }
+        }}
+        onDelete={async (optionId) => {
+          const ok = await delOption(optionId);
+          if (ok) {
+          const opts = await getOptionList({ projectId: record.projectId });
+          set_projectOptionsMap((old) => ({ ...old, [record.projectId]: opts || [] }));
+          }
+        }}
+        onScoreChange={async (optionId, score) => {
+          const ok = await updateOptionScore(optionId, score);
+          if (ok) {
+          const opts = await getOptionList({ projectId: record.projectId });
+          set_projectOptionsMap((old) => ({ ...old, [record.projectId]: opts || [] }));
+          }
+        }}
+      />
+    );
+  };
+
+  // 服务节点:展开渲染(与外层保持一致的 KCTable 风格)
+  const expandedRowRenderService = (record: any) => {
+    const parentCode: string = record.itemCode;
+    const rows = serviceOptionsMap[parentCode] || [];
+    const columns: ProColumns<any>[] = [
+      {
+        title: '',
+        dataIndex: 'drag',
+        width: 32,
+        render: (_, row: any) => (
+          <span
+            className="SE-DragHandle"
+            style={{ cursor: 'move', display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}
+            draggable
+            onDragStart={(e) => {
+              set_draggingOption({ parent: parentCode, key: String(row.id ?? row.code) });
+              e.dataTransfer.effectAllowed = 'move';
+              try { e.dataTransfer.setData('text/plain', String(row.id ?? row.code)); } catch {}
+            }}
+            onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
+            onDrop={(e) => {
+              e.preventDefault();
+              if (!draggingOption || draggingOption.parent !== parentCode) return;
+              const list = serviceOptionsMap[parentCode] || [];
+              const from = list.findIndex((r: any) => String(r.id ?? r.code) === draggingOption.key);
+              const to = list.findIndex((r: any) => String(r.id ?? r.code) === String(row.id ?? row.code));
+              if (from === -1 || to === -1 || from === to) return;
+              const next = moveItem(list, from, to);
+              set_serviceOptionsMap((old) => ({ ...old, [parentCode]: next }));
+              set_draggingOption(null);
+              set_reload((v)=>!v);
+              // TODO: 保存选项排序
+            }}
+            title="拖动排序"
+          >
+            <IconFont type="iconliebiao" style={{ fontSize: 16, color: '#99A6BF' }} />
+          </span>
+        ),
+      },
+      { title: '序号', dataIndex: 'index', width: 60, render: (_, __, index) => index + 1 },
+      { title: '选项代码', dataIndex: 'code', width: 120 },
+      { title: '选项名称', dataIndex: 'name', ellipsis: true },
+      {
+        title: '选项得分',
+        dataIndex: 'value',
+        width: 200,
+        render: (v: any, row: any) => {
+          const isEditing = row.__editing === true;
+          if (!isEditing) {
+    return (
+              <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+                <div style={{ padding: '2px 10px', border: '1px solid #dae2f2', borderRadius: 6, minWidth: 72, textAlign: 'center', background: '#f7f9fc' }}>{v ?? 0}</div>
+                <span
+                  onClick={() => {
+                    set_serviceOptionsMap((old) => {
+                      const list = [...(old[parentCode] || [])];
+                      const idx = list.findIndex((i: any) => String(i.id ?? i.code) === String(row.id ?? row.code));
+                      if (idx > -1) list[idx] = { ...list[idx], __editing: true } as any;
+                      return { ...old, [parentCode]: list };
+                    });
+                  }}
+                  style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', height: 28, width: 28, border: '1px solid #dae2f2', borderRadius: 6, cursor: 'pointer' }}
+                >
+                  <IconFont type="iconbianji" style={{ fontSize: 16, color: '#17181A' }} />
+                </span>
+          </div>
+            );
+          }
+          return (
+            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+              <InputNumber
+                min={0}
+                max={9999}
+                value={row.value}
+                onChange={(val) => {
+                  const next = Number(val) || 0;
+                  set_serviceOptionsMap((old) => {
+                    const list = [...(old[parentCode] || [])];
+                    const idx = list.findIndex((i: any) => String(i.id ?? i.code) === String(row.id ?? row.code));
+                    if (idx > -1) list[idx] = { ...list[idx], value: next, __editing: true } as any;
+                    return { ...old, [parentCode]: list };
+                  });
+                }}
+                style={{ width: 100 }}
+              />
+              <span
+                onClick={async () => {
+                  // 调用接口保存选项分值
+                  const res = await editEvaluationSelect(row.id, row.value);
+                  // 响应拦截器:成功返回 data 或 true,失败返回 false
+                  // 且 POST 成功时拦截器已自动显示"操作成功!"提示
+                  if (res !== false) {
+                    // 成功,退出编辑状态
+                    set_serviceOptionsMap((old) => {
+                      const list = [...(old[parentCode] || [])];
+                      const idx = list.findIndex((i: any) => String(i.id ?? i.code) === String(row.id ?? row.code));
+                      if (idx > -1) list[idx] = { ...list[idx], __editing: false } as any;
+                      return { ...old, [parentCode]: list };
+                    });
+                  }
+                  // 失败时拦截器已显示错误提示,这里只需保持编辑状态
+                }}
+                style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', height: 28, width: 28, border: '1px solid #dae2f2', borderRadius: 6, cursor: 'pointer' }}
+              >
+                <IconFont type="iconqueren" style={{ fontSize: 16, color: '#17181A' }} />
+              </span>
+              </div>
+          );
+        },
+      },
+      {
+        title: '操作',
+        valueType: 'option',
+        width: 120,
+        render: (_, row: any) => [
+          <Popconfirm 
+            key="del" 
+            title="是否确认删除?" 
+            onConfirm={async () => {
+              const res = await deleteEvaluationSelect(row.id);
+              if (res !== false) {
+                // 删除成功,重新加载该评价项的选项列表
+                const list = await getEvaluationSelect(parentCode);
+                set_serviceOptionsMap((old) => ({
+                  ...old,
+                  [parentCode]: (list || []) as any,
+                }));
+              }
+            }}
+          >
+            <a>删除</a>
+          </Popconfirm>
+        ],
+      },
+    ];
+
+    return (
+      <div className="SE-ExpandWrap">
+        <KCTable
+          newVer
+          columns={columns as ProColumns[]}
+          dataSource={rows}
+          rowKey={(r: any) => String(r.id ?? r.code)}
+          dragSortKey="id"
+          dragType="KC_TABLE_NESTED_ROW"
+          pagination={false}
+          toolBarRender={false as any}
+          search={false as any}
+          options={false as any}
+          bordered={false as any}
+          size={'small' as any}
+          dragSort
+          onDragSortEnd={(next)=>{ 
+            set_serviceOptionsMap((old)=> ({ ...old, [parentCode]: next })); 
+          }}
+        />
+      </div>
+    );
+  };
+
+  // 表格展开回调
+  const onExpand = async (expanded: boolean, record: any) => {
+    const node: any = currentCategory as any;
+    const rowKey = node?.nodeType === 'service' ? record.itemCode : record.projectId;
+    
+    // 更新展开行状态
+    if (expanded) {
+      set_expandedRowKeys((prev) => [...prev, rowKey]);
+    } else {
+      set_expandedRowKeys((prev) => prev.filter(k => k !== rowKey));
+      return;
+    }
+    
+    // 加载数据
+    if (node && node.nodeType === 'service') {
+      const list = await getEvaluationSelect(record.itemCode);
+      set_serviceOptionsMap((old: Record<string, { id: number | string; hospId?: string; itemCode: string; code: string; name: string; value: number; sort: number }[]>) => ({
+        ...old,
+        [record.itemCode]: (list || []) as { id: number | string; hospId?: string; itemCode: string; code: string; name: string; value: number; sort: number }[],
+      }));
+    } else {
+      const opts = await getOptionList({ projectId: record.projectId });
+      set_projectOptionsMap((old) => ({ ...old, [record.projectId]: opts || [] }));
+    }
+  };
+  
+  // 收起所有展开行
+  const collapseAllRows = () => {
+    set_expandedRowKeys([]);
+  };
+
+  useEffect(() => { fetchTree(); }, []);
+  useEffect(() => { if (sysModalOpen) fetchSystemTree(); }, [sysModalOpen]);
+  useEffect(() => {
+    if (currentCategory) {
+      set_tableParams((p) => ({ ...p, current: 1, categoryId: currentCategory.id }));
+      set_reload(true);
+      // 切换分类时清空展开行
+      set_expandedRowKeys([]);
+    }
+  }, [currentCategory]);
+
+  return (
+    <>
+    <div className="SystemNavMana">
+      <div className="leftTree">
+        <div className="search" style={{display:'flex',gap:8,alignItems:'center'}}>
+          <Input className="searchInput" placeholder="请输入" size="small" allowClear onChange={(e)=>setSearchValue(e.target.value)} />
+          <Tooltip title="添加系统">
+            <span className="add" onClick={()=> set_sysModalOpen(true)}>
+              <IconFont style={{ color: '#17181A', fontSize: 12 }} type="icon-xinzeng" />
+            </span>
+          </Tooltip>
+        </div>
+        <div className="treeeWrap">
+          {categoryTree && categoryTree.length>0 && (
+            <DirectoryTree
+              fieldNames={{ title:'name', key:'id' }}
+              rootStyle={{ overflowY:'scroll', overflowX:'hidden' }}
+              onSelect={async (keys, info)=> {
+                const node = info.node as any;
+                // 仅允许选择服务节点
+                if (node.nodeType !== 'service') return;
+                set_currentCategory(node);
+                set_tableParams((p)=>({ ...p, current:1, categoryId: node.id }));
+                set_reload(true);
+                // 服务节点:拉取右侧评价项(dataSource 渲染)
+                await loadServiceEvalItems(String(node.code || node.name));
+              }}
+              onExpand={(keys)=>{ setExpandedKeys(keys as React.Key[]); setAutoExpandParent(false);} }
+              expandedKeys={expandedKeys}
+              autoExpandParent={autoExpandParent}
+              selectedKeys={currentCategory && (currentCategory as any).nodeType==='service' ? [currentCategory.id] : []}
+              blockNode
+              className="KC-DirectoryTree"
+              icon={() => null}
+              titleRender={(nodeData: any) => {
+                const strTitle = nodeData.name as string;
+                const index = strTitle.indexOf(searchValue);
+                const beforeStr = strTitle.substring(0, index);
+                const afterStr = strTitle.slice(index + searchValue.length);
+                const title = index > -1 ? (
+                  <span>
+                    {beforeStr}
+                    <span className="site-tree-search-value" style={{ color: 'red', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{searchValue}</span>
+                    {afterStr}
+                  </span>
+                ) : (
+                  <span className="strTitle">{strTitle}</span>
+                );
+                const isDirectory = Array.isArray(nodeData.children) && nodeData.children.length > 0;
+                return (
+                  <div style={{display:'flex',alignItems:'center',height:32}}>
+                    <span className="node-title-area" style={{flex:1, minWidth:0}}>{title}</span>
+                    {nodeData.nodeType === 'system' && (
+                      <span
+                        className="inline-add"
+                        style={{display:'inline-flex', justifyContent:'center', alignItems:'center'}}
+                        onClick={async (e)=>{
+                          e.stopPropagation();
+                        set_serviceBindSystemId(String(nodeData.id));
+                        // 打开前重置已选,避免上次选择残留
+                        set_serviceSelected([]);
+                        await fetchServiceDict();
+                        set_serviceModalOpen(true);
+                        }}
+                      >
+                        <IconFont style={{ color: '#99A6BF', fontSize: 12 }} type="icon-xinzeng" />
+                      </span>
+                    )}
+                  </div>
+                );
+              }}
+              treeData={categoryTree as unknown as DataNode[]}
+              switcherIcon={(props:any)=> props.expanded ? (<img style={{ width:20,height:20, position:'relative', top:5 }} src={closeIcon} />) : (<img style={{ width:20,height:20, position:'relative', top:5 }} src={expandedIcon} />)}
+            />
+          )}
+        </div>
+      </div>
+
+      <div className="rightContent">
+        <div className="tableToolbar">
+          <div className="filter">
+            <span style={{ fontSize: 16, fontWeight: 500, color: '#17181A' }}>
+              {currentCategory?.name || '-'}
+            </span>
+          </div>
+          <div className={'btnGroup'}>
+            {expandedRowKeys.length > 0 && (
+              <span className="collapse" onClick={collapseAllRows}>
+                收起所有 ({expandedRowKeys.length})
+              </span>
+            )}
+            <span className="add" onClick={async ()=>{ 
+              // 获取当前已有的评价项,用于回显
+              const current: any = currentCategory as any;
+              if (current && current.nodeType === 'service') {
+                const existingItems = await getServiceEvaluationItem(String(current.code || current.name));
+                const existingKeys = (existingItems || []).map((item: any) => String(item.itemCode));
+                set_evalItemTargetKeys(existingKeys);
+              } else {
+                set_evalItemTargetKeys([]);
+              }
+              set_evalItemSelectedKeys([]); 
+              await fetchEvaluationItemsDict(); 
+              set_evalItemModalOpen(true); 
+            }}>
+              添加
+            </span>
+          </div>
+          <ModalForm
+            title="新增评价项目"
+            width={520}
+            trigger={<span style={{ display:'none' }} />}
+            onFinish={async (values) => {
+              if (!currentCategory) return false;
+              const ok = await addProject({ categoryId: currentCategory.id, ...(values as any) });
+              if (ok) set_reload(true);
+              return !!ok;
+            }}
+          >
+            <ProFormText name="projectCode" label="项目代码" placeholder="请输入" rules={[{ required: true, message: '必填' }]} />
+            <ProFormText name="projectName" label="项目名称" placeholder="请输入" rules={[{ required: true, message: '必填' }]} />
+            <ProFormDigit name="weight" label="权重%" min={0} max={100} placeholder="0-100" rules={[{ required: true, message: '必填' }]} />
+          </ModalForm>
+        </div>
+
+        {currentCategory && (
+          <KCTable
+            key={`table-${currentCategory.id}`}
+            newVer
+            reload={(currentCategory as any)?.nodeType === 'service' ? false : reload}
+            params={(currentCategory as any)?.nodeType === 'service' ? undefined : tableParams}
+            rowKey={(((currentCategory as any)?.nodeType === 'service')
+              ? ((r: any) => String(r?.itemCode ?? ''))
+              : ((r: any) => String(r?.projectId ?? ''))) as any}
+            dragSortKey={(currentCategory as any)?.nodeType === 'service' ? 'itemCode' : 'projectId'}
+            dragType="KC_TABLE_MAIN_ROW"
+            disabledDragKeys={expandedRowKeys}
+            scroll={{ y: `calc(100vh - 240px)` }}
+            columns={((currentCategory as any)?.nodeType === 'service' ? evalItemColumns : projectColumns) as ProColumns[]}
+            request={(((currentCategory as any)?.nodeType === 'service') ? undefined : ((p: any, s: any, f: any) => getTableData(p))) as any}
+            expandable={{
+              expandedRowRender: (currentCategory as any)?.nodeType === 'service' ? expandedRowRenderService : expandedRowRender,
+              onExpand,
+              expandedRowKeys,
+              onExpandedRowsChange: (keys: readonly React.Key[]) => set_expandedRowKeys([...keys]),
+              expandIconColumnIndex: 1  // 展开图标显示在第2列,第1列留给拖拽手柄
+            }}
+            pagination={false}
+            dragSort={(currentCategory as any)?.nodeType === 'service'}
+            dataSource={((currentCategory as any)?.nodeType === 'service') ? evalItemRows : undefined}
+            onDragSortEnd={(rows: any[])=>{ 
+              set_evalItemRows(rows); 
+              set_useLocalEvalOrder(true);
+            }}
+          />
+        )}
+      </div>
+    </div>
+    <Modal
+      title="添加系统"
+      open={sysModalOpen}
+      onCancel={()=> set_sysModalOpen(false)}
+      onOk={async ()=>{
+        const flat = (nodes: SystemTreeNode[]): SystemTreeNode[] => nodes.reduce<SystemTreeNode[]>((acc, n)=>{
+          acc.push(n);
+          if (n.children && n.children.length) acc.push(...flat(n.children));
+          return acc;
+        }, []);
+        const all = flat(systemTree);
+        const payload = all
+          .filter(n => sysTargetKeys.includes(n.code) && n.type === 3)
+          .map(n => ({ systemId: n.code, systemName: n.name }));
+        if (payload.length === 0) {
+          message.warning('请选择系统');
+          return;
+        }
+        const ok = await addSystems(payload as any);
+        if (ok) {
+          set_sysModalOpen(false);
+          // 刷新左侧“已选系统”列表
+          set_reload(true);
+          fetchTree();
+        }
+      }}
+      width={720}
+      destroyOnClose
+    >
+      <Transfer
+        className="tree-transfer"
+        listStyle={{ height: 540 }}
+        dataSource={(function buildFlat(){
+          const res: any[] = [];
+          const walk = (list: SystemTreeNode[], parentChecked: boolean)=>{
+            list.forEach(n=>{
+              res.push({ key: n.code, code: n.code, name: n.name, type: n.type, node: n });
+              if (n.children && n.children.length) walk(n.children, false);
+            })
+          };
+          walk(systemTree, false);
+          return res;
+        })()}
+        rowKey={(item)=> item.code}
+        titles={[`待选项`, `已选项`]}
+        targetKeys={sysTargetKeys}
+        selectedKeys={sysSelectedKeys}
+        showSearch
+        locale={{ itemUnit:'项', itemsUnit:'项', searchPlaceholder:'请输入' }}
+        render={(item)=> item.name}
+        onChange={(nextTargetKeys)=> set_sysTargetKeys(nextTargetKeys as string[])}
+        onSelectChange={(sourceSelectedKeys, targetSelectedKeys)=> set_sysSelectedKeys([...(sourceSelectedKeys as string[]), ...(targetSelectedKeys as string[])])}
+      >
+        {({ direction, onItemSelect, onItemSelectAll, selectedKeys })=>{
+          if (direction === 'left') {
+            const treeData = (function toTree(list: SystemTreeNode[]): any[] {
+              return list.map((n: SystemTreeNode): any => ({
+                key: n.code,
+                title: n.name,
+                // 不再用 children 判断是否可勾选;统一允许勾选,由 onCheck 决定加入哪些 key
+                disabled: false,
+                children: n.children ? (toTree(n.children) as any[]) : undefined,
+              }));
+            })(systemTree);
+
+            const checkedKeys = [...selectedKeys, ...sysTargetKeys];
+            return (
+              <Tree
+                checkable
+                defaultExpandAll
+                checkedKeys={checkedKeys}
+                treeData={treeData as any}
+                height={480}
+                expandedKeys={sysExpandedKeys}
+                onExpand={(keys)=> set_sysExpandedKeys(keys as string[])}
+                onCheck={(keys, { node })=>{
+                  // 仅加入“系统叶子”:type==3,或 type==5 但无 children(后端把系统标成5时兜底)
+                  const isSystemLeafByCode = (code: string) => {
+                    const flat = (function flat(){
+                      const res: any[] = [];
+                      const walk = (list: SystemTreeNode[])=>{ list.forEach(nn=>{ res.push({ code: nn.code, type: nn.type, children: nn.children }); if(nn.children) walk(nn.children); }); };
+                      walk(systemTree); return res;
+                    })();
+                    const found = flat.find(i=> i.code===code);
+                    if (!found) return false;
+                    const noChildren = !found.children || found.children.length===0;
+                    return found.type===3 || (found.type===5 && noChildren);
+                  };
+
+                  const getChildCodes = (n:any): string[] => {
+                    if (!n.children || n.children.length===0) return [n.key as string];
+                    return n.children.reduce((acc:any,c:any)=> acc.concat(getChildCodes(c)), []);
+                  };
+
+                  const isSelect = !(node as any).checked;
+                  const candidate = (node.children && node.children.length>0) ? getChildCodes(node) : [node.key as string];
+                  const systemLeafKeys = candidate.filter((code)=> isSystemLeafByCode(code));
+                  onItemSelectAll(systemLeafKeys as string[], isSelect);
+                }}
+              />
+            );
+          }
+          return null;
+        }}
+      </Transfer>
+    </Modal>
+    <Modal
+      title="添加服务"
+      open={serviceModalOpen}
+      onCancel={()=> { set_serviceModalOpen(false); set_serviceSelected([]); set_serviceBindSystemId(''); }}
+      confirmLoading={serviceSaving}
+      onOk={async ()=> {
+        if (!serviceBindSystemId) { set_serviceModalOpen(false); return; }
+        try {
+          set_serviceSaving(true);
+          const ok: any = await addServiceTypes(serviceBindSystemId, serviceSelected);
+          if (ok) {
+            set_serviceModalOpen(false);
+            set_serviceSelected([]);
+            set_serviceBindSystemId('');
+            // 刷新左侧列表
+            set_reload(true);
+            fetchTree();
+          }
+        } finally {
+          set_serviceSaving(false);
+        }
+      }}
+      width={560}
+      destroyOnClose
+    >
+      <Transfer
+        className="ServiceTransfer"
+        style={{ width: '100%' }}
+        titles={[`待选项`,`已选项`]}
+        dataSource={serviceDict}
+        rowKey={(item)=> item.key}
+        render={(item)=> item.title}
+        targetKeys={serviceSelected}
+        onChange={(next)=> set_serviceSelected(next as string[])}
+        listStyle={{ height: 460, width: 'calc(50% - 32px)' }}
+        operationStyle={{ margin: '0 12px', display: 'flex', alignItems: 'center' }}
+        locale={{ itemUnit:'项', itemsUnit:'项', searchPlaceholder:'请输入' }}
+        showSearch
+      />
+    </Modal>
+    <Modal
+      title="评价项目"
+      open={evalItemModalOpen}
+      onCancel={()=> { set_evalItemModalOpen(false); set_evalItemTargetKeys([]); set_evalItemSelectedKeys([]); }}
+      onOk={async ()=> {
+        // 依据当前选中的服务类型保存评价项目
+        const current: any = currentCategory as any;
+        if (!current || current.nodeType !== 'service') { set_evalItemModalOpen(false); return; }
+        // 从字典中匹配详细信息
+        const itemCodes = evalItemTargetKeys.map((k, idx)=>{
+          const found = evalItemDict.find(i=> i.key === k);
+          return { itemCode: k, itemName: found?.title || k, itemValue: 1, sort: idx + 1 };
+        });
+        const ok: any = await addEvaluationItem(String(current.code || current.name), itemCodes);
+        if (ok) {
+          set_evalItemModalOpen(false);
+          set_evalItemTargetKeys([]);
+          set_evalItemSelectedKeys([]);
+          // 刷新右侧表格:重新加载评价项列表
+          await loadServiceEvalItems(String(current.code || current.name));
+        }
+      }}
+      width={560}
+      destroyOnClose
+    >
+      <Transfer
+        titles={[`待选项`,`已选项`]}
+        dataSource={evalItemDict}
+        rowKey={(item)=> item.key}
+        render={(item)=> item.title}
+        targetKeys={evalItemTargetKeys}
+        selectedKeys={evalItemSelectedKeys}
+        onChange={(next)=> set_evalItemTargetKeys(next as string[])}
+        onSelectChange={(l,r)=> set_evalItemSelectedKeys([...(l as string[]), ...(r as string[])])}
+        listStyle={{ height: 460, width: 'calc(50% - 32px)' }}
+        operationStyle={{ margin: '0 12px', display: 'flex', alignItems: 'center' }}
+        showSearch
+        locale={{ itemUnit:'项', itemsUnit:'项', searchPlaceholder:'请输入' }}
+      />
+    </Modal>
+    <Modal
+      title="添加选项"
+      open={selectModalOpen}
+      onCancel={()=> { set_selectModalOpen(false); set_selectTargetKeys([]); set_selectSelectedKeys([]); set_bindItemCode(''); }}
+      onOk={async ()=> {
+        const selectCodes = selectTargetKeys.map((k, idx)=>{
+          const found = selectDict.find(i=> i.key === k);
+          return { itemCode: k, itemName: found?.title || k, itemValue: 1, sort: idx + 1 };
+        });
+        const ok: any = await addEvaluationSelect(bindItemCode, selectCodes);
+        if (ok !== false) {
+          set_selectModalOpen(false);
+          set_selectTargetKeys([]);
+          set_selectSelectedKeys([]);
+          // 重新加载该评价项的选项数据
+          const list = await getEvaluationSelect(bindItemCode);
+          set_serviceOptionsMap((old) => ({
+            ...old,
+            [bindItemCode]: (list || []) as any,
+          }));
+          set_bindItemCode('');
+          set_reload(true);
+        }
+      }}
+      width={560}
+      destroyOnClose
+    >
+      <Transfer
+        titles={[`待选项`,`已选项`]}
+        dataSource={selectDict}
+        rowKey={(item)=> item.key}
+        render={(item)=> item.title}
+        targetKeys={selectTargetKeys}
+        selectedKeys={selectSelectedKeys}
+        onChange={(next)=> set_selectTargetKeys(next as string[])}
+        onSelectChange={(l,r)=> set_selectSelectedKeys([...(l as string[]), ...(r as string[])])}
+        listStyle={{ height: 360, width: 'calc(50% - 32px)' }}
+        operationStyle={{ margin: '0 12px', display: 'flex', alignItems: 'center' }}
+        showSearch
+        locale={{ itemUnit:'项', itemsUnit:'项', searchPlaceholder:'请输入' }}
+      />
+    </Modal>
+  </>
+  );
+};
+
+export default ServiceEvaluatePage;
+
+

+ 302 - 0
src/pages/platform/setting/serviceEvaluate/service.ts

@@ -0,0 +1,302 @@
+/*
+ * @Author: code4eat awesomedema@gmail.com
+ * @Date: 2025-09-30 00:00:00
+ * @LastEditors: code4eat awesomedema@gmail.com
+ * @LastEditTime: 2025-09-30 00:00:00
+ * @FilePath: /KC-MiddlePlatform/src/pages/platform/setting/serviceEvaluate/service.ts
+ * @Description: 服务评价管理 - 服务层封装(默认使用 mock 接口)
+ */
+
+// 本地模拟返回:使用前端内存数据替代实际请求,方便在后端未就绪时开发、联调
+import { request } from 'umi';
+
+// 分类树节点
+export type CategoryNode = {
+  id: string;
+  name: string;
+  children?: CategoryNode[];
+};
+
+// 评价项目
+export type ProjectItem = {
+  projectId: string;
+  projectCode: string;
+  projectName: string;
+  weight: number; // 0-100
+};
+
+// 选项
+export type OptionItem = {
+  optionId: string;
+  optionCode: string;
+  optionName: string;
+  score: number;
+};
+
+// 分页响应(为兼容 KCTable 的 request,保持 list 字段)
+export type ListResp<T> = {
+  list: T[];
+};
+
+// ------------------ 真实接口:系统树获取 ------------------
+// 注意:页面内仍使用本地 mock 的分类树/项目/选项。
+// 本方法用于“添加系统”弹窗的数据源,请求服务端返回的 systemList 结构。
+export type SystemTreeNode = {
+  code: string;
+  name: string;
+  sort?: number;
+  type?: number;
+  children?: SystemTreeNode[];
+};
+
+export type GetSystemListResp = { systemList: SystemTreeNode[]; selectSystemList: (string | number)[] };
+
+export const getSystemListForModal = async (): Promise<GetSystemListResp> => {
+  const resp = await request<any>(
+    '/centerSys/evaluation/getSystemList',
+    { method: 'GET' },
+  );
+  // 后端返回形如 { status, msg, data: { systemList, selectSystemList } }
+  const data = resp?.data || resp || {};
+  const selectedRaw = data.selectSystemList || [];
+  const normalizeKey = (x: any) => String((x && (x.systemId || x.code)) || x);
+  const selected = Array.isArray(selectedRaw) ? selectedRaw.map((x: any) => normalizeKey(x)) : [];
+  return {
+    systemList: data.systemList || [],
+    selectSystemList: selected,
+  };
+};
+
+// 保存所选系统
+export const addSystems = async (
+  data: { systemId: string | number; systemName: string }[],
+) => {
+  return request('/centerSys/evaluation/addSystem', {
+    method: 'POST',
+    data,
+  });
+};
+
+// 获取左侧“已选系统”列表
+export type SelectedSystemItem = { id: string | number; hospId: string | number; systemId: string | number; systemName: string };
+export const getSelectedSystemList = async (): Promise<SelectedSystemItem[]> => {
+  const resp = await request<any>('/centerSys/evaluation/getSelectSystemList', { method: 'GET' });
+  // 兼容后端返回 { data: [...] }
+  return resp?.data || resp || [];
+};
+
+// 在系统下添加服务类型
+export const addServiceTypes = async (systemId: string | number, serviceTypes: (string | number)[]) => {
+  return request('/centerSys/evaluation/addService', {
+    method: 'POST',
+    data: { systemId, serviceTypes },
+  });
+};
+
+// 根据服务类型获取评价项列表
+export const getServiceEvaluationItem = async (serviceType: string) => {
+  return request<any>('/centerSys/evaluation/getServiceEvaluationItem', {
+    method: 'GET',
+    params: { serviceType },
+  });
+};
+
+// 获取右侧表格:评价项目列表(按 ItemCode)
+export const getEvaluationSelect = async (ItemCode: string) => {
+  return request<any>('/centerSys/evaluation/getEvaluationSelect', {
+    method: 'GET',
+    params: { ItemCode },
+  });
+};
+
+// 保存评价项目
+export const addEvaluationItem = async (
+  serviceType: string,
+  itemCodes: { itemCode: string; itemName: string; itemValue: number; sort: number }[],
+) => {
+  return request('/centerSys/evaluation/addEvaluationItem', {
+    method: 'POST',
+    data: { serviceType, itemCodes },
+  });
+};
+
+// 保存评价项的"选项"
+export const addEvaluationSelect = async (
+  itemCode: string,
+  selectCodes: { itemCode: string; itemName: string; itemValue: number; sort: number }[],
+) => {
+  return request('/centerSys/evaluation/addEvaluationSelect', {
+    method: 'POST',
+    data: { itemCode, selectCodes },
+  });
+};
+
+// 编辑选项得分
+export const editEvaluationSelect = async (id: number | string, value: number) => {
+  return request('/centerSys/evaluation/editEvaluationSelect', {
+    method: 'POST',
+    data: { id, value },
+  });
+};
+
+// 删除评价项目
+export const deleteEvaluationItem = async (id: number | string) => {
+  return request(`/centerSys/evaluation/deleteEvaluationItem?id=${id}`, {
+    method: 'POST',
+  });
+};
+
+// 删除选项
+export const deleteEvaluationSelect = async (id: number | string) => {
+  return request(`/centerSys/evaluation/deleteEvaluationSelect?id=${id}`, {
+    method: 'POST',
+  });
+};
+
+// 获取分类树
+// 内存数据:分类树
+const __categoryTree__: CategoryNode[] = [
+  {
+    id: 'park-app',
+    name: '停车服务APP',
+    children: [
+      { id: 'park-service', name: '停车服务', children: [] },
+      { id: 'guard-service', name: '护工服务', children: [] },
+      { id: 'food-app', name: '食堂点餐APP', children: [] },
+    ],
+  },
+];
+
+// 内存数据:项目、选项
+const __projects__: Record<string, ProjectItem[]> = {
+  'park-service': [
+    { projectId: 'p1', projectCode: 'P0001', projectName: '专业能力', weight: 25 },
+    { projectId: 'p2', projectCode: 'P0002', projectName: '服务态度', weight: 20 },
+  ],
+};
+
+const __options__: Record<string, OptionItem[]> = {
+  p1: [
+    { optionId: 'o11', optionCode: 'X0006', optionName: '1星', score: 100 },
+    { optionId: 'o12', optionCode: 'X0007', optionName: '2星', score: 2 },
+    { optionId: 'o13', optionCode: 'X0008', optionName: '3星', score: 3 },
+    { optionId: 'o14', optionCode: 'X0009', optionName: '4星', score: 4 },
+    { optionId: 'o15', optionCode: 'X0010', optionName: '5星', score: 5 },
+  ],
+  p2: [],
+};
+
+// 工具:模拟异步
+const delay = (ms = 200) => new Promise((r) => setTimeout(r, ms));
+
+export const getCategoryTree = async () => {
+  await delay();
+  return __categoryTree__;
+};
+
+// 新增分类节点(默认添加到指定父节点 children 下;未传 parentId 则添加到根)
+export const addCategory = async (parentId: string | undefined, name: string) => {
+  await delay();
+  const newNode: CategoryNode = { id: `cat_${Date.now()}`, name, children: [] };
+
+  if (!parentId) {
+    __categoryTree__.push(newNode);
+    return newNode;
+  }
+
+  // 递归查找父节点
+  const dfs = (list: CategoryNode[]): boolean => {
+    for (const node of list) {
+      if (node.id === parentId) {
+        node.children = node.children || [];
+        node.children.push(newNode);
+        return true;
+      }
+      if (node.children && node.children.length > 0) {
+        if (dfs(node.children)) return true;
+      }
+    }
+    return false;
+  };
+
+  dfs(__categoryTree__);
+  return newNode;
+};
+
+// 获取项目列表
+export const getProjectList = async (params: { categoryId: string; current?: number; pageSize?: number }) => {
+  await delay();
+  const list = __projects__[params.categoryId] || [];
+  return { list };
+};
+
+// 新增项目
+export const addProject = async (data: { categoryId: string; projectCode: string; projectName: string; weight: number }) => {
+  await delay();
+  const newItem: ProjectItem = {
+    projectId: `p_${Date.now()}`,
+    projectCode: data.projectCode,
+    projectName: data.projectName,
+    weight: Number(data.weight) || 0,
+  };
+  __projects__[data.categoryId] = __projects__[data.categoryId] || [];
+  __projects__[data.categoryId].push(newItem);
+  return true as unknown as any;
+};
+
+// 删除项目
+export const delProject = async (projectId: string) => {
+  await delay();
+  Object.keys(__projects__).forEach((k) => {
+    __projects__[k] = (__projects__[k] || []).filter((p) => p.projectId !== projectId);
+  });
+  delete __options__[projectId];
+  return true as unknown as any;
+};
+
+// 更新项目权重
+export const updateProjectWeight = async (projectId: string, weight: number) => {
+  await delay();
+  Object.keys(__projects__).forEach((k) => {
+    (__projects__[k] || []).forEach((p) => {
+      if (p.projectId === projectId) p.weight = Number(weight) || 0;
+    });
+  });
+  return true as unknown as any;
+};
+
+// 获取选项列表
+export const getOptionList = async (params: { projectId: string }) => {
+  await delay();
+  return __options__[params.projectId] || [];
+};
+
+// 新增选项
+export const addOption = async (data: { projectId: string; optionCode: string; optionName: string; score: number }) => {
+  await delay();
+  __options__[data.projectId] = __options__[data.projectId] || [];
+  __options__[data.projectId].push({ optionId: `o_${Date.now()}`, optionCode: data.optionCode, optionName: data.optionName, score: Number(data.score) || 0 });
+  return true as unknown as any;
+};
+
+// 删除选项
+export const delOption = async (optionId: string) => {
+  await delay();
+  Object.keys(__options__).forEach((k) => {
+    __options__[k] = (__options__[k] || []).filter((o) => o.optionId !== optionId);
+  });
+  return true as unknown as any;
+};
+
+// 更新选项分数
+export const updateOptionScore = async (optionId: string, score: number) => {
+  await delay();
+  Object.keys(__options__).forEach((k) => {
+    (__options__[k] || []).forEach((o) => {
+      if (o.optionId === optionId) o.score = Number(score) || 0;
+    });
+  });
+  return true as unknown as any;
+};
+
+

+ 166 - 0
src/pages/platform/setting/serviceEvaluate/style.less

@@ -0,0 +1,166 @@
+// 复用 systemNavMana 的布局风格
+.SystemNavMana {
+  display: flex;
+  flex-direction: row;
+  background: #F5F7FA;
+
+  .leftTree {
+    width: 220px;
+    height: calc(100vh - 80px);
+    background: #FFF;
+    border-radius: 4px;
+    overflow: hidden;
+    margin-right: 16px;
+    .search { padding: 16px; display:flex; align-items:center; gap:8px; }
+    .search .add {
+      cursor: pointer;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      width: 24px;
+      height: 24px;
+      background: #FAFCFF;
+      border-radius: 4px;
+      border: 1px solid #DAE2F2;
+      flex: 0 0 24px;
+      min-width: 24px;
+    }
+    .searchInput { border: 1px solid #CFD7E6; flex: 1; min-width: 0; }
+    .add { cursor: pointer; display: flex; justify-content: center; align-items: center; width: 24px; height: 24px; background: #FAFCFF; border-radius: 4px;border: 1px solid #DAE2F2;}
+    .treeeWrap { height: calc(100% - 50px); overflow-y: scroll; padding: 16px; padding-top: 0;padding-right: 8px; }
+    // 选中与悬浮样式还原
+    .kcmp-ant-tree.kcmp-ant-tree-directory .kcmp-ant-tree-treenode {
+      margin-bottom: 2px !important;
+      padding-bottom: 0;
+      &:hover { border-radius: 4px !important; background: #F0F2F5; }
+      &::before { bottom: 0 !important; }
+    }
+    // 悬浮到右侧新增图标时,不触发行背景高亮;但图标自身hover需要有背景
+    .kcmp-ant-tree-node-content-wrapper:hover .node-title-area + span:not(:hover) { background: transparent !important; }
+    .inline-add { cursor: pointer; width: 20px; height: 20px; border-radius: 4px; }
+    .inline-add:hover { background: #DAE2F2; }
+    .kcmp-ant-tree.kcmp-ant-tree-directory .kcmp-ant-tree-treenode-selected:hover::before,
+    .kcmp-ant-tree.kcmp-ant-tree-directory .kcmp-ant-tree-treenode-selected::before { border-radius: 4px; background: #F0F2F5; }
+    .kcmp-ant-tree.kcmp-ant-tree-directory .kcmp-ant-tree-treenode .kcmp-ant-tree-node-content-wrapper.kcmp-ant-tree-node-selected { font-weight: 500; color:#17181A; }
+    // 缩进与展开图标位置
+    .kcmp-ant-tree.kcmp-ant-tree-directory .kcmp-ant-tree-treenode .kcmp-ant-tree-indent .kcmp-ant-tree-indent-unit { width: 8px !important; }
+    .kcmp-ant-tree.kcmp-ant-tree-directory .kcmp-ant-tree-treenode .kcmp-ant-tree-switcher > span { top: 7px !important; }
+  }
+
+  .rightContent {
+    position: relative;
+    border-radius: 4px;
+    width: calc(100% - 220px);
+    padding: 16px;
+    background: #FFF;
+
+    .tableToolbar {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 16px;
+      gap: 16px;
+      .filter { 
+        display: flex; 
+        flex-direction: row; 
+        align-items: center;
+        flex: 1;
+        min-width: 0;
+      }
+      .filter .filterItem { 
+        display: flex; 
+        flex-direction: row; 
+        align-items: center; 
+      }
+      
+      .btnGroup {
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+        flex-shrink: 0;
+        min-width: max-content;
+        
+        span {
+          box-sizing: border-box;
+          cursor: pointer;
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+          font-size: 14px;
+          font-family: SourceHanSansCN-Normal, SourceHanSansCN;
+          font-weight: 400;
+          color: #17181A;
+          border: 1px solid #DAE2F2;
+          height: 26px;
+          min-height: 26px;
+          padding: 0 14px;
+          background: #FAFCFF;
+          border-radius: 4px;
+          white-space: nowrap;
+          flex-shrink: 0;
+          width: auto !important;
+          max-width: none !important;
+        }
+
+        .collapse {
+          margin-right: 8px;
+        }
+
+        .add {
+          color: #FFFFFF;
+          background: #3377FF;
+          border: 1px solid #3377FF;
+        }
+      }
+    }
+  }
+}
+
+.KC-DirectoryTree .kcmp-ant-tree-list { height: 100%; }
+
+// 选项子表样式
+.SE-SubTitle { font-weight: 600; }
+.SE-OptionsWrap { padding: 12px 8px; background: #fafbfd; border: 1px solid #dae2f2; border-radius: 4px; }
+.SE-OptionsHeader { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
+.SE-OptionsTable { width: 100%; }
+.SE-OptionsRow { display: grid; grid-template-columns: 60px 140px 1fr 160px 100px; align-items: center; min-height: 40px; border-top: 1px solid #eff2f7; }
+.SE-OptionsHead { background: #f7f9fc; border-top: none; }
+.SE-OptionsRow .col { padding: 6px 8px; }
+.SE-Empty { padding: 8px; color: #a3adbf; }
+
+// 展开区内嵌表格容器,避免覆盖外层行
+.SE-ExpandWrap {
+  padding: 8px 8px 8px 12px;
+  margin-left: 48px; // 缩进,便于层次识别
+  background: #FAFBFD;
+  border-left: 1px solid #DAE2F2; // 仅保留左侧引导线
+  border-top: 0;
+  border-right: 0;
+  border-bottom: 0;
+  border-radius: 0;
+  overflow: hidden; // 防止内表格边框/阴影越界覆盖外层
+  user-select: none; // 避免拖拽时选中文本影响体验
+
+  // 覆盖内嵌 KCTable 的外边框,避免双层边框
+  .KCTable {
+    border: 0 !important;
+  }
+}
+
+// 添加服务 - Transfer 样式修正
+.ServiceTransfer {
+  width: 100%;
+  display: flex;
+  .kcmp-ant-transfer-list {
+    flex: 1;
+    min-width: 0;
+  }
+  .kcmp-ant-transfer-operation {
+    display: flex;
+    align-items: center;
+    margin: 0 12px;
+  }
+}
+
+