Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- **项目性质**:SurveyController 官方 Windows 桌面端的 **macOS 端独立移植版**
- **GitHub 仓库**:https://github.com/SurveyController/SurveyController-Mac
- **License**:GPL-3.0(与官方一致)
- **当前版本**:v0.1.0(开发中,逐步对齐官方 v4.0.6)
- **当前版本**:v0.2.0(开发中,逐步对齐官方 v4.1.0)

## 官方源仓库

Expand Down Expand Up @@ -118,6 +118,7 @@ xcodebuild -project SurveyController.xcodeproj -scheme SurveyController \
- `core/config/`(schema + codec v6)→ `Core/Model/RuntimeConfig.swift` + `Data/ConfigCodec.swift`(与桌面版配置文件互通)
- `core/questions/`(概率/文本/一致性查询)+ `providers/http_logic.py` → `Core/Questions/`
- `wjx/provider/`(html_parser* + http_runtime 纯函数 + regexes + multiple_limits)→ `Provider/Wjx/`
- `wjx/provider/multilevel.py` → `Provider/Wjx/WjxMultilevel.swift`(多级下拉解析、选项拉取、提交映射)
- `wjx/provider/answering_builders.py` → `Provider/Wjx/WjxAnswerBuilder.swift`(简化点见下)
- `core/task/task_context.py` + engine → `Core/Model/ExecutionConfig.swift` + `Core/Engine/RunEngine.swift`
- `network/proxy/session/` + `session_policy.py` → `Core/Backend/BackendClient.swift` + `Core/Proxy/ProxyPool.swift`
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@

1. **原生 Mac 体验** - 纯 Swift + SwiftUI 构建,六步向导式流程,macOS 14+ 原生界面
2. **多平台支持** - v0.1 起支持问卷星全链路,腾讯问卷、Credamo见数后续版本跟进
3. **定制答案比例** - 自定义各选项权重与多选题命中概率分布,实时显示归一化比例
4. **指定ip地区** - 省市级地区选择,支持随机IP或指定特定地区IP提交
5. **配置导入导出** - 与 Windows 桌面版配置文件互通,跨设备同步
6. **运行体验** - 并发槽位、暂停/继续、完成系统通知、日志按次存档、防休眠
3. **问卷星新题型** - 对齐官方 v4.1.0,支持多级下拉题与隐藏空占位题过滤
4. **定制答案比例** - 自定义各选项权重与多选题命中概率分布,实时显示归一化比例
5. **指定ip地区** - 省市级地区选择,支持随机IP或指定特定地区IP提交
6. **配置导入导出** - 与 Windows 桌面版配置文件互通,跨设备同步
7. **运行体验** - 并发槽位、暂停/继续、完成系统通知、日志按次存档、防休眠

## 演示视频

Expand Down Expand Up @@ -57,7 +58,7 @@ open SurveyController.xcodeproj # Xcode 中 Cmd+R 运行
# 或命令行构建
xcodebuild -project SurveyController.xcodeproj -scheme SurveyController build

# 运行单元测试(86 个用例)
# 运行单元测试(102 个用例)
xcodebuild -project SurveyController.xcodeproj -scheme SurveyController test
```

Expand Down
16 changes: 8 additions & 8 deletions SurveyController.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 10102;
CURRENT_PROJECT_VERSION = 10200;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES;
Expand All @@ -331,7 +331,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1.2;
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.surveycontroller.mac";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
Expand All @@ -346,7 +346,7 @@
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 10102;
CURRENT_PROJECT_VERSION = 10200;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = NO;
Expand All @@ -360,7 +360,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1.2;
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.surveycontroller.mac";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
Expand All @@ -373,9 +373,9 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10102;
CURRENT_PROJECT_VERSION = 10200;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.1.2;
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.surveycontroller.mac.tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand All @@ -389,9 +389,9 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10102;
CURRENT_PROJECT_VERSION = 10200;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 0.1.2;
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.surveycontroller.mac.tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand Down
4 changes: 2 additions & 2 deletions SurveyController/App/AppVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import Foundation
/// 应用版本与 GitHub 仓库信息。
public enum AppVersion {
/// 当前版本(与 MARKETING_VERSION 同步)。
public static let version = "0.1.2"
public static let version = "0.2.0"
/// 当前对齐的官方桌面端版本。
public static let alignedOfficialVersion = "4.0.6"
public static let alignedOfficialVersion = "4.1.0"

public static let githubOwner = "SurveyController"
public static let githubRepo = "SurveyController-Mac"
Expand Down
4 changes: 4 additions & 0 deletions SurveyController/Core/Engine/RunEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ public final class RunEngine: @unchecked Sendable {
rng: localRng
)
context.submitProxyLeaseFactory = leaseFactory
context.isStopRequested = { [weak self] in
guard let self else { return true }
return self.queue.sync { self.stopRequested }
}

do {
let ok = try await submitHandler(config, context)
Expand Down
6 changes: 4 additions & 2 deletions SurveyController/Core/Model/ExecutionConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,10 @@ public enum ExecutionConfigBuilder {
}

static func normalizedEntryType(_ entry: QuestionEntry) -> String {
// text / multi_text 由配置创建时写入 question_type(对标桌面端向导产物),此处不做推断
entry.questionType
// text / multi_text 由配置创建时写入 question_type(对标桌面端向导产物),此处不做推断。
// 多级下拉在桌面端是独立编辑器类型,运行时按下拉处理(对标 runtime_preparation)。
if entry.questionType == "multilevel_dropdown" { return "dropdown" }
return entry.questionType
}

static func asProbList(_ value: Any?) -> Any {
Expand Down
25 changes: 14 additions & 11 deletions SurveyController/Provider/Wjx/WjxAnswerBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,33 @@ public enum WjxAnswerBuilder {
let config = state.config
let (entryType, configIndex) = configEntry

let action: AnswerAction?
switch entryType {
case "single":
return try buildSingleAction(question, configIndex, state: &state, rng: rng)
action = try buildSingleAction(question, configIndex, state: &state, rng: rng)
case "multiple":
return try buildMultipleAction(question, configIndex, state: &state, rng: rng)
action = try buildMultipleAction(question, configIndex, state: &state, rng: rng)
case "dropdown":
return try buildDropdownAction(question, configIndex, state: &state, rng: rng)
action = try buildDropdownAction(question, configIndex, state: &state, rng: rng)
case "text", "multi_text":
return buildTextAction(question, configIndex, config: config, rng: rng)
action = buildTextAction(question, configIndex, config: config, rng: rng)
case "location":
return nil
action = nil
case "matrix":
return try buildMatrixAction(question, configIndex, config: config, rng: rng)
action = try buildMatrixAction(question, configIndex, config: config, rng: rng)
case "scale":
return try buildScoreLikeAction(question, configIndex, config: config, answerType: "scale", rng: rng)
action = try buildScoreLikeAction(question, configIndex, config: config, answerType: "scale", rng: rng)
case "score":
return try buildScoreLikeAction(question, configIndex, config: config, answerType: "score", rng: rng)
action = try buildScoreLikeAction(question, configIndex, config: config, answerType: "score", rng: rng)
case "slider":
return buildSliderAction(question, configIndex, config: config)
action = buildSliderAction(question, configIndex, config: config)
case "order":
return buildOrderAction(question, rng: rng)
action = buildOrderAction(question, rng: rng)
default:
return nil
action = nil
}
// 对标 multilevel_answer_action:多级下拉把选项索引换成文本路径。
return try action.map { try WjxMultilevel.applyAnswer($0, question: question) }
}

static func runtimeOptionTexts(_ question: SurveyQuestionMeta) -> [String] {
Expand Down
25 changes: 24 additions & 1 deletion SurveyController/Provider/Wjx/WjxHtmlParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ public enum WjxHtmlParser {
if typeCode != "11" && looksLikeReorder(questionDiv) {
typeCode = "11"
}
// 对标 _question_div_is_empty_gapfill_placeholder:隐藏且无输入框的填空占位节点直接跳过。
if isHiddenEmptyGapfillPlaceholder(questionDiv, typeCode: typeCode) {
continue
}
// 对标 is_multilevel:type=1 且带「多级下拉」标记的输入框,按 type=7 处理。
let multilevelInput = try questionDiv.select("input[verify=\"多级下拉\"]").first()
let isMultilevel = typeCode == "1" && multilevelInput != nil
if isMultilevel {
typeCode = "7"
}
let isDescription = looksLikeDescription(questionDiv, typeCode: typeCode)
let isRequired = questionIsRequired(questionDiv)
var isRating = false
Expand Down Expand Up @@ -137,7 +147,7 @@ public enum WjxHtmlParser {
(sliderMin, sliderMax, sliderStep) = extractSliderRange(questionDiv, questionNumber: questionNumber)
}

let textInputCount = countTextInputs(questionDiv)
let textInputCount = isMultilevel ? 0 : countTextInputs(questionDiv)
let textInputLabels = textInputCount > 1 ? extractTextInputLabels(questionDiv) : []
let hasGapfill = normalizeAttr(questionDiv.attrText("gapfill")) == "1"
let isTextLikeQuestion = shouldTreatAsTextLike(
Expand All @@ -160,6 +170,10 @@ public enum WjxHtmlParser {
"display_num": displayNum ?? NSNull(),
"title": titleText,
"type_code": typeCode,
"provider_type": isMultilevel ? WjxMultilevel.providerType : typeCode,
"provider_page_raw": isMultilevel
? ["cid": (try? multilevelInput?.attr("cid")) ?? ""]
: NSNull(),
"options": optionCount,
"rows": metadata.matrixRows,
"row_texts": metadata.rowTexts,
Expand Down Expand Up @@ -429,6 +443,15 @@ public enum WjxHtmlParser {
return false
}

/// 对标 _question_div_is_empty_gapfill_placeholder。
static func isHiddenEmptyGapfillPlaceholder(_ questionDiv: Element, typeCode: String) -> Bool {
guard typeCode == "9" else { return false }
guard normalizeAttr(questionDiv.attrText("gapfill")) == "1" else { return false }
guard ["", "0"].contains(normalizeAttr(questionDiv.attrText("relation"))) else { return false }
guard selfOrAncestorsHidden(questionDiv) else { return false }
return countTextInputs(questionDiv) == 0
}

static func hasQuestionAncestor(_ questionDiv: Element, until fieldset: Element) -> Bool {
var current = questionDiv.parent()
while let element = current, element != fieldset {
Expand Down
Loading
Loading