初始化简单写作项目

This commit is contained in:
2026-07-10 17:09:39 +08:00
commit 31e631027b
74 changed files with 5123 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules/
android/build/
android/.gradle/
android/local.properties
android/app/build/
android/captures/
*.log
.DS_Store
Thumbs.db
dist/
+1
View File
@@ -0,0 +1 @@
{"docs":[{"id":"x1","title":"a","content":"b","updated":5}]}
+6
View File
@@ -0,0 +1,6 @@
{
"u1783657002516": {
"salt": "9c78238b2d54a790b0bc3b9ab74d6620",
"hash": "0957239573eb4216a2d0d83e1c83cbc7bda93616e5cbd350d6fd8d77d8687ee8e1d95f27c5d1e6d992758ed8e4183b555782fbb545a7612dbec6b43891169913"
}
}
+69
View File
@@ -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 下使用。
+101
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -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")
}
+19
View File
@@ -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()
}
+21
View File
@@ -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());
}
}
+41
View File
@@ -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 {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

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>
Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

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);
}
}
+34
View File
@@ -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
}
+3
View File
@@ -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')
+23
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
+248
View File
@@ -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" "$@"
+92
View File
@@ -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
+5
View File
@@ -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'
+16
View File
@@ -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'
}
+11
View File
@@ -0,0 +1,11 @@
{
"appId": "com.jiandan.xiezuo",
"appName": "简单写作",
"webDir": "src",
"server": {
"androidScheme": "https"
},
"android": {
"allowMixedContent": true
}
}
+1260
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"
}
}
+32
View File
@@ -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
+45
View File
@@ -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")
+1000
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+9
View File
@@ -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

+267
View File
@@ -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>
+32
View File
@@ -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"
}
]
}
+157
View File
@@ -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/`);
});
+705
View File
@@ -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;
}
+45
View File
@@ -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))
);
});
+371
View File
@@ -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) { /* 忽略,等待用户手动登录 */ }
})();
}
})();
+24
View File
@@ -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)");
})();
+84
View File
@@ -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/ 构建/安装脚本
```