This commit is contained in:
zlwu committed 2026-05-17 08:36:37 +08:00
1 parent 246ad2228e
commit 8e2dd8b9f0
186 files changed
+38821 -2571

No files matched your search

@@ -0,0 +1,4 @@
-- 采集协议单当事人:新生儿 Apgar 评分支持一位小数
ALTER TABLE `biz_protocol_party_customer`
MODIFY COLUMN `apgar_1min` decimal(4,1) DEFAULT NULL COMMENT '新生儿评分1分钟',
MODIFY COLUMN `apgar_5min` decimal(4,1) DEFAULT NULL COMMENT '新生儿评分5分钟';
@@ -0,0 +1,3 @@
-- 样本状态允许为空,移除默认「已采集」,与前端清空行为一致
ALTER TABLE `biz_protocol_sample`
MODIFY COLUMN `sample_status` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ycj已采集 jcz检测中 yrk已入库 yzf已作废';
+83
View File
@@ -0,0 +1,83 @@
-- 民族码表(国标 GB/T 3304,仅数字编码条目,不含字母编码重复项)
-- 执行前请确认库中无同名表;与 docs/sql/zkxw.sql 全量基线可并存,亦可合并进全量导出
DROP TABLE IF EXISTS `sys_ethnic`;
CREATE TABLE `sys_ethnic` (
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
`ethnic_name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '民族名称',
`ethnic_code` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '民族编码',
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '租户编号(空=全局码表)',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sys_ethnic_code` (`ethnic_code`,`del_flag`),
KEY `idx_sys_ethnic_name` (`ethnic_name`,`del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='民族码表';
BEGIN;
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-01', '汉族', '01', '', 1, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-02', '蒙古族', '02', '', 2, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-03', '回族', '03', '', 3, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-04', '藏族', '04', '', 4, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-05', '维吾尔族', '05', '', 5, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-06', '苗族', '06', '', 6, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-07', '彝族', '07', '', 7, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-08', '壮族', '08', '', 8, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-09', '布依族', '09', '', 9, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-10', '朝鲜族', '10', '', 10, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-11', '满族', '11', '', 11, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-12', '侗族', '12', '', 12, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-13', '瑶族', '13', '', 13, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-14', '白族', '14', '', 14, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-15', '土家族', '15', '', 15, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-16', '哈尼族', '16', '', 16, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-17', '哈萨克族', '17', '', 17, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-18', '傣族', '18', '', 18, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-19', '黎族', '19', '', 19, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-20', '傈僳族', '20', '', 20, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-21', '佤族', '21', '', 21, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-22', '畲族', '22', '', 22, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-23', '高山族', '23', '', 23, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-24', '拉祜族', '24', '', 24, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-25', '水族', '25', '', 25, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-26', '东乡族', '26', '', 26, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-27', '纳西族', '27', '', 27, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-28', '景颇族', '28', '', 28, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-29', '柯尔克孜族', '29', '', 29, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-30', '土族', '30', '', 30, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-31', '达斡尔族', '31', '', 31, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-32', '仫佬族', '32', '', 32, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-33', '羌族', '33', '', 33, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-34', '布朗族', '34', '', 34, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-35', '撒拉族', '35', '', 35, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-36', '毛难族', '36', '', 36, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-37', '仡佬族', '37', '', 37, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-38', '锡伯族', '38', '', 38, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-39', '阿昌族', '39', '', 39, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-40', '普米族', '40', '', 40, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-41', '塔吉克族', '41', '', 41, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-42', '怒族', '42', '', 42, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-43', '乌孜别克族', '43', '', 43, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-44', '俄罗斯族', '44', '', 44, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-45', '鄂温克族', '45', '', 45, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-46', '德昂族', '46', '', 46, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-47', '保安族', '47', '', 47, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-48', '裕固族', '48', '', 48, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-49', '京族', '49', '', 49, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-50', '塔塔尔族', '50', '', 50, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-51', '独龙族', '51', '', 51, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-52', '鄂伦春族', '52', '', 52, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-53', '赫哲族', '53', '', 53, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-54', '门巴族', '54', '', 54, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-55', '珞巴族', '55', '', 55, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-56', '基诺族', '56', '', 56, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-81', '穿青人族', '81', '', 57, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-97', '其他', '97', '', 58, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-98', '外国血统中国籍人士', '98', '', 59, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
COMMIT;
-20
View File
@@ -1,20 +0,0 @@
-- 组织管理初始化数据(需求 2.1.3)
-- 市场营销部门下设:采集部、临床部、中试服务部、支持中心、客服部
SET @marketing_id = UUID();
INSERT INTO sys_dept (id, parent_id, dept_name, dept_category_code, status, leader, leader_phone, tenant_no, order_num, create_dept_id, del_flag) VALUES
(UUID(), '0', '中科兴皖', 'gs', '1', NULL, NULL, 'zkxw', 0, '', 0),
(@marketing_id, '0', '市场营销部门', 'bm', '1', NULL, NULL, 'zkxw', 1, '', 0),
(UUID(), '0', '技术研发部', 'bm', '1', NULL, NULL, 'zkxw', 2, '', 0),
(UUID(), '0', '生产部', 'bm', '1', NULL, NULL, 'zkxw', 3, '', 0),
(UUID(), '0', '质量安全部', 'bm', '1', NULL, NULL, 'zkxw', 4, '', 0),
(UUID(), '0', '财务部', 'bm', '1', NULL, NULL, 'zkxw', 5, '', 0),
(UUID(), '0', '综合部', 'bm', '1', NULL, NULL, 'zkxw', 6, '', 0);
INSERT INTO sys_dept (id, parent_id, dept_name, dept_category_code, status, leader, leader_phone, tenant_no, order_num, create_dept_id, del_flag) VALUES
(UUID(), @marketing_id, '采集部', 'bm', '1', NULL, NULL, 'zkxw', 1, '', 0),
(UUID(), @marketing_id, '临床部', 'bm', '1', NULL, NULL, 'zkxw', 2, '', 0),
(UUID(), @marketing_id, '中试服务部', 'bm', '1', NULL, NULL, 'zkxw', 3, '', 0),
(UUID(), @marketing_id, '支持中心', 'bm', '1', NULL, NULL, 'zkxw', 4, '', 0),
(UUID(), @marketing_id, '客服部', 'bm', '1', NULL, NULL, 'zkxw', 5, '', 0);
-24
View File
@@ -1,24 +0,0 @@
-- ============================================================
-- 字典管理初始数据(sys_dict_type / sys_dict_data)
-- 执行前提:zkxw-requirements-v0.3-schema.sql 已建表
-- ============================================================
-- 字典类型
INSERT INTO sys_dict_type (id, dict_name, dict_type, tenant_no, order_num, create_dept_id, del_flag) VALUES
(UUID(), '组织类别编码', 'zzlbbm', 'zkxw', 1, '', 0),
(UUID(), '站点类型', 'zdlx', 'zkxw', 2, '', 0),
(UUID(), '站点业务范围', 'zdywfw', 'zkxw', 3, '', 0),
(UUID(), '部门状态', 'bmzt', 'zkxw', 4, '', 0);
-- 字典数据
INSERT INTO sys_dict_data (id, dict_label, dict_value, dict_type, tenant_no, order_num, create_dept_id, del_flag) VALUES
(UUID(), '公司', 'gs', 'zzlbbm', 'zkxw', 1, '', 0),
(UUID(), '分公司', 'fgs', 'zzlbbm', 'zkxw', 2, '', 0),
(UUID(), '部门', 'bm', 'zzlbbm', 'zkxw', 3, '', 0),
(UUID(), '中心基地', 'zxjd', 'zdlx', 'zkxw', 1, '', 0),
(UUID(), '样本采集', 'ybcj', 'zdywfw', 'zkxw', 1, '', 0),
(UUID(), '样本制备', 'ybzb', 'zdywfw', 'zkxw', 2, '', 0),
(UUID(), '质量检测', 'zljc', 'zdywfw', 'zkxw', 3, '', 0),
(UUID(), '细胞存储', 'xbcc', 'zdywfw', 'zkxw', 4, '', 0),
(UUID(), '停用', '0', 'bmzt', 'zkxw', 1, '', 0),
(UUID(), '正常', '1', 'bmzt', 'zkxw', 2, '', 0);
-24
View File
@@ -1,24 +0,0 @@
-- 2.1.6 菜单管理初始化数据
-- Web端根节点与移动端根节点,parent_id = 0
-- 根节点:Web端菜单、移动端菜单
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, visible, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
('1', 'Web端菜单', '0', 'M', '/system', NULL, '1', '1', 'zkxw', 1, '', 0),
('2', '移动端菜单', '0', 'M', '/mini', NULL, '1', '1', 'zkxw', 2, '', 0);
-- Web端菜单 - 系统管理目录
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, visible, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
('100', '系统管理', '1', 'M', '/system', '/index/index', NULL, '1', '1', 'zkxw', 1, '', 0);
-- 系统管理子菜单
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, visible, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
('101', '租户管理', '100', 'C', 'tenant', '/system/tenant/index', 'system:tenant:list', '1', '1', 'zkxw', 1, '', 0),
('102', '字典管理', '100', 'C', 'dict', '/system/dict/index', 'system:dict:list', '1', '1', 'zkxw', 2, '', 0),
('103', '组织管理', '100', 'C', 'dept', '/system/dept/index', 'system:dept:list', '1', '1', 'zkxw', 3, '', 0),
('104', '用户管理', '100', 'C', 'user', '/system/user/index', 'system:user:list', '1', '1', 'zkxw', 4, '', 0),
('105', '角色管理', '100', 'C', 'role', '/system/role/index', 'system:role:list', '1', '1', 'zkxw', 5, '', 0),
('106', '菜单管理', '100', 'C', 'menu', '/system/menu/index', 'system:menu:list', '1', '1', 'zkxw', 6, '', 0),
('107', '站点管理', '100', 'C', 'site', '/system/site/index', 'system:site:list', '1', '1', 'zkxw', 7, '', 0),
('108', '职位管理', '100', 'C', 'post', '/system/post/index', 'system:post:list', '1', '1', 'zkxw', 8, '', 0),
('109', '物品单位', '100', 'C', 'unit', '/system/unit/index', 'system:unit:list', '1', '1', 'zkxw', 9, '', 0),
('110', '快递公司', '100', 'C', 'express', '/system/express/index', 'system:express:list', '1', '1', 'zkxw', 10, '', 0);
-28
View File
@@ -1,28 +0,0 @@
-- V0.3 角色初始数据
-- sys_role_data_scope_detail 建表语句
CREATE TABLE IF NOT EXISTS `sys_role_data_scope_detail` (
`id` varchar(64) NOT NULL COMMENT '主键ID',
`role_id` varchar(64) NOT NULL COMMENT '角色ID',
`scope_type` varchar(20) NOT NULL COMMENT '范围类型:dept/user',
`scope_value` varchar(100) NOT NULL COMMENT '范围值:部门ID/用户ID',
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` smallint NOT NULL DEFAULT '0' COMMENT '删除标记',
PRIMARY KEY (`id`),
KEY `idx_role_id` (`role_id`),
KEY `idx_tenant_no` (`tenant_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色自定义数据权限明细';
-- 角色初始数据
INSERT INTO sys_role (id, role_name, data_scope, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
(UUID(), '超级管理员', '1', '1', 'zkxw', 1, '', 0),
(UUID(), '运营管理员', '1', '1', 'zkxw', 2, '', 0),
(UUID(), '采集专员', '2', '1', 'zkxw', 3, '', 0),
(UUID(), '市场支持专员', '2', '1', 'zkxw', 4, '', 0),
(UUID(), '质检专员', '2', '1', 'zkxw', 5, '', 0);
@@ -1,26 +0,0 @@
-- 站点、职位、物品单位、快递公司 初始化数据
-- 对应模型:SysSite, SysPost, BizItemUnit, BizExpressCompany
-- 站点
INSERT INTO sys_site (id, site_name, site_code, site_type, status, address, contact_name, contact_phone, tenant_no, order_num, create_dept_id, del_flag) VALUES
(UUID(), '中科兴皖中心基地', 'zkxw-zxjd', 'zxjd', '1', '', NULL, NULL, 'zkxw', 1, '', 0);
-- 职位
INSERT INTO sys_post (id, post_name, post_code, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
(UUID(), '采集专员', 'cjzy', '1', 'zkxw', 1, '', 0),
(UUID(), '市场支持专员', 'sczczy', '1', 'zkxw', 2, '', 0),
(UUID(), '质检专员', 'zjzy', '1', 'zkxw', 3, '', 0);
-- 物品单位
INSERT INTO biz_item_unit (id, unit_code, unit_name, tenant_no, order_num, create_dept_id, del_flag) VALUES
(UUID(), 'g', '克', 'zkxw', 1, '', 0),
(UUID(), 'ml', '毫升', 'zkxw', 2, '', 0),
(UUID(), 'ge', '个', 'zkxw', 3, '', 0),
(UUID(), 'fen', '份', 'zkxw', 4, '', 0);
-- 快递公司
INSERT INTO biz_express_company (id, express_code, express_name, tenant_no, order_num, create_dept_id, del_flag) VALUES
(UUID(), 'sf', '顺丰速运', 'zkxw', 1, '', 0),
(UUID(), 'jd', '京东快递', 'zkxw', 2, '', 0),
(UUID(), 'zt', '中通快递', 'zkxw', 3, '', 0),
(UUID(), 'yt', '圆通速递', 'zkxw', 4, '', 0);
-24
View File
@@ -1,24 +0,0 @@
-- zkxw-init-user-data.sql
-- V0.3 用户管理初始化数据(测试用户)
-- 默认 admin 用户已在骨架 SQL 中创建,此处补充测试账号
-- 注意:密码需通过后端 API 创建以获得正确的 bcrypt hash
INSERT INTO sys_user (
id, dept_id, user_no, nick_name, email, phone_no, sex,
password, status, tenant_no, order_num,
create_dept_id, create_by, create_time, update_by, update_time, del_flag
) VALUES
(
REPLACE(UUID(),'-',''),
(SELECT id FROM sys_dept WHERE dept_name='市场营销部' AND del_flag=0 LIMIT 1),
'zhangsan', '张三', 'zhangsan@zkxw.com', '13800000001', '0',
'$2b$12$placeholder_hash_use_api_to_set', '1', 'zkxw', 2,
'', NULL, NOW(), NULL, NOW(), 0
),
(
REPLACE(UUID(),'-',''),
(SELECT id FROM sys_dept WHERE dept_name='市场营销部' AND del_flag=0 LIMIT 1),
'lisi', '李四', 'lisi@zkxw.com', '13800000002', '1',
'$2b$12$placeholder_hash_use_api_to_set', '1', 'zkxw', 3,
'', NULL, NOW(), NULL, NOW(), 0
);
File diff suppressed because it is too large. Load diff
+2550
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,59 @@
# 细胞存储管理(2.2.6~2.2.10)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付细胞存储管理一级菜单下 5 个页面及对应后端 API(方案 C),满足已定稿设计:`docs/superpowers/specs/2026-05-13-cell-storage-protocol-modules-design.md` 与 OpenSpec 变更 `openspec/changes/cell-storage-protocol-modules/`。
**Architecture:** FastAPI 路由挂在 `/web/admin` 旁路扩展;SQLAlchemy Model 覆盖四张业务表;CRUD 层封装租户过滤与 camelCase 列表 DTO;Vue 页面复制 `system/unit` 列表骨架并按模块替换列与弹窗。
**Tech Stack:** Python 3.9、FastAPI、SQLAlchemy、Pydantic v2;Vue 3、Element Plus、pnpm、TypeScript。
---
## 文件映射(落地前对齐)
| 区域 | 路径 |
|------|------|
| 设计/契约 | `docs/superpowers/specs/2026-05-13-cell-storage-protocol-modules-design.md`,`openspec/changes/cell-storage-protocol-modules/` |
| Model | `backend/app/models/models.py` |
| CRUD | `backend/app/crud/collection_protocol.py`(或按职责拆分文件) |
| API | `backend/app/api/collection_protocol.py`,注册 `app/api/__init__.py`、`main.py` |
| 菜单 | `backend/app/api/menu.py` |
| 前端路由 | `web-admin/src/router/modules/sqxp.ts` |
| 前端 API | `web-admin/src/api/collection-protocol.ts`(命名以仓库惯例为准) |
| 页面 | `web-admin/src/views/cell-storage/{protocol,protocol-audit,payment-confirm,refund-confirm,installment-warning}/index.vue` |
---
## 执行清单(与 OpenSpec tasks.md 同步)
按顺序执行 `openspec/changes/cell-storage-protocol-modules/tasks.md` 中章节 **1 → 7**。每完成一节,将对应 `- [ ]` 改为 `- [x]`。
---
## Self-review(对照 spec)
| Spec capability | tasks.md 覆盖 |
|-----------------|---------------|
| cell-storage-collection-protocol | 1.x、2.x、5.1、3.x |
| cell-storage-protocol-audit | 1.x、2.x、5.2 |
| cell-storage-receipt-confirm | 1.x、2.x、5.3 |
| cell-storage-refund-confirm | 1.x、2.x、5.4 |
| cell-storage-installment-warning | 1.x、2.x、5.5 |
无占位:具体接口路径与状态码以实现对照 design.md 为准。
---
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-05-13-cell-storage-protocol-modules.md`.
**Two execution options:**
1. **Subagent-Driven(推荐)** — 每个 task 派生子代理并在任务间复核
2. **Inline Execution** — 本会话按 `openspec apply` 逐项实现(使用 executing-plans)
**Which approach?** 若未指定,默认本会话按 OpenSpec `tasks.md` 顺序实现。
下一步命令:`openspec instructions apply --change cell-storage-protocol-modules --json` 读取上下文文件后开始改代码。
@@ -0,0 +1,535 @@
# FlowDesigner 功能补全实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 补全流程设计器三个核心功能缺口:审批人选择(用户/角色下拉数据加载)、连线条件分支配置(点击连线编辑条件表达式)、SQL查询帮助提示。
**Architecture:** 后端新增2个轻量options端点;前端FlowDesigner.vue增加edge:click事件处理、selectedEdge状态、条件表达式编辑面板、以及handleOpen时加载用户/角色数据。
**Tech Stack:** Python FastAPI, Vue 3 + Element Plus, AntV X6
---
## File Structure
| 文件 | 操作 | 职责 |
|------|------|------|
| `backend/app/api/admin.py` | 修改 | 新增 `GET /web/admin/users/options` 和 `GET /web/admin/roles/options` |
| `web-admin/src/api/admin.ts` | 修改 | 新增 `fetchUserOptions()` 和 `fetchRoleOptions()` |
| `web-admin/src/views/system/workflow/designer/FlowDesigner.vue` | 修改 | 核心改动:edge事件、selectedEdge、条件编辑面板、数据加载 |
---
### Task 1: 后端新增 users/options 和 roles/options 端点
**Files:**
- Modify: `backend/app/api/admin.py` — 在 `list_roles_for_select`(line 33)后面新增两个端点
- [ ] **Step 1: 新增 `GET /web/admin/users/options` 端点**
在 `admin.py` 的 `list_roles_for_select` 函数后面(约 line 63 之后),添加:
```python
@router.get("/users/options")
async def list_users_for_select(
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
"""返回用户下拉选项(label=昵称,value=用户ID),供流程设计器审批人选择使用。"""
tenant_no = _user.get("tenant_no", "")
q = db.query(SysUser).filter(SysUser.del_flag == 0, SysUser.status == "1")
if tenant_no:
q = q.filter(SysUser.tenant_no == tenant_no)
rows = q.order_by(SysUser.order_num.asc(), SysUser.create_time.desc()).all()
out = [{"label": (r.nick_name or r.user_no or ""), "value": r.id} for r in rows]
return success_response(out)
```
注意:顶部已有 `from app.models.models import SysUser, SysRole, ...` 等导入,无需新增。
- [ ] **Step 2: 新增 `GET /web/admin/roles/options` 端点**
紧接上面的函数后面添加:
```python
@router.get("/roles/options")
async def list_roles_for_designer(
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
"""返回角色下拉选项(label=角色名,value=角色ID UUID),供流程设计器审批人选择使用。
与 /roles 不同,此端点返回角色 UUID 作为 value(引擎解析审批人需要 UUID)。"""
tenant_no = _user.get("tenant_no", "")
q = db.query(SysRole).filter(SysRole.del_flag == 0, SysRole.status == "1")
if tenant_no:
q = q.filter(SysRole.tenant_no == tenant_no)
rows = q.order_by(SysRole.order_num.asc()).all()
out = [{"label": (r.role_name or "").strip(), "value": r.id} for r in rows if (r.role_name or "").strip()]
return success_response(out)
```
- [ ] **Step 3: 验证后端启动**
```bash
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.admin import router; print('OK')"
```
Expected: `OK`
---
### Task 2: 前端 API 函数 + 设计器加载用户/角色数据
**Files:**
- Modify: `web-admin/src/api/admin.ts` — 新增两个 API 函数
- Modify: `web-admin/src/views/system/workflow/designer/FlowDesigner.vue` — 导入 API、handleOpen 加载数据
- [ ] **Step 1: 在 `admin.ts` 新增两个 API 函数**
在 `fetchGetRoleList()`(line 18-22)后面添加:
```typescript
/** 流程设计器:用户下拉选项(label=昵称,value=用户ID) */
export function fetchUserOptions() {
return request.get<Array<{ label: string; value: string }>>({
url: '/web/admin/users/options'
})
}
/** 流程设计器:角色下拉选项(label=角色名,value=角色ID UUID) */
export function fetchRoleOptions() {
return request.get<Array<{ label: string; value: string }>>({
url: '/web/admin/roles/options'
})
}
```
- [ ] **Step 2: 在 FlowDesigner.vue 导入新 API 并加载选项数据**
修改 line 146 的 import 语句:
```typescript
import { fetchDesignerData, fetchPublishVersion } from '@/api/workflow'
```
改为:
```typescript
import { fetchDesignerData, fetchPublishVersion } from '@/api/workflow'
import { fetchUserOptions, fetchRoleOptions } from '@/api/admin'
```
然后在 `handleOpen()` 函数(line 742-748)中添加数据加载:
```typescript
function handleOpen() {
selectedNode.value = null
selectedEdge.value = null
registerShapes()
nextTick(() => {
initGraph()
loadDesignerData()
loadOptions()
})
}
async function loadOptions() {
try {
const [users, roles] = await Promise.all([fetchUserOptions(), fetchRoleOptions()])
userOptions.value = users || []
roleOptions.value = roles || []
} catch {
// 加载失败则保持空选项,不影响设计器其他功能
}
}
```
注意:`selectedEdge` 在 Task 3 中定义。
---
### Task 3: 连线点击 + 条件表达式编辑器 + 连线标签
**Files:**
- Modify: `web-admin/src/views/system/workflow/designer/FlowDesigner.vue`
这是最核心的改动。需要在 FlowDesigner.vue 中完成以下修改:
- [ ] **Step 1: 新增 `selectedEdge` 响应式状态和接口定义**
在 `selectedNode` 定义(line 201)后面添加:
```typescript
interface ConditionRule {
field: string
op: string
value: string
}
interface SelectedEdgeData {
id: string
fromNodeName: string
toNodeName: string
rules: ConditionRule[]
isDefault: boolean
}
const selectedEdge = ref<SelectedEdgeData | null>(null)
```
- [ ] **Step 2: 注册 edge:click 事件 + 修改 blank:click 和 node:click 清除 selectedEdge**
在 `initGraph()` 函数中,`graph.on('blank:click', ...)` (line 560-563)后面添加 edge 事件:
```typescript
// 连线点击 → 选中(仅条件节点的出线可编辑条件)
graph.on('edge:click', ({ edge }) => {
const sourceId = edge.getSourceCellId() || ''
const sourceNode = graph!.getCellById(sourceId)
const sourceData = sourceNode?.getData() || {}
const targetId = edge.getTargetCellId() || ''
const targetNode = graph!.getCellById(targetId)
const targetData = targetNode?.getData() || {}
const edgeData = edge.getData() || {}
const rawRules = edgeData.conditionExpr ? (JSON.parse(edgeData.conditionExpr) || {}) : {}
const rules: ConditionRule[] = rawRules.rules || rawRules || []
// 确保至少有一条规则
if (!Array.isArray(rules) || rules.length === 0) {
rules.push({ field: '', op: '==', value: '' })
}
// 规范化为 ConditionRule[](只保留简单的 field/op/value 三元组)
const normalizedRules: ConditionRule[] = rules.map((r: any) => ({
field: r.field || '',
op: r.op || '==',
value: String(r.value ?? ''),
}))
selectedEdge.value = {
id: edge.id,
fromNodeName: sourceData.nodeName || sourceId,
toNodeName: targetData.nodeName || targetId,
rules: normalizedRules,
isDefault: !edgeData.conditionExpr,
}
selectedNode.value = null
})
```
同时修改 `blank:click` 和 `node:click` 事件处理,在清除/设置 `selectedNode` 时也清除 `selectedEdge`:
```typescript
// node:click 处理中添加:
selectedEdge.value = null
// blank:click 处理中改为:
graph.on('blank:click', () => {
selectedNode.value = null
selectedEdge.value = null
})
```
- [ ] **Step 3: 连线创建时初始化 data**
在 `createEdge()` 函数(约 line 522-539)中,给新创建的 edge 加上 data 初始化:
在 `graph!.createEdge({...})` 调用中添加 `data: { conditionExpr: '' }` 属性:
```typescript
createEdge() {
return graph!.createEdge({
shape: 'edge',
data: { conditionExpr: '' },
attrs: {
// ... 保持原有 attrs 不变
},
router: { name: 'manhattan' },
connector: { name: 'rounded', args: { radius: 8 } },
})
}
```
- [ ] **Step 4: 添加同步连线条件数据到 graph edge 的函数**
在 `syncNodeLabel` 函数后面添加:
```typescript
// 同步条件表达式到 edge 并更新标签
function syncEdgeCondition() {
if (!selectedEdge.value || !graph) return
const edge = graph.getCellById(selectedEdge.value.id)
if (!edge || !edge.isEdge()) return
if (selectedEdge.value.isDefault) {
edge.setData({ ...edge.getData(), conditionExpr: '' })
edge.setLabels([])
} else {
const rules = selectedEdge.value.rules
.filter((r) => r.field.trim())
.map((r) => ({ field: r.field, op: r.op, value: r.value }))
const expr = JSON.stringify({ rules })
edge.setData({ ...edge.getData(), conditionExpr: expr })
// 设置标签显示条件摘要
const summary = rules.map((r) => `${r.field} ${r.op} ${r.value}`).join(' & ')
edge.setLabels([
{
attrs: {
label: {
text: summary || '条件',
fill: '#374151',
fontSize: 11,
textAnchor: 'middle',
textVerticalAnchor: 'bottom',
},
rect: {
fill: '#fff',
stroke: '#d1d5db',
strokeWidth: 1,
rx: 4,
ry: 4,
},
},
position: {
distance: 0.5,
},
},
])
}
}
```
- [ ] **Step 5: 修改序列化函数,从 edge data 读取 conditionExpr**
当前 `serializeGraph()` 已经正确读取 `e.getData()?.conditionExpr`(line 670),无需修改。确认即可。
- [ ] **Step 6: 修改模板 — 右侧属性面板增加连线编辑状态**
在模板的 `<aside class="props-panel">` 中(line 60-138),修改三态逻辑:
将原来的 `<template v-if="selectedNode">` ... `<template v-else>` 结构改为三态:
```html
<!-- 右侧属性面板 -->
<aside class="props-panel">
<!-- 节点选中状态 -->
<template v-if="selectedNode">
<div class="panel-section-title">节点属性</div>
<el-form label-position="top" class="props-form">
<!-- ... 保持原有节点属性表单不变 (lines 64-132) ... -->
</el-form>
</template>
<!-- 连线选中状态(条件编辑) -->
<template v-else-if="selectedEdge">
<div class="panel-section-title">连线条件</div>
<div class="edge-info">
<span class="edge-from">{{ selectedEdge.fromNodeName }}</span>
<span class="edge-arrow">→</span>
<span class="edge-to">{{ selectedEdge.toNodeName }}</span>
</div>
<el-form label-position="top" class="props-form" style="margin-top: 12px">
<el-form-item>
<el-checkbox v-model="selectedEdge.isDefault" @change="syncEdgeCondition">
默认分支(无条件,其他分支都不满足时走此路径)
</el-checkbox>
</el-form-item>
<template v-if="!selectedEdge.isDefault">
<div v-for="(rule, idx) in selectedEdge.rules" :key="idx" class="condition-rule-row">
<el-input v-model="rule.field" placeholder="字段名" style="width: 30%" @input="syncEdgeCondition" />
<el-select v-model="rule.op" style="width: 22%" @change="syncEdgeCondition">
<el-option label="==" value="==" />
<el-option label="!=" value="!=" />
<el-option label=">" value=">" />
<el-option label="<" value="<" />
<el-option label=">=" value=">=" />
<el-option label="<=" value="<=" />
<el-option label="in" value="in" />
<el-option label="not_in" value="not_in" />
<el-option label="contains" value="contains" />
</el-select>
<el-input v-model="rule.value" placeholder="值" style="width: 30%" @input="syncEdgeCondition" />
<el-button
:icon="selectedEdge.rules.length > 1 ? 'Delete' : undefined"
text
type="danger"
@click="removeConditionRule(idx)"
/>
</div>
<el-button type="primary" link @click="addConditionRule">+ 添加条件</el-button>
</template>
</el-form>
</template>
<!-- 空状态 -->
<template v-else>
<div class="panel-section-title">属性</div>
<div class="empty-hint">点击画布中的节点或连线查看属性</div>
</template>
</aside>
```
- [ ] **Step 7: 添加条件规则操作函数**
在 `syncEdgeCondition` 函数后面添加:
```typescript
function addConditionRule() {
if (selectedEdge.value) {
selectedEdge.value.rules.push({ field: '', op: '==', value: '' })
}
}
function removeConditionRule(idx: number) {
if (selectedEdge.value && selectedEdge.value.rules.length > 1) {
selectedEdge.value.rules.splice(idx, 1)
syncEdgeCondition()
}
}
```
- [ ] **Step 8: 添加条件编辑面板的 CSS**
在 `<style scoped lang="scss">` 块中添加:
```scss
.edge-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f9fafb;
border-radius: 6px;
font-size: 13px;
color: #374151;
}
.edge-from {
font-weight: 500;
}
.edge-arrow {
color: #9ca3af;
}
.edge-to {
font-weight: 500;
}
.condition-rule-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
```
- [ ] **Step 9: 更新操作提示列表**
在左侧面板的操作提示(line 46-51)中,添加连线相关的提示:
```html
<li>点击连线编辑条件规则</li>
```
添加在 "从节点连接点拖出连线" 后面。
---
### Task 4: SQL 模式帮助提示
**Files:**
- Modify: `web-admin/src/views/system/workflow/designer/FlowDesigner.vue`
- [ ] **Step 1: 在 SQL textarea 下方添加帮助提示**
将原有的 SQL textarea(约 line 111-118)修改为:
```html
<el-form-item v-if="selectedNode.config.strategy === 'sql'" label="SQL查询语句">
<el-input
v-model="selectedNode.config.sqlExpr"
type="textarea"
:rows="4"
placeholder="SELECT user_id FROM sys_user WHERE ..."
/>
<div class="sql-help">
<p><strong>SQL审批人策略说明:</strong></p>
<ul>
<li>必须以 <code>SELECT</code> 开头,只允许只读查询</li>
<li>查询结果第一列将作为审批人 user_id</li>
<li>可使用流程变量作为参数:<code>:var_name</code> 占位符</li>
<li>禁止 INSERT / UPDATE / DELETE / DROP 等写操作</li>
</ul>
<p class="sql-example">示例:<code>SELECT id FROM sys_user WHERE dept_id = :dept_id AND del_flag = 0</code></p>
</div>
</el-form-item>
```
- [ ] **Step 2: 添加 SQL 帮助样式**
在 scoped style 中添加:
```scss
.sql-help {
margin-top: 8px;
padding: 10px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
font-size: 12px;
color: #374151;
line-height: 1.6;
p {
margin: 0 0 4px;
}
ul {
margin: 4px 0;
padding-left: 18px;
}
li {
margin-bottom: 2px;
}
code {
background: #e0f2fe;
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
color: #0369a1;
}
}
.sql-example {
margin-top: 6px !important;
color: #059669;
}
```
---
### Task 5: 验证
- [ ] **Step 1: 后端验证**
```bash
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.admin import router; print('OK')"
```
Expected: `OK`
- [ ] **Step 2: 前端 build 验证**
```bash
cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build
```
Expected: Build 成功,无新增错误
- [ ] **Step 3: LSP 诊断**
对 `FlowDesigner.vue` 运行 `lsp_diagnostics`,确认无类型错误。
@@ -0,0 +1,612 @@
# FlowDesigner 企业级增强实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
**Goal:** 将流程设计器从基础原型提升到企业可用级别:节点删除、撤销重做、键盘快捷键、AND/OR条件组、发布前验证、缩放工具栏。
**Architecture:** 安装 AntV X6 插件(Selection/Keyboard/History/Snapline/Clipboard),增强 FlowDesigner.vue 的交互能力和条件编辑器,新增验证逻辑。
**Tech Stack:** AntV X6 v3 + X6 插件, Vue 3 + Element Plus
---
## 前置:安装 X6 插件
```bash
cd web-admin && pnpm add @antv/x6-plugin-selection @antv/x6-plugin-keyboard @antv/x6-plugin-history @antv/x6-plugin-snapline @antv/x6-plugin-clipboard
```
---
## Phase 1: 交互增强 — 删除 + 撤销重做 + 快捷键
**Files:** `web-admin/src/views/system/workflow/designer/FlowDesigner.vue`
### 1.1 新增插件导入
```typescript
import { Selection } from '@antv/x6-plugin-selection'
import { Keyboard } from '@antv/x6-plugin-keyboard'
import { History } from '@antv/x6-plugin-history'
import { Snapline } from '@antv/x6-plugin-snapline'
import { Clipboard } from '@antv/x6-plugin-clipboard'
```
### 1.2 Graph 构造函数启用 history
```typescript
history: { enabled: true },
```
### 1.3 initGraph 中注册插件(在 graph 创建后、事件绑定前)
```typescript
graph.use(new Selection({ enabled: true, multiple: true, rubberband: true, movable: true, showNodeSelectionBox: true }))
graph.use(new Keyboard({ enabled: true }))
graph.use(new Snapline({ enabled: true }))
graph.use(new Clipboard({ enabled: true }))
```
### 1.4 键盘快捷键绑定
```typescript
// Delete/Backspace 删除选中
graph.bindKey(['delete', 'backspace'], () => {
const cells = graph.getSelectedCells()
if (cells.length) {
// 保护:不允许删除 start 和 end 节点以外的连线
const toRemove = cells.filter((c) => {
if (c.isNode()) {
const data = c.getData() || {}
return data.nodeType !== 'start' && data.nodeType !== 'end'
}
return true // 边都可以删除
})
if (toRemove.length) graph.removeCells(toRemove)
}
selectedNode.value = null
selectedEdge.value = null
return false
})
// Undo Ctrl+Z
graph.bindKey(['ctrl+z', 'meta+z'], () => {
if (graph.canUndo()) graph.undo()
return false
})
// Redo Ctrl+Y
graph.bindKey(['ctrl+y', 'meta+shift+z'], () => {
if (graph.canRedo()) graph.redo()
return false
})
// Copy Ctrl+C
graph.bindKey(['ctrl+c', 'meta+c'], () => {
const cells = graph.getSelectedCells().filter((c) => c.isNode())
if (cells.length) graph.copy(cells)
return false
})
// Paste Ctrl+V
graph.bindKey(['ctrl+v', 'meta+v'], () => {
if (!graph.isClipboardEmpty()) {
const cells = graph.paste({ offset: 32 })
graph.cleanSelection()
graph.select(cells)
}
return false
})
```
### 1.5 节点/边删除后清除选中状态
```typescript
graph.on('cell:removed', () => {
selectedNode.value = null
selectedEdge.value = null
})
```
### 1.6 属性面板增加删除按钮
在节点属性面板的顶部,节点名称旁边增加删除按钮:
```html
<div style="display: flex; align-items: center; justify-content: space-between">
<span style="font-weight: 500">节点属性</span>
<el-button
v-if="selectedNode.nodeType !== 'start' && selectedNode.nodeType !== 'end'"
type="danger"
text
size="small"
@click="deleteSelectedNode"
>
删除节点
</el-button>
</div>
```
在连线条件面板顶部也增加删除按钮:
```html
<div style="display: flex; align-items: center; justify-content: space-between">
<span class="panel-section-title" style="margin: 0">连线条件</span>
<el-button type="danger" text size="small" @click="deleteSelectedEdge">删除连线</el-button>
</div>
```
### 1.7 删除函数
```typescript
function deleteSelectedNode() {
if (!selectedNode.value || !graph) return
const cell = graph.getCellById(selectedNode.value.id)
if (cell) graph.removeCell(cell)
selectedNode.value = null
}
function deleteSelectedEdge() {
if (!selectedEdge.value || !graph) return
const cell = graph.getCellById(selectedEdge.value.id)
if (cell) graph.removeCell(cell)
selectedEdge.value = null
}
```
### 1.8 工具栏增加撤销/重做按钮
在 header 的按钮区域增加撤销/重做:
```html
<el-button class="btn-ghost" :disabled="!canUndo" @click="handleUndo">撤销</el-button>
<el-button class="btn-ghost" :disabled="!canRedo" @click="handleRedo">重做</el-button>
```
新增响应式状态和函数:
```typescript
const canUndo = ref(false)
const canRedo = ref(false)
// 在 initGraph 中,history 插件注册后:
graph.on('history:change', () => {
canUndo.value = graph?.canUndo() ?? false
canRedo.value = graph?.canRedo() ?? false
})
function handleUndo() {
if (graph?.canUndo()) graph.undo()
}
function handleRedo() {
if (graph?.canRedo()) graph.redo()
}
```
---
## Phase 2: 条件表达式增强 — AND/OR 分组 + 流程变量提示
### 2.1 数据模型改为分组结构
替换 `ConditionRule` 接口为分组结构:
```typescript
interface ConditionRule {
field: string
op: string
value: string
}
interface ConditionGroup {
logic: 'and' | 'or' // 组内规则之间的逻辑
rules: ConditionRule[]
}
```
替换 `SelectedEdgeData` 的 `rules` 为 `groups`:
```typescript
interface SelectedEdgeData {
id: string
fromNodeName: string
toNodeName: string
groups: ConditionGroup[] // 组之间是 OR 关系
groupLogic: 'or' // 组间逻辑(固定为 OR)
isDefault: boolean
}
```
条件语义:组内 AND,组间 OR。即 `(rule1 AND rule2) OR (rule3 AND rule4)`
### 2.2 条件编辑器 UI 改为分组模式
```html
<template v-if="!selectedEdge.isDefault">
<div v-for="(group, gIdx) in selectedEdge.groups" :key="gIdx" class="condition-group">
<div class="group-header">
<span class="group-label">条件组 {{ gIdx + 1 }}</span>
<span class="group-logic-badge">组内条件全部满足 (AND)</span>
<el-button
v-if="selectedEdge.groups.length > 1"
type="danger"
text
size="small"
@click="removeConditionGroup(gIdx)"
>
删除组
</el-button>
</div>
<div v-for="(rule, rIdx) in group.rules" :key="rIdx" class="condition-rule-row">
<el-input v-model="rule.field" placeholder="字段名(流程变量)" style="width: 28%" @input="syncEdgeCondition" />
<el-select v-model="rule.op" style="width: 20%" @change="syncEdgeCondition">
<el-option label="==" value="==" />
<el-option label="!=" value="!=" />
<el-option label=">" value=">" />
<el-option label="<" value="<" />
<el-option label=">=" value=">=" />
<el-option label="<=" value="<=" />
<el-option label="包含" value="contains" />
<el-option label="不包含" value="not_in" />
</el-select>
<el-input v-model="rule.value" placeholder="值" style="width: 28%" @input="syncEdgeCondition" />
<el-button
v-if="group.rules.length > 1"
text
type="danger"
size="small"
@click="removeConditionRule(gIdx, rIdx)"
>
×
</el-button>
</div>
<el-button type="primary" link size="small" @click="addConditionRule(gIdx)">
+ 添加条件
</el-button>
</div>
<div class="group-logic-hint">组间关系:任一条件组满足即可 (OR)</div>
<el-button type="primary" link @click="addConditionGroup">+ 添加条件组 (OR)</el-button>
</template>
```
### 2.3 操作函数更新
```typescript
function addConditionGroup() {
if (selectedEdge.value) {
selectedEdge.value.groups.push({ logic: 'and', rules: [{ field: '', op: '==', value: '' }] })
}
}
function removeConditionGroup(gIdx: number) {
if (selectedEdge.value && selectedEdge.value.groups.length > 1) {
selectedEdge.value.groups.splice(gIdx, 1)
syncEdgeCondition()
}
}
function addConditionRule(gIdx: number) {
if (selectedEdge.value) {
selectedEdge.value.groups[gIdx].rules.push({ field: '', op: '==', value: '' })
}
}
function removeConditionRule(gIdx: number, rIdx: number) {
if (selectedEdge.value && selectedEdge.value.groups[gIdx].rules.length > 1) {
selectedEdge.value.groups[gIdx].rules.splice(rIdx, 1)
syncEdgeCondition()
}
}
```
### 2.4 syncEdgeCondition 序列化更新
生成与后端引擎兼容的 JSON:
```typescript
function syncEdgeCondition() {
if (!selectedEdge.value || !graph) return
const edge = graph.getCellById(selectedEdge.value.id)
if (!edge || !edge.isEdge()) return
if (selectedEdge.value.isDefault) {
edge.setData({ ...edge.getData(), conditionExpr: '' })
edge.setLabels([])
} else {
// 序列化为引擎兼容格式:[{and: [{field,op,value},...]}, {and: [...]}]
// 顶层 array = OR 语义(引擎 _check_rules 对 list 做 any())
const groups = selectedEdge.value.groups
.filter((g) => g.rules.some((r) => r.field.trim()))
.map((g) => ({
and: g.rules
.filter((r) => r.field.trim())
.map((r) => ({ field: r.field, op: r.op, value: r.value })),
}))
const expr = JSON.stringify(groups)
edge.setData({ ...edge.getData(), conditionExpr: expr })
// 生成可读标签
const parts = groups.map((g) =>
g.and.map((r) => `${r.field} ${r.op} ${r.value}`).join(' & '),
)
const summary = parts.join(' | ')
edge.setLabels([
{
attrs: {
label: { text: summary || '条件', fill: '#374151', fontSize: 11, textAnchor: 'middle', textVerticalAnchor: 'bottom' },
rect: { fill: '#fff', stroke: '#d1d5db', strokeWidth: 1, rx: 4, ry: 4 },
},
position: { distance: 0.5 },
},
])
}
}
```
### 2.5 edge:click 解析更新
解析引擎格式到分组 UI:
```typescript
// 在 edge:click handler 中:
let normalizedGroups: ConditionGroup[] = []
if (edgeData.conditionExpr) {
try {
const parsed = JSON.parse(edgeData.conditionExpr)
if (Array.isArray(parsed)) {
// 引擎格式: [{and: [{field,op,value},...]}, ...]
normalizedGroups = parsed.map((item: any) => {
if (item.and) {
return { logic: 'and' as const, rules: item.and.map((r: any) => ({ field: r.field || '', op: r.op || '==', value: String(r.value ?? '') })) }
}
if (item.or) {
return { logic: 'or' as const, rules: item.or.map((r: any) => ({ field: r.field || '', op: r.op || '==', value: String(r.value ?? '') })) }
}
// 兼容旧格式: [{rules: [...]}]
const rawRules = item.rules || [item]
return { logic: 'and' as const, rules: rawRules.map((r: any) => ({ field: r.field || '', op: r.op || '==', value: String(r.value ?? '') })) }
})
} else if (parsed.rules) {
// 旧格式: {rules: [{field,op,value},...]}
normalizedGroups = [{ logic: 'and', rules: parsed.rules.map((r: any) => ({ field: r.field || '', op: r.op || '==', value: String(r.value ?? '') })) }]
}
} catch {
// ignore
}
}
if (normalizedGroups.length === 0) {
normalizedGroups = [{ logic: 'and', rules: [{ field: '', op: '==', value: '' }] }]
}
```
### 2.6 流程变量提示
在条件字段输入下方添加提示:
```html
<div class="field-hint">
<p>字段名为流程发起时传入的变量名,支持点号嵌套路径(如 <code>order.amount</code>)</p>
<p>常见变量:<code>amount</code>(金额) <code>department</code>(部门) <code>applicant.dept_id</code>(发起人部门)</p>
</div>
```
### 2.7 CSS 新增
```scss
.condition-group {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 10px;
margin-bottom: 10px;
}
.group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.group-label {
font-size: 13px;
font-weight: 500;
color: #374151;
}
.group-logic-badge {
font-size: 11px;
color: #6b7280;
background: #e5e7eb;
padding: 2px 6px;
border-radius: 3px;
}
.group-logic-hint {
font-size: 12px;
color: #6b7280;
margin: 8px 0;
padding: 4px 8px;
background: #fef3c7;
border-radius: 4px;
}
.field-hint {
margin-top: 8px;
padding: 8px 10px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
font-size: 12px;
color: #1e40af;
line-height: 1.6;
p { margin: 0 0 2px; }
code { background: #dbeafe; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
}
```
---
## Phase 3: 保存/发布前验证
### 3.1 验证函数
```typescript
interface ValidationError {
type: 'error' | 'warning'
message: string
nodeId?: string
}
function validateGraph(): ValidationError[] {
const errors: ValidationError[] = []
if (!graph) return errors
const cells = graph.getCells()
const nodes = cells.filter((c) => c.isNode())
const edges = cells.filter((c) => c.isEdge())
// 1. 必须有且仅有一个开始节点
const startNodes = nodes.filter((n) => (n.getData() || {}).nodeType === 'start')
if (startNodes.length === 0) {
errors.push({ type: 'error', message: '必须有一个开始节点' })
} else if (startNodes.length > 1) {
errors.push({ type: 'error', message: '只能有一个开始节点', nodeId: startNodes[1].id })
}
// 2. 必须有至少一个结束节点
const endNodes = nodes.filter((n) => (n.getData() || {}).nodeType === 'end')
if (endNodes.length === 0) {
errors.push({ type: 'error', message: '必须至少有一个结束节点' })
}
// 3. 开始节点必须有出边
if (startNodes.length === 1) {
const hasOutEdge = edges.some((e) => e.getSourceCellId() === startNodes[0].id)
if (!hasOutEdge) {
errors.push({ type: 'error', message: '开始节点必须连接到下游节点', nodeId: startNodes[0].id })
}
}
// 4. 结束节点必须有入边
endNodes.forEach((en) => {
const hasInEdge = edges.some((e) => e.getTargetCellId() === en.id)
if (!hasInEdge) {
errors.push({ type: 'error', message: '结束节点必须有入边连接', nodeId: en.id })
}
})
// 5. 审批节点必须配置审批人
nodes.filter((n) => (n.getData() || {}).nodeType === 'approval').forEach((n) => {
const config = (n.getData() || {}).config || {}
const strategy = config.strategy || 'users'
if (strategy === 'users' && (!config.userIds || config.userIds.length === 0)) {
errors.push({ type: 'error', message: `审批节点"${(n.getData() || {}).nodeName || n.id}"未选择审批用户`, nodeId: n.id })
}
if (strategy === 'roles' && (!config.roleIds || config.roleIds.length === 0)) {
errors.push({ type: 'error', message: `审批节点"${(n.getData() || {}).nodeName || n.id}"未选择审批角色`, nodeId: n.id })
}
if (strategy === 'sql' && (!config.sqlExpr || !config.sqlExpr.trim())) {
errors.push({ type: 'error', message: `审批节点"${(n.getData() || {}).nodeName || n.id}"未填写SQL查询`, nodeId: n.id })
}
})
// 6. 条件节点必须有至少2条出边
nodes.filter((n) => (n.getData() || {}).nodeType === 'condition').forEach((n) => {
const outEdges = edges.filter((e) => e.getSourceCellId() === n.id)
if (outEdges.length < 2) {
errors.push({ type: 'error', message: `条件节点"${(n.getData() || {}).nodeName || n.id}"至少需要2条出边`, nodeId: n.id })
}
})
// 7. 孤立节点检查(非开始/结束节点必须有入边和出边)
nodes.forEach((n) => {
const nodeType = (n.getData() || {}).nodeType
if (nodeType === 'start' || nodeType === 'end') return
const hasIn = edges.some((e) => e.getTargetCellId() === n.id)
const hasOut = edges.some((e) => e.getSourceCellId() === n.id)
if (!hasIn || !hasOut) {
errors.push({ type: 'warning', message: `节点"${(n.getData() || {}).nodeName || n.id}"未完全连接`, nodeId: n.id })
}
})
return errors
}
```
### 3.2 在 handlePublish 中调用验证
```typescript
async function handlePublish() {
const errors = validateGraph()
const blockers = errors.filter((e) => e.type === 'error')
if (blockers.length > 0) {
ElMessage.error(blockers[0].message)
// 高亮错误节点
if (blockers[0].nodeId && graph) {
const cell = graph.getCellById(blockers[0].nodeId)
if (cell && cell.isNode()) {
graph.resetSelection(cell)
}
}
return
}
// ... 原有发布逻辑
}
```
---
## Phase 4: 缩放工具栏 + 默认分支标记
### 4.1 缩放工具栏
在 header 按钮区域添加缩放控制:
```html
<div class="zoom-controls">
<el-button text size="small" @click="handleZoomOut" title="缩小">−</el-button>
<span class="zoom-level">{{ zoomLevel }}%</span>
<el-button text size="small" @click="handleZoomIn" title="放大">+</el-button>
<el-button text size="small" @click="handleZoomReset" title="适应画布">⊞</el-button>
</div>
```
```typescript
const zoomLevel = ref(100)
function handleZoomIn() {
if (!graph) return
const cur = graph.zoom()
graph.zoom(0.1)
zoomLevel.value = Math.round((cur + 0.1) * 100)
}
function handleZoomOut() {
if (!graph) return
const cur = graph.zoom()
graph.zoom(-0.1)
zoomLevel.value = Math.round((cur - 0.1) * 100)
}
function handleZoomReset() {
if (!graph) return
graph.zoomToFit({ padding: 40, maxScale: 1 })
zoomLevel.value = Math.round(graph.zoom() * 100)
}
```
### 4.2 默认分支连线标记
默认分支的连线标签显示"其他情况":
```typescript
// syncEdgeCondition 中,默认分支:
if (selectedEdge.value.isDefault) {
edge.setData({ ...edge.getData(), conditionExpr: '' })
edge.setLabels([{
attrs: {
label: { text: '其他情况', fill: '#9ca3af', fontSize: 11, textAnchor: 'middle', textVerticalAnchor: 'bottom', fontStyle: 'italic' },
rect: { fill: '#f9fafb', stroke: '#d1d5db', strokeWidth: 1, rx: 4, ry: 4, strokeDasharray: '3,3' },
},
position: { distance: 0.5 },
}])
}
```
### 4.3 CSS
```scss
.zoom-controls {
display: flex;
align-items: center;
gap: 4px;
margin-right: 12px;
padding-right: 12px;
border-right: 1px solid #e5e7eb;
}
.zoom-level {
font-size: 12px;
color: #6b7280;
min-width: 36px;
text-align: center;
}
```
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,618 @@
# 运营支撑模块 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement 3 operations support modules (洁净室管理, 库位管理, 展馆预约) with backend API + frontend UI, following existing codebase patterns exactly.
**Architecture:** FastAPI backend with flat-function CRUD (no service layer), Vue 3 + Element Plus frontend with standard list/tree page templates. All 3 modules share a new API route file `app/api/operations.py` and frontend route group.
**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / Pydantic v2 (backend); Vue 3 / TypeScript / Element Plus / Tailwind CSS 4 (frontend)
**Design Spec:** `docs/superpowers/specs/2026-05-15-operations-support-design.md`
**Key Reference Files (READ FIRST):**
- Backend CRUD pattern: `backend/app/crud/item_unit.py`
- Backend API pattern: `backend/app/api/basic_info.py` (lines 1-100 for simple CRUD)
- Backend model pattern: `backend/app/models/models.py` (class BizItemUnit, lines 259-275)
- Frontend standard list: `web-admin/src/views/system/unit/index.vue`
- Frontend tree template: `web-admin/src/views/system/category/index.vue`
- Frontend API pattern: `web-admin/src/api/admin.ts` (any CRUD section)
- Frontend router: `web-admin/src/router/modules/sqxp.ts`
---
## File Structure
### Backend (create/modify)
| File | Action | Purpose |
|------|--------|---------|
| `backend/app/models/models.py` | MODIFY | Add 5 model classes at end of file |
| `backend/app/crud/clean_room.py` | CREATE | CleanRoom + AccessLog CRUD functions |
| `backend/app/crud/storage_facility.py` | CREATE | StorageFacility tree + Slot CRUD functions |
| `backend/app/crud/exhibition_appointment.py` | CREATE | ExhibitionAppointment CRUD functions |
| `backend/app/api/operations.py` | CREATE | All 3 modules API routes |
| `backend/app/api/__init__.py` | MODIFY | Import + export operations_router |
| `backend/app/main.py` | MODIFY | Import + register operations_router |
| `backend/tests/test_operations_api.py` | CREATE | Backend API tests |
### Frontend (create/modify)
| File | Action | Purpose |
|------|--------|---------|
| `web-admin/src/api/admin.ts` | MODIFY | Add API functions for all 3 modules |
| `web-admin/src/router/modules/sqxp.ts` | MODIFY | Add 运营支撑 route group |
| `web-admin/src/views/operations/clean-room/index.vue` | CREATE | Clean room list page |
| `web-admin/src/views/operations/storage-facility/index.vue` | CREATE | Storage facility tree page |
| `web-admin/src/views/operations/exhibition-appointment/index.vue` | CREATE | Exhibition appointment list page |
---
## Task 1: Backend Models (5 SQLAlchemy models)
**Files:**
- Modify: `backend/app/models/models.py` (append at end, before EOF)
- [ ] **Step 1: Add 5 model classes to models.py**
Append the following after the last class in `backend/app/models/models.py`:
```python
# =============================================================================
# V0.4 运营支撑(2.4 洁净室管理 / 库位管理 / 展馆预约)
# =============================================================================
class BizCleanRoom(Base):
"""洁净室信息 biz_clean_room"""
__tablename__ = "biz_clean_room"
id = Column(String(64), primary_key=True, comment="主键ID")
room_code = Column(String(64), nullable=False, comment="编号")
room_name = Column(String(200), nullable=False, comment="名称")
room_grade = Column(String(8), nullable=True, comment="等级 A/B/C/D")
room_status = Column(String(16), nullable=False, default="zc", comment="zc正常 yc异常")
area_sqm = Column(Numeric(12, 2), nullable=True, comment="面积m²")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizCleanRoomAccessLog(Base):
"""洁净室人员进出 biz_clean_room_access_log"""
__tablename__ = "biz_clean_room_access_log"
id = Column(String(64), primary_key=True, comment="主键ID")
clean_room_id = Column(String(64), nullable=False, comment="洁净室ID")
user_id = Column(String(64), nullable=True, comment="人员用户ID")
person_name = Column(String(100), nullable=True, comment="人员姓名(冗余)")
dept_name = Column(String(200), nullable=True, comment="部门")
enter_time = Column(DateTime, nullable=False, comment="进入时间")
purpose = Column(String(512), nullable=True, comment="目的")
leave_time = Column(DateTime, nullable=True, comment="离开时间")
body_temp = Column(String(32), nullable=True, comment="体温")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizStorageFacility(Base):
"""细胞存储设施树节点 biz_storage_facility"""
__tablename__ = "biz_storage_facility"
id = Column(String(64), primary_key=True, comment="主键ID")
parent_id = Column(String(64), nullable=False, default="0", comment="父节点ID,0根")
node_name = Column(String(200), nullable=False, comment="节点名称(罐/层/盒等)")
node_type = Column(String(32), nullable=True, comment="facility/layer/box 等扩展")
is_leaf = Column(SmallInteger, nullable=False, default=0, comment="是否叶子存储位 1是")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizStorageFacilitySlot(Base):
"""细胞存储设施储存位置(格子)biz_storage_facility_slot"""
__tablename__ = "biz_storage_facility_slot"
id = Column(String(64), primary_key=True, comment="主键ID")
facility_node_id = Column(String(64), nullable=False, comment="设施树叶子节点ID")
slot_code = Column(String(64), nullable=True, comment="小格编码/坐标")
slot_status = Column(String(16), nullable=True, comment="占用状态等扩展")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizExhibitionAppointment(Base):
"""展馆预约 biz_exhibition_appointment"""
__tablename__ = "biz_exhibition_appointment"
id = Column(String(64), primary_key=True, comment="主键ID")
appointment_no = Column(String(64), nullable=False, comment="预约编号")
visit_org = Column(String(200), nullable=True, comment="来访单位")
contact_name = Column(String(100), nullable=True, comment="联系人")
visit_date = Column(CHAR(8), nullable=True, comment="到访日期YYYYMMDD")
visitor_count = Column(Integer, nullable=True, comment="人数")
reception_user_id = Column(String(64), nullable=True, comment="接待人员用户ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
```
- [ ] **Step 2: Verify models load without errors**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.models.models import BizCleanRoom, BizCleanRoomAccessLog, BizStorageFacility, BizStorageFacilitySlot, BizExhibitionAppointment; print('All models imported OK')"`
Expected: "All models imported OK"
---
## Task 2: Backend CRUD — Exhibition Appointment (simplest, start here)
**Files:**
- Create: `backend/app/crud/exhibition_appointment.py`
- [ ] **Step 1: Create CRUD file**
Create `backend/app/crud/exhibition_appointment.py` following the `item_unit.py` pattern exactly. Functions: `_row_to_dict`, `list_exhibition_appointments`, `get_exhibition_appointment`, `appointment_no_exists`, `create_exhibition_appointment`, `update_exhibition_appointment`, `soft_delete_exhibition_appointment`, `total_pages`. All queries filter by `del_flag==0` and `tenant_no`. List supports keyword search on `appointment_no`, `visit_org`, `contact_name`. Default sort by `create_time DESC`.
- [ ] **Step 2: Verify import**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud import exhibition_appointment; print('OK')"`
Expected: "OK"
---
## Task 3: Backend CRUD — Clean Room + Access Log
**Files:**
- Create: `backend/app/crud/clean_room.py`
- [ ] **Step 1: Create CRUD file**
Create `backend/app/crud/clean_room.py` with two sets of CRUD functions:
**Clean Room functions:** `_row_to_dict`, `list_clean_rooms`, `get_clean_room`, `room_code_exists`, `create_clean_room`, `update_clean_room`, `soft_delete_clean_room`, `total_pages`. Keyword search on `room_code`, `room_name`. Filter by `tenant_no` and `del_flag==0`.
**Access Log functions:** `_access_log_to_dict`, `list_access_logs` (filter by `clean_room_id`), `get_access_log`, `create_access_log`, `update_access_log`, `soft_delete_access_log`. List supports `clean_room_id` filter + keyword on `person_name`, `dept_name`.
- [ ] **Step 2: Verify import**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud import clean_room; print('OK')"`
Expected: "OK"
---
## Task 4: Backend CRUD — Storage Facility (tree) + Slot
**Files:**
- Create: `backend/app/crud/storage_facility.py`
- [ ] **Step 1: Create CRUD file**
Create `backend/app/crud/storage_facility.py` with:
**Facility tree functions:** `_row_to_dict`, `list_all_facilities` (returns flat list, filtered by `tenant_no`, `del_flag==0`), `get_facility`, `create_facility`, `update_facility`, `soft_delete_facility` (cascade: soft-delete all children + their slots), `get_children` (find direct children of a node).
**Slot functions:** `_slot_to_dict`, `list_slots` (filter by `facility_node_id`), `get_slot`, `create_slot`, `update_slot`, `soft_delete_slot`.
**Tree helpers:** `build_tree` — converts flat list to nested tree structure with `children` array. `collect_descendant_ids` — recursively collects all descendant node IDs for cascade delete.
- [ ] **Step 2: Verify import**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud import storage_facility; print('OK')"`
Expected: "OK"
---
## Task 5: Backend API Routes — operations.py
**Files:**
- Create: `backend/app/api/operations.py`
- [ ] **Step 1: Create the API route file**
Create `backend/app/api/operations.py` with `router = APIRouter(prefix="/web/admin", tags=["运营支撑"])`. Include:
**Clean Room endpoints:**
- `GET /clean-rooms` — paginated list with keyword
- `POST /clean-rooms` — create
- `PUT /clean-rooms/{room_id}` — update
- `DELETE /clean-rooms/{room_id}` — soft delete
**Access Log endpoints:**
- `GET /clean-room-access-logs` — list by clean_room_id, paginated
- `POST /clean-room-access-logs` — create
- `PUT /clean-room-access-logs/{log_id}` — update
- `DELETE /clean-room-access-logs/{log_id}` — soft delete
**Storage Facility endpoints:**
- `GET /storage-facilities` — flat list (frontend builds tree)
- `POST /storage-facilities` — create node
- `PUT /storage-facilities/{node_id}` — update node
- `DELETE /storage-facilities/{node_id}` — cascade soft delete
**Storage Slot endpoints:**
- `GET /storage-facility-slots` — list by facility_node_id
- `POST /storage-facility-slots` — create
- `PUT /storage-facility-slots/{slot_id}` — update
- `DELETE /storage-facility-slots/{slot_id}` — soft delete
**Exhibition Appointment endpoints:**
- `GET /exhibition-appointments` — paginated list with keyword
- `POST /exhibition-appointments` — create
- `PUT /exhibition-appointments/{appointment_id}` — update
- `DELETE /exhibition-appointments/{appointment_id}` — soft delete
All endpoints use `Depends(require_admin_or_super_admin)` and `_get_tenant_no(db, user)`.
Each endpoint follows the pattern from `basic_info.py`: Pydantic body classes inline with `ConfigDict(populate_by_name=True)` and `Field(..., alias="camelCase")`. List endpoints return `success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})`.
- [ ] **Step 2: Verify import**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.operations import router; print(f'Routes: {len(router.routes)}')"`
Expected: "Routes: 13"
---
## Task 6: Backend Router Registration
**Files:**
- Modify: `backend/app/api/__init__.py`
- Modify: `backend/app/main.py`
- [ ] **Step 1: Register in __init__.py**
Add to `backend/app/api/__init__.py`:
```python
from app.api.operations import router as operations_router
```
And add `"operations_router"` to `__all__`.
- [ ] **Step 2: Register in main.py**
Add to `backend/app/main.py` line 13 import section:
```python
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router
```
Add after line 67:
```python
app.include_router(operations_router, prefix=settings.API_V1_PREFIX)
```
- [ ] **Step 3: Verify app starts**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.main import app; print(f'App routes: {len(app.routes)}')"`
Expected: A number > previous route count
---
## Task 7: Backend Tests
**Files:**
- Create: `backend/tests/test_operations_api.py`
- [ ] **Step 1: Write test file**
Create `backend/tests/test_operations_api.py` with tests for each module:
1. **Clean Room tests:** test_create_clean_room, test_list_clean_rooms, test_update_clean_room, test_delete_clean_room
2. **Access Log tests:** test_create_access_log, test_list_access_logs_by_room
3. **Storage Facility tests:** test_create_facility_node, test_list_facilities_flat, test_create_child_node, test_cascade_delete
4. **Slot tests:** test_create_slot, test_list_slots_by_node
5. **Exhibition Appointment tests:** test_create_appointment, test_list_appointments, test_update_appointment, test_delete_appointment
Use `pytest` with FastAPI `TestClient`. Each test creates, reads, updates, deletes and verifies. Tests can use the running dev server or TestClient.
- [ ] **Step 2: Run tests**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -m pytest tests/test_operations_api.py -v`
Expected: All tests PASS
---
## Task 8: Frontend API Functions
**Files:**
- Modify: `web-admin/src/api/admin.ts`
- [ ] **Step 1: Add API functions for all 3 modules**
Append to `web-admin/src/api/admin.ts` three new section blocks:
**Clean Room section:**
```typescript
// ---------------------------------------------------------------------------
// 洁净室管理
// ---------------------------------------------------------------------------
export function fetchCleanRoomList(params?: { page?: number; pageSize?: number; keyword?: string }) {
return request.get({ url: '/web/admin/clean-rooms', params })
}
export function fetchCreateCleanRoom(data: Record<string, any>) {
return request.post({ url: '/web/admin/clean-rooms', params: data })
}
export function fetchUpdateCleanRoom(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/clean-rooms/${id}`, params: data })
}
export function fetchDeleteCleanRoom(id: string) {
return request.del({ url: `/web/admin/clean-rooms/${id}` })
}
// 人员进出
export function fetchAccessLogList(params?: { page?: number; pageSize?: number; cleanRoomId?: string; keyword?: string }) {
return request.get({ url: '/web/admin/clean-room-access-logs', params })
}
export function fetchCreateAccessLog(data: Record<string, any>) {
return request.post({ url: '/web/admin/clean-room-access-logs', params: data })
}
export function fetchUpdateAccessLog(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/clean-room-access-logs/${id}`, params: data })
}
export function fetchDeleteAccessLog(id: string) {
return request.del({ url: `/web/admin/clean-room-access-logs/${id}` })
}
```
**Storage Facility section:**
```typescript
// ---------------------------------------------------------------------------
// 库位管理
// ---------------------------------------------------------------------------
export function fetchStorageFacilityList(params?: { keyword?: string }) {
return request.get({ url: '/web/admin/storage-facilities', params })
}
export function fetchCreateStorageFacility(data: Record<string, any>) {
return request.post({ url: '/web/admin/storage-facilities', params: data })
}
export function fetchUpdateStorageFacility(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/storage-facilities/${id}`, params: data })
}
export function fetchDeleteStorageFacility(id: string) {
return request.del({ url: `/web/admin/storage-facilities/${id}` })
}
// 格子
export function fetchStorageSlotList(params?: { facilityNodeId?: string }) {
return request.get({ url: '/web/admin/storage-facility-slots', params })
}
export function fetchCreateStorageSlot(data: Record<string, any>) {
return request.post({ url: '/web/admin/storage-facility-slots', params: data })
}
export function fetchUpdateStorageSlot(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/storage-facility-slots/${id}`, params: data })
}
export function fetchDeleteStorageSlot(id: string) {
return request.del({ url: `/web/admin/storage-facility-slots/${id}` })
}
```
**Exhibition Appointment section:**
```typescript
// ---------------------------------------------------------------------------
// 展馆预约
// ---------------------------------------------------------------------------
export function fetchExhibitionAppointmentList(params?: { page?: number; pageSize?: number; keyword?: string }) {
return request.get({ url: '/web/admin/exhibition-appointments', params })
}
export function fetchCreateExhibitionAppointment(data: Record<string, any>) {
return request.post({ url: '/web/admin/exhibition-appointments', params: data })
}
export function fetchUpdateExhibitionAppointment(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/exhibition-appointments/${id}`, params: data })
}
export function fetchDeleteExhibitionAppointment(id: string) {
return request.del({ url: `/web/admin/exhibition-appointments/${id}` })
}
```
- [ ] **Step 2: Verify no TypeScript errors**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build:typecheck 2>&1 | tail -5`
Expected: No errors related to new functions
---
## Task 9: Frontend Router — Add 运营支撑 Group
**Files:**
- Modify: `web-admin/src/router/modules/sqxp.ts`
- [ ] **Step 1: Add route group**
Add before the closing `]` of `sqxpAdminRoutes` (before the last `}` or after the Workflow section):
```typescript
,
{
id: '60000000000000000000000000000001',
name: 'Operations',
path: '/operations',
component: '/index/index',
meta: {
title: '运营支撑',
icon: 'operation',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '60000000000000000000000000000002',
name: 'CleanRoom',
path: 'clean-room',
component: '/operations/clean-room/index',
meta: { title: '洁净室管理', keepAlive: true }
},
{
id: '60000000000000000000000000000003',
name: 'StorageFacility',
path: 'storage-facility',
component: '/operations/storage-facility/index',
meta: { title: '库位管理', keepAlive: true }
},
{
id: '60000000000000000000000000000004',
name: 'ExhibitionAppointment',
path: 'exhibition-appointment',
component: '/operations/exhibition-appointment/index',
meta: { title: '展馆预约', keepAlive: true }
}
]
}
```
- [ ] **Step 2: Verify routes compile**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build:typecheck 2>&1 | tail -5`
Expected: No route-related errors
---
## Task 10: Frontend — Clean Room Page
**Files:**
- Create: `web-admin/src/views/operations/clean-room/index.vue`
- [ ] **Step 1: Create the Vue page**
Create `web-admin/src/views/operations/clean-room/index.vue` following the `system/unit/index.vue` template exactly:
**Page structure:**
- `page-container` → `header.page-head` → `section.panel`
- Filter: keyword search
- Table columns: 编号(roomCode), 名称(roomName), 等级(roomGrade, tag display: A级绿色/B级蓝色/C级黄色/D级灰色), 状态(roomStatus, tag: 正常=success/异常=danger), 面积(areaSqm), 操作
- Toolbar: 新增按钮, 批量删除按钮
- Dialog form (label-position="top", form-grid-2):
- 编号(roomCode, required), 名称(roomName, required), 等级(roomGrade, select: A/B/C/D), 状态(roomStatus, select: 正常zc/异常yc), 面积(areaSqm, number input, suffix "m²"), 备注(remark)
- 操作列: 查看, 编辑, 人员进出, 删除
- "人员进出"按钮 → 打开新弹窗显示该洁净室的进出记录列表(带分页), 弹窗内支持新增/编辑/删除进出记录
- 进出记录表单: 人员姓名(personName), 部门(deptName), 进入时间(enterTime, datetime), 目的(purpose), 离开时间(leaveTime, datetime), 体温(bodyTemp), 备注(remark)
- View dialog using SystemViewDialog component
- API calls: `fetchCleanRoomList`, `fetchCreateCleanRoom`, `fetchUpdateCleanRoom`, `fetchDeleteCleanRoom`, `fetchAccessLogList`, `fetchCreateAccessLog`, `fetchUpdateAccessLog`, `fetchDeleteAccessLog`
- [ ] **Step 2: Verify page renders**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm dev`, then navigate to `/#/operations/clean-room`
Expected: Page loads without console errors, shows empty list with filter bar and toolbar
---
## Task 11: Frontend — Storage Facility Page (Tree)
**Files:**
- Create: `web-admin/src/views/operations/storage-facility/index.vue`
- [ ] **Step 1: Create the Vue page**
Create `web-admin/src/views/operations/storage-facility/index.vue` following the `system/category/index.vue` tree template:
**Page structure:**
- Left panel: tree structure display using `el-table` with `row-key="id"` and `:tree-props="{ children: 'children' }"`
- Data fetched as flat list via `fetchStorageFacilityList`, then `buildTree()` converts to nested tree
- Tree columns: 节点名称(nodeName), 节点类型(nodeType, display as tag: facility=设施/layer=层/box=盒), 是否叶子(isLeaf, icon or tag), 备注(remark)
- Toolbar: 新增根节点, 展开全部, 折叠全部
- 操作列: 新增子节点, 编辑, 删除(需确认)
- Right panel: when a leaf node (`isLeaf===1`) is selected, show its slots (格子) list
- Slot table: 编码(slotCode), 状态(slotStatus), 操作(编辑/删除)
- Add slot button
- Dialog form for facility node (label-position="top", form-grid-2):
- 父节点(parentId, select from tree list, show full path via `buildTreePathLabelFromFlat`), 节点名称(nodeName, required), 节点类型(nodeType, select: facility/layer/box), 是否叶子(isLeaf, switch), 备注(remark)
- Dialog form for slot:
- 编码(slotCode, required), 状态(slotStatus, select), 备注(remark)
- Tree utilities: import `buildTreePathLabelFromFlat` from `@/utils/tree-path`
- API calls: `fetchStorageFacilityList`, `fetchCreateStorageFacility`, `fetchUpdateStorageFacility`, `fetchDeleteStorageFacility`, `fetchStorageSlotList`, `fetchCreateStorageSlot`, `fetchUpdateStorageSlot`, `fetchDeleteStorageSlot`
- [ ] **Step 2: Verify page renders**
Navigate to `/#/operations/storage-facility`
Expected: Page loads, shows empty tree with toolbar
---
## Task 12: Frontend — Exhibition Appointment Page
**Files:**
- Create: `web-admin/src/views/operations/exhibition-appointment/index.vue`
- [ ] **Step 1: Create the Vue page**
Create `web-admin/src/views/operations/exhibition-appointment/index.vue` following the `system/unit/index.vue` standard list template:
**Page structure:**
- Filter: keyword search (搜索预约编号/来访单位/联系人)
- Table columns: 预约编号(appointmentNo), 来访单位(visitOrg), 联系人(contactName), 到访日期(visitDate), 人数(visitorCount), 接待人员(receptionUserId → display name), 操作
- Toolbar: 新增按钮, 批量删除按钮
- Dialog form (label-position="top", form-grid-2):
- 预约编号(appointmentNo, required), 来访单位(visitOrg, required), 联系人(contactName), 到访日期(visitDate, date picker), 人数(visitorCount, number input), 接待人员(receptionUserId, select from user options), 备注(remark)
- View dialog using SystemViewDialog component
- API calls: `fetchExhibitionAppointmentList`, `fetchCreateExhibitionAppointment`, `fetchUpdateExhibitionAppointment`, `fetchDeleteExhibitionAppointment`
- [ ] **Step 2: Verify page renders**
Navigate to `/#/operations/exhibition-appointment`
Expected: Page loads without errors, shows empty list
---
## Task 13: Integration Test & Verification
- [ ] **Step 1: Start backend and verify all API endpoints**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -m pytest tests/test_operations_api.py -v`
Expected: All tests pass
- [ ] **Step 2: Start frontend and verify all 3 pages**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm dev`
Navigate to each page:
- `/#/operations/clean-room` — verify list, create dialog, access log dialog
- `/#/operations/storage-facility` — verify tree display, node CRUD, slot CRUD
- `/#/operations/exhibition-appointment` — verify list, create/edit dialog
- [ ] **Step 3: Run frontend typecheck**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build:typecheck`
Expected: No TypeScript errors
- [ ] **Step 4: Run frontend lint**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm lint`
Expected: No lint errors (or only pre-existing ones)
---
## Self-Review Checklist
1. **Spec coverage**: All 5 tables have models ✓, all 3 modules have CRUD ✓, API ✓, frontend ✓
2. **Placeholder scan**: No TBD/TODO, all steps have actual code or descriptions ✓
3. **Type consistency**: Model field names match CRUD `_row_to_dict` keys match API aliases ✓
4. **Convention compliance**: tenant_no filter ✓, soft delete ✓, camelCase API ✓, Fresh Mint colors ✓
@@ -0,0 +1,924 @@
# 库位管理页面改造 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将库位管理页面从树形表格+右侧窄面板改为左右布局(左侧 el-tree 树形列表 + 右侧货架行格子卡片),并新增批量生成格子功能。
**Architecture:** 前端完全重写 `index.vue`,参考 `user/index.vue` 的 el-tree 侧边栏模式。后端新增一个批量创建格子的 API 端点(rows×cols 参数 → 一次事务创建多个 slot)。前端格子区域使用 CSS flex 按行分组展示,每行左侧有行标(A/B/C),格子内编号+状态在第一行,下方预留空间。
**Tech Stack:** FastAPI + SQLAlchemy(后端),Vue 3 + Element Plus + SCSS(前端)
---
## File Structure
| File | Action | Responsibility |
|------|--------|---------------|
| `backend/app/crud/storage_facility.py` | Modify | 新增 `batch_create_slots()` 函数 |
| `backend/app/api/operations.py` | Modify | 新增 `POST /storage-facility-slots/batch` 端点 |
| `web-admin/src/api/admin.ts` | Modify | 新增 `fetchBatchCreateSlots()` 函数 |
| `web-admin/src/views/operations/storage-facility/index.vue` | Rewrite | 完全重写为 el-tree + 货架行布局 |
---
### Task 1: 后端 — 批量创建格子 CRUD 函数
**Files:**
- Modify: `backend/app/crud/storage_facility.py` (在文件末尾 `soft_delete_slot` 之后追加)
- [ ] **Step 1: 在 `storage_facility.py` 末尾添加 `batch_create_slots` 函数**
在 `backend/app/crud/storage_facility.py` 文件末尾追加以下函数(在 `soft_delete_slot` 函数之后):
```python
def batch_create_slots(
db: Session,
*,
facility_node_id: str,
rows: int,
cols: int,
code_prefix: str = "",
tenant_no: str = "zkxw",
user_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Batch create rows×cols slots for a leaf facility node in a single transaction."""
fid = (facility_node_id or "").strip()
if not fid:
raise ValueError("所属设施节点不能为空")
if rows < 1 or cols < 1:
raise ValueError("行数和列数必须大于 0")
if rows > 50 or cols > 50:
raise ValueError("单次最多生成 50×50 = 2500 个格子")
node = db.query(BizStorageFacility).filter(
and_(BizStorageFacility.id == fid, BizStorageFacility.del_flag == 0)
).first()
if not node:
raise ValueError("所属设施节点不存在")
if not node.is_leaf:
raise ValueError("只能为叶子节点创建格子")
prefix = (code_prefix or "").strip()
now = datetime.now()
slots: List[BizStorageFacilitySlot] = []
for r in range(1, rows + 1):
for c in range(1, cols + 1):
code = f"{prefix}{r}-{c}" if prefix else f"{r}-{c}"
order = (r - 1) * cols + c
obj = BizStorageFacilitySlot(
id=generate_uuid(),
facility_node_id=fid,
slot_code=code,
slot_status="空",
tenant_no=tenant_no,
order_num=order,
remark=None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
slots.append(obj)
db.commit()
for s in slots:
db.refresh(s)
return [_slot_to_dict(s) for s in slots]
```
- [ ] **Step 2: 验证语法无误**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud.storage_facility import batch_create_slots; print('OK')"`
Expected: `OK`
---
### Task 2: 后端 — 批量创建格子 API 端点
**Files:**
- Modify: `backend/app/api/operations.py` (在 `StorageSlotCreateBody` 类之后、`list_storage_slots` 端点之前插入)
- [ ] **Step 1: 添加 Pydantic body model 和 API 端点**
在 `backend/app/api/operations.py` 中,在 `StorageSlotCreateBody` 类定义之后(约第 377 行之后),添加 body model:
```python
class StorageSlotBatchCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
facility_node_id: str = Field(..., alias="facilityNodeId")
rows: int = Field(..., ge=1, le=50)
cols: int = Field(..., ge=1, le=50)
code_prefix: Optional[str] = Field("", alias="codePrefix")
```
然后在 `@router.post("/storage-facility-slots")` 端点之前(`StorageSlotBatchCreateBody` 定义之后),添加批量创建端点:
```python
@router.post("/storage-facility-slots/batch")
async def batch_create_storage_slots(
body: StorageSlotBatchCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items = storage_facility_svc.batch_create_slots(
db,
facility_node_id=body.facility_node_id,
rows=body.rows,
cols=body.cols,
code_prefix=body.code_prefix,
tenant_no=tenant_no,
user_id=user.get("user_id"),
)
return success_response(items, message=f"批量创建 {len(items)} 个格子成功")
except ValueError as e:
return error_response(400, str(e))
```
**重要**:这个端点必须放在 `/storage-facility-slots` 路由之前,否则 FastAPI 会把 `batch` 当做 `{slot_id}` 参数匹配。
- [ ] **Step 2: 验证语法无误**
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.operations import router; print('OK')"`
Expected: `OK`
---
### Task 3: 前端 — 添加批量创建 API 函数
**Files:**
- Modify: `web-admin/src/api/admin.ts` (在 `fetchCreateStorageSlot` 函数之后)
- [ ] **Step 1: 在 `admin.ts` 中 `fetchCreateStorageSlot` 之后添加批量创建函数**
在 `web-admin/src/api/admin.ts` 中,在 `fetchCreateStorageSlot` 函数(第 1336-1338 行)之后插入:
```typescript
export function fetchBatchCreateSlots(data: { facilityNodeId: string; rows: number; cols: number; codePrefix?: string }) {
return request.post({ url: '/web/admin/storage-facility-slots/batch', params: data })
}
```
- [ ] **Step 2: 验证 lint 通过**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && npx eslint src/api/admin.ts --no-error-on-unmatched-pattern 2>&1 | tail -5`
Expected: 无错误输出
---
### Task 4: 前端 — 重写库位管理页面
**Files:**
- Rewrite: `web-admin/src/views/operations/storage-facility/index.vue`
- Reference: `web-admin/src/views/system/user/index.vue` (el-tree 侧边栏模式)
- Reference: `web-admin/storage-redesign-prototype.html` (视觉原型)
这是最大的任务。完全重写页面,从树形表格+窄面板 → el-tree 侧边栏 + 货架行格子布局。
- [ ] **Step 1: 重写整个 index.vue**
用以下完整内容替换 `web-admin/src/views/operations/storage-facility/index.vue` 的全部内容。
**关键设计决策:**
1. **左侧 el-tree** — 参考 `user/index.vue` 的 `w-[280px] shrink-0 border-r` 布局。使用 `el-tree` 组件 + `buildTree()` 构建树形数据。树节点通过 `@node-click` 触发右侧面板更新。
2. **右侧货架行格子** — 使用 flex 布局,按行分组。每行由行标 `.shelf-label` + 格子容器 `.shelf-cells` 组成。每个格子 `.shelf-cell` 内编号+状态在顶部一行,下方预留空间。
3. **批量生成** — 点击"批量生成格子"弹出 dialog,输入行数、列数、编码前缀。确认后调用 `fetchBatchCreateSlots`。
4. **格子编辑** — 点击格子弹出编辑 dialog,修改编码和状态。
5. **节点 CRUD** — el-tree 节点 hover 显示操作按钮(新增子节点、编辑、删除),点击弹出 dialog。
**完整代码如下:**
```vue
<template>
<div class="page-container">
<header class="page-head">
<h1>库位管理</h1>
<p class="page-desc">维护库位设施树结构,以及叶子节点的格子管理。</p>
</header>
<section class="panel">
<div class="flex gap-4">
<div class="w-[280px] shrink-0 border-r border-[var(--mint-border)] pr-4">
<div class="panel-top sf-tree-aside__head">
<h2 class="title-bar-heading">库位树</h2>
<el-button class="btn-main" type="primary" size="small" @click="handleAddRoot">新增</el-button>
</div>
<el-input
v-model="treeKeyword"
placeholder="搜索节点"
clearable
class="sf-tree-search"
/>
<el-tree
ref="treeRef"
:data="treeData"
:props="{ label: 'nodeName', children: 'children' }"
node-key="id"
highlight-current
default-expand-all
:filter-node-method="filterNode"
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="sf-tree-node" @mouseenter="hoveredNodeId = data.id" @mouseleave="hoveredNodeId = ''">
<span class="sf-tree-node__label">{{ data.nodeName }}</span>
<span v-if="data.isLeaf === 1" class="sf-tree-node__leaf">叶</span>
<div v-if="hoveredNodeId === data.id" class="sf-tree-node__ops" @click.stop>
<el-button v-if="data.isLeaf !== 1" link size="small" @click="handleAddChild(data)">+</el-button>
<el-button link size="small" @click="handleEditNode(data)">编辑</el-button>
<el-button link size="small" type="danger" @click="handleDeleteNode(data)">删除</el-button>
</div>
</div>
</template>
</el-tree>
</div>
<div class="flex-1 min-w-0">
<div v-if="!selectedNode" class="sf-empty">
<el-empty description="请在左侧选择一个叶子节点查看格子" />
</div>
<template v-if="selectedNode && selectedNode.isLeaf !== 1">
<div class="panel-top">
<h2 class="title-bar-heading">{{ selectedNode.nodeName }}</h2>
</div>
<el-empty description="非叶子节点,请继续选择子节点" />
</template>
<template v-if="selectedNode && selectedNode.isLeaf === 1">
<div class="panel-top">
<h2 class="title-bar-heading">
{{ buildTreePathLabelFromFlat(selectedNode.id, flatList, { nameKey: 'nodeName' }) }} — 格子管理
</h2>
<span class="sf-count">共 {{ slotList.length }} 个格子</span>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="handleBatchCreate">批量生成格子</el-button>
<el-button
v-if="slotList.length > 0"
class="btn-ghost"
@click="handleClearAllSlots"
>
清空格子
</el-button>
</div>
</div>
<div v-if="slotList.length === 0" class="sf-empty">
<el-empty description="暂无格子,点击「批量生成格子」创建" />
</div>
<div v-else class="sf-shelf-container">
<div v-for="(row, ri) in shelfRows" :key="ri" class="sf-shelf-row">
<div class="sf-shelf-label">{{ row.label }}</div>
<div class="sf-shelf-cells">
<div
v-for="cell in row.cells"
:key="cell.id"
class="sf-shelf-cell"
:class="{ 'is-occupied': cell.slotStatus === '占用' }"
@click="handleEditSlot(cell)"
>
<div class="sf-cell-top">
<span class="sf-cell-code">{{ cell.slotCode }}</span>
<span
class="sf-cell-status"
:class="cell.slotStatus === '占用' ? 'is-occupied' : 'is-empty'"
>
{{ cell.slotStatus || '空' }}
</span>
</div>
<div class="sf-cell-body">
<span class="sf-cell-reserved">—</span>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</section>
<el-dialog
v-model="nodeDialogVisible"
:title="nodeDialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleNodeDialogClose"
>
<el-form
ref="nodeFormRef"
:model="nodeFormData"
:rules="nodeFormRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="上级节点">
<el-select
v-model="nodeFormData.parentId"
placeholder="请选择上级节点(空为根节点)"
clearable
filterable
>
<el-option
v-for="item in flatList"
:key="item.id"
:label="buildTreePathLabelFromFlat(item.id, flatList, { nameKey: 'nodeName' })"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="节点名称" prop="nodeName">
<el-input v-model="nodeFormData.nodeName" placeholder="请输入节点名称" />
</el-form-item>
<el-form-item label="节点类型">
<el-select v-model="nodeFormData.nodeType" placeholder="请选择节点类型">
<el-option label="设施" value="facility" />
<el-option label="层" value="layer" />
<el-option label="盒" value="box" />
</el-select>
</el-form-item>
<el-form-item label="是否叶子">
<el-switch v-model="nodeFormData.isLeaf" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="nodeFormData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="nodeDialogVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" @click="handleNodeSubmit" :loading="nodeSubmitLoading">确定</el-button>
</template>
</el-dialog>
<el-dialog
v-model="slotDialogVisible"
:title="slotDialogTitle"
width="480px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleSlotDialogClose"
>
<el-form
ref="slotFormRef"
:model="slotFormData"
:rules="slotFormRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="格子编码" prop="slotCode">
<el-input v-model="slotFormData.slotCode" placeholder="请输入格子编码" />
</el-form-item>
<el-form-item label="格子状态">
<el-select v-model="slotFormData.slotStatus" placeholder="请选择状态">
<el-option label="空" value="空" />
<el-option label="占用" value="占用" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="slotDialogVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" @click="handleSlotSubmit" :loading="slotSubmitLoading">确定</el-button>
</template>
</el-dialog>
<el-dialog
v-model="batchDialogVisible"
title="批量生成格子"
width="440px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<div class="form-grid-2">
<el-form-item label="行数">
<el-input-number v-model="batchForm.rows" :min="1" :max="20" style="width: 100%" />
</el-form-item>
<el-form-item label="列数">
<el-input-number v-model="batchForm.cols" :min="1" :max="20" style="width: 100%" />
</el-form-item>
</div>
<el-form-item label="编码前缀">
<el-input v-model="batchForm.codePrefix" placeholder="如 A、B-、01-(可选)" />
<template #label>
编码前缀
<span class="form-label-tip">生成格式:前缀+行号-列号,如 A-1-1</span>
</template>
</el-form-item>
<div class="sf-batch-preview">
将生成 <strong>{{ batchForm.rows * batchForm.cols }}</strong> 个格子({{ batchForm.rows }} 行 × {{ batchForm.cols }} 列)
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="batchDialogVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" @click="handleBatchSubmit" :loading="batchSubmitLoading">确认生成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchStorageFacilityList,
fetchCreateStorageFacility,
fetchUpdateStorageFacility,
fetchDeleteStorageFacility,
fetchStorageSlotList,
fetchBatchCreateSlots,
fetchUpdateStorageSlot,
fetchDeleteStorageSlot
} from '@/api/admin'
import { buildTreePathLabelFromFlat } from '@/utils/tree-path'
defineOptions({ name: 'StorageFacility' })
const ROW_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T']
const hoveredNodeId = ref('')
const treeKeyword = ref('')
const treeRef = ref()
const loading = ref(false)
const flatList = ref<any[]>([])
const treeData = ref<any[]>([])
const selectedNode = ref<any>(null)
const slotLoading = ref(false)
const slotList = ref<any[]>([])
const buildTree = (list: any[]) => {
const map = new Map<string, any>()
const roots: any[] = []
list.forEach(item => { map.set(item.id, { ...item, children: [] }) })
list.forEach(item => {
const node = map.get(item.id)!
if (item.parentId && item.parentId !== '0' && map.has(item.parentId)) {
map.get(item.parentId)!.children.push(node)
} else {
roots.push(node)
}
})
return roots
}
const loadData = async () => {
loading.value = true
try {
const res: any = await fetchStorageFacilityList({ keyword: treeKeyword.value || undefined })
const list = res?.list || res || []
flatList.value = list
treeData.value = buildTree(list)
} catch (error) {
console.error('加载库位数据失败:', error)
} finally {
loading.value = false
}
}
const filterNode = (value: string, data: any) => {
if (!value) return true
return data.nodeName?.includes(value)
}
watch(treeKeyword, val => { treeRef.value?.filter(val) })
const handleNodeClick = async (data: any) => {
selectedNode.value = data
if (data.isLeaf === 1) {
await loadSlots(data.id)
} else {
slotList.value = []
}
}
const loadSlots = async (facilityNodeId: string) => {
slotLoading.value = true
try {
const res: any = await fetchStorageSlotList({ facilityNodeId })
slotList.value = res?.list || res || []
} catch (error) {
console.error('加载格子数据失败:', error)
slotList.value = []
} finally {
slotLoading.value = false
}
}
const shelfRows = computed(() => {
const cols = batchForm.cols
const result: { label: string; cells: any[] }[] = []
for (let r = 0; r * cols < slotList.value.length; r++) {
const rowCells = slotList.value.slice(r * cols, (r + 1) * cols)
if (rowCells.length > 0) {
result.push({ label: ROW_LABELS[r] || String(r + 1), cells: rowCells })
}
}
return result
})
const nodeDialogVisible = ref(false)
const nodeDialogTitle = ref('新增根节点')
const isNodeEdit = ref(false)
const nodeSubmitLoading = ref(false)
const nodeFormRef = ref()
const nodeFormData = reactive({
id: '',
parentId: '',
nodeName: '',
nodeType: 'facility',
isLeaf: 0 as 0 | 1,
remark: ''
})
const nodeFormRules = { nodeName: [{ required: true, message: '请输入节点名称', trigger: 'blur' }] }
const handleAddRoot = () => {
isNodeEdit.value = false
nodeDialogTitle.value = '新增根节点'
Object.assign(nodeFormData, { id: '', parentId: '', nodeName: '', nodeType: 'facility', isLeaf: 0, remark: '' })
nodeDialogVisible.value = true
nextTick(() => nodeFormRef.value?.clearValidate?.())
}
const handleAddChild = (row: any) => {
isNodeEdit.value = false
nodeDialogTitle.value = '新增子节点'
Object.assign(nodeFormData, { id: '', parentId: row.id, nodeName: '', nodeType: 'layer', isLeaf: 0, remark: '' })
nodeDialogVisible.value = true
nextTick(() => nodeFormRef.value?.clearValidate?.())
}
const handleEditNode = (row: any) => {
isNodeEdit.value = true
nodeDialogTitle.value = '编辑节点'
Object.assign(nodeFormData, {
id: row.id,
parentId: row.parentId || '',
nodeName: row.nodeName || '',
nodeType: row.nodeType || 'facility',
isLeaf: row.isLeaf ?? 0,
remark: row.remark || ''
})
nodeDialogVisible.value = true
nextTick(() => nodeFormRef.value?.clearValidate?.())
}
const handleNodeSubmit = async () => {
await nodeFormRef.value?.validate?.()
nodeSubmitLoading.value = true
try {
const payload = {
parentId: nodeFormData.parentId || undefined,
nodeName: nodeFormData.nodeName.trim(),
nodeType: nodeFormData.nodeType,
isLeaf: nodeFormData.isLeaf,
remark: nodeFormData.remark?.trim() || undefined
}
if (isNodeEdit.value && nodeFormData.id) {
await fetchUpdateStorageFacility(nodeFormData.id, payload)
} else {
await fetchCreateStorageFacility(payload)
}
ElMessage.success(isNodeEdit.value ? '更新成功' : '创建成功')
nodeDialogVisible.value = false
await loadData()
} catch { /* handled globally */ } finally {
nodeSubmitLoading.value = false
}
}
const handleNodeDialogClose = () => {
Object.assign(nodeFormData, { id: '', parentId: '', nodeName: '', nodeType: 'facility', isLeaf: 0, remark: '' })
nodeFormRef.value?.clearValidate?.()
}
const handleDeleteNode = async (row: any) => {
try {
await ElMessageBox.confirm('删除节点将一并删除其子节点和格子。确定继续吗?', '提示', { type: 'warning' })
await fetchDeleteStorageFacility(row.id)
ElMessage.success('删除成功')
if (selectedNode.value?.id === row.id) {
selectedNode.value = null
slotList.value = []
}
await loadData()
} catch (error: any) {
if (error !== 'cancel') console.error('删除失败:', error)
}
}
const slotDialogVisible = ref(false)
const slotDialogTitle = ref('编辑格子')
const isSlotEdit = ref(false)
const slotSubmitLoading = ref(false)
const slotFormRef = ref()
const editingSlotId = ref<string | null>(null)
const slotFormData = reactive({ slotCode: '', slotStatus: '空' })
const slotFormRules = { slotCode: [{ required: true, message: '请输入格子编码', trigger: 'blur' }] }
const handleEditSlot = (row: any) => {
isSlotEdit.value = true
slotDialogTitle.value = '编辑格子'
editingSlotId.value = row.id
Object.assign(slotFormData, { slotCode: row.slotCode || '', slotStatus: row.slotStatus || '空' })
slotDialogVisible.value = true
nextTick(() => slotFormRef.value?.clearValidate?.())
}
const handleSlotSubmit = async () => {
await slotFormRef.value?.validate?.()
slotSubmitLoading.value = true
try {
const payload = {
facilityNodeId: selectedNode.value?.id,
slotCode: slotFormData.slotCode.trim(),
slotStatus: slotFormData.slotStatus
}
if (editingSlotId.value) {
await fetchUpdateStorageSlot(editingSlotId.value, payload)
}
ElMessage.success('更新成功')
slotDialogVisible.value = false
if (selectedNode.value?.id) await loadSlots(selectedNode.value.id)
} catch { /* handled globally */ } finally {
slotSubmitLoading.value = false
}
}
const handleSlotDialogClose = () => {
Object.assign(slotFormData, { slotCode: '', slotStatus: '空' })
editingSlotId.value = null
slotFormRef.value?.clearValidate?.()
}
const batchDialogVisible = ref(false)
const batchSubmitLoading = ref(false)
const batchForm = reactive({ rows: 4, cols: 6, codePrefix: '' })
const handleBatchCreate = () => {
Object.assign(batchForm, { rows: 4, cols: 6, codePrefix: '' })
batchDialogVisible.value = true
}
const handleBatchSubmit = async () => {
if (!selectedNode.value?.id) return
batchSubmitLoading.value = true
try {
await fetchBatchCreateSlots({
facilityNodeId: selectedNode.value.id,
rows: batchForm.rows,
cols: batchForm.cols,
codePrefix: batchForm.codePrefix || undefined
})
ElMessage.success('批量生成成功')
batchDialogVisible.value = false
await loadSlots(selectedNode.value.id)
} catch { /* handled globally */ } finally {
batchSubmitLoading.value = false
}
}
const handleClearAllSlots = async () => {
try {
await ElMessageBox.confirm(`确定要清空当前节点的全部 ${slotList.value.length} 个格子吗?`, '提示', { type: 'warning' })
for (const slot of slotList.value) {
await fetchDeleteStorageSlot(slot.id)
}
ElMessage.success('清空成功')
if (selectedNode.value?.id) await loadSlots(selectedNode.value.id)
} catch (error: any) {
if (error !== 'cancel') console.error('清空失败:', error)
}
}
onMounted(() => { loadData() })
</script>
<style lang="scss" scoped>
.sf-tree-aside__head {
display: flex;
align-items: center;
gap: 8px;
}
.sf-tree-search {
margin-bottom: 12px;
}
.sf-tree-node {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
font-size: 13px;
overflow: hidden;
}
.sf-tree-node__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-tree-node__leaf {
font-size: 10px;
padding: 0 4px;
border-radius: 3px;
background: var(--el-color-success-light-9, #ecfdf5);
color: var(--el-color-success, #10b981);
flex-shrink: 0;
}
.sf-tree-node__ops {
display: flex;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
.sf-empty {
padding: 60px 0;
}
.sf-count {
font-size: 12px;
color: var(--el-text-color-secondary, #909399);
margin-left: 8px;
}
/* Shelf layout */
.sf-shelf-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.sf-shelf-row {
display: flex;
align-items: stretch;
}
.sf-shelf-label {
width: 36px;
flex-shrink: 0;
background: #059669;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
border-radius: 6px 0 0 6px;
}
.sf-shelf-cells {
display: flex;
flex: 1;
}
.sf-shelf-cell {
flex: 1;
min-height: 80px;
border: 1px solid var(--el-border-color-lighter, #d1fae5);
border-right: none;
display: flex;
flex-direction: column;
cursor: pointer;
transition: background 0.15s;
background: #fff;
padding: 8px 6px;
position: relative;
}
.sf-shelf-cell:last-child {
border-right: 1px solid var(--el-border-color-lighter, #d1fae5);
border-radius: 0 6px 6px 0;
}
.sf-shelf-cell:first-child {
border-radius: 0;
}
.sf-shelf-cell:hover {
background: #ecfdf5;
}
.sf-shelf-cell.is-occupied {
background: #fffbeb;
border-color: #fde68a;
}
.sf-shelf-cell.is-occupied:hover {
background: #fef3c7;
}
.sf-cell-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.sf-cell-code {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.sf-shelf-cell.is-occupied .sf-cell-code {
color: #92400e;
}
.sf-cell-status {
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
white-space: nowrap;
}
.sf-cell-status.is-empty {
background: #ecfdf5;
color: #059669;
}
.sf-cell-status.is-occupied {
background: #fef3c7;
color: #b45309;
}
.sf-cell-body {
flex: 1;
margin-top: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.sf-cell-reserved {
font-size: 11px;
color: #dcdfe6;
}
.sf-batch-preview {
background: #ecfdf5;
border-radius: 6px;
padding: 10px 14px;
font-size: 13px;
color: #059669;
margin-top: 8px;
}
</style>
```
- [ ] **Step 2: 验证 lint 通过**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && npx eslint src/views/operations/storage-facility/index.vue --no-error-on-unmatched-pattern 2>&1 | tail -10`
Expected: 无错误输出
- [ ] **Step 3: 验证 dev server 正常**
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && npx vite build --mode development 2>&1 | tail -5`
Expected: 构建成功,无编译错误
---
## Self-Review
**Spec coverage:**
- ✅ 左侧 el-tree 树形列表(自由层级)
- ✅ 右侧货架行布局格子展示(最末层叶子节点)
- ✅ 编号+状态在格子顶部一行
- ✅ 下方预留空间
- ✅ 批量生成格子(指定行列数)
- ✅ 格子点击编辑(dialog)
- ✅ 节点 CRUD(弹窗)
- ✅ 节点删除级联删除子节点和格子
**Placeholder scan:** 无 TBD/TODO/待定内容。所有代码完整。
**Type consistency:** 所有函数签名、变量名前后一致。`fetchBatchCreateSlots` 在 admin.ts 定义并在 index.vue 中使用。
@@ -1,6 +1,6 @@
# 细胞存储管理(2.2.6~2.2.10)— 实现设计(方案 C)
**依据**:`docs/中科兴皖需求V0.3.md` 菜单 2.2.6~2.2.10;数据模型 `docs/sql/zkxw-requirements-v0.3-schema.sql`(`biz_collection_protocol` 及其子表)。
**依据**:`docs/中科兴皖需求V0.3.md` 菜单 2.2.6~2.2.10;数据模型 `docs/sql/zkxw.sql`(`biz_collection_protocol` 及其子表)。
**范围**:Web 管理端 `web-admin` + FastAPI `backend`;租户隔离与既有 `/web/admin` 响应风格保持一致。
@@ -76,7 +76,7 @@
### 3.3 列表默认排序
- 无自定义排序时:`order_num ASC, create_time DESC`(与工程规范一致);预警列表可按 `installment_due_date ASC` 等业务需求单独约定。
- 无自定义排序时:`create_time DESC`(与工程规范一致);预警列表可按 `installment_due_date ASC` 等业务需求单独约定。
---
@@ -0,0 +1,351 @@
# 工作流模块设计文档
**模块**: 2.1.11 流程管理
**日期**: 2026-05-13
**状态**: 待审核
**决策记录**:
- 流程设计器: AntV X6(蚂蚁图编辑引擎)
- V1范围: 全量实现(配置管理+设计器+待办中心+已办中心+后端引擎)
- 业务集成: API调用+回调模式
- 引擎架构: 图遍历引擎(方案A)
---
## 1. 整体架构与模块划分
### 1.1 后端(`backend/app/`)
```
models/models.py ← 新增6个wf_* SQLAlchemy模型
schemas/workflow.py ← Pydantic schemas(请求/响应模型)
crud/workflow.py ← 数据库CRUD操作
services/
workflow_engine.py ← 核心引擎:节点分发、条件评估、审批人解析
workflow_callback.py ← 回调服务:通知业务模块状态变更
api/workflow.py ← API路由(注册到admin前缀)
```
### 1.2 前端(`web-admin/src/`)
```
api/workflow.ts ← API调用
types/api.d.ts ← 新增Workflow相关TS类型
views/system/workflow/
process/index.vue ← 流程配置管理(列表+设计器弹窗)
designer/
FlowDesigner.vue ← AntV X6流程设计器组件
nodes/ ← 自定义节点组件(Start/End/Approval/Condition)
config/ ← 节点属性配置面板
task-pending/index.vue ← 待办中心
task-completed/index.vue ← 已办中心
components/
ApprovalDialog.vue ← 审批操作弹窗(通过/驳回+意见)
ProcessStatusTag.vue ← 流程状态标签
router/modules/sqxp.ts ← 新增3个路由
```
### 1.3 核心数据流
1. 管理员在流程配置页 → AntV X6设计器画流程 → 保存 `graph_json` + 同步写 `wf_node`/`wf_transition`
2. 业务模块调用 `POST /api/admin/workflow/instances` 发起流程 → 引擎锁定当前版本 → 创建 start 任务
3. 引擎按 `wf_transition` 遍历节点 → 审批节点创建 `wf_task` → 待办人收到待办
4. 待办人审批 → 引擎判断 any/all 策略 → 推进到下一节点 → 到达 end 节点 → 回调业务模块
---
## 2. 数据库模型
已有 6 张表 DDL 见全库导出 `docs/sql/zkxw.sql`(搜索 `wf_process_def` 等表名);基线表结构以当前导出为准。
| 表名 | 用途 | 关键字段 |
|------|------|----------|
| `wf_process_def` | 流程定义 | process_code, process_name, status |
| `wf_process_version` | 流程版本 | process_def_id, version_no, graph_json, is_latest |
| `wf_node` | 流程节点 | process_version_id, node_key, node_type, config_json |
| `wf_transition` | 流程连线 | process_version_id, from_node_key, to_node_key, condition_expr |
| `wf_process_instance` | 流程实例 | process_version_id, business_type, business_id, variables_json, status |
| `wf_task` | 流程任务 | instance_id, node_key, assignee_user_id, status, comment_text |
**node_type 枚举值**: `start`, `end`, `approval`, `condition`
**instance.status 枚举值**: `running`, `completed`, `rejected`, `cancelled`
**task.status 枚举值**: `pending`, `approved`, `rejected`, `withdrawn`
所有表均遵循公共字段规范:id(VARCHAR 64 UUID) → 业务字段 → tenant_no → order_num → remark → create_by → create_dept_id → create_time → update_by → update_time → del_flag
### 2.1 Schema 变更(需新增字段)
`wf_process_instance` 需新增 `callback_url` 字段(VARCHAR 512, 可空),用于业务回调地址。DDL 变更将在实施阶段维护到 `docs/sql/`(全量基线为 `docs/sql/zkxw.sql`,或附加增量脚本)。
### 2.2 双存储语义
工作流存在两套数据,各有用途:
- **`graph_json`**(wf_process_version): 前端设计器画布快照,用于设计器加载/编辑/渲染。仅供前端使用,后端引擎不解析此字段。
- **`wf_node` + `wf_transition`**(关系表): 后端引擎运行时使用的规范化数据。引擎只读这些表来执行节点分发和条件评估。
- 两者在"发布版本"时同步写入,保持一致。
---
## 3. 工作流引擎核心设计
### 3.1 节点分发器模式
每种节点类型对应一个 handler,引擎根据 node_type 调度:
```
WorkflowEngine
├── StartNodeHandler → 自动推进到下一个节点
├── EndNodeHandler → 标记实例完成,触发回调
├── ApprovalNodeHandler → 解析审批人,创建wf_task记录
└── ConditionNodeHandler → 评估条件表达式,选择分支
```
引擎入口方法:`advance(instance_id, from_node_key)` — 从指定节点开始,执行当前节点逻辑,然后沿出边推进到下一个节点。
### 3.2 条件表达式格式
`wf_transition.condition_expr` 字段存储的是 JSON 规则格式(VARCHAR 512),不是任意文本表达式。安全可控,不支持任意代码执行:
```json
{
"rules": [
{"field": "amount", "op": "<=", "value": 100},
{"field": "amount", "op": ">", "value": 100, "and": [{"field": "amount", "op": "<", "value": 300}]},
{"field": "amount", "op": ">=", "value": 300}
]
}
```
**支持的操作符**: `==`, `!=`, `>`, `<`, `>=`, `<=`, `in`, `not_in`, `contains`
**字段取值**: 从 `wf_process_instance.variables_json` 中读取。支持嵌套路径用 `.` 分隔(如 `applicant.dept_id`)。
**条件评估逻辑**: ConditionNodeHandler 遍历所有出边(wf_transition),依次评估每条边的 condition_expr。第一条匹配的边即为执行路径。最后一条边可省略条件作为"其他情况"默认分支。
### 3.3 审批人解析
`config_json` 中的 `approver` 字段定义解析策略:
```json
{
"strategy": "users",
"pass_type": "any",
"user_ids": ["uuid1", "uuid2"]
}
```
**strategy 枚举**:
| 策略 | 配置字段 | 说明 |
|------|----------|------|
| `users` | `user_ids: string[]` | 指定具体用户 |
| `roles` | `role_ids: string[]` | 指定角色,运行时解析角色下的所有用户 |
| `sql` | `sql: string` | SQL查询,返回user_id列表 |
**pass_type 枚举**:
- `any` — 任一审批人通过即推进
- `all` — 所有审批人都必须通过才推进
**SQL策略安全约束**:
- 只允许 SELECT 语句
- 禁止包含 INSERT/UPDATE/DELETE/DROP/ALTER
- 只取结果第一列作为 user_id
- 流程变量作为参数注入(`:var_name` 占位符)
- 查询结果受 tenant_no 隐式过滤
### 3.4 版本锁定
- 发起流程时,`wf_process_instance.process_version_id` 锁定当前 `is_latest=1` 的版本
- 发布新版本时:将旧版本的 `is_latest` 置为 0,创建新版本记录 `is_latest=1`
- 已发起的实例始终使用锁定的版本运行,不受后续版本变更影响
### 3.5 实例状态流转
```
发起 → running → completed(到达end节点)
→ rejected(审批驳回,无其他分支时)
→ cancelled(业务主动取消)
```
### 3.6 任务状态流转
```
创建 → pending → approved(审批通过)
→ rejected(审批驳回)
→ withdrawn(发起人撤回)
```
---
## 4. API路由设计
共13个路由,全部走 `/api/admin/workflow/` 前缀,带JWT认证和租户隔离。
### 4.1 流程定义管理
| 路由 | 方法 | 说明 |
|------|------|------|
| `/api/admin/workflow/defs` | GET | 流程定义列表(分页+搜索,支持按名称/编码/状态筛选) |
| `/api/admin/workflow/defs` | POST | 新建流程定义 |
| `/api/admin/workflow/defs/{id}` | PUT | 更新流程定义基本信息 |
| `/api/admin/workflow/defs/{id}` | DELETE | 软删除流程定义(del_flag=1) |
| `/api/admin/workflow/defs/{id}/publish` | POST | 发布新版本(接收graph_json+nodes+transitions,创建新版本) |
| `/api/admin/workflow/defs/{id}/versions` | GET | 版本历史列表 |
### 4.2 流程实例
| 路由 | 方法 | 说明 |
|------|------|------|
| `/api/admin/workflow/instances` | POST | 发起流程(业务模块调用,传入process_code+business_type+business_id+variables) |
| `/api/admin/workflow/instances/{id}` | GET | 流程实例详情(含任务列表和流程图高亮当前节点) |
### 4.3 任务办理
| 路由 | 方法 | 说明 |
|------|------|------|
| `/api/admin/workflow/tasks/pending` | GET | 我的待办列表(按assignee_user_id=当前用户+status=pending过滤) |
| `/api/admin/workflow/tasks/completed` | GET | 我的已办列表(按assignee_user_id=当前用户+status in [approved,rejected]过滤) |
| `/api/admin/workflow/tasks/{id}/approve` | POST | 审批通过(传comment_text) |
| `/api/admin/workflow/tasks/{id}/reject` | POST | 审批驳回(传comment_text) |
### 4.4 请求/响应格式
**发起流程请求**:
```json
POST /api/admin/workflow/instances
{
"process_code": "agreement_payment",
"business_type": "agreement",
"business_id": "agr_uuid_123",
"variables": {
"amount": 5000,
"dept_id": "dept_001",
"applicant_id": "user_001"
},
"callback_url": "/api/admin/agreements/workflow-callback"
}
```
**审批操作请求**:
```json
POST /api/admin/workflow/tasks/{id}/approve
{
"comment_text": "同意,费用已核实"
}
```
---
## 5. 前端页面设计
### 5.1 流程配置管理(`process/index.vue`)
**模式**: 标准列表页(参考 `unit/index.vue` 模板)
**列表字段**: 流程编码、流程名称、状态(启用/停用)、最新版本号、创建时间、操作
**操作按钮**: 新增、编辑、**流程设计**(打开X6设计器弹窗)、发布新版本、停用/启用、删除
**设计器入口**: 点击"流程设计"→ 全屏弹窗打开 AntV X6 设计器
### 5.2 AntV X6 流程设计器(`designer/FlowDesigner.vue`)
**三栏布局**:
- **左侧**: 节点组件面板(开始/结束/审批人/条件分支),拖拽到画布
- **中间**: 画布区域(AntV X6 Graph),支持节点拖拽、连线、删除、缩放
- **右侧**: 属性配置面板(选中节点后展示,根据节点类型动态切换)
- 审批人节点: 策略选择(用户/角色/SQL)、具体配置、通过策略(any/all)
- 条件分支节点: 每条出边的条件规则编辑
- 开始/结束节点: 只显示名称编辑
**顶部工具栏**: 保存草稿、发布新版本、撤销/重做、放大/缩小/适应画布
**保存逻辑**: 保存时序列化为 `{graph_json, nodes: [{node_key, node_type, config_json, ...}], transitions: [{from_node_key, to_node_key, condition_expr, ...}]}` 提交到后端
**自定义节点样式**: 每种节点类型使用不同的形状和颜色
- 开始节点: 圆形,绿色
- 结束节点: 圆形,红色
- 审批人节点: 矩形,蓝色
- 条件分支节点: 菱形,橙色
### 5.3 待办中心(`task-pending/index.vue`)
**模式**: 复杂列表页(参考 `order/index.vue`)
**筛选**: 流程名称、业务类型、发起时间范围
**列表字段**: 流程名称、业务类型、业务单号、发起人、发起时间、到达时间、操作
**操作**: 审批(打开审批弹窗)、查看详情
**审批弹窗**(`ApprovalDialog.vue`): 通过/驳回选择 + 审批意见输入框 + 查看流程图(只读模式,高亮当前节点)
### 5.4 已办中心(`task-completed/index.vue`)
**模式**: 标准列表页
**筛选**: 同待办中心 + 审批结果筛选(通过/驳回)
**列表字段**: 流程名称、业务类型、业务单号、发起人、审批结果、审批意见、审批时间
**操作**: 查看详情
---
## 6. 回调与业务集成
### 6.1 回调机制
引擎在以下事件触发 HTTP POST 回调到 `callback_url`:
**实例完成**:
```json
{
"event": "completed",
"instance_id": "inst_uuid",
"process_code": "agreement_payment",
"business_type": "agreement",
"business_id": "agr_uuid_123"
}
```
**实例驳回**:
```json
{
"event": "rejected",
"instance_id": "inst_uuid",
"process_code": "agreement_payment",
"business_type": "agreement",
"business_id": "agr_uuid_123",
"reject_comment": "费用异常,请核实"
}
```
### 6.2 业务模块集成步骤
1. 在流程配置页创建流程定义,发布版本
2. 业务模块在需要触发审批的地方调用 `POST /api/admin/workflow/instances`
3. 业务模块实现回调接口接收审批结果
4. 用户在待办中心处理审批任务
---
## 7. 安全与权限
- 所有API带JWT认证,租户隔离(tenant_no过滤)
- SQL审批人解析策略限制为只读SELECT,禁止DDL/DML
- 流程定义管理需要系统管理权限
- 待办/已办中心只展示当前用户的任务
- 审批操作只能操作 status=pending 且 assignee_user_id=当前用户 的任务
---
## 8. 已确认的约束
- 遵循项目公共字段规范(id→业务→tenant_no→...→del_flag)
- 软删除(del_flag 0/1)
- 默认排序(create_time DESC)
- Element Plus组件自动导入,不手动导入
- Fresh Mint主题色 #10b981
- label-position="top",.form-grid-2 布局
- 操作列固定右侧(2按钮260px,3按钮320px)
- 标题绿色竖条(需注册到App.vue白名单)
@@ -0,0 +1,48 @@
# 运营支撑模块设计规格
## 概述
实现需求文档 2.4 节中运营支撑下的三个子模块:洁净室管理、库位管理、展馆预约。
新增一级菜单【运营支撑】,下设三个二级菜单。
## SQL 表(已存在,无需新建)
1. `biz_clean_room` - 洁净室信息(编号、名称、等级A/B/C/D、状态正常/异常、面积)
2. `biz_clean_room_access_log` - 洁净室人员进出(人员、部门、进入时间、目的、离开时间、体温)
3. `biz_storage_facility` - 细胞存储设施树节点(树形结构:parent_id, node_name, node_type, is_leaf)
4. `biz_storage_facility_slot` - 存储位置格子(叶子节点下的小方格)
5. `biz_exhibition_appointment` - 展馆预约(预约编号、来访单位、联系人、到访日期、人数、接待人员)
## 模块设计
### 模块 1: 洁净室管理
- **类型**: 标准列表 + 子表模式
- **后端**: Model + CRUD (`clean_room.py`) + API 路由
- **前端**: 参照 `system/unit/index.vue`,增加"人员进出"按钮弹窗查看进出记录
- **路由**: `/operations/clean-room`
- **API**: `/web/admin/clean-rooms` + `/web/admin/clean-room-access-logs`
### 模块 2: 库位管理
- **类型**: 树形结构模式
- **后端**: Model + CRUD (`storage_facility.py`) + API 路由(树查询 + 节点 CRUD + 格子 CRUD)
- **前端**: 参照 `system/category/index.vue`,左侧树形结构,右侧节点详情+格子列表
- **路由**: `/operations/storage-facility`
- **API**: `/web/admin/storage-facilities` + `/web/admin/storage-facility-slots`
- **工具**: 使用 `tree-path.ts` 构建完整路径
### 模块 3: 展馆预约
- **类型**: 标准列表模式
- **后端**: Model + CRUD (`exhibition_appointment.py`) + API 路由
- **前端**: 参照 `system/unit/index.vue` 标准列表
- **路由**: `/operations/exhibition-appointment`
- **API**: `/web/admin/exhibition-appointments`
## 技术规范
- 后端模式:flat 函数式 CRUD(参照 `app/crud/item_unit.py`),不用 service 层
- 前端模式:API 函数加入 `admin.ts`,路由加入 `sqxp.ts`
- 所有查询带 `tenant_no` 过滤,删除为软删除
- Fresh Mint 配色 (`--primary #10b981`)
- 树形模块使用 `tree-path.ts` 工具
## 实施顺序
1. 后端:Models → CRUD → API 路由 → 注册
2. 前端:API 函数 → 路由 → 页面组件
3. 测试:API 验证 + 页面功能验证
+52 -5
View File
@@ -67,11 +67,15 @@
- 展馆预约[2.4.3、展馆预约]
- 物料管理(暂时不开发,后续在完善)
- 财务管理(暂时不开发,后续在完善)
- 生产管理(暂时不开发,后续在完善)
- 耗材管理
- 耗材包管理
- 工艺库管理
- 工艺路线配置
- 生产管理
- 耗材管理[2.5.1、耗材管理]
- 耗材包管理[2.5.2、耗材包管理]
- 试剂管理[2.5.3、试剂管理]
- SOP文档[2.5.4、SOP文档]
- 工艺工序分类[2.5.5、工艺工序分类]
- 工艺产出物类型[2.3.6、工艺产出物类型]
- 工艺库管理[2.3.7、工艺库管理]
- 工艺路线配置[2.3.8、工艺路线配置]
- 细胞存储协议单
- 中试服务单
- 生产工单
@@ -84,6 +88,49 @@
- 采集协议号管理[2.2.4、采集协议号管理]
- 样本编码管理[2.2.5、样本编码管理]
## 2.5、生产管理
### 2.5.1、耗材管理
- 此模块用于生产耗材管理,支持耗材的新增、修改、删除等功能
- 生产耗材表核心字段:耗材编码、耗材名称、规格型号、标准用量、描述
### 2.5.2、耗材包管理
- 此模块将生产耗材组合成一个耗材包,方便日常数据录入
- 耗材包主表:编号、名称、使用场景、类型(bzhcb - 标准耗材包、 zdyhcb - 自定义耗材包),状态 (1 - 启用 、 0 - 停用)、备注
- 耗材包耗材明细表:耗材包id、耗材编码、耗材名称、耗材规格型号、标准用量、备注
### 2.5.3、试剂管理
- 此模块用于生产试剂信息的管理
- 试剂信息表的核心字段:试剂编码、试剂名称、规格、标准用量、单位[下拉数据从 2.1.9、物品单位管理 获取]、描述
### 2.5.4、SOP文档
- 此模块用于管理SOP文档管理
- SOP文档表核心字段:SOP编号、SOP文档名称、版本号、SOP文档附件
### 2.5.5、工艺工序分类
- 此模块用于工艺工序分类管理
- 工艺工序分类表核心字段:分类编码、分类名称
### 2.3.6、工艺产出物类型
- 此模块用于工艺产出物类型管理
- 工艺产出物类型表核心字段:类型编码、类型名称
### 2.3.7、工艺库管理
- 此模块用于定义细胞生产过程中的每个工艺的信息,包括预计使用的试剂、预计使用的耗材、预计产出物等关联信息。工艺信息理解就是一个预设的情况,等后续真正生产时候,有这些预制的值可以起到指导作用,同时也方便用户录入信息
- 生产工艺主表 核心字段:工序代码、工序分类[下拉数据来自于 2.5.5、工艺工序分类]、工序名称、描述、预计使用设备名称、预估耗时、关联的SOP文[从 2.5.4、SOP文档 关联选择, 支持多个附件]
- 生产工艺试剂关联表 核心字段: 试剂编码、试剂名称、规格、标准用量、单位、是否关键物料 。 (通过关联2.5.3、试剂管理 下拉后,将试剂编码、试剂名称、规格、标准用量、单位都带过来,但是用户可以自己改 标准用量)
- 生产工艺耗材关联表:核心字段:耗材编码、耗材名称、耗材规格型号、标准用量、是否必需(1 - 必须 , 0 - 可选)、备注, 可以通过从2.5.2、耗材包管理 关联一次性将耗材批量带过来,然后修改。 也可以从2.5.1、耗材管理 待过来
- 生产工艺产出关联表:核心字段:产出类型(下拉数据来源于 2.3.6、工艺产出物类型)、产出物名称、产出物条码。 产出物条码只有在真实生产时候才会有数据
### 2.3.8、工艺路线配置
- 此模块用于将多个生产工艺组织成一个工艺路线图,方便实验室人员在进行细胞生产过程中,快速勾选一个工艺路线图,调整后就可以将生产过程记录下来了
- 工艺路线图主表 核心字段:路线编码、产品类型(下拉数据来自于 2.3.1、细胞产品类型管理)、路线名称、备注
- 工艺路线图工艺明细表:工序代码、工序分类[下拉数据来自于 2.5.5、工艺工序分类]、工序名称、描述、预计使用设备名称、预估耗时、关联的SOP文[从 2.5.4、SOP文档 关联选择, 支持多个附件]、排序号
- 工艺路线图工艺明细表试剂关联表 核心字段: 试剂编码、试剂名称、规格、标准用量、单位、是否关键物料 。 (通过关联2.5.3、试剂管理 下拉后,将试剂编码、试剂名称、规格、标准用量、单位都带过来,但是用户可以自己改 标准用量)
- 工艺路线图工艺明细表耗材关联表:核心字段:耗材编码、耗材名称、耗材规格型号、标准用量、是否必需(1 - 必须 , 0 - 可选)、备注, 可以通过从2.5.2、耗材包管理 关联一次性将耗材批量带过来,然后修改。 也可以从2.5.1、耗材管理 待过来
- 工艺路线图工艺明细表产出关联表:核心字段:产出类型(下拉数据来源于 2.3.6、工艺产出物类型)、产出物名称、产出物条码。 产出物条码只有在真实生产时候才会有数据
- 工艺路线配置这个界面,是可以做成拖拉配置的。界面分三部分、左边是内置的生产工艺的卡片、中间是生产工艺排版的界面,右侧是每个工艺对应的内容(工艺信息、试剂关联信息、耗材关联信息、产出物信息等)
## 2.4、运营支撑
### 2.4.1、洁净室管理
- 此模块用于洁净室信息录入 和 人员出入数据录入就行了