patternIds = new ArrayList<>();
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDisplayItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDisplayItem.java
new file mode 100644
index 00000000..ff0625e1
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDisplayItem.java
@@ -0,0 +1,111 @@
+package com.huozige.lab.container.offlineform.model;
+
+import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem;
+
+/**
+ * 表单渲染用的扁平节点。
+ *
+ * definition 中的 step/items 是树形结构,RecyclerView 和当前 FormAdapter 渲染时需要按顺序遍历。
+ * 这个类保存树节点拍平后的展示信息,同时保留层级深度,避免把渲染状态写回 definition 模型。
+ */
+public class OfflineFormDisplayItem {
+ public static final int VIEW_TYPE_GROUP = 10001;
+ public static final int VIEW_TYPE_TEXT = 10002;
+
+ // group/text 节点使用,保存标题、内容、children、defaultCollapsed 等结构信息。
+ private final OfflineFormNode node;
+ // field 节点使用,保存具体表单项,后续交给对应表单项 handler 渲染。
+ private final BaseFormItem formItem;
+ // 当前节点在树中的深度,用于控制缩进和判断父子层级。
+ private final int depth;
+ // 当前 step 内的稳定展示编号,用于记录展开/收起状态。
+ private final int displayId;
+
+ private OfflineFormDisplayItem(OfflineFormNode node, BaseFormItem formItem, int depth, int displayId) {
+ this.node = node;
+ this.formItem = formItem;
+ this.depth = depth;
+ this.displayId = displayId;
+ }
+
+ /**
+ * 创建 group 展示节点。
+ */
+ public static OfflineFormDisplayItem group(OfflineFormNode node, int depth, int displayId) {
+ return new OfflineFormDisplayItem(node, null, depth, displayId);
+ }
+
+ /**
+ * 创建普通说明文本展示节点。
+ */
+ public static OfflineFormDisplayItem text(OfflineFormNode node, int depth, int displayId) {
+ return new OfflineFormDisplayItem(node, null, depth, displayId);
+ }
+
+ /**
+ * 创建可填报字段展示节点。
+ */
+ public static OfflineFormDisplayItem field(OfflineFormNode node, int depth, int displayId) {
+ return new OfflineFormDisplayItem(node, node.getField(), depth, displayId);
+ }
+
+ /**
+ * 判断当前展示节点是否为 group。
+ */
+ public boolean isGroup() {
+ return node != null && OfflineFormNode.TYPE_GROUP.equals(node.getNodeType());
+ }
+
+ /**
+ * 判断当前展示节点是否为普通说明文本。
+ */
+ public boolean isText() {
+ return node != null && OfflineFormNode.TYPE_TEXT.equals(node.getNodeType());
+ }
+
+ /**
+ * 判断当前展示节点是否为可填报字段。
+ */
+ public boolean isField() {
+ return formItem != null;
+ }
+
+ public String getFieldDisplayTitle() {
+ if (!isField()) {
+ return "";
+ }
+ String title = formItem.getTitle();
+ if (title != null && !title.isEmpty()) {
+ return title;
+ }
+ return node == null ? "" : node.getTitle();
+ }
+
+ /**
+ * 获取 group/text 对应的原始节点。
+ */
+ public OfflineFormNode getNode() {
+ return node;
+ }
+
+ /**
+ * 获取 field 对应的表单项模型。
+ */
+ public BaseFormItem getFormItem() {
+ return formItem;
+ }
+
+ /**
+ * 获取当前展示节点的层级深度。
+ */
+ public int getDepth() {
+ return depth;
+ }
+
+ /**
+ * 获取当前展示节点在当前 step 内的展示编号。
+ */
+ public int getDisplayId() {
+ return displayId;
+ }
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNode.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNode.java
new file mode 100644
index 00000000..c3e9df63
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNode.java
@@ -0,0 +1,22 @@
+package com.huozige.lab.container.offlineform.model;
+
+import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class OfflineFormNode {
+ public static final String TYPE_GROUP = "group";
+ public static final String TYPE_TEXT = "text";
+ public static final String TYPE_FIELD = "field";
+
+ private String nodeType = TYPE_GROUP;
+ private String title = "";
+ private String content = "";
+ private boolean defaultCollapsed;
+ private BaseFormItem field;
+ private List children = new ArrayList<>();
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNodeFactory.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNodeFactory.java
new file mode 100644
index 00000000..706ce403
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNodeFactory.java
@@ -0,0 +1,80 @@
+package com.huozige.lab.container.offlineform.model;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonHelper;
+import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonKeys;
+import com.huozige.lab.container.offlineform.formitem.OfflineFormItemRegistry;
+import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem;
+import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class OfflineFormNodeFactory {
+ private OfflineFormNodeFactory() {
+ }
+
+ public static List buildNodes(List nodes) {
+ List result = new ArrayList<>();
+ if (nodes == null) {
+ return result;
+ }
+
+ for (PatternNodeInput nodeInput : nodes) {
+ if (nodeInput == null) {
+ continue;
+ }
+ OfflineFormNode node = new OfflineFormNode();
+ node.setNodeType(nodeInput.nodeType);
+ node.setTitle(nodeInput.title);
+ node.setContent(nodeInput.content);
+ node.setDefaultCollapsed(nodeInput.defaultCollapsed);
+ if (OfflineFormNode.TYPE_FIELD.equals(nodeInput.nodeType) && nodeInput.field != null) {
+ node.setField(OfflineFormItemRegistry.fromInput(nodeInput.field));
+ } else {
+ node.setChildren(buildNodes(nodeInput.children));
+ }
+ result.add(node);
+ }
+
+ return result;
+ }
+
+ public static List cloneNodes(List nodes) {
+ List result = new ArrayList<>();
+ if (nodes == null) {
+ return result;
+ }
+ for (OfflineFormNode node : nodes) {
+ if (node != null) {
+ result.add(cloneNode(node));
+ }
+ }
+ return result;
+ }
+
+ private static OfflineFormNode cloneNode(OfflineFormNode source) {
+ OfflineFormNode node = new OfflineFormNode();
+ node.setNodeType(source.getNodeType());
+ node.setTitle(source.getTitle());
+ node.setContent(source.getContent());
+ node.setDefaultCollapsed(source.isDefaultCollapsed());
+ if (source.getField() != null) {
+ node.setField(cloneFormItem(source.getField()));
+ }
+ node.setChildren(cloneNodes(source.getChildren()));
+ return node;
+ }
+
+ private static BaseFormItem cloneFormItem(BaseFormItem item) {
+ JSONObject jsonObject = OfflineFormItemRegistry.toJson(item);
+ FormItemInput input = OfflineFormItemJsonHelper.buildBaseInput(jsonObject);
+ Object options = jsonObject.get(OfflineFormItemJsonKeys.FIELD_OPTIONS);
+ if (options != null) {
+ Class> optionsClass = OfflineFormItemRegistry.getOptionsClass(input.itemType);
+ input.options = optionsClass == null ? options : JSON.parseObject(JSON.toJSONString(options), optionsClass);
+ }
+ return OfflineFormItemRegistry.fromInput(input);
+ }
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecord.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecord.java
new file mode 100644
index 00000000..4d97d159
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecord.java
@@ -0,0 +1,47 @@
+package com.huozige.lab.container.offlineform.model;
+
+import java.text.SimpleDateFormat;
+import java.util.HashMap;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OfflineFormRecord {
+ // 单条填报记录唯一标识,也是记录文件名称。
+ private String recordId = "";
+ // 该记录所属表单定义的业务唯一标识。
+ private String patternId = "";
+ // 填报时使用的表单定义版本号。
+ private String schemaVersion = "";
+ // 当前记录状态,包含草稿、已提交、已导出。
+ private OfflineFormRecordStatus status = OfflineFormRecordStatus.DRAFT;
+ // 记录创建时间戳。
+ private long createdAt;
+ // 记录最后更新时间戳。
+ private long updatedAt;
+ // 字段值集合,key 为表单项 id,value 为用户填写值。
+ private Map values = new HashMap<>();
+
+ public static OfflineFormRecord createSubmitted(String patternId, String schemaVersion, Map values) {
+ long now = System.currentTimeMillis();
+ return new OfflineFormRecord(createRecordId(), patternId, schemaVersion, OfflineFormRecordStatus.SUBMITTED, now, now, values);
+ }
+
+ public static OfflineFormRecord createDraft(String patternId, String schemaVersion, Map values) {
+ long now = System.currentTimeMillis();
+ return new OfflineFormRecord(createRecordId(), patternId, schemaVersion, OfflineFormRecordStatus.DRAFT, now, now, values);
+ }
+
+ private static String createRecordId() {
+ String time = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.CHINA).format(new Date());
+ return time + "_" + UUID.randomUUID().toString();
+ }
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecordStatus.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecordStatus.java
new file mode 100644
index 00000000..62bc2288
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecordStatus.java
@@ -0,0 +1,7 @@
+package com.huozige.lab.container.offlineform.model;
+
+public enum OfflineFormRecordStatus {
+ DRAFT,
+ SUBMITTED,
+ EXPORTED
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormStep.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormStep.java
new file mode 100644
index 00000000..ea1b0f86
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormStep.java
@@ -0,0 +1,13 @@
+package com.huozige.lab.container.offlineform.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class OfflineFormStep {
+ private String stepId = "";
+ private String title = "";
+ private List items = new ArrayList<>();
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternInput.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternInput.java
new file mode 100644
index 00000000..00376ce5
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternInput.java
@@ -0,0 +1,19 @@
+package com.huozige.lab.container.offlineform.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class PatternInput {
+ public String ticket = "";
+
+ public String patternId = "";
+ public String schemaVersion = "";
+ public String title = "";
+ public String description = "";
+
+ public List steps = new ArrayList<>();
+ public List displayColumns = new ArrayList<>();
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternNodeInput.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternNodeInput.java
new file mode 100644
index 00000000..ea6d56b5
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternNodeInput.java
@@ -0,0 +1,18 @@
+package com.huozige.lab.container.offlineform.model;
+
+import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class PatternNodeInput {
+ public String nodeType = OfflineFormNode.TYPE_GROUP;
+ public String title = "";
+ public String content = "";
+ public boolean defaultCollapsed;
+ public FormItemInput field;
+ public List children = new ArrayList<>();
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternStepInput.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternStepInput.java
new file mode 100644
index 00000000..cb887edb
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternStepInput.java
@@ -0,0 +1,13 @@
+package com.huozige.lab.container.offlineform.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class PatternStepInput {
+ public String stepId = "";
+ public String title = "";
+ public List items = new ArrayList<>();
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/AttachmentFormItemValue.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/AttachmentFormItemValue.java
new file mode 100644
index 00000000..60adf6c4
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/AttachmentFormItemValue.java
@@ -0,0 +1,11 @@
+package com.huozige.lab.container.offlineform.model.formitem.common;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class AttachmentFormItemValue {
+ private String originalName;
+ private String fileName;
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/BaseFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/BaseFormItem.java
new file mode 100644
index 00000000..a67717b0
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/BaseFormItem.java
@@ -0,0 +1,28 @@
+package com.huozige.lab.container.offlineform.model.formitem.common;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public abstract class BaseFormItem {
+ private String itemType;
+ private String id;
+ private String title;
+ private String hint;
+ private boolean required;
+ private String errorMessage;
+
+ public BaseFormItem(String itemType, String id, String title, String hint, boolean required) {
+ this.itemType = itemType;
+ this.id = id;
+ this.title = title;
+ this.hint = hint;
+ this.required = required;
+ }
+
+ public abstract String getValue();
+ public abstract boolean isEmpty();
+ public abstract boolean validate();
+ public abstract void clearError();
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/FormItemInput.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/FormItemInput.java
new file mode 100644
index 00000000..47ad7484
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/FormItemInput.java
@@ -0,0 +1,14 @@
+package com.huozige.lab.container.offlineform.model.formitem.common;
+
+import lombok.Data;
+
+@Data
+public class FormItemInput {
+ public String itemId = "";
+ public String title = "";
+ public String hint = "";
+ public boolean required;
+ public String itemType = "";
+ public String value = "";
+ public Object options;
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItem.java
new file mode 100644
index 00000000..247c3b78
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItem.java
@@ -0,0 +1,92 @@
+package com.huozige.lab.container.offlineform.model.formitem.file;
+
+import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue;
+import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem;
+import com.huozige.lab.container.offlineform.util.Utils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class FileFormItem extends BaseFormItem {
+ private FileItemConfig fileItemConfig = new FileItemConfig();
+ private List files = new ArrayList<>();
+ private String patternId = "";
+
+ public FileFormItem(String itemType, String id, String title, String hint, boolean required) {
+ super(itemType, id, title, hint, required);
+ }
+
+ @Override
+ public String getValue() {
+ return Utils.serializeAttachmentValues(files);
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return files == null || files.isEmpty();
+ }
+
+ public void setValue(String value) {
+ files = parseAttachments(value);
+ }
+
+ public void addFile(AttachmentFormItemValue file) {
+ if (file == null || file.getOriginalName() == null || file.getOriginalName().isEmpty()
+ || file.getFileName() == null || file.getFileName().isEmpty()) {
+ return;
+ }
+ if (files == null) {
+ files = new ArrayList<>();
+ }
+ files.add(file);
+ }
+
+ public void removeFile(String originalName) {
+ if (files != null) {
+ files.removeIf(file -> file != null && originalName.equals(file.getOriginalName()));
+ }
+ }
+
+ public boolean containsOriginalName(String originalName) {
+ if (files == null) {
+ return false;
+ }
+ for (AttachmentFormItemValue file : files) {
+ if (file != null && originalName.equals(file.getOriginalName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean validate() {
+ clearError();
+ if (isRequired() && isEmpty()) {
+ setErrorMessage("请上传" + getTitle());
+ return false;
+ }
+ int maxCount = fileItemConfig == null ? 0 : fileItemConfig.getMaxCount();
+ if (maxCount > 0 && files != null && files.size() > maxCount) {
+ setErrorMessage("最多只能上传" + maxCount + "个文件");
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void clearError() {
+ setErrorMessage(null);
+ }
+
+ public static List parseAttachments(String value) {
+ return Utils.parseAttachmentValues(value, attachment ->
+ attachment.getOriginalName() != null && !attachment.getOriginalName().isEmpty()
+ && attachment.getFileName() != null && !attachment.getFileName().isEmpty());
+ }
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItemOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItemOptions.java
new file mode 100644
index 00000000..6ff4bafa
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItemOptions.java
@@ -0,0 +1,10 @@
+package com.huozige.lab.container.offlineform.model.formitem.file;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class FileFormItemOptions {
+ private FileItemConfig fileItemConfig;
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileItemConfig.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileItemConfig.java
new file mode 100644
index 00000000..02f70b82
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileItemConfig.java
@@ -0,0 +1,12 @@
+package com.huozige.lab.container.offlineform.model.formitem.file;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class FileItemConfig {
+ private int maxCount;
+ private String allowedExtensions = "";
+ private int maxFileSizeKb;
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageCompressionOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageCompressionOptions.java
new file mode 100644
index 00000000..8deddb87
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageCompressionOptions.java
@@ -0,0 +1,19 @@
+package com.huozige.lab.container.offlineform.model.formitem.image;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ImageCompressionOptions {
+ public static final int DEFAULT_MAX_LONG_EDGE = 1600;
+ public static final int DEFAULT_JPEG_QUALITY = 80;
+ public static final int DEFAULT_MAX_FILE_SIZE_KB = 0;
+ public static final int DEFAULT_MIN_QUALITY = 60;
+
+ private boolean enableCompression;
+ private int maxLongEdge = DEFAULT_MAX_LONG_EDGE;
+ private int jpegQuality = DEFAULT_JPEG_QUALITY;
+ private int maxFileSizeKb = DEFAULT_MAX_FILE_SIZE_KB;
+ private int minQuality = DEFAULT_MIN_QUALITY;
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItem.java
new file mode 100644
index 00000000..2a6d05ac
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItem.java
@@ -0,0 +1,92 @@
+package com.huozige.lab.container.offlineform.model.formitem.image;
+
+import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue;
+import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem;
+import com.huozige.lab.container.offlineform.util.Utils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ImageFormItem extends BaseFormItem {
+ public static final int DEFAULT_MAX_COUNT = 0;
+
+ private int maxCount = DEFAULT_MAX_COUNT;
+ private boolean allowImageUpload;
+ private ImageCompressionOptions compression = new ImageCompressionOptions();
+ private ImageWatermarkOptions watermark = new ImageWatermarkOptions();
+ private List images = new ArrayList<>();
+ private String patternId = "";
+
+ public ImageFormItem(String itemType, String id, String title, String hint, boolean required) {
+ super(itemType, id, title, hint, required);
+ }
+
+ @Override
+ public String getValue() {
+ return Utils.serializeAttachmentValues(images);
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return images == null || images.isEmpty();
+ }
+
+ public void setValue(String value) {
+ images = parseImages(value);
+ }
+
+ public void addImage(AttachmentFormItemValue image) {
+ if (images == null) {
+ images = new ArrayList<>();
+ }
+ images.add(image);
+ }
+
+ public void removeImage(AttachmentFormItemValue image) {
+ if (images != null) {
+ images.remove(image);
+ }
+ }
+
+ public void setImagesFromFileNames(List fileNames) {
+ images = new ArrayList<>();
+ if (fileNames == null) {
+ return;
+ }
+ for (String fileName : fileNames) {
+ if (fileName != null && !fileName.isEmpty()) {
+ AttachmentFormItemValue image = new AttachmentFormItemValue();
+ image.setFileName(fileName);
+ images.add(image);
+ }
+ }
+ }
+
+ @Override
+ public boolean validate() {
+ clearError();
+ if (isRequired() && isEmpty()) {
+ setErrorMessage("请添加" + getTitle());
+ return false;
+ }
+ if (maxCount > 0 && images != null && images.size() > maxCount) {
+ setErrorMessage("最多只能添加" + maxCount + "张图片");
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void clearError() {
+ setErrorMessage(null);
+ }
+
+ public static List parseImages(String value) {
+ return Utils.parseAttachmentValues(value, image -> image.getFileName() != null && !image.getFileName().isEmpty());
+ }
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItemOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItemOptions.java
new file mode 100644
index 00000000..533372ad
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItemOptions.java
@@ -0,0 +1,13 @@
+package com.huozige.lab.container.offlineform.model.formitem.image;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ImageFormItemOptions {
+ private int maxCount;
+ private boolean allowImageUpload;
+ private ImageCompressionOptions compression;
+ private ImageWatermarkOptions watermark;
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkField.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkField.java
new file mode 100644
index 00000000..f9edb1e4
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkField.java
@@ -0,0 +1,11 @@
+package com.huozige.lab.container.offlineform.model.formitem.image;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ImageWatermarkField {
+ private String key = "";
+ private String value = "";
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkOptions.java
new file mode 100644
index 00000000..dcac99b8
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkOptions.java
@@ -0,0 +1,14 @@
+package com.huozige.lab.container.offlineform.model.formitem.image;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ImageWatermarkOptions {
+ private boolean enableTimestamp;
+ private List items = new ArrayList<>();
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItem.java
new file mode 100644
index 00000000..585881a8
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItem.java
@@ -0,0 +1,187 @@
+package com.huozige.lab.container.offlineform.model.formitem.list;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.huozige.lab.container.offlineform.formitem.OfflineFormItemValueHelper;
+import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionFlattener;
+import com.huozige.lab.container.offlineform.model.OfflineFormNode;
+import com.huozige.lab.container.offlineform.model.OfflineFormNodeFactory;
+import com.huozige.lab.container.offlineform.model.PatternNodeInput;
+import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem;
+import com.huozige.lab.container.utilities.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ListFormItem extends BaseFormItem {
+ private int minCount;
+ private int maxCount;
+ private int defaultCount;
+ private String addButtonText = "";
+ private String itemTitle = "";
+ private String patternId = "";
+ private List templateInputs = new ArrayList<>();
+ private List templateNodes = new ArrayList<>();
+ private List rows = new ArrayList<>();
+
+ public ListFormItem(String itemType, String id, String title, String hint, boolean required) {
+ super(itemType, id, title, hint, required);
+ }
+
+ @Override
+ public String getValue() {
+ JSONArray array = new JSONArray();
+ for (ListFormItemRow row : rows) {
+ JSONObject rowValue = new JSONObject();
+ for (BaseFormItem field : OfflineFormDefinitionFlattener.flattenFields(row.getNodes())) {
+ rowValue.put(field.getId(), field.getValue());
+ }
+ array.add(rowValue);
+ }
+ return array.toJSONString();
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return rows == null || rows.isEmpty();
+ }
+
+ public void setValue(String value) {
+ rows = new ArrayList<>();
+ if (!StringUtils.isNullOrBlank(value)) {
+ JSONArray array = parseRows(value);
+ for (int i = 0; i < array.size(); i++) {
+ JSONObject rowValue = array.getJSONObject(i);
+ ListFormItemRow row = addRow();
+ if (row != null && rowValue != null) {
+ applyRowValue(row, rowValue);
+ }
+ }
+ }
+ ensureDefaultRows();
+ clearError();
+ }
+
+ public void ensureDefaultRows() {
+ int targetCount = Math.max(0, Math.max(defaultCount, minCount));
+ while (rows.size() < targetCount && canAddRow()) {
+ rows.add(createRow());
+ }
+ }
+
+ public boolean canAddRow() {
+ return maxCount <= 0 || rows.size() < maxCount;
+ }
+
+ public boolean canRemoveRow() {
+ return rows.size() > Math.max(0, minCount);
+ }
+
+ public ListFormItemRow addRow() {
+ if (!canAddRow()) {
+ return null;
+ }
+ ListFormItemRow row = createRow();
+ rows.add(row);
+ return row;
+ }
+
+ public boolean removeRow(int index) {
+ if (canRemoveRow() && index >= 0 && index < rows.size()) {
+ rows.remove(index);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean validate() {
+ clearError();
+ if (isRequired() && rows.isEmpty()) {
+ setErrorMessage("\u8bf7\u6dfb\u52a0" + getTitle());
+ return false;
+ }
+ if (minCount > 0 && rows.size() < minCount) {
+ setErrorMessage(getTitle() + "\u81f3\u5c11\u9700\u8981" + minCount + "\u9879");
+ return false;
+ }
+ if (maxCount > 0 && rows.size() > maxCount) {
+ setErrorMessage(getTitle() + "\u6700\u591a\u53ea\u80fd" + maxCount + "\u9879");
+ return false;
+ }
+
+ boolean valid = true;
+ for (ListFormItemRow row : rows) {
+ for (BaseFormItem field : OfflineFormDefinitionFlattener.flattenFields(row.getNodes())) {
+ if (!field.validate()) {
+ valid = false;
+ }
+ }
+ }
+ if (!valid) {
+ setErrorMessage("\u8bf7\u68c0\u67e5" + getTitle());
+ }
+ return valid;
+ }
+
+ @Override
+ public void clearError() {
+ setErrorMessage(null);
+ for (ListFormItemRow row : rows) {
+ for (BaseFormItem field : OfflineFormDefinitionFlattener.flattenFields(row.getNodes())) {
+ field.clearError();
+ }
+ }
+ }
+
+ public void setPatternId(String patternId) {
+ this.patternId = patternId == null ? "" : patternId;
+ for (ListFormItemRow row : rows) {
+ OfflineFormItemValueHelper.applyPatternId(
+ OfflineFormDefinitionFlattener.flattenFields(row.getNodes()),
+ this.patternId);
+ }
+ }
+
+ public int getRowCount() {
+ return rows == null ? 0 : rows.size();
+ }
+
+ public static JSONArray parseRows(String value) {
+ if (StringUtils.isNullOrBlank(value)) {
+ return new JSONArray();
+ }
+ try {
+ return JSON.parseArray(value);
+ } catch (RuntimeException e) {
+ return new JSONArray();
+ }
+ }
+
+ private ListFormItemRow createRow() {
+ List rowNodes = OfflineFormNodeFactory.cloneNodes(templateNodes);
+ OfflineFormItemValueHelper.applyPatternId(OfflineFormDefinitionFlattener.flattenFields(rowNodes), patternId);
+ return new ListFormItemRow(rowNodes);
+ }
+
+ private void applyRowValue(ListFormItemRow row, JSONObject rowValue) {
+ for (BaseFormItem field : OfflineFormDefinitionFlattener.flattenFields(row.getNodes())) {
+ OfflineFormItemValueHelper.applyValue(field, rowValue.getString(field.getId()));
+ }
+ }
+
+ @Getter
+ public static class ListFormItemRow {
+ private final List nodes;
+
+ ListFormItemRow(List nodes) {
+ this.nodes = nodes == null ? new ArrayList<>() : nodes;
+ }
+ }
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItemOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItemOptions.java
new file mode 100644
index 00000000..a110e604
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItemOptions.java
@@ -0,0 +1,26 @@
+package com.huozige.lab.container.offlineform.model.formitem.list;
+
+import com.huozige.lab.container.offlineform.model.PatternNodeInput;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class ListFormItemOptions {
+ private int minCount;
+ private int maxCount;
+ private int defaultCount;
+ private String addButtonText = "";
+ private String itemTitle = "";
+ private List children = new ArrayList<>();
+ private List items = new ArrayList<>();
+
+ public List resolveTemplateNodes() {
+ if (children != null && !children.isEmpty()) {
+ return children;
+ }
+ return items == null ? new ArrayList<>() : items;
+ }
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItem.java
new file mode 100644
index 00000000..33fd3dbe
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItem.java
@@ -0,0 +1,43 @@
+package com.huozige.lab.container.offlineform.model.formitem.picker;
+
+import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem;
+import com.huozige.lab.container.utilities.StringUtils;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class PickerFormItem extends BaseFormItem {
+ private String value = "";
+ private boolean includeSeconds;
+
+ public PickerFormItem(String itemType, String id, String title, String hint, boolean required) {
+ super(itemType, id, title, hint, required);
+ }
+
+ @Override
+ public String getValue() {
+ return value;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return StringUtils.isNullOrBlank(value);
+ }
+
+ @Override
+ public boolean validate() {
+ clearError();
+ if (isRequired() && isEmpty()) {
+ setErrorMessage("请选择" + getTitle());
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void clearError() {
+ setErrorMessage(null);
+ }
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItemOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItemOptions.java
new file mode 100644
index 00000000..8b2c3f90
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItemOptions.java
@@ -0,0 +1,10 @@
+package com.huozige.lab.container.offlineform.model.formitem.picker;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class PickerFormItemOptions {
+ private boolean includeSeconds;
+}
diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/select/SelectFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/select/SelectFormItem.java
new file mode 100644
index 00000000..4f5de5b5
--- /dev/null
+++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/select/SelectFormItem.java
@@ -0,0 +1,75 @@
+package com.huozige.lab.container.offlineform.model.formitem.select;
+
+import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+public class SelectFormItem extends BaseFormItem {
+ @Getter
+ @AllArgsConstructor
+ public static class Option {
+ private String value;
+ private String displayText;
+ }
+
+ private List