@leiyi-works/dsh-cloud-sync 0.0.0-stage → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -2
- package/client.js +284 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +19 -0
- package/index.js +650 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -3
- package/sync.js +1548 -0
package/README.md
CHANGED
|
@@ -1,3 +1,132 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-cloud-sync — 云端同步
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 DSH 的状态增量备份到**你自己的**云服务器,换机或新机登录后一键拉回。
|
|
4
|
+
**通道只用 SSH**,服务器上不装服务、不开端口。
|
|
5
|
+
|
|
6
|
+
## 服务器地址从哪来(包里不含地址)
|
|
7
|
+
|
|
8
|
+
插件包**不内置任何服务器地址或密钥路径**。连接信息按这个顺序取:
|
|
9
|
+
|
|
10
|
+
1. 插件配置里的 `host` / `identityFile` / `port`(在 profile 的 `cordis.patch.yml` 里写);
|
|
11
|
+
2. 没写的话,读 `<DSH_HOME>/keyrule-secrets/ssh.json`(默认位置,可用 `connectionFile` 改):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{ "host": "root@1.2.3.4", "identityFile": "C:/Users/你/.dsh/keyrule-secrets/key.pem", "port": 22 }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
3. 两处都没有 → 插件显示「未配置服务器」,**不联网、不报错**,等你填。
|
|
18
|
+
|
|
19
|
+
这样地址跟着「钥则」的密钥目录走(该目录本身在同步范围内),插件本身是通用的。
|
|
20
|
+
|
|
21
|
+
## 同步什么
|
|
22
|
+
|
|
23
|
+
**黑名单制**:默认同步**整个 `~/.dsh`**,只排除「可重建 / 机器相关 / 不能乱传」的那几类。
|
|
24
|
+
任何插件在 `~/.dsh` 下新建的数据目录(`h3-kit`、`llm-deepseek`、`runninghub`…)**自动就带上**,不用改代码。
|
|
25
|
+
|
|
26
|
+
| 会同步(示例) | 内容 |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `sessions/` | 聊天记录(`session.v4.jsonl.zstd`) |
|
|
29
|
+
| `storages/` | 工作区列表、归档/置顶、定时任务、会话投影缓存 |
|
|
30
|
+
| `data/*` | 插件状态(`session-tmpdir-*` 除外) |
|
|
31
|
+
| `profiles/<profile>/` | 除 `node_modules`、`.dsh-market`、`.plugin-manager` 之外的全部配置 |
|
|
32
|
+
| `attachments/` | 聊天图片附件(可关) |
|
|
33
|
+
| `h3-kit/`、`llm-deepseek/`、`runninghub/` | 插件自己建的数据目录 |
|
|
34
|
+
| 根目录 | `AGENTS.md`、`standing-rules.md`、`standing-rules/`、`ops/`、`.credentials.yaml`、`keyrule-secrets/`、插件的小 json |
|
|
35
|
+
| 本地 link 插件源码 | profile 里 `link:` 的目录(自动发现) |
|
|
36
|
+
|
|
37
|
+
**不同步(黑名单)**:
|
|
38
|
+
|
|
39
|
+
| 排除项 | 原因 |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `dsh-runtimes/`(446 MB) | 安装器会重建 |
|
|
42
|
+
| `cache/` | 缓存 |
|
|
43
|
+
| `profiles/*/node_modules/` | pnpm 装,跨机复制会坏(新机 `pnpm install` 重建) |
|
|
44
|
+
| `.cloud-sync/` | 本机的同步记忆(known/tombstones/backup/removed),传了会污染别的机器 |
|
|
45
|
+
| `profiles/*/.dsh-market`、`profiles/*/.plugin-manager` | 本机插件管理日志/索引 |
|
|
46
|
+
| `*.log`、`*.tmp`、`*.bak*`、`.DS_Store` | 垃圾 |
|
|
47
|
+
| `data/session-tmpdir-*` | 会话临时目录的本机簿记 |
|
|
48
|
+
| `.dshw-*`、`.anonymous-user-id`、`.credentials.yaml.newmachine` | 机器相关 |
|
|
49
|
+
|
|
50
|
+
## 云上结构
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
/opt/dsh-sync/
|
|
54
|
+
machines/<槽位名>/manifest.json ← 文件清单(相对路径 + 大小 + mtime)
|
|
55
|
+
machines/<槽位名>/files/dsh/... ← ~/.dsh 的内容
|
|
56
|
+
machines/<槽位名>/files/plugins/<名字>/ ← 本地插件源码
|
|
57
|
+
machines/<槽位名>/incoming/<机器名>/ ← 上传暂存(每台机器一个,互不干扰)
|
|
58
|
+
shared/tombstones.json ← 所有机器共享的删除记录
|
|
59
|
+
trash/<时间戳>/... ← 本地删掉的会话/附件,保留 14 天
|
|
60
|
+
versions/<时间戳>/... ← 被覆盖前的旧版本,保留 7 天
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
槽位名默认是主机名(本机 `lavm`/桌面机名),可在插件配置里改。
|
|
64
|
+
|
|
65
|
+
## 行为
|
|
66
|
+
|
|
67
|
+
- **推送**:增量(大小 + mtime 比对,2 秒容差),只传变化的文件;本地不存在的条目
|
|
68
|
+
→ 服务端移到 `trash/`(配合会话临时目录插件的 7 天清理,云端不会越攒越多)。
|
|
69
|
+
上传前先把文件复制到本机暂存区并核对大小,避开「会话日志正在追加」的撕裂;核对不过的
|
|
70
|
+
文件留到下一轮(`quietSeconds` 内的文件同理)。传输 120 秒没有进展会主动断开并报错,
|
|
71
|
+
不会无限挂着。
|
|
72
|
+
- **恢复**:只补「本地没有」或「云端更新」的文件;覆盖前备份到
|
|
73
|
+
`~/.dsh/.cloud-sync/backup/<时间戳>/`;**永不删本地文件**;最近 5 分钟改过的本地文件跳过。
|
|
74
|
+
- **删掉的不复活**(`tombstones.json` / `known.json`):本机删过的文件记一条墓碑,
|
|
75
|
+
恢复时看到墓碑就跳过(云端那份比墓碑还新时才允许拉回);本机「确实拥有过、现在没了」
|
|
76
|
+
的文件也按本地删除处理。新机没有任何记录,所以该恢复的照样全部恢复。
|
|
77
|
+
想强行拉回就传 `force: true`(会把共享删除记录一起撤掉)。
|
|
78
|
+
- **多台电脑共用一个删除记录**(云端 `shared/tombstones.json`):A 机删掉的文件,
|
|
79
|
+
B 机下次同步时会跟着删(先放进 `.cloud-sync/removed/<时间戳>/`,可找回);
|
|
80
|
+
比记录还新的文件视为「删掉后又被重建」,保留并撤销那条记录。
|
|
81
|
+
跨机删除覆盖**整个 `~/.dsh`**(会话、storages、data、附件,以及 h3-kit / llm-deepseek
|
|
82
|
+
这类插件数据目录);只有 `plugins/`(源码靠 npm)、`profiles/`(靠覆盖更新)、
|
|
83
|
+
`.credentials.yaml`、`keyrule-secrets/` 不会被别的机器删掉。
|
|
84
|
+
`applyRemoteDeletes: false` 可以关掉「跟着删」,只保留「不拉回」。
|
|
85
|
+
- **本地不留垃圾**:`backup/`、`removed/` 只保留 `backupDays`(默认 7)天、
|
|
86
|
+
`staging/` 只留 1 天,每次推送/恢复都会顺手清理,不会越攒越多。
|
|
87
|
+
- **自动**:本机配置成 **每天一次**(`dailySync: true`):当天还没同步过就在
|
|
88
|
+
「打开 DSH 后 20 秒内」补一轮,当天已同步则不再跑;应用跨过零点一直开着时,0:05 后补一轮。
|
|
89
|
+
推送时跳过最近 10 秒内还在写的文件;`pauseWhenIdle` 已关;
|
|
90
|
+
新机(本地没有任何会话)首次启动自动从唯一的云端槽位恢复。
|
|
91
|
+
- **入口**:左侧面板列表里的 **「云端同步」**(`sidebar.panellist` + `main` 面板),
|
|
92
|
+
点开即自动补一轮同步,面板里有「立即同步」大按钮;设置页不再单独占一页。
|
|
93
|
+
- **恢复插件**:新机上原 `link:` 路径不存在时,插件源码落到 `localPluginRoot`(默认
|
|
94
|
+
`D:/DSH工作区`),并把 profile `package.json` 的 `link:` 改写成本机路径。
|
|
95
|
+
|
|
96
|
+
## 工具
|
|
97
|
+
|
|
98
|
+
| 工具 | 用途 |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `cloud_sync_status` | 状态:槽位、云端各机器大小/时间、上次推/拉、下次自动推送、最近日志 |
|
|
101
|
+
| `cloud_sync_push` | 立即增量推送(首次含附件约 5 分钟) |
|
|
102
|
+
| `cloud_sync_pull` | 从云端恢复(可指定 `from`、`force`) |
|
|
103
|
+
|
|
104
|
+
## 新机恢复步骤
|
|
105
|
+
|
|
106
|
+
1. 装好 DSH(安装器)。
|
|
107
|
+
2. 装本插件:`dsh` 设置 → 插件 → 添加本地目录 `D:\DSH工作区\dsh-cloud-sync`,
|
|
108
|
+
或从 npm 安装。
|
|
109
|
+
3. 确认私钥在 `C:\Users\<用户>\.dsh\keyrule-secrets\jdcloud-lavm.pem`
|
|
110
|
+
(换机包里有;没有的话从旧机拷,登录授权也走这把钥匙)。
|
|
111
|
+
`ssh jdcloud` 别名由「常驻钥则」插件自动重建(profile 里配了 `sshHost`),不需要手改。
|
|
112
|
+
4. 点左侧面板列表的「云端同步」→ 面板里点「立即同步」补一轮;要拉回旧数据用
|
|
113
|
+
`cloud_sync_pull` 工具(或面板恢复后按提示操作)。
|
|
114
|
+
5. 恢复完成后到 设置 → 插件 里执行一次依赖安装(`pnpm install`),重启 DSH。
|
|
115
|
+
6. 打开老会话即可看到聊天记录;图片附件要等首批上传完成。
|
|
116
|
+
|
|
117
|
+
## 配置项(改 profile 的 `cordis.patch.yml`)
|
|
118
|
+
|
|
119
|
+
`host` / `port` / `identityFile` / `connectionFile` / `remoteRoot` / `machineId` / `localPluginRoot` /
|
|
120
|
+
`intervalMinutes` / `dailySync` / `autoSync` / `pauseWhenIdle` / `idlePauseMinutes` /
|
|
121
|
+
`applyRemoteDeletes` / `restoreOnEmpty` / `includeAttachments` / `includeCredentials` /
|
|
122
|
+
`includeKeyruleSecrets` / `includeLocalPlugins` / `quietSeconds` / `trashDays` /
|
|
123
|
+
`versionDays` / `backupDays`。
|
|
124
|
+
|
|
125
|
+
## 自测
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
node test-engine.mjs
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
在临时目录造假 DSH 数据,推到 `/opt/dsh-sync-test`,再拉回新目录逐文件比哈希
|
|
132
|
+
(覆盖增量、删除传播、中文名、超长路径、插件源码 relocation、幂等)。
|
package/client.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cloud-sync — Client 半。
|
|
3
|
+
*
|
|
4
|
+
* 左侧面板列表里一个「云端同步」入口:点开就是同步面板,打开即自动补一轮,
|
|
5
|
+
* 面板里还有一个大号「立即同步」按钮。设置页不再单独占一页。
|
|
6
|
+
*/
|
|
7
|
+
window.__ModuleLoader__.load({
|
|
8
|
+
id: '@leiyi-works/dsh-cloud-sync',
|
|
9
|
+
factory(require) {
|
|
10
|
+
const React = require('react')
|
|
11
|
+
const h = React.createElement
|
|
12
|
+
const NS = 'cloudSync'
|
|
13
|
+
const PANEL_ID = 'cloud-sync'
|
|
14
|
+
const API_PREFIX = '__dsh-cloud-sync/'
|
|
15
|
+
const CSS_TAG_ID = '@leiyi-works/dsh-cloud-sync/client.css'
|
|
16
|
+
const CSS = [
|
|
17
|
+
'.csyRoot{height:100%;overflow:auto;box-sizing:border-box;padding:calc(var(--dsh-frame-top-clearance,0px) + 24px) 24px 40px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:1.55}',
|
|
18
|
+
'.csyInner{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:16px}',
|
|
19
|
+
'.csyTitle{margin:0;font-size:22px;font-weight:600}',
|
|
20
|
+
'.csyHint{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px}',
|
|
21
|
+
'.csyCard{display:flex;align-items:center;gap:14px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));border-radius:12px;padding:16px}',
|
|
22
|
+
'.csyBig{appearance:none;cursor:pointer;border:none;border-radius:10px;padding:12px 26px;font-size:15px;font-weight:600;line-height:20px;background:var(--dsw-alias-state-business-primary,#3f7fd4);color:#fff}',
|
|
23
|
+
'.csyBig:disabled{opacity:.55;cursor:default}',
|
|
24
|
+
'.csyCardText{display:flex;flex-direction:column;gap:3px;min-width:0}',
|
|
25
|
+
'.csyCardMain{font-size:14px;font-weight:600}',
|
|
26
|
+
'.csyNote{font-size:12px;color:var(--dsw-alias-label-secondary);word-break:break-all}',
|
|
27
|
+
'.csyGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}',
|
|
28
|
+
'.csyCell{border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.18));border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:4px}',
|
|
29
|
+
'.csyCellName{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
30
|
+
'.csyCellValue{font-size:15px;font-weight:600}',
|
|
31
|
+
'.csyLog{font-family:ui-monospace,Consolas,monospace;font-size:12px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.08));border-radius:10px;padding:12px;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}',
|
|
32
|
+
'.csyBar{height:5px;border-radius:3px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.14));overflow:hidden;width:100%}',
|
|
33
|
+
'.csyBarFill{height:100%;background:var(--dsw-alias-state-business-primary,#3f7fd4);transition:width .3s ease}',
|
|
34
|
+
'.csyOk{color:var(--dsw-alias-state-success-primary,#2c9d5a)}',
|
|
35
|
+
'.csyErr{color:var(--dsw-alias-state-error-primary,#d14a4a)}',
|
|
36
|
+
].join('')
|
|
37
|
+
try {
|
|
38
|
+
if (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css="${CSS_TAG_ID}"]`) === null) {
|
|
39
|
+
const tag = document.createElement('style')
|
|
40
|
+
tag.dataset.plugin = '@leiyi-works/dsh-cloud-sync'
|
|
41
|
+
tag.dataset.pluginCss = CSS_TAG_ID
|
|
42
|
+
tag.textContent = CSS
|
|
43
|
+
document.head.appendChild(tag)
|
|
44
|
+
}
|
|
45
|
+
} catch { /* ignore */ }
|
|
46
|
+
|
|
47
|
+
function pageBase() {
|
|
48
|
+
if (typeof document !== 'undefined' && typeof document.baseURI === 'string' && document.baseURI !== '') return document.baseURI
|
|
49
|
+
if (typeof location !== 'undefined' && typeof location.href === 'string') return location.href
|
|
50
|
+
return 'http://localhost/'
|
|
51
|
+
}
|
|
52
|
+
const apiUrl = (suffix) => {
|
|
53
|
+
try { return new URL(API_PREFIX + suffix, pageBase()).href } catch { return API_PREFIX + suffix }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const zh = {
|
|
57
|
+
label: '云端同步',
|
|
58
|
+
title: '云端同步',
|
|
59
|
+
hint: '把聊天记录、工作区/定时任务、插件清单与本地插件源码、钥则、凭据增量推到自己的云服务器;换机时从设置里点这个面板就能拉回。',
|
|
60
|
+
syncNow: '立即同步',
|
|
61
|
+
syncing: '同步中…',
|
|
62
|
+
alreadyRunning: '已在同步中,稍候会自动刷新',
|
|
63
|
+
unconfigured: '未配置服务器:在 profile 的 cordis.patch.yml 里填 host,或在 keyrule-secrets/ssh.json 里写连接信息',
|
|
64
|
+
lastSync: '上次同步',
|
|
65
|
+
cloudSize: '云端占用',
|
|
66
|
+
autoPlan: '自动同步',
|
|
67
|
+
files: '个文件',
|
|
68
|
+
never: '还没有',
|
|
69
|
+
running: '正在同步',
|
|
70
|
+
done: '已完成',
|
|
71
|
+
failed: '失败',
|
|
72
|
+
log: '最近日志',
|
|
73
|
+
idle: '空闲',
|
|
74
|
+
minutes: '分钟一次',
|
|
75
|
+
daily: '每天一次',
|
|
76
|
+
nextAt: '下次',
|
|
77
|
+
machine: '本机槽位',
|
|
78
|
+
updatedAt: '更新于',
|
|
79
|
+
}
|
|
80
|
+
const en = {
|
|
81
|
+
label: 'Cloud sync',
|
|
82
|
+
title: 'Cloud sync',
|
|
83
|
+
hint: 'Push chat history, plugins, rules and credentials to your cloud server; open this panel on another machine to restore.',
|
|
84
|
+
syncNow: 'Sync now',
|
|
85
|
+
syncing: 'Syncing…',
|
|
86
|
+
alreadyRunning: 'A sync is already running — this panel will refresh automatically',
|
|
87
|
+
unconfigured: 'No server configured: set host in the profile cordis.patch.yml, or put connection info in keyrule-secrets/ssh.json',
|
|
88
|
+
lastSync: 'Last sync',
|
|
89
|
+
cloudSize: 'Cloud usage',
|
|
90
|
+
autoPlan: 'Auto sync',
|
|
91
|
+
files: 'files',
|
|
92
|
+
never: 'never',
|
|
93
|
+
running: 'syncing',
|
|
94
|
+
done: 'done',
|
|
95
|
+
failed: 'failed',
|
|
96
|
+
log: 'Recent log',
|
|
97
|
+
idle: 'idle',
|
|
98
|
+
minutes: 'min',
|
|
99
|
+
daily: 'daily',
|
|
100
|
+
nextAt: 'next',
|
|
101
|
+
machine: 'Machine slot',
|
|
102
|
+
updatedAt: 'updated',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const mb = (bytes) => `${((Number(bytes) || 0) / 1048576).toFixed(1)} MB`
|
|
106
|
+
const when = (value) => {
|
|
107
|
+
if (value === undefined || value === null || Number(value) === 0) return null
|
|
108
|
+
try { return new Date(Number(value)).toLocaleString() } catch { return String(value) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 左侧面板列表里的小图标。 */
|
|
112
|
+
function SyncIcon(props) {
|
|
113
|
+
const size = props && typeof props.size === 'number' && props.size > 0 ? props.size : 18
|
|
114
|
+
return h('svg', {
|
|
115
|
+
width: size,
|
|
116
|
+
height: size,
|
|
117
|
+
viewBox: '0 0 24 24',
|
|
118
|
+
fill: 'none',
|
|
119
|
+
'aria-hidden': true,
|
|
120
|
+
style: { display: 'block' },
|
|
121
|
+
},
|
|
122
|
+
h('path', {
|
|
123
|
+
d: 'M7.2 18.2h9.4a3.4 3.4 0 0 0 .3-6.78A5.1 5.1 0 0 0 7.7 9.6a4.1 4.1 0 0 0-.5 8.6z',
|
|
124
|
+
stroke: 'currentColor', strokeWidth: 1.6, strokeLinejoin: 'round',
|
|
125
|
+
}),
|
|
126
|
+
h('path', { d: 'M12 12.6v4.2M10 14.4l2-1.9 2 1.9', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function SyncPanel(props) {
|
|
130
|
+
const t = (key) => (typeof props.t === 'function' ? props.t(key) : zh[key] !== undefined ? zh[key] : key)
|
|
131
|
+
const [status, setStatus] = React.useState(null)
|
|
132
|
+
const [error, setError] = React.useState(null)
|
|
133
|
+
const [notice, setNotice] = React.useState(null)
|
|
134
|
+
const [starting, setStarting] = React.useState(false)
|
|
135
|
+
const autoTried = React.useRef(false)
|
|
136
|
+
const refreshing = React.useRef(false)
|
|
137
|
+
|
|
138
|
+
const post = React.useCallback(async (path, body) => {
|
|
139
|
+
const response = await fetch(apiUrl(path), {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'content-type': 'application/json' },
|
|
142
|
+
body: JSON.stringify(body ?? {}),
|
|
143
|
+
})
|
|
144
|
+
const payload = await response.json().catch(() => ({}))
|
|
145
|
+
if (!response.ok || payload.ok === false) throw new Error(payload.error ?? `HTTP ${response.status}`)
|
|
146
|
+
return payload
|
|
147
|
+
}, [])
|
|
148
|
+
|
|
149
|
+
const refresh = React.useCallback(async () => {
|
|
150
|
+
if (refreshing.current) return
|
|
151
|
+
refreshing.current = true
|
|
152
|
+
try {
|
|
153
|
+
const response = await fetch(apiUrl('status'), { cache: 'no-store' })
|
|
154
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
155
|
+
const payload = await response.json()
|
|
156
|
+
setStatus(payload)
|
|
157
|
+
setError(null)
|
|
158
|
+
// 打开面板就自动补一轮(刚同步过 30 秒内不重复)
|
|
159
|
+
if (!autoTried.current) {
|
|
160
|
+
autoTried.current = true
|
|
161
|
+
const running = payload?.job?.phase === 'running'
|
|
162
|
+
const lastAt = Number(payload?.schedule?.lastPushAt) || 0
|
|
163
|
+
if (!running && Date.now() - lastAt > 30 * 1000) {
|
|
164
|
+
setStarting(true)
|
|
165
|
+
const payload = await post('push', { auto: false })
|
|
166
|
+
.catch((failure) => { setError(failure instanceof Error ? failure.message : String(failure)); return undefined })
|
|
167
|
+
setStarting(false)
|
|
168
|
+
setNotice(payload?.alreadyRunning === true ? t('alreadyRunning') : null)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (failure) {
|
|
172
|
+
setError(failure instanceof Error ? failure.message : String(failure))
|
|
173
|
+
} finally {
|
|
174
|
+
refreshing.current = false
|
|
175
|
+
}
|
|
176
|
+
}, [post])
|
|
177
|
+
|
|
178
|
+
React.useEffect(() => {
|
|
179
|
+
void refresh()
|
|
180
|
+
}, [refresh])
|
|
181
|
+
|
|
182
|
+
const running = status?.job?.phase === 'running'
|
|
183
|
+
React.useEffect(() => {
|
|
184
|
+
if (!running) return undefined
|
|
185
|
+
const timer = window.setInterval(() => { void refresh() }, 1500)
|
|
186
|
+
return () => window.clearInterval(timer)
|
|
187
|
+
}, [running, refresh])
|
|
188
|
+
|
|
189
|
+
const syncNow = async () => {
|
|
190
|
+
setStarting(true)
|
|
191
|
+
setError(null)
|
|
192
|
+
setNotice(null)
|
|
193
|
+
try {
|
|
194
|
+
const payload = await post('push', { auto: false })
|
|
195
|
+
if (payload?.alreadyRunning === true) setNotice(t('alreadyRunning'))
|
|
196
|
+
await refresh()
|
|
197
|
+
} catch (failure) {
|
|
198
|
+
setError(failure instanceof Error ? failure.message : String(failure))
|
|
199
|
+
} finally {
|
|
200
|
+
setStarting(false)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const job = status?.job
|
|
205
|
+
const machines = status?.remote?.machines ?? []
|
|
206
|
+
const mine = machines.find((item) => item.id === status?.machine)
|
|
207
|
+
const progress = job?.progress
|
|
208
|
+
const percent = progress !== undefined && progress.total !== undefined && Number(progress.total) > 0
|
|
209
|
+
? Math.min(100, Math.round((Number(progress.done) / Number(progress.total)) * 100))
|
|
210
|
+
: undefined
|
|
211
|
+
const configured = status === null || (typeof status?.config?.host === 'string' && status.config.host !== '')
|
|
212
|
+
const busy = running || starting || (status !== null && !configured)
|
|
213
|
+
const lastPushAt = status?.schedule?.lastPushAt
|
|
214
|
+
const resultText = error !== null
|
|
215
|
+
? error
|
|
216
|
+
: !configured
|
|
217
|
+
? t('unconfigured')
|
|
218
|
+
: notice !== null
|
|
219
|
+
? notice
|
|
220
|
+
: job?.phase === 'running'
|
|
221
|
+
? `${t('running')}:${job.log?.slice(-1)[0] ?? ''}`
|
|
222
|
+
: job?.phase === 'failed'
|
|
223
|
+
? `${t('failed')}:${job.error ?? ''}`
|
|
224
|
+
: lastPushAt !== undefined
|
|
225
|
+
? `${when(lastPushAt)} · ${status?.last?.push?.uploaded ?? 0} ${t('files')}`
|
|
226
|
+
: t('never')
|
|
227
|
+
|
|
228
|
+
return h('div', { className: 'csyRoot' }, h('div', { className: 'csyInner' },
|
|
229
|
+
h('h1', { className: 'csyTitle' }, t('title')),
|
|
230
|
+
h('p', { className: 'csyHint' }, t('hint')),
|
|
231
|
+
|
|
232
|
+
h('div', { className: 'csyCard' },
|
|
233
|
+
h('button', { type: 'button', className: 'csyBig', disabled: busy, onClick: () => { void syncNow() } },
|
|
234
|
+
busy ? t('syncing') : t('syncNow')),
|
|
235
|
+
h('div', { className: 'csyCardText' },
|
|
236
|
+
h('span', { className: error !== null ? 'csyCardMain csyErr' : notice !== null ? 'csyCardMain csyNote' : 'csyCardMain' }, resultText),
|
|
237
|
+
percent === undefined ? null : h('div', { className: 'csyBar' }, h('div', { className: 'csyBarFill', style: { width: `${percent}%` } })))),
|
|
238
|
+
|
|
239
|
+
h('div', { className: 'csyGrid' },
|
|
240
|
+
h('div', { className: 'csyCell' },
|
|
241
|
+
h('span', { className: 'csyCellName' }, t('lastSync')),
|
|
242
|
+
h('span', { className: 'csyCellValue' }, when(lastPushAt) ?? t('never'))),
|
|
243
|
+
h('div', { className: 'csyCell' },
|
|
244
|
+
h('span', { className: 'csyCellName' }, t('cloudSize')),
|
|
245
|
+
h('span', { className: 'csyCellValue' }, mine === undefined ? '—' : mb(mine.filesBytes)),
|
|
246
|
+
h('span', { className: 'csyNote' }, `${status?.local?.files ?? 0} ${t('files')} · ${status?.machine ?? ''}`)),
|
|
247
|
+
h('div', { className: 'csyCell' },
|
|
248
|
+
h('span', { className: 'csyCellName' }, t('autoPlan')),
|
|
249
|
+
h('span', { className: 'csyCellValue' }, status?.config?.autoSync !== true
|
|
250
|
+
? t('off')
|
|
251
|
+
: status?.config?.dailySync === true ? t('daily') : `${status.config.intervalMinutes} ${t('minutes')}`),
|
|
252
|
+
h('span', { className: 'csyNote' }, status?.schedule?.nextPushAt === undefined ? '' : `${t('nextAt')} ${when(status.schedule.nextPushAt)}`)),
|
|
253
|
+
|
|
254
|
+
Array.isArray(job?.log) && job.log.length > 0
|
|
255
|
+
? h('pre', { className: 'csyLog' }, job.log.slice(-12).join('\n'))
|
|
256
|
+
: null)))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function apply(ctx) {
|
|
260
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-cloud-sync: dictionaries')
|
|
261
|
+
const t = ctx.locale.bind(NS)
|
|
262
|
+
// 主面板(点左侧入口后显示)
|
|
263
|
+
ctx.effect(() => ctx.slots.inject('main', () => ctx.slots.register({
|
|
264
|
+
name: 'main',
|
|
265
|
+
key: PANEL_ID,
|
|
266
|
+
locale: NS,
|
|
267
|
+
}, SyncPanel)), 'dsh-cloud-sync: main panel')
|
|
268
|
+
// 左侧面板列表入口
|
|
269
|
+
ctx.effect(() => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
270
|
+
name: 'sidebar.panellist',
|
|
271
|
+
id: PANEL_ID,
|
|
272
|
+
order: 45,
|
|
273
|
+
label: () => t('label'),
|
|
274
|
+
locale: NS,
|
|
275
|
+
}, SyncIcon)), 'dsh-cloud-sync: sidebar entry')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
name: '@leiyi-works/dsh-cloud-sync',
|
|
280
|
+
inject: ['slots', 'locale'],
|
|
281
|
+
apply,
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="云端同步">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="csyCloud" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#6fb3f2"/>
|
|
5
|
+
<stop offset="1" stop-color="#3f7fd4"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="csyArrow" x1="0" y1="0" x2="0" y2="1">
|
|
8
|
+
<stop offset="0" stop-color="#ffffff"/>
|
|
9
|
+
<stop offset="1" stop-color="#dbe9fb"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<rect width="64" height="64" rx="14" fill="#101a2b"/>
|
|
13
|
+
<path d="M20 40h24a8 8 0 0 0 .6-15.97A12 12 0 0 0 21.6 22.8 9.6 9.6 0 0 0 20 40Z"
|
|
14
|
+
fill="url(#csyCloud)" opacity="0.95"/>
|
|
15
|
+
<path d="M32 46V31" stroke="url(#csyArrow)" stroke-width="4" stroke-linecap="round"/>
|
|
16
|
+
<path d="M25.5 36.5 32 30l6.5 6.5" fill="none" stroke="url(#csyArrow)" stroke-width="4"
|
|
17
|
+
stroke-linecap="round" stroke-linejoin="round"/>
|
|
18
|
+
<circle cx="32" cy="49.5" r="2.6" fill="#9ec9f7"/>
|
|
19
|
+
</svg>
|