初始化简单写作项目
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
android/build/
|
||||||
|
android/.gradle/
|
||||||
|
android/local.properties
|
||||||
|
android/app/build/
|
||||||
|
android/captures/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
dist/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"docs":[{"id":"x1","title":"a","content":"b","updated":5}]}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"u1783657002516": {
|
||||||
|
"salt": "9c78238b2d54a790b0bc3b9ab74d6620",
|
||||||
|
"hash": "0957239573eb4216a2d0d83e1c83cbc7bda93616e5cbd350d6fd8d77d8687ee8e1d95f27c5d1e6d992758ed8e4183b555782fbb545a7612dbec6b43891169913"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 简单写作(跨平台极简写作 PWA)
|
||||||
|
|
||||||
|
一个极简风格的写作工具,原生跨平台:浏览器打开即用,可安装到 Windows 桌面与安卓主屏,支持离线,并带**基于 WebDAV 的端到端加密多端同步**。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
- 极简纸张风格写作界面
|
||||||
|
- **打字机滚动**:当前输入行始终保持在屏幕中央(点工具栏按钮开关,默认开启)
|
||||||
|
- 专注模式(F11):隐藏一切只剩文字
|
||||||
|
- 多篇文档管理(新建 / 切换 / 删除),按更新时间排序
|
||||||
|
- 本地自动保存(localStorage),关掉浏览器也不丢
|
||||||
|
- 实时字数统计 + 写作计时
|
||||||
|
- 导出 TXT / Markdown / 复制全文
|
||||||
|
- 深色 / 浅色主题
|
||||||
|
- 多端同步(WebDAV):内容经你的密码端到端加密后上传(网盘只存密文,无法读取)
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
```
|
||||||
|
src/ 源代码(网页、样式、JS、图标、服务器)
|
||||||
|
android/ Android 构建工程
|
||||||
|
scripts/ 构建/安装脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
## 一键启动(推荐)
|
||||||
|
```bash
|
||||||
|
node src/start.js
|
||||||
|
# 浏览器访问 http://localhost:8080
|
||||||
|
```
|
||||||
|
该命令启动写作页面(8080),并内置 WebDAV 同步代理(同源 `/api/webdav/`)。
|
||||||
|
可用 `PORT=9000 node src/start.js` 改端口。
|
||||||
|
|
||||||
|
## 仅本地写作(无需同步)
|
||||||
|
```bash
|
||||||
|
python -m http.server 8000
|
||||||
|
# 浏览器访问 http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 多端同步(WebDAV)
|
||||||
|
通过你自己的网盘(坚果云 / Nextcloud / 自建 WebDAV)做存储,内容端到端加密后上传,网盘只存密文、无法读取。
|
||||||
|
|
||||||
|
### 1. 启动服务
|
||||||
|
用 `node src/start.js` 启动(已内置 WebDAV 代理,无需再单独跑其它服务)。
|
||||||
|
|
||||||
|
### 2. 在写作软件里登录
|
||||||
|
点右上角 ☁ 按钮,填写 WebDAV 信息后点"登录并同步":
|
||||||
|
- **WebDAV 服务器地址**:如坚果云 `https://dav.jianguoyun.com/dav/`、Nextcloud `https://你的域名/remote.php/dav/files/用户名/`
|
||||||
|
- **存储路径**:文件名,默认 `jiandan-sync.json`(可带子目录,如 `notes/app.json`)
|
||||||
|
- **WebDAV 用户名 / 密码**:登录该网盘的账号密码
|
||||||
|
- **加密密码**:内容端到端加密用的密码(与 WebDAV 密码可不同,请牢记)
|
||||||
|
- 刷新页面后会**自动恢复**并继续后台同步。
|
||||||
|
|
||||||
|
> 浏览器直连第三方 WebDAV 会被 CORS 拦截,因此 WebDAV 请求统一经本机 `src/start.js` 的 `/api/webdav/` 代理转发(代理只转发密文,不接触明文)。**所以使用 WebDAV 也必须通过 `node src/start.js` 启动**,不能直接用 `file://` 打开页面。
|
||||||
|
|
||||||
|
WebDAV 实时性:编辑后约 1.2 秒自动上传,并**每 8 秒轮询一次**远程变更(WebDAV 协议无推送能力,这是准实时方案)。
|
||||||
|
|
||||||
|
### 3. 多设备
|
||||||
|
Windows 与安卓上填**同一 WebDAV 账号与同一加密密码**即可双向同步(按"最后修改时间"合并,最后写入获胜)。
|
||||||
|
|
||||||
|
## 跨网络 / 公网部署(真正异地多端)
|
||||||
|
局域网内用电脑 IP 即可;若要在不同网络(如公司/家里)同步,需把服务部署到有公网 IP 的机器,并用 **HTTPS**(浏览器只在 `localhost` 或 HTTPS 下才允许使用加密 API):
|
||||||
|
|
||||||
|
- 最简单:用 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflared/) 或 `ngrok` 把本地端口暴露为 HTTPS 地址;
|
||||||
|
- 或放到任何云服务器 + 反向代理(Nginx/Caddy)+ 免费证书;
|
||||||
|
- 务必用 HTTPS 的地址填到软件里,否则加密同步会被浏览器禁用。
|
||||||
|
|
||||||
|
## 安全说明
|
||||||
|
- 文档内容在浏览器内用 `PBKDF2` 派生的 `AES-GCM` 密钥加密后才上传,WebDAV 网盘仅存储密文。
|
||||||
|
- 加密密码只存在于你的浏览器内存,不上传给任何人;网盘服务商 / 运维方均无法读取你的文字。
|
||||||
|
- 但加密密码一旦遗忘,内容将无法恢复,请务必牢记。
|
||||||
|
- WebDAV 账密由你的网盘保管;代理以 HTTPS 经同源转发,请尽量在可信网络或 HTTPS 下使用。
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.chunchun.xiezuo"
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.chunchun.xiezuo"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.chunchun.xiezuo;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">纯纯写作</string>
|
||||||
|
<string name="title_activity_main">纯纯写作</string>
|
||||||
|
<string name="package_name">com.chunchun.xiezuo</string>
|
||||||
|
<string name="custom_url_scheme">com.chunchun.xiezuo</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.0'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m -Dhttps.protocols=TLSv1.2,TLSv1.3 -Djavax.net.ssl.trustStoreType=JKS
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.overridePathCheck=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ext {
|
||||||
|
minSdkVersion = 22
|
||||||
|
compileSdkVersion = 34
|
||||||
|
targetSdkVersion = 34
|
||||||
|
androidxActivityVersion = '1.8.0'
|
||||||
|
androidxAppCompatVersion = '1.6.1'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
|
androidxCoreVersion = '1.12.0'
|
||||||
|
androidxFragmentVersion = '1.6.2'
|
||||||
|
coreSplashScreenVersion = '1.0.1'
|
||||||
|
androidxWebkitVersion = '1.9.0'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.1.5'
|
||||||
|
androidxEspressoCoreVersion = '3.5.1'
|
||||||
|
cordovaAndroidVersion = '10.1.1'
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.jiandan.xiezuo",
|
||||||
|
"appName": "简单写作",
|
||||||
|
"webDir": "src",
|
||||||
|
"server": {
|
||||||
|
"androidScheme": "https"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"allowMixedContent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "jiandan-xiezuo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "简单写作 - 极简专注的跨平台写作工具",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/start.js",
|
||||||
|
"cap:add": "npx cap add android",
|
||||||
|
"cap:sync": "npx cap sync",
|
||||||
|
"cap:open": "npx cap open android"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/android": "^6.0.0",
|
||||||
|
"@capacitor/cli": "^6.0.0",
|
||||||
|
"@capacitor/core": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
title Bote APK Builder
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo 波忒 Android APK 一键构建
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-17.0.17.10-hotspot
|
||||||
|
set ANDROID_HOME=C:\Android
|
||||||
|
set PATH=%JAVA_HOME%\bin;%PATH%
|
||||||
|
|
||||||
|
echo [1/1] 正在编译 APK(首次约需5-10分钟)...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
cd /d "%~dp0..\android"
|
||||||
|
call gradlew.bat assembleDebug
|
||||||
|
|
||||||
|
if exist "app\build\outputs\apk\debug\app-debug.apk" (
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo 构建成功!
|
||||||
|
echo ========================================
|
||||||
|
copy /y "app\build\outputs\apk\debug\app-debug.apk" "%USERPROFILE%\Desktop\bote.apk" >nul
|
||||||
|
echo APK 已复制到桌面: bote.apk
|
||||||
|
echo.
|
||||||
|
) else (
|
||||||
|
echo.
|
||||||
|
echo 构建失败,请截图上方错误信息
|
||||||
|
)
|
||||||
|
pause
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Android APK 构建环境一键安装脚本
|
||||||
|
# 右键此文件 → 使用 PowerShell 运行,或在终端输入:powershell -File install_android_build.ps1
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$JDK_HOME = "C:\Program Files\Eclipse Adoptium\jdk-17.0.17.10-hotspot"
|
||||||
|
$ANDROID_HOME = "C:\Android"
|
||||||
|
|
||||||
|
Write-Host "===== 1/3: 刷新环境变量 =====" -ForegroundColor Green
|
||||||
|
$env:JAVA_HOME = $JDK_HOME
|
||||||
|
$env:ANDROID_HOME = $ANDROID_HOME
|
||||||
|
$env:PATH = "$JDK_HOME\bin;$ANDROID_HOME\cmdline-tools\latest\bin;$env:PATH"
|
||||||
|
java -version
|
||||||
|
|
||||||
|
Write-Host "===== 2/3: 安装 Android SDK 组件 =====" -ForegroundColor Green
|
||||||
|
# 接受 SDK 许可
|
||||||
|
cmd /c "yes | `"$ANDROID_HOME\cmdline-tools\latest\bin\sdkmanager.bat`" --sdk_root=$ANDROID_HOME --licenses" 2>$null
|
||||||
|
|
||||||
|
# 安装必要的 SDK 组件
|
||||||
|
cmd /c "`"$ANDROID_HOME\cmdline-tools\latest\bin\sdkmanager.bat`" --sdk_root=$ANDROID_HOME platform-tools platforms;android-35 build-tools;35.0.0"
|
||||||
|
|
||||||
|
Write-Host "===== 3/3: 构建 APK =====" -ForegroundColor Green
|
||||||
|
$projectDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location "$projectDir\..\android"
|
||||||
|
|
||||||
|
$env:JAVA_HOME = $JDK_HOME
|
||||||
|
$env:ANDROID_HOME = $ANDROID_HOME
|
||||||
|
$env:PATH = "$JDK_HOME\bin;$ANDROID_HOME\cmdline-tools\latest\bin;$env:PATH"
|
||||||
|
|
||||||
|
cmd /c "set JAVA_HOME=$JDK_HOME&& set ANDROID_HOME=$ANDROID_HOME&& set PATH=%JAVA_HOME%\bin;%ANDROID_HOME%\cmdline-tools\latest\bin;%PATH%&& gradlew assembleDebug"
|
||||||
|
|
||||||
|
$apkPath = "$projectDir\..\android\app\build\outputs\apk\debug\app-debug.apk"
|
||||||
|
if (Test-Path $apkPath) {
|
||||||
|
Write-Host "===== APK 构建成功! =====" -ForegroundColor Green
|
||||||
|
Write-Host "APK 位置: $apkPath" -ForegroundColor Yellow
|
||||||
|
Write-Host "大小: $((Get-Item $apkPath).Length / 1MB) MB" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# 复制到桌面方便找
|
||||||
|
Copy-Item $apkPath "$env:USERPROFILE\Desktop\bote.apk" -Force
|
||||||
|
Write-Host "已复制到桌面: bote.apk" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host "构建失败,请检查上面的错误信息" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "按任意键退出..." -ForegroundColor Gray
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||||
|
<rect width="192" height="192" rx="40" fill="#f4efe6"/>
|
||||||
|
<rect x="40" y="36" width="112" height="120" rx="10" fill="#b9925a"/>
|
||||||
|
<rect x="56" y="60" width="80" height="8" rx="4" fill="#fbf8f1"/>
|
||||||
|
<rect x="56" y="80" width="80" height="8" rx="4" fill="#fbf8f1"/>
|
||||||
|
<rect x="56" y="100" width="56" height="8" rx="4" fill="#fbf8f1"/>
|
||||||
|
<path d="M118 132 l24 24 -8 0 -20 -20 z" fill="#8a645a"/>
|
||||||
|
<path d="M134 156 l4 8 -10 0 4 -10 z" fill="#8a645a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1,267 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#f4efe6" />
|
||||||
|
<meta name="description" content="简单写作 - 极简专注的跨平台写作工具" />
|
||||||
|
<title>简单写作</title>
|
||||||
|
<link rel="manifest" href="manifest.json" />
|
||||||
|
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="icon.svg" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 侧边栏:文章列表 -->
|
||||||
|
<aside id="sidebar" class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<span class="brand">简单写作</span>
|
||||||
|
<button id="newDocBtn" class="icon-btn" title="新建文章" aria-label="新建文章">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="docList" class="doc-list"></div>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button id="themeBtn" class="text-btn" title="切换主题">🌙 主题</button>
|
||||||
|
<button id="installBtn" class="text-btn" hidden>⬇ 安装</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 遮罩(移动端侧边栏) -->
|
||||||
|
<div id="overlay" class="overlay"></div>
|
||||||
|
|
||||||
|
<!-- 主写作区 -->
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<button id="menuBtn" class="icon-btn" title="文章列表" aria-label="文章列表">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>
|
||||||
|
</button>
|
||||||
|
<input id="titleInput" class="title-input" placeholder="无标题" />
|
||||||
|
<button id="renameBtn" class="icon-btn rename-btn" title="修改文章名" aria-label="修改文章名">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button id="focusBtn" class="icon-btn" title="阅读模式" aria-label="阅读模式">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M21 5c-1.11-.35-2.33-.5-3.5-.5-1.95 0-4.05.4-5.5 1.5-1.45-1.1-3.55-1.5-5.5-1.5S2.45 4.9 1 6v14.65c0 .25.25.5.5.5.1 0 .15-.05.25-.05C3.1 20.45 5.05 20 6.5 20c1.95 0 4.05.4 5.5 1.5 1.35-.85 3.8-1.5 5.5-1.5 1.65 0 3.35.3 4.75 1.05.1.05.15.05.25.05.25 0 .5-.25.5-.5V6c-.6-.45-1.25-.75-2-1zm0 13.5c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5V8c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5v11.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="typeBtn" class="icon-btn" title="打字机滚动(当前行居中)" aria-label="打字机滚动">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M4 7h16v2H4zm2 4h2v6H6zm10 0h2v6h-2zM4 17h16v2H4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="syncBtn" class="icon-btn" title="多端同步" aria-label="多端同步">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M19 18H6a4 4 0 1 1 .9-7.9A5.5 5.5 0 0 1 18 9.5a4 4 0 0 1 1 8.5zM6 16h13a2 2 0 1 0 0-4 2 2 0 0 0-1.5.7l-.4.4-.9-.9.5-.5A3.5 3.5 0 0 1 18 11a3 3 0 0 0-5.8-1l-.7.7-.7-.7a4.5 4.5 0 0 0-8.6 1.9A3 3 0 0 0 6 18z"/></svg>
|
||||||
|
<span id="syncBadge" class="sync-badge" hidden></span>
|
||||||
|
</button>
|
||||||
|
<button id="exportBtn" class="icon-btn" title="导出" aria-label="导出">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 3v10.55l3.3-3.3 1.4 1.42L12 17.4l-4.7-4.73 1.4-1.42 3.3 3.3V3zM5 19h14v2H5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="historyBtn" class="icon-btn" title="历史记录" aria-label="历史记录">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M13 3a9 9 0 0 0-9 9H1l4 4 4-4H6a7 7 0 1 1 2.05 4.95l-1.42 1.42A9 9 0 1 0 13 3zm-1 5v5l4.25 2.52.75-1.23-3.5-2.08V8z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="settingsBtn" class="icon-btn" title="设置" aria-label="设置">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M19.14 12.94a7.49 7.49 0 0 0 .05-.94 7.49 7.49 0 0 0-.05-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.61-.22l-2.39.96a7 7 0 0 0-1.62-.94l-.36-2.54a.5.5 0 0 0-.5-.42h-3.84a.5.5 0 0 0-.5.42l-.36 2.54a7 7 0 0 0-1.62.94l-2.39-.96a.5.5 0 0 0-.61.22l-1.92 3.32a.5.5 0 0 0 .12.64l2.03 1.58c-.03.31-.05.62-.05.94s.02.63.05.94l-2.03 1.58a.5.5 0 0 0-.12.64l1.92 3.32a.5.5 0 0 0 .61.22l2.39-.96c.5.38 1.04.7 1.62.94l.36 2.54a.5.5 0 0 0 .5.42h3.84a.5.5 0 0 0 .5-.42l.36-2.54c.58-.24 1.12-.56 1.62-.94l2.39.96a.5.5 0 0 0 .61-.22l1.92-3.32a.5.5 0 0 0-.12-.64l-2.03-1.58zM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 导出菜单 -->
|
||||||
|
<div id="exportMenu" class="export-menu" hidden>
|
||||||
|
<button data-type="txt">导出为 TXT</button>
|
||||||
|
<button data-type="md">导出为 Markdown</button>
|
||||||
|
<button data-type="copy">复制全文</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-wrap">
|
||||||
|
<div id="editor" class="editor" contenteditable="true" spellcheck="false"
|
||||||
|
role="textbox" aria-multiline="true" data-placeholder="开始写作吧……"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 设置面板 -->
|
||||||
|
<div id="settingsMask" class="settings-mask" hidden>
|
||||||
|
<div class="settings-panel">
|
||||||
|
<div class="settings-header">
|
||||||
|
<span>设置</span>
|
||||||
|
<button id="settingsClose" class="icon-btn" title="关闭" aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-body">
|
||||||
|
<label class="set-row">
|
||||||
|
<span>主题</span>
|
||||||
|
<select id="setTheme">
|
||||||
|
<option value="system">跟随系统</option>
|
||||||
|
<option value="light">浅色</option>
|
||||||
|
<option value="dark">深色</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>正文字号</span>
|
||||||
|
<select id="setFont">
|
||||||
|
<option value="16">小</option>
|
||||||
|
<option value="19">中</option>
|
||||||
|
<option value="22">大</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>正文字体</span>
|
||||||
|
<select id="setFontFamily">
|
||||||
|
<option value='"Georgia", "Songti SC", "SimSun", serif'>宋体 / 衬线</option>
|
||||||
|
<option value='-apple-system, "Segoe UI", "Microsoft YaHei", sans-serif'>黑体 / 无衬线</option>
|
||||||
|
<option value='"Kaiti SC", "KaiTi", "STKaiti", serif'>楷体</option>
|
||||||
|
<option value='"LXGW WenKai", "Kaiti SC", "KaiTi", "STKaiti", serif'>霞鹜文楷</option>
|
||||||
|
<option value='"YouYuan", "圆体-简", sans-serif'>圆体</option>
|
||||||
|
<option value='"Consolas", "Courier New", "SFMono-Regular", monospace'>等宽</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>编辑区宽度</span>
|
||||||
|
<select id="setWidth">
|
||||||
|
<option value="720">窄</option>
|
||||||
|
<option value="920">适中</option>
|
||||||
|
<option value="1100">宽</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>段首缩进(字符)</span>
|
||||||
|
<select id="setIndent">
|
||||||
|
<option value="0">0(不缩进)</option>
|
||||||
|
<option value="2">2(默认)</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="6">6</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>段间距</span>
|
||||||
|
<select id="setParaGap">
|
||||||
|
<option value="0">0(无额外间距)</option>
|
||||||
|
<option value="0.6" selected>0.6 行高(默认)</option>
|
||||||
|
<option value="0.7">0.7 行高</option>
|
||||||
|
<option value="0.8">0.8 行高</option>
|
||||||
|
<option value="0.9">0.9 行高</option>
|
||||||
|
<option value="1">1 行高</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>行间距</span>
|
||||||
|
<select id="setLine">
|
||||||
|
<option value="1.2">紧凑 1.2</option>
|
||||||
|
<option value="1.5">适中 1.5</option>
|
||||||
|
<option value="1.8">舒适 1.8</option>
|
||||||
|
<option value="2">宽松 2.0</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="set-row">
|
||||||
|
<span>排版(写入正文)</span>
|
||||||
|
<button id="applyTypoBtn" class="text-btn primary" type="button">应用排版</button>
|
||||||
|
</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<span>输入区颜色</span>
|
||||||
|
<div class="color-picks" id="paperPicks">
|
||||||
|
<button type="button" class="color-swatch" data-color="" title="跟随主题(默认)" style="background:linear-gradient(135deg,#fbf8f1 50%,#232220 50%)"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#ffffff" title="纯白" style="background:#ffffff"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#f7f3e8" title="米黄(护眼)" style="background:#f7f3e8"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#e8f0e4" title="豆绿(护眼)" style="background:#e8f0e4"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#eef1f6" title="淡蓝" style="background:#eef1f6"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#1b1a18" title="纯黑" style="background:#1b1a18"></button>
|
||||||
|
<input type="color" id="setPaperCustom" title="自定义颜色" value="#fbf8f1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>打字机滚动</span>
|
||||||
|
<input type="checkbox" id="setType" />
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>每日目标字数</span>
|
||||||
|
<input type="number" id="setGoal" min="0" step="100" placeholder="0 = 不限制" />
|
||||||
|
</label>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>阅读模式显示字数</span>
|
||||||
|
<input type="checkbox" id="setFocusCount" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 历史记录面板 -->
|
||||||
|
<div id="historyMask" class="settings-mask" hidden>
|
||||||
|
<div class="settings-panel">
|
||||||
|
<div class="settings-header">
|
||||||
|
<span>历史记录</span>
|
||||||
|
<button id="historyClose" class="icon-btn" title="关闭" aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-body">
|
||||||
|
<p class="history-tip">自动保存的版本快照。点「预览」查看内容,点「恢复」覆盖当前正文(原内容会再存一个版本,可随时找回)。</p>
|
||||||
|
<div id="historyList" class="history-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 重命名弹窗 -->
|
||||||
|
<div id="renameMask" class="rename-mask" hidden>
|
||||||
|
<div class="rename-dialog">
|
||||||
|
<div class="rename-dialog-header">修改文章名</div>
|
||||||
|
<input id="renameInput" class="rename-input" placeholder="输入文章名" />
|
||||||
|
<div class="rename-dialog-btns">
|
||||||
|
<button id="renameCancel" class="text-btn">取消</button>
|
||||||
|
<button id="renameConfirm" class="text-btn primary">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="syncPanel" class="sync-panel" hidden>
|
||||||
|
<div class="sync-panel-header">
|
||||||
|
<span>多端同步</span>
|
||||||
|
<button id="syncClose" class="icon-btn" title="关闭" aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="sync-panel-body">
|
||||||
|
<div id="syncLogin">
|
||||||
|
<div id="modeWebdav">
|
||||||
|
<label>WebDAV 服务器地址</label>
|
||||||
|
<input id="wdServer" placeholder="https://dav.jianguoyun.com/dav/" />
|
||||||
|
<label>存储路径(文件名)</label>
|
||||||
|
<input id="wdPath" placeholder="jiandan-sync.json" />
|
||||||
|
<label>WebDAV 用户名</label>
|
||||||
|
<input id="wdUser" placeholder="WebDAV 账号" />
|
||||||
|
<label>WebDAV 密码</label>
|
||||||
|
<input id="wdPass" type="password" placeholder="登录 WebDAV 的密码" />
|
||||||
|
<label>加密密码(端到端,与 WebDAV 密码可不同,请牢记)</label>
|
||||||
|
<input id="wdCrypto" type="password" placeholder="用于加密内容,服务器无法读取" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="switch-row"><input type="checkbox" id="syncAutoLogin" checked /> 自动上传(编辑后自动同步到 WebDAV)</label>
|
||||||
|
|
||||||
|
<div class="sync-btns">
|
||||||
|
<button id="syncLoginBtn" class="text-btn primary">登录并同步</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="syncReady" hidden>
|
||||||
|
<p class="sync-user">已登录:<b id="syncUserLabel"></b></p>
|
||||||
|
<label class="switch-row"><input type="checkbox" id="syncAuto" checked /> 自动同步(编辑后)</label>
|
||||||
|
<div class="sync-btns">
|
||||||
|
<button id="syncNow" class="text-btn primary">立即同步</button>
|
||||||
|
<button id="syncLogout" class="text-btn">退出</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="backup-box">
|
||||||
|
<div class="backup-title">手动备份存档 <span class="backup-hint">(独立快照,防自动同步误覆盖)</span></div>
|
||||||
|
<div class="sync-btns backup-btns">
|
||||||
|
<button id="backupNow" class="text-btn">+ 生成存档</button>
|
||||||
|
<button id="backupRefresh" class="text-btn" title="刷新存档列表">刷新列表</button>
|
||||||
|
</div>
|
||||||
|
<label>历史存档</label>
|
||||||
|
<select id="backupSelect"><option value="">(暂无存档)</option></select>
|
||||||
|
<div class="sync-btns">
|
||||||
|
<button id="backupRestore" class="text-btn primary">恢复所选存档</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="syncStatus" class="sync-status"></p>
|
||||||
|
<p class="sync-tip">内容以你的密码端到端加密后上传,服务器仅存储密文,无法读取你的文字。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
<script src="sync.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "简单写作",
|
||||||
|
"short_name": "简单写作",
|
||||||
|
"description": "极简专注的跨平台写作工具",
|
||||||
|
"start_url": "./index.html",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#f4efe6",
|
||||||
|
"theme_color": "#f4efe6",
|
||||||
|
"lang": "zh-CN",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/* 简单写作 · 一站式启动器
|
||||||
|
* 提供:静态写作页面 + WebDAV 同步代理(同源 /api/webdav/,绕过浏览器 CORS)
|
||||||
|
* 用法: node start.js (默认端口 8080,可用 PORT 覆盖)
|
||||||
|
*/
|
||||||
|
const http = require("http");
|
||||||
|
const https = require("https");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
const ROOT = __dirname;
|
||||||
|
|
||||||
|
// ===================== WebDAV 代理(绕过浏览器 CORS,仅转发密文) =====================
|
||||||
|
function wdReq(targetBase, p, user, pass, method, body, extraHeaders) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const base = targetBase.replace(/\/$/, "");
|
||||||
|
const full = p ? base + "/" + String(p).replace(/^\//, "") : base;
|
||||||
|
let target;
|
||||||
|
try { target = new URL(full); } catch (e) { return reject(new Error("WebDAV 地址无效")); }
|
||||||
|
const auth = "Basic " + Buffer.from(user + ":" + pass, "utf8").toString("base64");
|
||||||
|
const mod = target.protocol === "https:" ? https : http;
|
||||||
|
const opt = {
|
||||||
|
method,
|
||||||
|
hostname: target.hostname,
|
||||||
|
port: target.port || (target.protocol === "https:" ? 443 : 80),
|
||||||
|
path: (target.pathname || "/") + (target.search || ""),
|
||||||
|
headers: Object.assign({ Authorization: auth }, extraHeaders || {}),
|
||||||
|
};
|
||||||
|
const req = mod.request(opt, (resp) => {
|
||||||
|
let data = "";
|
||||||
|
resp.on("data", (c) => (data += c));
|
||||||
|
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: data }));
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
if (body != null) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wdMkcolRecursive(base, dir, user, pass) {
|
||||||
|
const parts = String(dir).split("/").filter(Boolean);
|
||||||
|
let cur = base.replace(/\/$/, "");
|
||||||
|
for (const part of parts) {
|
||||||
|
cur = cur + "/" + part;
|
||||||
|
try { await wdReq(cur, "", user, pass, "MKCOL"); } catch (e) { /* 已存在则忽略 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wdParseProp(xml, tag) {
|
||||||
|
const m = xml.match(new RegExp(tag + "\\b[^>]*>([^<]*)<", "i"));
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 PROPFIND (Depth:1) 的 multistatus,取出所有 href(忽略命名空间前缀差异)
|
||||||
|
function wdParseHrefs(xml) {
|
||||||
|
const out = [];
|
||||||
|
const re = /<[^>]*:?href[^>]*>([^<]+)<\/[^>]*:?href>/gi;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(xml)) !== null) {
|
||||||
|
let h = m[1].trim();
|
||||||
|
try { h = decodeURIComponent(h); } catch (e) { /* 保留原样 */ }
|
||||||
|
out.push(h);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendWebdavJson(res, code, obj) {
|
||||||
|
res.writeHead(code, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWebdav(req, res, url) {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (c) => (body += c));
|
||||||
|
req.on("end", async () => {
|
||||||
|
let json = {};
|
||||||
|
try { if (body) json = JSON.parse(body); } catch { return sendWebdavJson(res, 400, { error: "请求格式错误" }); }
|
||||||
|
const { base, path: p, user, pass } = json;
|
||||||
|
if (!base || !user) return sendWebdavJson(res, 400, { error: "缺少 WebDAV 服务器地址或用户名" });
|
||||||
|
const ep = url.pathname.replace("/api/webdav/", "");
|
||||||
|
try {
|
||||||
|
if (ep === "propfind") {
|
||||||
|
const xml = '<?xml version="1.0" encoding="utf-8"?>' +
|
||||||
|
'<D:propfind xmlns:D="DAV:"><D:prop><D:getetag/><D:getlastmodified/></D:prop></D:propfind>';
|
||||||
|
const r = await wdReq(base, p, user, pass, "PROPFIND", xml, { Depth: "0", "Content-Type": "application/xml" });
|
||||||
|
if (r.status === 404) return sendWebdavJson(res, 404, { status: 404 });
|
||||||
|
return sendWebdavJson(res, 200, {
|
||||||
|
status: r.status,
|
||||||
|
etag: r.headers["etag"] || wdParseProp(r.body, "getetag"),
|
||||||
|
lastmodified: r.headers["last-modified"] || wdParseProp(r.body, "getlastmodified"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ep === "get") {
|
||||||
|
const r = await wdReq(base, p, user, pass, "GET");
|
||||||
|
return sendWebdavJson(res, r.status, { status: r.status, etag: r.headers["etag"], content: r.body });
|
||||||
|
}
|
||||||
|
if (ep === "list") {
|
||||||
|
// 列目录(Depth:1),返回目录下所有 href,供前端筛选备份存档
|
||||||
|
const xml = '<?xml version="1.0" encoding="utf-8"?>' +
|
||||||
|
'<D:propfind xmlns:D="DAV:"><D:prop><D:getlastmodified/></D:prop></D:propfind>';
|
||||||
|
const r = await wdReq(base, p, user, pass, "PROPFIND", xml, { Depth: "1", "Content-Type": "application/xml" });
|
||||||
|
if (r.status === 404) return sendWebdavJson(res, 200, { status: 404, hrefs: [] });
|
||||||
|
return sendWebdavJson(res, 200, { status: r.status, hrefs: wdParseHrefs(r.body) });
|
||||||
|
}
|
||||||
|
if (ep === "put") {
|
||||||
|
const dir = String(p || "").includes("/") ? p.substring(0, p.lastIndexOf("/")) : "";
|
||||||
|
if (dir) await wdMkcolRecursive(base, dir, user, pass);
|
||||||
|
const r = await wdReq(base, p, user, pass, "PUT", json.content || "", { "Content-Type": "application/json" });
|
||||||
|
return sendWebdavJson(res, 200, { status: r.status, etag: r.headers["etag"] });
|
||||||
|
}
|
||||||
|
return sendWebdavJson(res, 404, { error: "未知端点" });
|
||||||
|
} catch (e) {
|
||||||
|
return sendWebdavJson(res, 502, { error: String((e && e.message) || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 静态文件 =====================
|
||||||
|
const MIME = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".webmanifest": "application/manifest+json",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
};
|
||||||
|
|
||||||
|
function serveStatic(req, res, url) {
|
||||||
|
let pathname = decodeURIComponent(url.pathname);
|
||||||
|
if (pathname === "/" || pathname === "") pathname = "/index.html";
|
||||||
|
const filePath = path.normalize(path.join(ROOT, pathname));
|
||||||
|
if (!filePath.startsWith(ROOT)) { res.writeHead(403); return res.end("Forbidden"); }
|
||||||
|
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
return res.end("404 Not Found");
|
||||||
|
}
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Cache-Control": "no-cache" });
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 路由 =====================
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, "http://localhost");
|
||||||
|
if (url.pathname.startsWith("/api/webdav/")) { handleWebdav(req, res, url); return; }
|
||||||
|
serveStatic(req, res, url);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, "0.0.0.0", () => {
|
||||||
|
console.log(`[简单写作] 已启动 → http://localhost:${PORT}`);
|
||||||
|
console.log(`[简单写作] WebDAV 代理 → http://localhost:${PORT}/api/webdav/`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4efe6;
|
||||||
|
--paper: #fbf8f1;
|
||||||
|
--text: #2f2a24;
|
||||||
|
--text-soft: #8a8175;
|
||||||
|
--line: #e5ddcf;
|
||||||
|
--accent: #b9925a;
|
||||||
|
--sidebar-bg: #efe8db;
|
||||||
|
--hover: #e9e1d2;
|
||||||
|
--shadow: 0 2px 12px rgba(80, 60, 30, 0.06);
|
||||||
|
--font-ui: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
--font-write: "Georgia", "Songti SC", "SimSun", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #1b1a18;
|
||||||
|
--paper: #232220;
|
||||||
|
--text: #e6e1d6;
|
||||||
|
--text-soft: #8f887b;
|
||||||
|
--line: #34322e;
|
||||||
|
--accent: #c9a15f;
|
||||||
|
--sidebar-bg: #201f1d;
|
||||||
|
--hover: #2c2a27;
|
||||||
|
--shadow: 0 2px 14px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
/* hidden 属性必须始终生效,避免被 display 规则覆盖(如设置面板的 flex) */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 侧边栏 ---------- */
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: transform 0.28s ease, width 0.28s ease;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.doc-item:hover { background: var(--hover); }
|
||||||
|
.doc-item.active { background: var(--hover); }
|
||||||
|
.doc-item.active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 20%;
|
||||||
|
height: 60%;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item .d-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.doc-item .d-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin-top: 3px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.doc-item .del-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px; top: 8px;
|
||||||
|
border: none; background: transparent;
|
||||||
|
color: var(--text-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.doc-item:hover .del-btn { opacity: 1; }
|
||||||
|
.doc-item .del-btn:hover { background: var(--line); color: #c0392b; }
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 主区 ---------- */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn-wrap { display: none; }
|
||||||
|
|
||||||
|
.title-input {
|
||||||
|
flex: 1;
|
||||||
|
visibility: hidden;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-write);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions { display: flex; gap: 4px; }
|
||||||
|
|
||||||
|
/* 重命名按钮默认隐藏,仅移动端显示 */
|
||||||
|
.rename-btn { display: none; }
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--hover); color: var(--text); }
|
||||||
|
.icon-btn.active { color: var(--accent); background: var(--hover); }
|
||||||
|
|
||||||
|
/* 同步按钮右下角的轻量绿色圆点(上传成功后显示) */
|
||||||
|
#syncBtn { position: relative; }
|
||||||
|
.sync-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2ecc71;
|
||||||
|
box-shadow: 0 0 0 2px var(--paper);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sync-badge[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.text-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.text-btn:hover { background: var(--hover); color: var(--text); }
|
||||||
|
|
||||||
|
/* ---------- 编辑区 ---------- */
|
||||||
|
.editor-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑区 */
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 920px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-write);
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 2;
|
||||||
|
padding: 48px 16px 40vh;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* contenteditable:保留空白与换行、长词换行、竖排书写方向常规 */
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
position: relative;
|
||||||
|
-webkit-user-modify: read-write-plaintext-only; /* 尽量按纯文本编辑,减少富文本粘贴 */
|
||||||
|
}
|
||||||
|
/* 段落块:段间距 = 段间距档位 × 行高(--para-gap,纯排版、不写入正文) */
|
||||||
|
/* 段首缩进也通过 CSS text-indent 实现,不写入正文 */
|
||||||
|
.editor .para { margin: 0; text-indent: var(--para-indent, 0); }
|
||||||
|
.editor .para + .para { margin-top: var(--para-gap, 0); }
|
||||||
|
/* 占位符(内容为空时显示) */
|
||||||
|
.editor.is-empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--text-soft);
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑区滚动条:细、不预留空间、不用时透明,悬停/聚焦/拖动时悬浮显示 */
|
||||||
|
.editor { scrollbar-width: thin; scrollbar-color: transparent transparent; }
|
||||||
|
.editor::-webkit-scrollbar { width: 8px; }
|
||||||
|
.editor::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.editor::-webkit-scrollbar-thumb {
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.editor:hover::-webkit-scrollbar-thumb,
|
||||||
|
.editor:focus::-webkit-scrollbar-thumb,
|
||||||
|
.editor:active::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--line);
|
||||||
|
}
|
||||||
|
.editor::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--text-soft);
|
||||||
|
}
|
||||||
|
/* 深色主题下略微调亮,保持可见但依旧克制 */
|
||||||
|
[data-theme="dark"] .editor:hover::-webkit-scrollbar-thumb,
|
||||||
|
[data-theme="dark"] .editor:focus::-webkit-scrollbar-thumb,
|
||||||
|
[data-theme="dark"] .editor:active::-webkit-scrollbar-thumb { background-color: #4a463f; }
|
||||||
|
[data-theme="dark"] .editor::-webkit-scrollbar-thumb:hover { background-color: var(--text-soft); }
|
||||||
|
|
||||||
|
/* ---------- 导出菜单 ---------- */
|
||||||
|
.export-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 18px;
|
||||||
|
top: 52px;
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 40;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
.export-menu button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.export-menu button:hover { background: var(--hover); }
|
||||||
|
|
||||||
|
/* ---------- 遮罩 ---------- */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
z-index: 25;
|
||||||
|
}
|
||||||
|
.overlay.show { opacity: 1; pointer-events: auto; }
|
||||||
|
|
||||||
|
/* ---------- 阅读模式 ---------- */
|
||||||
|
body.reading .sidebar,
|
||||||
|
body.reading .topbar {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
body.reading .sidebar { transform: translateX(-100%); }
|
||||||
|
body.reading .topbar {
|
||||||
|
height: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body.reading .editor {
|
||||||
|
padding-top: 12vh;
|
||||||
|
cursor: default;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
body.reading .topbar,
|
||||||
|
body.reading .sidebar { transition: all 0.3s; }
|
||||||
|
|
||||||
|
/* 阅读模式下右上角悬浮退出提示 */
|
||||||
|
.reading-exit {
|
||||||
|
position: fixed;
|
||||||
|
top: 14px; right: 18px;
|
||||||
|
z-index: 50;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* 阅读模式下默认隐藏,鼠标/手指移到顶部才浮现 */
|
||||||
|
body.reading .reading-exit { opacity: 0; pointer-events: none; }
|
||||||
|
body.reading.top-shown .reading-exit { opacity: 0.75; pointer-events: auto; }
|
||||||
|
.reading-exit:hover { opacity: 1 !important; }
|
||||||
|
|
||||||
|
/* ---------- 移动端 ---------- */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.sidebar.open { transform: translateX(0); }
|
||||||
|
.editor {
|
||||||
|
font-size: 17px;
|
||||||
|
padding: 42px 0 40vh 16px; /* top 42px 为固定顶栏留空 */
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
/* 顶栏始终显示,不隐藏 */
|
||||||
|
.topbar {
|
||||||
|
padding: 6px 8px;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
/* 移动端隐藏标题框,仅弹窗方式修改 */
|
||||||
|
.title-input { display: none; }
|
||||||
|
/* 移动端显示重命名按钮 */
|
||||||
|
.rename-btn { display: inline-flex; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 721px) {
|
||||||
|
#menuBtn { display: none; }
|
||||||
|
}
|
||||||
|
/* 大屏显示器:进一步放宽输入宽度 */
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
.editor { max-width: 1100px; }
|
||||||
|
}
|
||||||
|
@media (min-width: 2000px) {
|
||||||
|
.editor { max-width: 1280px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 重命名弹窗 ---------- */
|
||||||
|
.rename-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn 0.2s;
|
||||||
|
}
|
||||||
|
.rename-dialog {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px 20px 16px;
|
||||||
|
width: min(360px, 90vw);
|
||||||
|
box-shadow: var(--shadow), 0 8px 30px rgba(0,0,0,0.12);
|
||||||
|
animation: slideUp 0.25s ease;
|
||||||
|
}
|
||||||
|
.rename-dialog-header {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.rename-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: var(--font-write);
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.rename-input:focus { border-color: var(--accent); }
|
||||||
|
.rename-dialog-btns {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
/* ---------- 同步面板 ---------- */
|
||||||
|
.sync-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 18px;
|
||||||
|
top: 52px;
|
||||||
|
width: 300px;
|
||||||
|
max-width: calc(100vw - 36px);
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 40;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.sync-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sync-panel-body { padding: 14px; }
|
||||||
|
.sync-panel label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
}
|
||||||
|
.sync-panel input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.sync-panel input:focus { border-color: var(--accent); }
|
||||||
|
.sync-panel select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.sync-panel select:focus { border-color: var(--accent); }
|
||||||
|
.sync-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.text-btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.text-btn.primary:hover { filter: brightness(1.05); color: #fff; background: var(--accent); }
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.switch-row input { width: auto; }
|
||||||
|
.sync-user { font-size: 13px; margin-bottom: 4px; }
|
||||||
|
.sync-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-top: 12px;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
.sync-tip {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin-top: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 手动备份存档 ---------- */
|
||||||
|
.backup-box {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
.backup-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.backup-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.backup-btns { margin-top: 10px; justify-content: flex-start; }
|
||||||
|
|
||||||
|
/* ---------- 设置面板 ---------- */
|
||||||
|
.settings-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
.settings-panel {
|
||||||
|
width: 360px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
max-height: 86vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.settings-body { padding: 4px 16px 14px; }
|
||||||
|
.set-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.set-row:last-child { border-bottom: none; }
|
||||||
|
.set-row > span { color: var(--text); }
|
||||||
|
.set-row select,
|
||||||
|
.set-row input[type="number"] {
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.set-row select:focus,
|
||||||
|
.set-row input:focus { border-color: var(--accent); }
|
||||||
|
.set-row input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 历史记录面板 ---------- */
|
||||||
|
.history-tip {
|
||||||
|
margin: 10px 2px 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.history-list { padding: 6px 0 4px; }
|
||||||
|
.history-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-soft);
|
||||||
|
padding: 18px 4px;
|
||||||
|
}
|
||||||
|
.history-item {
|
||||||
|
padding: 11px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.history-item:last-child { border-bottom: none; }
|
||||||
|
.h-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.h-time { font-size: 13.5px; font-weight: 600; color: var(--text); }
|
||||||
|
.h-words { font-size: 12px; color: var(--text-soft); flex-shrink: 0; }
|
||||||
|
.h-snippet {
|
||||||
|
margin: 5px 0 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-soft);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.h-actions { display: flex; gap: 6px; }
|
||||||
|
.h-actions .text-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.h-full {
|
||||||
|
margin: 9px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: var(--font-write);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
#goalCount {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入区颜色选择 */
|
||||||
|
.color-picks {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.color-swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.color-swatch:hover { transform: scale(1.12); }
|
||||||
|
.color-swatch.active {
|
||||||
|
box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px var(--accent);
|
||||||
|
}
|
||||||
|
#setPaperCustom {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/* 简单写作 · Service Worker(离线缓存,network-first) */
|
||||||
|
const CACHE = "jiandan-v1";
|
||||||
|
const ASSETS = [
|
||||||
|
"./",
|
||||||
|
"./index.html",
|
||||||
|
"./styles.css",
|
||||||
|
"./app.js",
|
||||||
|
"./manifest.json",
|
||||||
|
"./icon.svg"
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (e) => {
|
||||||
|
if (e.request.method !== "GET") return;
|
||||||
|
// 同步 API 不走缓存,直接请求
|
||||||
|
if (e.request.url.includes("/api/")) {
|
||||||
|
e.respondWith(fetch(e.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 其余资源:先请求网络(保证更新即时),失败再回退缓存(离线可用)
|
||||||
|
e.respondWith(
|
||||||
|
fetch(e.request)
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.status === 200 && res.type === "basic") {
|
||||||
|
const clone = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(e.request, clone));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(e.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
/* ==================== 简单写作 · WebDAV 同步(前端) ==================== */
|
||||||
|
/* 内容在浏览器端用 AES-GCM 端到端加密,经本机后端 /api/webdav/ 代理转发到 WebDAV(代理不接触明文)。
|
||||||
|
* 浏览器直连第三方 WebDAV 会被 CORS 拦截,因此统一经同源代理。
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
const CC = () => window.JianDan;
|
||||||
|
const SYNC_KEY = "jiandan.syncCfg";
|
||||||
|
|
||||||
|
let cfg = loadCfg(); // { webdav:{base,path,user,pass,crypto}, lastSync, auto }
|
||||||
|
if (cfg.auto === undefined) cfg.auto = true; // 默认开启自动上传
|
||||||
|
let state = { token: null, key: null, loggedIn: false, username: "", wd: null, lastEtag: null };
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const panel = $("syncPanel");
|
||||||
|
const syncBtn = $("syncBtn");
|
||||||
|
const badge = $("syncBadge");
|
||||||
|
|
||||||
|
// 上传成功后在同步图标右下角显示绿色✅;同步中/失败/登出时隐藏
|
||||||
|
function showBadge(on) {
|
||||||
|
if (badge) badge.hidden = !on;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧版配置若残留「自建服务器」模式,统一迁移为 WebDAV
|
||||||
|
if (cfg.mode === "server") { delete cfg.mode; saveCfg(); }
|
||||||
|
|
||||||
|
// ---------- 配置持久化 ----------
|
||||||
|
function loadCfg() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(SYNC_KEY)) || {}; }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
function saveCfg() { localStorage.setItem(SYNC_KEY, JSON.stringify(cfg)); }
|
||||||
|
|
||||||
|
// ---------- 加解密(Web Crypto,端到端) ----------
|
||||||
|
function b64(buf) {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let bin = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
function b64ToBytes(b) {
|
||||||
|
const s = atob(b);
|
||||||
|
const a = new Uint8Array(s.length);
|
||||||
|
for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
async function deriveKey(password, username) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const salt = enc.encode("jiandan-sync-v1::" + username);
|
||||||
|
const base = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]);
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
|
||||||
|
base,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function enc(key, text) {
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text || ""));
|
||||||
|
return b64(iv) + ":" + b64(ct);
|
||||||
|
}
|
||||||
|
async function dec(key, blob) {
|
||||||
|
if (!blob) return "";
|
||||||
|
const [ivb, ctb] = String(blob).split(":");
|
||||||
|
if (!ivb || !ctb) return "";
|
||||||
|
const iv = b64ToBytes(ivb);
|
||||||
|
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, b64ToBytes(ctb));
|
||||||
|
return new TextDecoder().decode(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 工具 ----------
|
||||||
|
async function encryptDoc(key, d) {
|
||||||
|
return { id: d.id, title: await enc(key, d.title), content: await enc(key, d.content), updated: d.updated };
|
||||||
|
}
|
||||||
|
async function decryptDoc(key, e) {
|
||||||
|
try {
|
||||||
|
return { id: e.id, title: await dec(key, e.title), content: await dec(key, e.content), updated: e.updated };
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function mergeIntoLocal(remoteDocs) {
|
||||||
|
const apiObj = CC();
|
||||||
|
const map = {};
|
||||||
|
for (const d of apiObj.getDocs()) map[d.id] = d;
|
||||||
|
for (const r of remoteDocs) {
|
||||||
|
const ex = map[r.id];
|
||||||
|
if (!ex || (r.updated || 0) > (ex.updated || 0)) map[r.id] = r;
|
||||||
|
}
|
||||||
|
apiObj.setDocs(Object.values(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 路径工具(备份存档用) ----------
|
||||||
|
const pad2 = (n) => String(n).padStart(2, "0");
|
||||||
|
function dirOf(p) { const i = String(p || "").lastIndexOf("/"); return i >= 0 ? p.slice(0, i) : ""; }
|
||||||
|
function joinPath(dir, name) { return dir ? dir.replace(/\/$/, "") + "/" + name : name; }
|
||||||
|
function backupName() {
|
||||||
|
const d = new Date();
|
||||||
|
return `jiandan-backup-${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}.json`;
|
||||||
|
}
|
||||||
|
function fmtBackupLabel(name) {
|
||||||
|
const m = name.match(/jiandan-backup-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.json/i);
|
||||||
|
return m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- WebDAV 代理 API(同源,绕过 CORS) ----------
|
||||||
|
async function wdApi(ep, o) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/webdav/" + ep, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(o),
|
||||||
|
});
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) { return { status: 0, error: e.message }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 同步核心:WebDAV ----------
|
||||||
|
let wdSyncing = false;
|
||||||
|
let wdDirty = false;
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
async function wdSyncNow() {
|
||||||
|
if (!state.loggedIn) return;
|
||||||
|
if (wdSyncing) return;
|
||||||
|
wdSyncing = true;
|
||||||
|
showBadge(false);
|
||||||
|
setStatus("同步中…");
|
||||||
|
try {
|
||||||
|
const wd = state.wd;
|
||||||
|
const key = state.key;
|
||||||
|
// 1. 检查远程是否有变化
|
||||||
|
const pr = await wdApi("propfind", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass });
|
||||||
|
if (pr.status === 401 || pr.status === 403) { setStatus("WebDAV 认证失败,请检查账号密码"); stopPoll(); return; }
|
||||||
|
if (pr.status !== 200 && pr.status !== 207 && pr.status !== 404) {
|
||||||
|
throw new Error("WebDAV " + (pr.error || pr.status));
|
||||||
|
}
|
||||||
|
let remoteDocs = [];
|
||||||
|
if (pr.status === 200 && pr.etag && pr.etag === state.lastEtag && !wdDirty) {
|
||||||
|
// 远程无变化且本地无改动
|
||||||
|
setStatus("已是最新 · " + CC().fmtDate(cfg.lastSync || Date.now()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pr.status === 200) {
|
||||||
|
const gr = await wdApi("get", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass });
|
||||||
|
if (gr.status === 200 && gr.content) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(gr.content);
|
||||||
|
for (const e of (parsed.docs || [])) {
|
||||||
|
const d = await decryptDoc(key, e);
|
||||||
|
if (d) remoteDocs.push(d);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 忽略坏数据 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. 合并远程到本地
|
||||||
|
mergeIntoLocal(remoteDocs);
|
||||||
|
// 3. 把本地最新整文件推回 WebDAV
|
||||||
|
const apiObj = CC();
|
||||||
|
const toPush = [];
|
||||||
|
for (const d of apiObj.getDocs()) toPush.push(await encryptDoc(key, d));
|
||||||
|
const payload = JSON.stringify({ docs: toPush, v: 1 });
|
||||||
|
const ur = await wdApi("put", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass, content: payload });
|
||||||
|
if (ur.status !== 200 && ur.status !== 201 && ur.status !== 204 && ur.status !== 207) {
|
||||||
|
throw new Error("上传失败 " + (ur.error || ur.status));
|
||||||
|
}
|
||||||
|
wdDirty = false;
|
||||||
|
state.lastEtag = ur.etag || pr.etag || null;
|
||||||
|
apiObj.persist();
|
||||||
|
apiObj.renderList();
|
||||||
|
apiObj.renderEditor();
|
||||||
|
cfg.lastSync = Date.now();
|
||||||
|
saveCfg();
|
||||||
|
showBadge(true);
|
||||||
|
setStatus("已同步 · " + apiObj.fmtDate(cfg.lastSync));
|
||||||
|
} catch (e) {
|
||||||
|
showBadge(false);
|
||||||
|
setStatus("同步失败:" + e.message);
|
||||||
|
} finally {
|
||||||
|
wdSyncing = false;
|
||||||
|
schedulePoll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll() {
|
||||||
|
if (pollTimer) clearTimeout(pollTimer);
|
||||||
|
if (!state.loggedIn || cfg.auto === false) return;
|
||||||
|
pollTimer = setTimeout(() => wdSyncNow(), 8000);
|
||||||
|
}
|
||||||
|
function stopPoll() { if (pollTimer) clearTimeout(pollTimer); }
|
||||||
|
|
||||||
|
// ---------- 手动备份存档(独立快照,不覆盖主同步文件) ----------
|
||||||
|
async function manualBackup() {
|
||||||
|
if (!state.loggedIn) { setStatus("请先登录 WebDAV"); return; }
|
||||||
|
const wd = state.wd, key = state.key;
|
||||||
|
setStatus("生成存档中…");
|
||||||
|
try {
|
||||||
|
const apiObj = CC();
|
||||||
|
const toPush = [];
|
||||||
|
for (const d of apiObj.getDocs()) toPush.push(await encryptDoc(key, d));
|
||||||
|
const payload = JSON.stringify({ docs: toPush, v: 1, backupAt: Date.now() });
|
||||||
|
const name = backupName();
|
||||||
|
const p = joinPath(dirOf(wd.path), name);
|
||||||
|
const r = await wdApi("put", { base: wd.base, path: p, user: wd.user, pass: wd.pass, content: payload });
|
||||||
|
if (r.status !== 200 && r.status !== 201 && r.status !== 204 && r.status !== 207) {
|
||||||
|
throw new Error("存档失败 " + (r.error || r.status));
|
||||||
|
}
|
||||||
|
setStatus("已生成存档 · " + fmtBackupLabel(name));
|
||||||
|
loadBackupList();
|
||||||
|
} catch (e) { setStatus("存档失败:" + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackupList() {
|
||||||
|
if (!state.loggedIn) return;
|
||||||
|
const wd = state.wd;
|
||||||
|
const sel = $("backupSelect");
|
||||||
|
if (!sel) return;
|
||||||
|
try {
|
||||||
|
const r = await wdApi("list", { base: wd.base, path: dirOf(wd.path), user: wd.user, pass: wd.pass });
|
||||||
|
const names = (r.hrefs || [])
|
||||||
|
.map((h) => h.split("/").filter(Boolean).pop() || "")
|
||||||
|
.filter((n) => /^jiandan-backup-.*\.json$/i.test(n));
|
||||||
|
// 去重 + 按时间倒序(文件名含时间戳,字典序即时间序)
|
||||||
|
const uniq = Array.from(new Set(names)).sort().reverse();
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (!uniq.length) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = ""; o.textContent = "(暂无存档)";
|
||||||
|
sel.appendChild(o);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const n of uniq) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = n; o.textContent = fmtBackupLabel(n);
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 忽略列表加载失败 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBackup() {
|
||||||
|
if (!state.loggedIn) { setStatus("请先登录 WebDAV"); return; }
|
||||||
|
const sel = $("backupSelect");
|
||||||
|
const name = sel ? sel.value : "";
|
||||||
|
if (!name) { setStatus("请先选择一个存档"); return; }
|
||||||
|
if (!window.confirm(`确定用存档「${fmtBackupLabel(name)}」恢复吗?\n将与当前内容合并(同一篇以更新时间较新者为准),不会删除本地文章。`)) return;
|
||||||
|
const wd = state.wd, key = state.key;
|
||||||
|
setStatus("恢复中…");
|
||||||
|
try {
|
||||||
|
const p = joinPath(dirOf(wd.path), name);
|
||||||
|
const gr = await wdApi("get", { base: wd.base, path: p, user: wd.user, pass: wd.pass });
|
||||||
|
if (gr.status !== 200 || !gr.content) throw new Error("读取存档失败 " + (gr.error || gr.status));
|
||||||
|
const parsed = JSON.parse(gr.content);
|
||||||
|
const remoteDocs = [];
|
||||||
|
for (const e of (parsed.docs || [])) { const d = await decryptDoc(key, e); if (d) remoteDocs.push(d); }
|
||||||
|
if (!remoteDocs.length) throw new Error("存档为空或解密失败(检查加密密码)");
|
||||||
|
mergeIntoLocal(remoteDocs);
|
||||||
|
const apiObj = CC();
|
||||||
|
apiObj.persist(); apiObj.renderList(); apiObj.renderEditor();
|
||||||
|
setStatus("已从存档恢复 · " + fmtBackupLabel(name));
|
||||||
|
wdDirty = true;
|
||||||
|
wdSyncNow(); // 把恢复合并后的结果推回主同步文件
|
||||||
|
} catch (e) { setStatus("恢复失败:" + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 统一入口 ----------
|
||||||
|
function syncNow() { return wdSyncNow(); }
|
||||||
|
let wdTimer = null;
|
||||||
|
function scheduleSync() {
|
||||||
|
if (!state.loggedIn) return;
|
||||||
|
wdDirty = true;
|
||||||
|
if (cfg.auto === false) return;
|
||||||
|
if (wdTimer) clearTimeout(wdTimer);
|
||||||
|
wdTimer = setTimeout(() => wdSyncNow(), 1200);
|
||||||
|
}
|
||||||
|
window.JianDanSync = { scheduleSync };
|
||||||
|
|
||||||
|
// ---------- 登录:WebDAV ----------
|
||||||
|
async function doWdLogin() {
|
||||||
|
const base = $("wdServer").value.trim();
|
||||||
|
const path = $("wdPath").value.trim() || "jiandan-sync.json";
|
||||||
|
const user = $("wdUser").value.trim();
|
||||||
|
const pass = $("wdPass").value;
|
||||||
|
const cryptoPass = $("wdCrypto").value;
|
||||||
|
if (!base || !user || !pass || !cryptoPass) { setStatus("请填写完整(WebDAV 账号/密码 + 加密密码)"); return; }
|
||||||
|
if (!crypto.subtle) { setStatus("需 HTTPS 才能同步"); return; }
|
||||||
|
const autoOn = $("syncAutoLogin") ? $("syncAutoLogin").checked : true;
|
||||||
|
setStatus("连接 WebDAV 中…");
|
||||||
|
try {
|
||||||
|
const pr = await wdApi("propfind", { base, path, user, pass });
|
||||||
|
if (pr.status !== 200 && pr.status !== 207 && pr.status !== 404) {
|
||||||
|
throw new Error("连接失败(" + (pr.error || pr.status) + "),检查地址/账号");
|
||||||
|
}
|
||||||
|
const key = await deriveKey(cryptoPass, user);
|
||||||
|
state = { token: null, key, loggedIn: true, username: user, wd: { base, path, user, pass }, lastEtag: null };
|
||||||
|
cfg.webdav = { base, path, user, pass, crypto: cryptoPass };
|
||||||
|
cfg.auto = autoOn !== false; saveCfg();
|
||||||
|
showReady();
|
||||||
|
wdSyncNow();
|
||||||
|
} catch (e) { setStatus("登录失败:" + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLogout() {
|
||||||
|
stopPoll();
|
||||||
|
showBadge(false);
|
||||||
|
state = { token: null, key: null, loggedIn: false, username: "", wd: null, lastEtag: null };
|
||||||
|
delete cfg.webdav;
|
||||||
|
saveCfg();
|
||||||
|
showLogin(); setStatus("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- UI ----------
|
||||||
|
function showReady() {
|
||||||
|
$("syncLogin").hidden = true;
|
||||||
|
$("syncReady").hidden = false;
|
||||||
|
$("syncUserLabel").textContent = state.username + "(WebDAV)";
|
||||||
|
$("syncAuto").checked = cfg.auto !== false;
|
||||||
|
$("syncStatus").textContent = cfg.lastSync ? ("上次同步 " + CC().fmtDate(cfg.lastSync)) : "";
|
||||||
|
loadBackupList();
|
||||||
|
}
|
||||||
|
function showLogin() {
|
||||||
|
$("syncLogin").hidden = false;
|
||||||
|
$("syncReady").hidden = true;
|
||||||
|
const autoEl = $("syncAutoLogin");
|
||||||
|
if (autoEl) autoEl.checked = cfg.auto !== false;
|
||||||
|
if (cfg.webdav) {
|
||||||
|
$("wdServer").value = cfg.webdav.base || "";
|
||||||
|
$("wdPath").value = cfg.webdav.path || "jiandan-sync.json";
|
||||||
|
$("wdUser").value = cfg.webdav.user || "";
|
||||||
|
$("wdPass").value = cfg.webdav.pass || "";
|
||||||
|
$("wdCrypto").value = cfg.webdav.crypto || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setStatus(t) { const el = $("syncStatus"); if (el) el.textContent = t; }
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
if (!state.loggedIn) showLogin(); else showReady();
|
||||||
|
panel.hidden = false;
|
||||||
|
}
|
||||||
|
function closePanel() { panel.hidden = true; }
|
||||||
|
|
||||||
|
// ---------- 事件 ----------
|
||||||
|
syncBtn.addEventListener("click", (e) => { e.stopPropagation(); panel.hidden = !panel.hidden; if (!panel.hidden) openPanel(); });
|
||||||
|
$("syncClose").addEventListener("click", closePanel);
|
||||||
|
$("syncLoginBtn").addEventListener("click", doWdLogin);
|
||||||
|
$("syncNow").addEventListener("click", syncNow);
|
||||||
|
$("syncLogout").addEventListener("click", doLogout);
|
||||||
|
$("backupNow").addEventListener("click", manualBackup);
|
||||||
|
$("backupRefresh").addEventListener("click", loadBackupList);
|
||||||
|
$("backupRestore").addEventListener("click", restoreBackup);
|
||||||
|
$("syncAuto").addEventListener("change", (e) => { cfg.auto = e.target.checked; saveCfg(); if (e.target.checked) schedulePoll(); });
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!panel.hidden && !panel.contains(e.target) && e.target.id !== "syncBtn") closePanel();
|
||||||
|
});
|
||||||
|
["wdServer", "wdPath", "wdUser", "wdPass", "wdCrypto"].forEach((id) =>
|
||||||
|
$(id).addEventListener("keydown", (e) => { if (e.key === "Enter") doWdLogin(); })
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- WebDAV 自动恢复(刷新后免登录继续同步) ----------
|
||||||
|
if (cfg.webdav && cfg.webdav.base && cfg.webdav.crypto) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const w = cfg.webdav;
|
||||||
|
const key = await deriveKey(w.crypto, w.user);
|
||||||
|
const pr = await wdApi("propfind", { base: w.base, path: w.path, user: w.user, pass: w.pass });
|
||||||
|
state = { token: null, key, loggedIn: true, username: w.user, wd: { base: w.base, path: w.path, user: w.user, pass: w.pass }, lastEtag: null };
|
||||||
|
showReady();
|
||||||
|
wdSyncNow();
|
||||||
|
} catch (e) { /* 忽略,等待用户手动登录 */ }
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const http = require("http");
|
||||||
|
function req(path, method, data) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const body = data ? JSON.stringify(data) : null;
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (body) headers["Content-Length"] = Buffer.byteLength(body);
|
||||||
|
const r = http.request({ host: "localhost", port: 8080, path, method, headers }, (res) => {
|
||||||
|
let d = ""; res.on("data", (c) => (d += c)); res.on("end", () => resolve({ status: res.statusCode, body: d.slice(0, 200) }));
|
||||||
|
});
|
||||||
|
r.on("error", reject);
|
||||||
|
if (body) r.write(body);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
// 静态页面
|
||||||
|
const page = await req("/", "GET");
|
||||||
|
console.log("GET / ->", page.status, page.body.includes("简单写作") ? "(含页面)" : "");
|
||||||
|
const js = await req("/app.js", "GET");
|
||||||
|
console.log("GET /app.js ->", js.status);
|
||||||
|
// WebDAV 代理(用一个无效地址,验证代理在运行并能正确返回错误而非崩溃)
|
||||||
|
const wd = await req("/api/webdav/propfind", "POST", { base: "https://invalid.invalid.example/", path: "x.json", user: "u", pass: "p" });
|
||||||
|
console.log("webdav proxy ->", wd.status, "(代理已响应,预期非 200)");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 简单写作 · 使用说明
|
||||||
|
|
||||||
|
## 启动项目
|
||||||
|
|
||||||
|
在项目目录下执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node src/start.js
|
||||||
|
```
|
||||||
|
|
||||||
|
看到 `[简单写作] 已启动 → http://localhost:8080` 后,浏览器打开该地址即可使用。
|
||||||
|
|
||||||
|
也可以用 `npm start` 一键启动。
|
||||||
|
|
||||||
|
指定端口:`PORT=9000 node src/start.js`(PowerShell 用 `$env:PORT=9000; node src/start.js`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装为桌面应用(PWA)
|
||||||
|
|
||||||
|
打开 http://localhost:8080 后,浏览器地址栏右侧会出现安装图标 ⊕,点击即可安装到桌面,以后像本地软件一样打开,支持离线写作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 基本功能
|
||||||
|
|
||||||
|
| 功能 | 操作 |
|
||||||
|
|---|---|
|
||||||
|
| 新建文章 | 左侧列表点 ➕ |
|
||||||
|
| 切换文章 | 左侧列表点击对应项 |
|
||||||
|
| 删除文章 | 左侧列表点 🗑 |
|
||||||
|
| 重命名文章 | 点笔图标(移动端)或直接在顶栏编辑(桌面端) |
|
||||||
|
| 导出 | 工具栏点「导出」→ 选 TXT / Markdown / 复制全文 |
|
||||||
|
| 字数统计 | 自动显示在顶栏 |
|
||||||
|
| 专注模式 | 按 F11,隐藏一切 UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 排版设置
|
||||||
|
|
||||||
|
点齿轮图标打开设置面板:
|
||||||
|
|
||||||
|
- **行间距**:文字行之间的间距
|
||||||
|
- **段间距**:段落之间的间距
|
||||||
|
- **段首缩进**:每段开头缩进(纯 CSS,不写入正文)
|
||||||
|
- **打字机滚动**:输入行始终保持在屏幕中央
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 多端同步(WebDAV)
|
||||||
|
|
||||||
|
1. 点右上角 ☁ 打开同步面板
|
||||||
|
2. 填写 WebDAV 信息:
|
||||||
|
- **服务器地址**:如坚果云 `https://dav.jianguoyun.com/dav/`
|
||||||
|
- **存储路径**:默认 `jiandan-sync.json`
|
||||||
|
- **用户名 / 密码**:网盘账号
|
||||||
|
- **加密密码**:端到端加密密码(请牢记)
|
||||||
|
3. 点「登录并同步」
|
||||||
|
|
||||||
|
**注意:** 必须通过 `node src/start.js` 启动才能使用同步,直接用 `file://` 打开不行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 构建 Android APK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts\build_apk.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
首次构建前需配置环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
powershell -File scripts\install_android_build.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ 源代码(网页、样式、JS、图标、服务器)
|
||||||
|
android/ Android 构建工程
|
||||||
|
scripts/ 构建/安装脚本
|
||||||
|
```
|
||||||