Add word-book practice, iOS app shell, and fix embedded WebView blank screen.

Ship dual-track learning (daily accumulation vs textbook),沪教/商务词书 APIs and UI, native iOS wrapper with bundled H5, and production book import on deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-06 21:09:27 +08:00
parent 2fae90b597
commit e76fa586f1
76 changed files with 47488 additions and 445 deletions
+52
View File
@@ -0,0 +1,52 @@
# WordLoop iOS App
原生 iOS 壳应用,内嵌最新 Vue 前端构建产物,通过 `WKWebView` 展示,API 指向所选服务器。
## 与 H5 同步
Xcode 编译前会自动执行 `ios/scripts/build-web-assets.sh`,将 `frontend/` 最新代码构建并复制到 App 包内的 `www/`。因此首页(日常积累 / 词书练习双模式)、设置页(微信风格账号安全)等与本地 H5 保持一致,不依赖生产站是否已部署新版。
手动构建前端资源:
```bash
bash ios/scripts/build-web-assets.sh
```
## 服务器配置
| 环境 | API 地址 | 说明 |
|------|----------|------|
| **生产** | `https://w.tkmind.cn/api` | 默认环境 |
| **本地** | `http://127.0.0.1:18004/api` | 对应 `./start.sh` 后端;真机请改为电脑局域网 IP |
底部可展开切换服务器。界面与登录在同一 WebView,勾选「记住登录状态」后下次打开自动进入首页。
## 打开工程
```bash
open ios/WordLoop/WordLoop.xcodeproj
```
在 Xcode 中选择你的 **Team**Signing & Capabilities),然后运行到模拟器或真机。
## 本地调试
1. 项目根目录启动服务:
```bash
./start.sh
```
2. iOS 模拟器:底部展开服务器栏,选「本地服务器」,默认 `http://127.0.0.1:18003`API 自动映射到 18004)。
3. 真机:将本地地址改为 Mac 局域网 IP,例如 `http://192.168.1.8:18003`
## 架构说明
- **原生层**SwiftUI + WKWebView + 底部服务器栏
- **界面层**:内嵌 `frontend` 构建产物,经 `wordloop://` 本地协议加载(避免 `file://` 白屏)
- **数据层**:请求发往所选服务器的 `/api`
内嵌页通过 `loadHTMLString` 加载,`app.js` 构建时会内联到 `</body>` 前(避免在 `<head>` 执行时 `#app` 尚未存在导致白屏)。
若出现白屏,请先执行 `bash ios/scripts/build-web-assets.sh`,再在 Xcode **Product → Clean Build Folder** 后重新 Run。
@@ -0,0 +1,414 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
A10000010000000000000001 /* WordLoopApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000001 /* WordLoopApp.swift */; };
A10000010000000000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000002 /* ContentView.swift */; };
A10000010000000000000003 /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000003 /* ServerEnvironment.swift */; };
A10000010000000000000004 /* ServerGatewayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000004 /* ServerGatewayView.swift */; };
A10000010000000000000005 /* WordLoopWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000005 /* WordLoopWebView.swift */; };
A10000010000000000000009 /* BundleWebSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000009 /* BundleWebSchemeHandler.swift */; };
A10000010000000000000006 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000006 /* Assets.xcassets */; };
A10000010000000000000008 /* www in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000008 /* www */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
A10000020000000000000001 /* WordLoopApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordLoopApp.swift; sourceTree = "<group>"; };
A10000020000000000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A10000020000000000000003 /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = "<group>"; };
A10000020000000000000004 /* ServerGatewayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerGatewayView.swift; sourceTree = "<group>"; };
A10000020000000000000005 /* WordLoopWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordLoopWebView.swift; sourceTree = "<group>"; };
A10000020000000000000006 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A10000020000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A10000020000000000000008 /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; path = www; sourceTree = "<group>"; };
A10000020000000000000009 /* BundleWebSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleWebSchemeHandler.swift; sourceTree = "<group>"; };
A10000030000000000000001 /* WordLoop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WordLoop.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A10000040000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A10000050000000000000001 = {
isa = PBXGroup;
children = (
A10000050000000000000002 /* WordLoop */,
A10000050000000000000003 /* Products */,
);
sourceTree = "<group>";
};
A10000050000000000000006 /* Services */ = {
isa = PBXGroup;
children = (
A10000020000000000000009 /* BundleWebSchemeHandler.swift */,
);
path = Services;
sourceTree = "<group>";
};
A10000050000000000000002 /* WordLoop */ = {
isa = PBXGroup;
children = (
A10000020000000000000001 /* WordLoopApp.swift */,
A10000020000000000000002 /* ContentView.swift */,
A10000050000000000000004 /* Models */,
A10000050000000000000005 /* Views */,
A10000050000000000000006 /* Services */,
A10000020000000000000006 /* Assets.xcassets */,
A10000020000000000000008 /* www */,
A10000020000000000000007 /* Info.plist */,
);
path = WordLoop;
sourceTree = "<group>";
};
A10000050000000000000003 /* Products */ = {
isa = PBXGroup;
children = (
A10000030000000000000001 /* WordLoop.app */,
);
name = Products;
sourceTree = "<group>";
};
A10000050000000000000004 /* Models */ = {
isa = PBXGroup;
children = (
A10000020000000000000003 /* ServerEnvironment.swift */,
);
path = Models;
sourceTree = "<group>";
};
A10000050000000000000005 /* Views */ = {
isa = PBXGroup;
children = (
A10000020000000000000004 /* ServerGatewayView.swift */,
A10000020000000000000005 /* WordLoopWebView.swift */,
);
path = Views;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A10000060000000000000001 /* WordLoop */ = {
isa = PBXNativeTarget;
buildConfigurationList = A10000090000000000000003 /* Build configuration list for PBXNativeTarget "WordLoop" */;
buildPhases = (
A100000C0000000000000001 /* Build Web Assets */,
A10000070000000000000001 /* Sources */,
A10000040000000000000001 /* Frameworks */,
A10000080000000000000001 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = WordLoop;
productName = WordLoop;
productReference = A10000030000000000000001 /* WordLoop.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A100000A0000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 2650;
TargetAttributes = {
A10000060000000000000001 = {
CreatedOnToolsVersion = 16.0;
};
};
};
buildConfigurationList = A10000090000000000000001 /* Build configuration list for PBXProject "WordLoop" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = "zh-Hans";
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
);
mainGroup = A10000050000000000000001;
productRefGroup = A10000050000000000000003 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A10000060000000000000001 /* WordLoop */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A10000080000000000000001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10000010000000000000006 /* Assets.xcassets in Resources */,
A10000010000000000000008 /* www in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
A100000C0000000000000001 /* Build Web Assets */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/../scripts/build-web-assets.sh",
"$(SRCROOT)/../../frontend/package.json",
);
name = "Build Web Assets";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/WordLoop/www/index.html",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/bash;
shellScript = "set -euo pipefail\nexport PATH=\"/usr/local/bin:/opt/homebrew/bin:${PATH}\"\nbash \"${SRCROOT}/../scripts/build-web-assets.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A10000070000000000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10000010000000000000001 /* WordLoopApp.swift in Sources */,
A10000010000000000000002 /* ContentView.swift in Sources */,
A10000010000000000000003 /* ServerEnvironment.swift in Sources */,
A10000010000000000000004 /* ServerGatewayView.swift in Sources */,
A10000010000000000000005 /* WordLoopWebView.swift in Sources */,
A10000010000000000000009 /* BundleWebSchemeHandler.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A100000B0000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 426W6QBL47;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A100000B0000000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 426W6QBL47;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A100000B0000000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 426W6QBL47;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = WordLoop/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.tkmind.wordloop;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A100000B0000000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 426W6QBL47;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = WordLoop/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.tkmind.wordloop;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A10000090000000000000001 /* Build configuration list for PBXProject "WordLoop" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A100000B0000000000000001 /* Debug */,
A100000B0000000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A10000090000000000000003 /* Build configuration list for PBXNativeTarget "WordLoop" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A100000B0000000000000003 /* Debug */,
A100000B0000000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A100000A0000000000000001 /* Project object */;
}
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A10000060000000000000001"
BuildableName = "WordLoop.app"
BlueprintName = "WordLoop"
ReferencedContainer = "container:WordLoop.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A10000060000000000000001"
BuildableName = "WordLoop.app"
BlueprintName = "WordLoop"
ReferencedContainer = "container:WordLoop.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A10000060000000000000001"
BuildableName = "WordLoop.app"
BlueprintName = "WordLoop"
ReferencedContainer = "container:WordLoop.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.969",
"green" : "0.431",
"red" : "0.310"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.984",
"green" : "0.965",
"red" : "0.956"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+22
View File
@@ -0,0 +1,22 @@
import SwiftUI
struct ContentView: View {
@State private var environment = ServerSettings.environment
@State private var localBaseURL = ServerSettings.localBaseURL
@State private var activeBaseURL = ServerSettings.activeBaseURL
var body: some View {
WordLoopWebContainer(
environment: $environment,
localBaseURL: $localBaseURL,
baseURL: activeBaseURL,
onBaseURLChange: { baseURL in
activeBaseURL = baseURL
}
)
}
}
#Preview {
ContentView()
}
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh-Hans</string>
<key>CFBundleDisplayName</key>
<string>WordLoop</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>127.0.0.1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>连接本地开发服务器(前后端)时需要访问局域网。</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,125 @@
import Foundation
enum ServerEnvironment: String, CaseIterable, Identifiable {
case production
case local
var id: String { rawValue }
var title: String {
switch self {
case .production: return "生产服务器"
case .local: return "本地服务器"
}
}
var subtitle: String {
switch self {
case .production: return "w.tkmind.cn"
case .local: return "开发机前后端 (18003/18004)"
}
}
var defaultBaseURL: String {
switch self {
case .production: return "https://w.tkmind.cn"
case .local: return "http://127.0.0.1:18003"
}
}
var iconName: String {
switch self {
case .production: return "cloud.fill"
case .local: return "laptopcomputer"
}
}
}
enum ServerSettings {
private static let environmentKey = "wordloop.server.environment"
private static let localBaseURLKey = "wordloop.server.localBaseURL"
static var environment: ServerEnvironment {
get {
guard let raw = UserDefaults.standard.string(forKey: environmentKey),
let env = ServerEnvironment(rawValue: raw) else {
return .production
}
return env
}
set {
UserDefaults.standard.set(newValue.rawValue, forKey: environmentKey)
}
}
static var localBaseURL: String {
get {
UserDefaults.standard.string(forKey: localBaseURLKey)
?? ServerEnvironment.local.defaultBaseURL
}
set {
UserDefaults.standard.set(newValue.trimmingCharacters(in: .whitespacesAndNewlines),
forKey: localBaseURLKey)
}
}
static var activeBaseURL: String {
switch environment {
case .production:
return ServerEnvironment.production.defaultBaseURL
case .local:
return normalizedBaseURL(localBaseURL)
}
}
static var activeAPIBaseURL: String {
apiBaseURL(for: activeBaseURL)
}
static func apiBaseURL(for webBaseURL: String) -> String {
let normalized = normalizedBaseURL(webBaseURL)
if normalized.contains(":18003") {
return normalized.replacingOccurrences(of: ":18003", with: ":18004") + "/api"
}
return normalized + "/api"
}
static var bundledWWWDirectory: URL? {
if let url = Bundle.main.url(forResource: "www", withExtension: nil) {
return url
}
let fallback = Bundle.main.bundleURL.appendingPathComponent("www", isDirectory: true)
return FileManager.default.fileExists(atPath: fallback.path) ? fallback : nil
}
static var bundledHomeURL: URL? {
bundledWWWDirectory?.appendingPathComponent("index.html")
}
static func bundledReadAccessURL() -> URL? {
bundledWWWDirectory ?? Bundle.main.bundleURL
}
static func homeURL(for baseURL: String) -> URL? {
bundledHomeURL ?? {
let trimmed = normalizedBaseURL(baseURL)
return URL(string: "\(trimmed)/")
}()
}
static func loginURL(for baseURL: String) -> URL? {
if bundledHomeURL != nil {
return bundledHomeURL
}
let trimmed = normalizedBaseURL(baseURL)
return URL(string: "\(trimmed)/login")
}
static func normalizedBaseURL(_ value: String) -> String {
var url = value.trimmingCharacters(in: .whitespacesAndNewlines)
while url.hasSuffix("/") {
url.removeLast()
}
return url
}
}
@@ -0,0 +1,99 @@
import Foundation
import WebKit
/// wordloop:// App Bundle www file://
final class BundleWebSchemeHandler: NSObject, WKURLSchemeHandler {
private let wwwRoot: URL
private let queue = DispatchQueue(label: "wordloop.bundle-web-scheme")
init(wwwRoot: URL) {
self.wwwRoot = wwwRoot
}
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
queue.async { [wwwRoot] in
self.serve(urlSchemeTask: urlSchemeTask, wwwRoot: wwwRoot)
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
// cancelled navigations; no response needed
}
private func serve(urlSchemeTask: WKURLSchemeTask, wwwRoot: URL) {
guard let requestURL = urlSchemeTask.request.url else {
fail(urlSchemeTask, code: 400, message: "Bad request")
return
}
var path = requestURL.path
if path.isEmpty || path == "/" {
path = "/index.html"
}
if path.hasPrefix("/") {
path.removeFirst()
}
let fileURL = wwwRoot.appendingPathComponent(path)
guard fileURL.path.hasPrefix(wwwRoot.path),
FileManager.default.fileExists(atPath: fileURL.path) else {
fail(urlSchemeTask, code: 404, message: "Not found: \(path)")
return
}
do {
var data = try Data(contentsOf: fileURL)
let mime = Self.mimeType(for: fileURL.pathExtension)
guard let response = HTTPURLResponse(
url: requestURL,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": mime,
"Content-Length": String(data.count),
"Access-Control-Allow-Origin": "*",
]
) else {
fail(urlSchemeTask, code: 500, message: "Bad response")
return
}
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
} catch {
fail(urlSchemeTask, code: 500, message: error.localizedDescription)
}
}
private func fail(_ task: WKURLSchemeTask, code: Int, message: String) {
let error = NSError(
domain: "WordLoopWeb",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
task.didFailWithError(error)
}
private static func mimeType(for ext: String) -> String {
switch ext.lowercased() {
case "html", "htm": return "text/html"
case "js": return "text/javascript; charset=utf-8"
case "css": return "text/css"
case "json": return "application/json"
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "svg": return "image/svg+xml"
case "woff2": return "font/woff2"
case "woff": return "font/woff"
default: return "application/octet-stream"
}
}
}
enum BundledWebEntry {
static let schemeURL = URL(string: "wordloop://app/index.html#/")!
static func register(in configuration: WKWebViewConfiguration, wwwRoot: URL) {
configuration.setURLSchemeHandler(BundleWebSchemeHandler(wwwRoot: wwwRoot), forURLScheme: "wordloop")
}
}
@@ -0,0 +1,141 @@
import SwiftUI
struct ServerBottomBar: View {
@Binding var environment: ServerEnvironment
@Binding var localBaseURL: String
let onServerChange: (String) -> Void
@State private var expanded = false
@State private var validationMessage: String?
private var resolvedBaseURL: String {
environment == .production
? ServerEnvironment.production.defaultBaseURL
: ServerSettings.normalizedBaseURL(localBaseURL)
}
private var shortLabel: String {
switch environment {
case .production:
return "w.tkmind.cn"
case .local:
let url = resolvedBaseURL
if let host = URL(string: url)?.host {
return host
}
return "本地"
}
}
var body: some View {
VStack(spacing: 0) {
Divider()
if expanded {
expandedPanel
}
collapsedRow
}
.background(Color(.systemBackground))
}
private var collapsedRow: some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
expanded.toggle()
}
} label: {
HStack(spacing: 8) {
Image(systemName: "server.rack")
.font(.caption)
.foregroundStyle(Color(red: 0.31, green: 0.43, blue: 0.97))
Text("服务器")
.font(.caption)
.foregroundStyle(.secondary)
Text(environment.title)
.font(.caption.weight(.semibold))
Text(shortLabel)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
Image(systemName: expanded ? "chevron.down" : "chevron.up")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var expandedPanel: some View {
VStack(alignment: .leading, spacing: 10) {
Picker("服务器", selection: $environment) {
ForEach(ServerEnvironment.allCases) { item in
Text(item.title).tag(item)
}
}
.pickerStyle(.segmented)
.onChange(of: environment) { _, _ in
validationMessage = nil
applyServerChange()
}
if environment == .local {
TextField("http://192.168.x.x:18003", text: $localBaseURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
.onSubmit {
applyServerChange()
}
Text("模拟器 127.0.0.1:18003;真机填电脑局域网 IP")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let validationMessage {
Text(validationMessage)
.font(.caption2)
.foregroundStyle(.red)
}
}
.padding(.horizontal, 14)
.padding(.bottom, 8)
}
private func applyServerChange() {
validationMessage = nil
let baseURL = resolvedBaseURL
guard baseURL.hasPrefix("http://") || baseURL.hasPrefix("https://") else {
validationMessage = "地址需以 http:// 或 https:// 开头"
return
}
guard ServerSettings.apiBaseURL(for: baseURL).hasPrefix("http") else {
validationMessage = "地址格式无效"
return
}
ServerSettings.environment = environment
if environment == .local {
ServerSettings.localBaseURL = baseURL
}
onServerChange(baseURL)
}
}
#Preview {
ServerBottomBar(
environment: .constant(.production),
localBaseURL: .constant("http://127.0.0.1:18003"),
onServerChange: { _ in }
)
}
@@ -0,0 +1,247 @@
import SwiftUI
import WebKit
@MainActor
final class WebViewStore: ObservableObject {
@Published var isLoading = true
@Published var canGoBack = false
@Published var pageTitle = "WordLoop"
weak var webView: WKWebView?
var schemeHandler: BundleWebSchemeHandler?
}
struct WordLoopWebView: UIViewRepresentable {
let entryURL: URL
let apiBaseURL: String
let usesBundledFrontend: Bool
@ObservedObject var store: WebViewStore
func makeCoordinator() -> Coordinator {
Coordinator(store: store, usesBundledFrontend: usesBundledFrontend)
}
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
configuration.websiteDataStore = .default()
configuration.userContentController.addUserScript(
Self.makeInjectionScript(apiBaseURL: apiBaseURL)
)
let webView = WKWebView(frame: .zero, configuration: configuration)
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
webView.navigationDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
webView.scrollView.bounces = true
webView.isOpaque = false
webView.backgroundColor = UIColor(red: 0.956, green: 0.965, blue: 0.984, alpha: 1)
context.coordinator.attach(webView)
context.coordinator.load(entryURL: entryURL, apiBaseURL: apiBaseURL, in: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.attach(webView)
context.coordinator.load(entryURL: entryURL, apiBaseURL: apiBaseURL, in: webView)
context.coordinator.updateAPIBase(apiBaseURL, in: webView)
}
private static func makeInjectionScript(apiBaseURL: String) -> WKUserScript {
let escapedAPI = apiBaseURL
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
let source = """
window.__WORDLOOP_RUNTIME__ = {
embedded: true,
iosApp: true,
apiBase: '\(escapedAPI)'
};
"""
return WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true)
}
final class Coordinator: NSObject, WKNavigationDelegate {
private let store: WebViewStore
private let usesBundledFrontend: Bool
private var loadedEntryURL: URL?
private var loadedAPIBaseURL: String?
private var pendingAPIBaseURL: String?
init(store: WebViewStore, usesBundledFrontend: Bool) {
self.store = store
self.usesBundledFrontend = usesBundledFrontend
}
func attach(_ webView: WKWebView) {
store.webView = webView
}
func load(entryURL: URL, apiBaseURL: String, in webView: WKWebView) {
let entryChanged = loadedEntryURL != entryURL
let apiChanged = loadedAPIBaseURL != apiBaseURL
guard entryChanged || (apiChanged && loadedEntryURL == nil) else {
if apiChanged {
updateAPIBase(apiBaseURL, in: webView)
}
return
}
loadedEntryURL = entryURL
loadedAPIBaseURL = apiBaseURL
pendingAPIBaseURL = apiBaseURL
store.isLoading = true
if usesBundledFrontend, let bundleEntry = Self.bundledHTMLPayload() {
// loadHTMLString wordloop:// WKWebView /
webView.loadHTMLString(bundleEntry.html, baseURL: bundleEntry.baseURL)
} else {
webView.load(URLRequest(url: entryURL, cachePolicy: .reloadIgnoringLocalCacheData))
}
}
private static func bundledHTMLPayload() -> (html: String, baseURL: URL)? {
guard let wwwRoot = ServerSettings.bundledWWWDirectory,
let indexURL = ServerSettings.bundledHomeURL,
let html = try? String(contentsOf: indexURL, encoding: .utf8),
!html.isEmpty else {
return nil
}
return (html, wwwRoot)
}
func updateAPIBase(_ apiBaseURL: String, in webView: WKWebView) {
guard loadedAPIBaseURL != apiBaseURL else { return }
loadedAPIBaseURL = apiBaseURL
let escapedAPI = apiBaseURL
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
webView.evaluateJavaScript("""
window.__WORDLOOP_RUNTIME__ = window.__WORDLOOP_RUNTIME__ || {};
window.__WORDLOOP_RUNTIME__.apiBase = '\(escapedAPI)';
if (window.__wordloopSetApiBase) window.__wordloopSetApiBase('\(escapedAPI)');
""")
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
store.isLoading = true
store.canGoBack = webView.canGoBack
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
store.isLoading = false
store.canGoBack = webView.canGoBack
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
store.isLoading = false
store.canGoBack = webView.canGoBack
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
store.isLoading = false
store.canGoBack = webView.canGoBack
store.pageTitle = webView.title ?? "WordLoop"
if let apiBaseURL = pendingAPIBaseURL {
updateAPIBase(apiBaseURL, in: webView)
}
}
}
}
struct WordLoopWebContainer: View {
@Binding var environment: ServerEnvironment
@Binding var localBaseURL: String
let baseURL: String
let onBaseURLChange: (String) -> Void
@StateObject private var store = WebViewStore()
@State private var entryURL: URL
@State private var apiBaseURL: String
@State private var usesBundledFrontend: Bool
init(
environment: Binding<ServerEnvironment>,
localBaseURL: Binding<String>,
baseURL: String,
onBaseURLChange: @escaping (String) -> Void
) {
_environment = environment
_localBaseURL = localBaseURL
self.baseURL = baseURL
self.onBaseURLChange = onBaseURLChange
let hasBundle = ServerSettings.bundledWWWDirectory != nil
let resolvedEntry = hasBundle
? (ServerSettings.bundledHomeURL ?? BundledWebEntry.schemeURL)
: (ServerSettings.homeURL(for: baseURL)
?? URL(string: ServerEnvironment.production.defaultBaseURL + "/")!)
let resolvedAPI = ServerSettings.apiBaseURL(for: baseURL)
_entryURL = State(initialValue: resolvedEntry)
_apiBaseURL = State(initialValue: resolvedAPI)
_usesBundledFrontend = State(initialValue: hasBundle)
}
var body: some View {
VStack(spacing: 0) {
NavigationStack {
ZStack {
WordLoopWebView(
entryURL: entryURL,
apiBaseURL: apiBaseURL,
usesBundledFrontend: usesBundledFrontend,
store: store
)
.ignoresSafeArea(edges: .bottom)
if store.isLoading {
ProgressView("加载中…")
.padding(20)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.navigationTitle(store.pageTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
store.webView?.reload()
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(store.webView == nil)
Button {
if store.webView?.canGoBack == true {
store.webView?.goBack()
}
} label: {
Image(systemName: "chevron.backward")
}
.disabled(!store.canGoBack)
}
}
}
ServerBottomBar(
environment: $environment,
localBaseURL: $localBaseURL,
onServerChange: handleServerChange
)
}
}
private func handleServerChange(_ newBaseURL: String) {
let newAPI = ServerSettings.apiBaseURL(for: newBaseURL)
onBaseURLChange(newBaseURL)
apiBaseURL = newAPI
if !usesBundledFrontend, let remoteURL = ServerSettings.homeURL(for: newBaseURL) {
entryURL = remoteURL
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import SwiftUI
@main
struct WordLoopApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
FRONTEND="$ROOT/frontend"
OUT="$ROOT/ios/WordLoop/WordLoop/www"
echo ">>> 构建 iOS 内嵌前端"
cd "$FRONTEND"
if [ ! -d node_modules ]; then
npm install --silent
fi
npm run build:ios
rm -rf "$OUT"
mkdir -p "$OUT"
cp -R dist/* "$OUT/"
python3 - <<PY
from pathlib import Path
out = Path("$OUT")
html_path = out / "index.html"
js_path = out / "assets" / "app.js"
css_path = out / "assets" / "app.css"
html = html_path.read_text(encoding="utf-8")
js = js_path.read_text(encoding="utf-8")
css = css_path.read_text(encoding="utf-8")
# 单文件内联,避免 wordloop:// 二次请求脚本失败
html = html.replace('<link rel="stylesheet" href="wordloop://app/assets/app.css">', f"<style>{css}</style>")
html = html.replace(
'<script src="wordloop://app/assets/app.js"></script>',
"",
)
# 脚本放在 </body> 前,确保 #app 已存在(head 内联会导致 Vue mount 失败、白屏)
html = html.replace(
"</body>",
f" <script>{js}</script>\\n </body>",
)
html_path.write_text(html, encoding="utf-8")
print(">>> 已内联 app.js / app.css 到 index.html")
PY
echo ">>> 已复制到 $OUT"