@alwith/module-drive 0.0.0-stage → 0.1.0-rc.23
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/LICENSE +94 -0
- package/README.md +50 -2
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/desktop-provenance.json +34 -0
- package/dist/badge-react.d.ts +9 -0
- package/dist/badge-react.js +1 -0
- package/dist/badge.d.ts +10 -0
- package/dist/badge.js +1 -0
- package/dist/browse.d.ts +33 -0
- package/dist/browse.js +1 -0
- package/dist/content/chat-condense.d.ts +22 -0
- package/dist/content/chat-condense.js +1 -0
- package/dist/content/collab.d.ts +34 -0
- package/dist/content/collab.js +1 -0
- package/dist/content/comment-anchors.d.ts +23 -0
- package/dist/content/comment-anchors.js +2 -0
- package/dist/content/comment-decorations.d.ts +23 -0
- package/dist/content/comment-decorations.js +3 -0
- package/dist/content/comment-drawer.d.ts +44 -0
- package/dist/content/comment-drawer.js +1 -0
- package/dist/content/comment-mentions.d.ts +18 -0
- package/dist/content/comment-mentions.js +1 -0
- package/dist/content/comment-surface.d.ts +25 -0
- package/dist/content/comment-surface.js +1 -0
- package/dist/content/condense-task.d.ts +44 -0
- package/dist/content/condense-task.js +1 -0
- package/dist/content/condense.d.ts +20 -0
- package/dist/content/condense.js +1 -0
- package/dist/content/editor.d.ts +5 -0
- package/dist/content/editor.js +1 -0
- package/dist/content/file-actions.d.ts +43 -0
- package/dist/content/file-actions.js +1 -0
- package/dist/content/file-link.d.ts +4 -0
- package/dist/content/file-link.js +1 -0
- package/dist/content/frontmatter-panel.d.ts +7 -0
- package/dist/content/frontmatter-panel.js +1 -0
- package/dist/content/frontmatter.d.ts +9 -0
- package/dist/content/frontmatter.js +7 -0
- package/dist/content/host.d.ts +49 -0
- package/dist/content/host.js +1 -0
- package/dist/content/html-bridge.d.ts +1 -0
- package/dist/content/html-bridge.js +187 -0
- package/dist/content/html-comments.d.ts +5 -0
- package/dist/content/html-comments.js +1 -0
- package/dist/content/markdown-comments.d.ts +4 -0
- package/dist/content/markdown-comments.js +2 -0
- package/dist/content/mention-picker.d.ts +10 -0
- package/dist/content/mention-picker.js +1 -0
- package/dist/content/save-version.d.ts +2 -0
- package/dist/content/save-version.js +1 -0
- package/dist/content/translations.d.ts +2 -0
- package/dist/content/translations.js +1 -0
- package/dist/content/use-file-comments.d.ts +12 -0
- package/dist/content/use-file-comments.js +1 -0
- package/dist/content/yuplink.d.ts +11 -0
- package/dist/content/yuplink.js +2 -0
- package/dist/content.css +64 -0
- package/dist/content.d.ts +10 -0
- package/dist/content.js +1 -0
- package/dist/contract.d.ts +780 -0
- package/dist/contract.js +0 -0
- package/dist/core.d.ts +22 -0
- package/dist/core.js +1 -0
- package/dist/files.d.ts +7 -0
- package/dist/files.js +1 -0
- package/dist/global-search.d.ts +34 -0
- package/dist/global-search.js +1 -0
- package/dist/icons.d.ts +5 -0
- package/dist/icons.js +1 -0
- package/dist/launcher.d.ts +15 -0
- package/dist/launcher.js +2 -0
- package/dist/locales.d.ts +985 -0
- package/dist/locales.js +1 -0
- package/dist/mass-delete.d.ts +9 -0
- package/dist/mass-delete.js +1 -0
- package/dist/notifications.d.ts +14 -0
- package/dist/notifications.js +1 -0
- package/dist/pinned-docs.d.ts +14 -0
- package/dist/pinned-docs.js +1 -0
- package/dist/pinned.d.ts +12 -0
- package/dist/pinned.js +1 -0
- package/dist/react.d.ts +29 -0
- package/dist/react.js +1 -0
- package/dist/roots.d.ts +10 -0
- package/dist/roots.js +1 -0
- package/dist/settings.d.ts +14 -0
- package/dist/settings.js +1 -0
- package/dist/sharing.d.ts +12 -0
- package/dist/sharing.js +1 -0
- package/dist/status.d.ts +16 -0
- package/dist/status.js +1 -0
- package/dist/styles.css +458 -0
- package/dist/summary.d.ts +10 -0
- package/dist/summary.js +1 -0
- package/dist/tauri.d.ts +6 -0
- package/dist/tauri.js +1 -0
- package/dist/transfer-feed.d.ts +36 -0
- package/dist/transfer-feed.js +1 -0
- package/dist/ui.d.ts +96 -0
- package/dist/ui.js +1 -0
- package/package.json +117 -4
- package/rust/Cargo.toml +14 -0
- package/rust/src/contract.rs +366 -0
- package/rust/src/lib.rs +5 -0
- package/rust/src/ownership.rs +4 -0
- package/rust/src/service.rs +140 -0
- package/rust/src/sync.rs +1602 -0
package/rust/src/sync.rs
ADDED
|
@@ -0,0 +1,1602 @@
|
|
|
1
|
+
//! 契约 DTO 与 UI 快照类型。字段名 / 顺序对齐 docs/drive-sync-api-contract.md。
|
|
2
|
+
|
|
3
|
+
use serde::{Deserialize, Serialize};
|
|
4
|
+
|
|
5
|
+
/// 服务端 id(projectId / fileId / versionId / folderId):契约未钉死是数字还是字符串,
|
|
6
|
+
/// 两种都原样收发;索引里按 `as_key()` 存文本。
|
|
7
|
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, specta::Type)]
|
|
8
|
+
#[serde(untagged)]
|
|
9
|
+
pub enum RemoteId {
|
|
10
|
+
Number(i64),
|
|
11
|
+
Text(String),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
impl RemoteId {
|
|
15
|
+
pub fn as_key(&self) -> String {
|
|
16
|
+
match self {
|
|
17
|
+
RemoteId::Number(value) => value.to_string(),
|
|
18
|
+
RemoteId::Text(value) => value.clone(),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
pub fn from_key(key: &str) -> Self {
|
|
23
|
+
match key.parse::<i64>() {
|
|
24
|
+
Ok(value) => RemoteId::Number(value),
|
|
25
|
+
Err(_) => RemoteId::Text(key.to_string()),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl std::fmt::Display for RemoteId {
|
|
31
|
+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
32
|
+
formatter.write_str(&self.as_key())
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// 响应信封 `{success, message, data}`;吊销时 401 + `code: DEVICE_REVOKED`。
|
|
37
|
+
#[derive(Debug, Deserialize)]
|
|
38
|
+
#[serde(bound(deserialize = "T: serde::Deserialize<'de>"))]
|
|
39
|
+
pub struct Envelope<T> {
|
|
40
|
+
pub success: bool,
|
|
41
|
+
#[serde(default)]
|
|
42
|
+
pub message: Option<String>,
|
|
43
|
+
#[serde(default)]
|
|
44
|
+
pub code: Option<String>,
|
|
45
|
+
#[serde(default)]
|
|
46
|
+
pub data: Option<T>,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
50
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
51
|
+
pub enum ProjectKind {
|
|
52
|
+
Material,
|
|
53
|
+
Knowledge,
|
|
54
|
+
Skill,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
58
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
59
|
+
pub enum ProjectScope {
|
|
60
|
+
Personal,
|
|
61
|
+
Dept,
|
|
62
|
+
Team,
|
|
63
|
+
Company,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// `GET /sync/roots` 的一项。
|
|
67
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
68
|
+
#[serde(rename_all = "camelCase")]
|
|
69
|
+
pub struct SyncRoot {
|
|
70
|
+
pub project_id: RemoteId,
|
|
71
|
+
pub project_key: String,
|
|
72
|
+
pub name: String,
|
|
73
|
+
pub kind: ProjectKind,
|
|
74
|
+
/// 契约 4.1:scope 可能为空,客户端按有无 deptName 归到 DEPT / COMPANY
|
|
75
|
+
#[serde(default)]
|
|
76
|
+
pub scope: Option<ProjectScope>,
|
|
77
|
+
#[serde(default)]
|
|
78
|
+
pub dept_name: Option<String>,
|
|
79
|
+
/// 一律用服务端返回值,客户端不自行推导。
|
|
80
|
+
///
|
|
81
|
+
/// **可以为 null**(服务端 7662157):SKILL 项目不再进目录树 —— 它走 `skills_path` 装进
|
|
82
|
+
/// `~/.alwith/skills/`,人不需要浏览。null 的一律挡在同步与目录树之外。
|
|
83
|
+
#[serde(default)]
|
|
84
|
+
pub drive_path: Option<String>,
|
|
85
|
+
/// 树上的哪一节:`PERSONAL` / `DEPT` / `COMPANY`(TEAM 归其上级部门);SKILL 为 null。
|
|
86
|
+
#[serde(default)]
|
|
87
|
+
pub node: Option<String>,
|
|
88
|
+
/// 这一节里的哪个容器:`MATERIAL`(资料库)/ `KNOWLEDGE`(知识库);SKILL 为 null。
|
|
89
|
+
#[serde(default)]
|
|
90
|
+
pub container: Option<String>,
|
|
91
|
+
/// 分组用的节点路径:`我的空间` / `部门/<部门名>` / `公司`。
|
|
92
|
+
///
|
|
93
|
+
/// **画树按它分组,不要去切 `drive_path` 的字符串** —— 服务端特意给了这个字段,
|
|
94
|
+
/// 切字符串会在部门名自己带斜杠时炸。
|
|
95
|
+
#[serde(default)]
|
|
96
|
+
pub node_path: Option<String>,
|
|
97
|
+
pub sync_enabled: bool,
|
|
98
|
+
pub can_write: bool,
|
|
99
|
+
pub cloud_only: bool,
|
|
100
|
+
#[serde(default)]
|
|
101
|
+
pub cursor: Option<String>,
|
|
102
|
+
#[serde(default)]
|
|
103
|
+
pub skills_path: Option<String>,
|
|
104
|
+
/// 项目所属部门 id。**按 id 比对,不按 `deptName` 显示名** —— 显示名会带尾空格、会被改名,
|
|
105
|
+
/// 用它判「是不是本人部门的」曾经把人挡在外面。老服务端不返回 → None,回落 `dept_name`。
|
|
106
|
+
#[serde(default)]
|
|
107
|
+
pub department_id: Option<RemoteId>,
|
|
108
|
+
/// 项目归属人(DEPT / COMPANY 为 null)。服务端新加的字段,老服务端不返回 → None
|
|
109
|
+
#[serde(default)]
|
|
110
|
+
pub owner_id: Option<RemoteId>,
|
|
111
|
+
/// 是否归属当前用户。「我的空间」看它而不是 `scope == PERSONAL`:管理员对全公司每个人的
|
|
112
|
+
/// 个人知识库都有读权限,按 scope 分组会把 280 个别人的库全塞进「我的空间」。
|
|
113
|
+
/// 老服务端不返回 → None,调用方回落 `scope == PERSONAL`。
|
|
114
|
+
#[serde(default)]
|
|
115
|
+
pub is_mine: Option<bool>,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// `GET /sync/snapshot` 的一个文件。
|
|
119
|
+
///
|
|
120
|
+
/// 除 `fileId` / `path` 外一律按「服务端可能不给」处理:老库里 `file_versions` 有 sha256 / size 为
|
|
121
|
+
/// NULL 的历史行(2026-06 前上传的文件),一个必填字段就能让整个项目的 snapshot 反序列化失败、
|
|
122
|
+
/// 整轮同步中断。缺 sha256 时改按 versionId 判定变更、按文件下载(见 engine.rs)。
|
|
123
|
+
/// 其余未知字段由 serde 默认忽略(没有 deny_unknown_fields)。
|
|
124
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
125
|
+
#[serde(rename_all = "camelCase")]
|
|
126
|
+
pub struct SnapshotFile {
|
|
127
|
+
pub file_id: RemoteId,
|
|
128
|
+
pub path: String,
|
|
129
|
+
#[serde(default)]
|
|
130
|
+
pub version_id: Option<RemoteId>,
|
|
131
|
+
#[serde(default)]
|
|
132
|
+
pub sha256: Option<String>,
|
|
133
|
+
#[serde(default)]
|
|
134
|
+
pub size: Option<u64>,
|
|
135
|
+
#[serde(default)]
|
|
136
|
+
pub mime_type: Option<String>,
|
|
137
|
+
#[serde(default)]
|
|
138
|
+
pub cloud_only: bool,
|
|
139
|
+
/// 为什么不给内容:`archive` / `session` / `no_download_permission`。**服务端目前不返回它**
|
|
140
|
+
/// (只有 `cloudOnly: bool`),客户端据此在占位文件里写「未知」而不是猜一个 —— 给了就照写。
|
|
141
|
+
#[serde(default)]
|
|
142
|
+
pub cloud_only_reason: Option<String>,
|
|
143
|
+
/// 需要走修改申请的文件:**正常下载到本地**,但本地改动不上行(见 `FileState::ReadOnly`)。
|
|
144
|
+
/// 与 `cloud_only` 是两回事 —— cloudOnly 是「本地根本没有内容」,readOnly 是「有内容、不许改」。
|
|
145
|
+
#[serde(default)]
|
|
146
|
+
pub read_only: bool,
|
|
147
|
+
#[serde(default)]
|
|
148
|
+
pub updated_at: Option<String>,
|
|
149
|
+
/// 6.7:中继上报 active_clients>0 的文件,桌面端停止上行、下行照常
|
|
150
|
+
#[serde(default)]
|
|
151
|
+
pub collab_active: bool,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
155
|
+
#[serde(rename_all = "camelCase")]
|
|
156
|
+
pub struct SnapshotFolder {
|
|
157
|
+
pub folder_id: RemoteId,
|
|
158
|
+
pub path: String,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
162
|
+
#[serde(rename_all = "camelCase")]
|
|
163
|
+
pub struct Snapshot {
|
|
164
|
+
pub files: Vec<SnapshotFile>,
|
|
165
|
+
pub folders: Vec<SnapshotFolder>,
|
|
166
|
+
pub cursor: String,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
170
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
171
|
+
pub enum EventType {
|
|
172
|
+
FileCreated,
|
|
173
|
+
FileUpdated,
|
|
174
|
+
FileRenamed,
|
|
175
|
+
FileMoved,
|
|
176
|
+
FileDeleted,
|
|
177
|
+
FolderCreated,
|
|
178
|
+
FolderRenamed,
|
|
179
|
+
FolderMoved,
|
|
180
|
+
FolderDeleted,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
184
|
+
#[serde(rename_all = "camelCase")]
|
|
185
|
+
pub struct ChangeEvent {
|
|
186
|
+
pub seq: i64,
|
|
187
|
+
pub event_type: EventType,
|
|
188
|
+
#[serde(default)]
|
|
189
|
+
pub file_id: Option<RemoteId>,
|
|
190
|
+
#[serde(default)]
|
|
191
|
+
pub folder_id: Option<RemoteId>,
|
|
192
|
+
pub path: String,
|
|
193
|
+
#[serde(default)]
|
|
194
|
+
pub old_path: Option<String>,
|
|
195
|
+
#[serde(default)]
|
|
196
|
+
pub version_id: Option<RemoteId>,
|
|
197
|
+
#[serde(default)]
|
|
198
|
+
pub sha256: Option<String>,
|
|
199
|
+
#[serde(default)]
|
|
200
|
+
pub size: Option<u64>,
|
|
201
|
+
#[serde(default)]
|
|
202
|
+
pub mime_type: Option<String>,
|
|
203
|
+
#[serde(default)]
|
|
204
|
+
pub actor_id: Option<RemoteId>,
|
|
205
|
+
#[serde(default)]
|
|
206
|
+
pub cloud_only: bool,
|
|
207
|
+
/// 见 `SnapshotFile::cloud_only_reason`
|
|
208
|
+
#[serde(default)]
|
|
209
|
+
pub cloud_only_reason: Option<String>,
|
|
210
|
+
/// 见 `SnapshotFile::read_only`
|
|
211
|
+
#[serde(default)]
|
|
212
|
+
pub read_only: bool,
|
|
213
|
+
#[serde(default)]
|
|
214
|
+
pub created_at: Option<String>,
|
|
215
|
+
#[serde(default)]
|
|
216
|
+
pub collab_active: bool,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
220
|
+
#[serde(rename_all = "camelCase")]
|
|
221
|
+
pub struct ChangesPage {
|
|
222
|
+
pub events: Vec<ChangeEvent>,
|
|
223
|
+
pub next_cursor: String,
|
|
224
|
+
pub has_more: bool,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
228
|
+
#[serde(rename_all = "camelCase")]
|
|
229
|
+
pub struct BlobInfo {
|
|
230
|
+
pub sha256: String,
|
|
231
|
+
pub size: u64,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
235
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
236
|
+
pub enum BatchOpKind {
|
|
237
|
+
Create,
|
|
238
|
+
Update,
|
|
239
|
+
Rename,
|
|
240
|
+
Move,
|
|
241
|
+
Delete,
|
|
242
|
+
Mkdir,
|
|
243
|
+
Rmdir,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
247
|
+
#[serde(rename_all = "camelCase")]
|
|
248
|
+
pub struct BatchOp {
|
|
249
|
+
pub op_id: String,
|
|
250
|
+
pub op: BatchOpKind,
|
|
251
|
+
pub path: String,
|
|
252
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
253
|
+
pub new_path: Option<String>,
|
|
254
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
255
|
+
pub sha256: Option<String>,
|
|
256
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
257
|
+
pub size: Option<u64>,
|
|
258
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
259
|
+
pub mime_type: Option<String>,
|
|
260
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
261
|
+
pub base_version_id: Option<RemoteId>,
|
|
262
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
263
|
+
pub file_id: Option<RemoteId>,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
267
|
+
#[serde(rename_all = "camelCase")]
|
|
268
|
+
pub struct BatchRequest {
|
|
269
|
+
pub project_id: RemoteId,
|
|
270
|
+
pub device_id: String,
|
|
271
|
+
pub ops: Vec<BatchOp>,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
275
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
276
|
+
pub enum BatchStatus {
|
|
277
|
+
Ok,
|
|
278
|
+
Conflict,
|
|
279
|
+
Forbidden,
|
|
280
|
+
MissingBlob,
|
|
281
|
+
Error,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
285
|
+
#[serde(rename_all = "camelCase")]
|
|
286
|
+
pub struct RemoteVersion {
|
|
287
|
+
pub version_id: RemoteId,
|
|
288
|
+
/// 同 `SnapshotFile::sha256`:历史版本行可能为 NULL,缺了就按 fileId 下载
|
|
289
|
+
#[serde(default)]
|
|
290
|
+
pub sha256: Option<String>,
|
|
291
|
+
#[serde(default)]
|
|
292
|
+
pub size: Option<u64>,
|
|
293
|
+
#[serde(default)]
|
|
294
|
+
pub updated_by: Option<String>,
|
|
295
|
+
#[serde(default)]
|
|
296
|
+
pub updated_at: Option<String>,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
300
|
+
#[serde(rename_all = "camelCase")]
|
|
301
|
+
pub struct BatchResult {
|
|
302
|
+
pub op_id: String,
|
|
303
|
+
pub status: BatchStatus,
|
|
304
|
+
#[serde(default)]
|
|
305
|
+
pub file_id: Option<RemoteId>,
|
|
306
|
+
#[serde(default)]
|
|
307
|
+
pub version_id: Option<RemoteId>,
|
|
308
|
+
#[serde(default)]
|
|
309
|
+
pub remote: Option<RemoteVersion>,
|
|
310
|
+
#[serde(default)]
|
|
311
|
+
pub message: Option<String>,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
315
|
+
#[serde(rename_all = "camelCase")]
|
|
316
|
+
pub struct BatchResponse {
|
|
317
|
+
pub results: Vec<BatchResult>,
|
|
318
|
+
#[serde(default)]
|
|
319
|
+
pub cursor: Option<String>,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
323
|
+
#[serde(rename_all = "camelCase")]
|
|
324
|
+
pub struct RegisterDeviceRequest {
|
|
325
|
+
pub device_name: String,
|
|
326
|
+
pub platform: String,
|
|
327
|
+
pub client_version: String,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
331
|
+
#[serde(rename_all = "camelCase")]
|
|
332
|
+
pub struct DeviceRegistration {
|
|
333
|
+
pub device_id: String,
|
|
334
|
+
pub token: String,
|
|
335
|
+
#[serde(default)]
|
|
336
|
+
pub expires_at: Option<String>,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
340
|
+
#[serde(rename_all = "camelCase")]
|
|
341
|
+
pub struct DeviceInfo {
|
|
342
|
+
pub device_id: String,
|
|
343
|
+
#[serde(default)]
|
|
344
|
+
pub device_name: Option<String>,
|
|
345
|
+
#[serde(default)]
|
|
346
|
+
pub platform: Option<String>,
|
|
347
|
+
#[serde(default)]
|
|
348
|
+
pub client_version: Option<String>,
|
|
349
|
+
#[serde(default)]
|
|
350
|
+
pub last_seen_at: Option<String>,
|
|
351
|
+
#[serde(default)]
|
|
352
|
+
pub revoked_at: Option<String>,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ─── 引擎 / UI 快照 ───────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
/// 单个文件的同步状态(文件树角标)。
|
|
358
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
359
|
+
#[serde(rename_all = "camelCase")]
|
|
360
|
+
pub enum FileState {
|
|
361
|
+
Synced,
|
|
362
|
+
Pending,
|
|
363
|
+
Uploading,
|
|
364
|
+
Conflict,
|
|
365
|
+
CloudOnly,
|
|
366
|
+
Ignored,
|
|
367
|
+
SkippedTooLarge,
|
|
368
|
+
/// 超过「自动下载」阈值的大文件:云端有、本地只有一个 `.yupcloud` 占位,**点一下就拉**。
|
|
369
|
+
///
|
|
370
|
+
/// 与 `CloudOnly` 是两回事,不能混:`CloudOnly` 是「这份内容不许落到你的盘上」(权限 /
|
|
371
|
+
/// 会话原文 / 归档),点多少下都不会下来;这个只是「默认先不下,免得几十兆的原始数据表
|
|
372
|
+
/// 把同步占满」,用户想要随时能拿(2026-09-07 老板拍板:大文件不自动下载,标注,按需下载)。
|
|
373
|
+
NotDownloaded,
|
|
374
|
+
/// 正在按需下载(用户点了那个占位)。给文件树上的转圈用
|
|
375
|
+
Downloading,
|
|
376
|
+
/// 服务端 FORBIDDEN(受管控文件 / 需审批删除 / sync_enabled=false),不重试
|
|
377
|
+
Forbidden,
|
|
378
|
+
/// 服务端 `readOnly=true`:内容正常落到本地,但本地改动**不上行**,改要走网页上的修改申请
|
|
379
|
+
ReadOnly,
|
|
380
|
+
Error,
|
|
381
|
+
/// 协同中(服务端 collabActive=true):Yjs 文档是真相,本地不上行、下行照常
|
|
382
|
+
Collab,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
impl FileState {
|
|
386
|
+
pub fn as_str(self) -> &'static str {
|
|
387
|
+
match self {
|
|
388
|
+
FileState::Synced => "synced",
|
|
389
|
+
FileState::Pending => "pending",
|
|
390
|
+
FileState::Uploading => "uploading",
|
|
391
|
+
FileState::Conflict => "conflict",
|
|
392
|
+
FileState::CloudOnly => "cloudOnly",
|
|
393
|
+
FileState::Ignored => "ignored",
|
|
394
|
+
FileState::SkippedTooLarge => "skippedTooLarge",
|
|
395
|
+
FileState::NotDownloaded => "notDownloaded",
|
|
396
|
+
FileState::Downloading => "downloading",
|
|
397
|
+
FileState::Forbidden => "forbidden",
|
|
398
|
+
FileState::ReadOnly => "readOnly",
|
|
399
|
+
FileState::Error => "error",
|
|
400
|
+
FileState::Collab => "collab",
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
pub fn parse(value: &str) -> Option<Self> {
|
|
405
|
+
Some(match value {
|
|
406
|
+
"synced" => FileState::Synced,
|
|
407
|
+
"pending" => FileState::Pending,
|
|
408
|
+
"uploading" => FileState::Uploading,
|
|
409
|
+
"conflict" => FileState::Conflict,
|
|
410
|
+
"cloudOnly" => FileState::CloudOnly,
|
|
411
|
+
"ignored" => FileState::Ignored,
|
|
412
|
+
"skippedTooLarge" => FileState::SkippedTooLarge,
|
|
413
|
+
"notDownloaded" => FileState::NotDownloaded,
|
|
414
|
+
"downloading" => FileState::Downloading,
|
|
415
|
+
"forbidden" => FileState::Forbidden,
|
|
416
|
+
"readOnly" => FileState::ReadOnly,
|
|
417
|
+
"error" => FileState::Error,
|
|
418
|
+
"collab" => FileState::Collab,
|
|
419
|
+
_ => return None,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
425
|
+
#[serde(tag = "kind", rename_all = "camelCase")]
|
|
426
|
+
pub enum EngineState {
|
|
427
|
+
Idle,
|
|
428
|
+
Scanning,
|
|
429
|
+
Syncing,
|
|
430
|
+
Paused,
|
|
431
|
+
Error { message: String },
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/// 索引里的根(服务端根 + 本地 pinned / cursor)。
|
|
435
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
436
|
+
#[serde(rename_all = "camelCase")]
|
|
437
|
+
pub struct RootInfo {
|
|
438
|
+
pub project_id: RemoteId,
|
|
439
|
+
pub project_key: String,
|
|
440
|
+
pub name: String,
|
|
441
|
+
pub kind: ProjectKind,
|
|
442
|
+
pub scope: ProjectScope,
|
|
443
|
+
pub dept_name: Option<String>,
|
|
444
|
+
/// 见 `SyncRoot::drive_path`。**空串 = 不进目录树**(SKILL)。
|
|
445
|
+
pub drive_path: String,
|
|
446
|
+
/// 见 `SyncRoot::node` / `container` / `node_path`。面板按 `node_path` 分组
|
|
447
|
+
pub node: Option<String>,
|
|
448
|
+
pub container: Option<String>,
|
|
449
|
+
pub node_path: Option<String>,
|
|
450
|
+
pub skills_path: Option<String>,
|
|
451
|
+
pub sync_enabled: bool,
|
|
452
|
+
pub can_write: bool,
|
|
453
|
+
pub cloud_only: bool,
|
|
454
|
+
pub cursor: Option<String>,
|
|
455
|
+
pub pinned: bool,
|
|
456
|
+
/// 见 `SyncRoot::department_id`
|
|
457
|
+
pub department_id: Option<RemoteId>,
|
|
458
|
+
/// 见 `SyncRoot::owner_id`;服务端没给就是 None
|
|
459
|
+
pub owner_id: Option<RemoteId>,
|
|
460
|
+
/// 见 `SyncRoot::is_mine`;None = 老服务端,前端回落按 scope 分组
|
|
461
|
+
pub is_mine: Option<bool>,
|
|
462
|
+
/// 本地绝对路径(`<local_root>/<drivePath>`)
|
|
463
|
+
pub local_path: String,
|
|
464
|
+
/// 是否在本轮同步范围内 = 自己的(`isMine`)+ 公司级 + 本人部门的 + 用户点开过的,且 `syncEnabled`。
|
|
465
|
+
/// 归属判不出且个人库数量超阈值时的兜底见 `engine::LEGACY_PERSONAL_ROOT_LIMIT` 与 `OwnershipGuard`
|
|
466
|
+
pub selected: bool,
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
470
|
+
#[serde(rename_all = "camelCase")]
|
|
471
|
+
pub struct FileStateEvent {
|
|
472
|
+
pub project_id: RemoteId,
|
|
473
|
+
/// 云端相对路径
|
|
474
|
+
pub path: String,
|
|
475
|
+
/// 本地绝对路径
|
|
476
|
+
pub local_path: String,
|
|
477
|
+
pub state: FileState,
|
|
478
|
+
/// 云端记的字节数(索引里没有这一行时是 0)。
|
|
479
|
+
///
|
|
480
|
+
/// 给文件树上那个占位用:「这份还没下,12.4 MB,点一下拉」——只说「还没下」而不说多大,
|
|
481
|
+
/// 用户没法判断值不值得点(2026-09-07 老板拍板大文件按需下载时明确要「标注 + 大小」)。
|
|
482
|
+
#[serde(default)]
|
|
483
|
+
pub size: u64,
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/// 用户改了一个 `readOnly=true` 的文件。这类文件正常落到本地(能看、能被 agent 读),
|
|
487
|
+
/// 但改动不上行 —— 静默丢弃是最糟的:用户以为存上去了。桌面端据此提示「这份文件需要走修改申请」,
|
|
488
|
+
/// 并给一个 `<webBase>/file/<fileId>` 的入口。
|
|
489
|
+
///
|
|
490
|
+
/// 每个 (文件, 内容 sha256) 只报一次(引擎内去重),否则每一轮同步都会再弹一次。
|
|
491
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
492
|
+
#[serde(rename_all = "camelCase")]
|
|
493
|
+
pub struct ReadOnlyChange {
|
|
494
|
+
pub project_id: RemoteId,
|
|
495
|
+
/// 云端相对路径
|
|
496
|
+
pub path: String,
|
|
497
|
+
/// 本地绝对路径
|
|
498
|
+
pub local_path: String,
|
|
499
|
+
/// 文件名(提示语里用)
|
|
500
|
+
pub name: String,
|
|
501
|
+
/// 服务端 fileId;还没上过云的文件没有 → 只能给项目级入口
|
|
502
|
+
pub file_id: Option<RemoteId>,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/// 批量删除安全阀触发:某个 root 一轮里净删除达到阈值,已挂起、等用户确认是否也从云端删除。
|
|
506
|
+
/// 前端据此弹一个 Dropbox 式确认(确认删除 / 取消恢复)。
|
|
507
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
508
|
+
#[serde(rename_all = "camelCase")]
|
|
509
|
+
pub struct PendingDeletes {
|
|
510
|
+
pub project_id: RemoteId,
|
|
511
|
+
/// 项目在云盘里的路径 / 名字(提示语里用,比裸 id 好认)
|
|
512
|
+
pub drive_path: String,
|
|
513
|
+
/// 本轮被拦下的净删除文件数
|
|
514
|
+
pub count: u32,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
518
|
+
#[serde(rename_all = "camelCase")]
|
|
519
|
+
pub struct ConflictRecord {
|
|
520
|
+
pub project_id: RemoteId,
|
|
521
|
+
pub path: String,
|
|
522
|
+
pub local_path: String,
|
|
523
|
+
/// 本地版本另存的冲突副本(本地绝对路径);远端在本地删除后又改过的那种冲突没有副本
|
|
524
|
+
pub conflict_copy_path: Option<String>,
|
|
525
|
+
pub remote_updated_by: Option<String>,
|
|
526
|
+
pub at: String,
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
530
|
+
#[serde(rename_all = "camelCase")]
|
|
531
|
+
pub struct SyncError {
|
|
532
|
+
pub project_id: Option<RemoteId>,
|
|
533
|
+
pub path: Option<String>,
|
|
534
|
+
pub message: String,
|
|
535
|
+
pub at: String,
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/// 一条失败记录是**哪个方向**的。两句话不一样,用户能做的动作也不一样:
|
|
539
|
+
/// 「没传上去」要等重试 / 去要权限,「没下下来」可以当场点一下手动拉。
|
|
540
|
+
///
|
|
541
|
+
/// 2026-09-07 之前下行失败只落在内存 ring 里,重启就没 —— 于是「下载卡死十分钟」在界面上
|
|
542
|
+
/// 一片安静。方向这个字段是把下行接进同一套可见性的前提。
|
|
543
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
544
|
+
#[serde(rename_all = "lowercase")]
|
|
545
|
+
pub enum SyncDirection {
|
|
546
|
+
Upload,
|
|
547
|
+
Download,
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
impl SyncDirection {
|
|
551
|
+
pub fn as_str(self) -> &'static str {
|
|
552
|
+
match self {
|
|
553
|
+
SyncDirection::Upload => "upload",
|
|
554
|
+
SyncDirection::Download => "download",
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/// 认不出来的一律当上行:老库里的记录都是上行留下的(v7 之前只有上行会写这张表)。
|
|
559
|
+
pub fn parse(raw: &str) -> Self {
|
|
560
|
+
if raw == "download" { SyncDirection::Download } else { SyncDirection::Upload }
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/// 一个同步失败、至今没落地的文件。`StatusSnapshot::failed` 是它的计数,这里是明细
|
|
565
|
+
/// (哪个文件、服务端原话是什么、试了几次、下次什么时候再试)。
|
|
566
|
+
///
|
|
567
|
+
/// `message` 一律**原样**保留服务端那句话:`PARENT_FOLDER_NOT_FOUND: 上级目录不存在: …`
|
|
568
|
+
/// 这种错误的价值全在细节里,翻译成「上传失败」等于把排查线索扔了。
|
|
569
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
570
|
+
#[serde(rename_all = "camelCase")]
|
|
571
|
+
pub struct FailedUpload {
|
|
572
|
+
pub project_id: RemoteId,
|
|
573
|
+
/// 云端相对路径
|
|
574
|
+
pub path: String,
|
|
575
|
+
/// 本地绝对路径(项目根已经不在索引里时为空串)
|
|
576
|
+
pub local_path: String,
|
|
577
|
+
/// 文件名(提示语里用)
|
|
578
|
+
pub name: String,
|
|
579
|
+
/// `error`(会按退避重试)或 `forbidden`(要人去处理权限,内容再变才重发)
|
|
580
|
+
pub state: FileState,
|
|
581
|
+
/// 服务端原话
|
|
582
|
+
pub message: String,
|
|
583
|
+
/// 已经试过几次
|
|
584
|
+
pub attempts: u32,
|
|
585
|
+
/// 上行还是下行 —— UI 那句话不一样
|
|
586
|
+
pub direction: SyncDirection,
|
|
587
|
+
pub first_at: String,
|
|
588
|
+
pub last_at: String,
|
|
589
|
+
/// 下一次会重试的时间;已经到点了就是过去的时间
|
|
590
|
+
pub next_attempt_at: String,
|
|
591
|
+
/// 这条还会不会自己再试。`false` = 再试也是同一个答案(目录非空、没权限、云端那份
|
|
592
|
+
/// 已经没了……),引擎已经停手,得有人去处理 —— UI 必须说清楚,不然用户看到的就是
|
|
593
|
+
/// 一条「已试 21 次」永远挂在那里,既不成功也不消失(2026-09-15 用户原话)。
|
|
594
|
+
pub retryable: bool,
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
598
|
+
#[serde(rename_all = "camelCase")]
|
|
599
|
+
pub struct StatusSnapshot {
|
|
600
|
+
pub state: EngineState,
|
|
601
|
+
/// 本轮待上传文件数
|
|
602
|
+
pub pending: u32,
|
|
603
|
+
/// 正在上传
|
|
604
|
+
pub uploading: u32,
|
|
605
|
+
/// 本轮还没下完的文件数(下行)。状态栏的「同步中 N 个文件」用它 + `uploading`
|
|
606
|
+
pub downloading: u32,
|
|
607
|
+
/// 累计已同步文件数(索引里 synced 行数)
|
|
608
|
+
pub synced: u32,
|
|
609
|
+
/// 上行失败、至今没传上去的文件数(状态栏那句「N 个文件没传上去」)。
|
|
610
|
+
///
|
|
611
|
+
/// 2026-09-07 之前这个数不存在:失败的行停在 `FileState::Error`,重试六轮还是同一句
|
|
612
|
+
/// `PARENT_FOLDER_NOT_FOUND`,而 UI 上一个字都没有 —— 用户以为文件已经在云上了。
|
|
613
|
+
/// 静默失败比失败本身更伤人。明细见 `Engine::failed_uploads`
|
|
614
|
+
pub failed: u32,
|
|
615
|
+
pub conflicts: Vec<ConflictRecord>,
|
|
616
|
+
pub errors: Vec<SyncError>,
|
|
617
|
+
pub last_sync_at: Option<String>,
|
|
618
|
+
/// `/ws/sync` 变更推送是否在线;离线时回落 60 秒轮询
|
|
619
|
+
pub push_connected: bool,
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
impl Default for StatusSnapshot {
|
|
623
|
+
fn default() -> Self {
|
|
624
|
+
Self {
|
|
625
|
+
state: EngineState::Idle,
|
|
626
|
+
pending: 0,
|
|
627
|
+
uploading: 0,
|
|
628
|
+
downloading: 0,
|
|
629
|
+
synced: 0,
|
|
630
|
+
failed: 0,
|
|
631
|
+
conflicts: Vec::new(),
|
|
632
|
+
errors: Vec::new(),
|
|
633
|
+
last_sync_at: None,
|
|
634
|
+
push_connected: false,
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ─── 6.1 SSO ─────────────────────────────────────────────────────────────────
|
|
640
|
+
|
|
641
|
+
/// `GET /auth/sso/config`(公开)。`enabled=false` 时其余字段可缺。
|
|
642
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
643
|
+
#[serde(rename_all = "camelCase")]
|
|
644
|
+
pub struct SsoConfig {
|
|
645
|
+
pub enabled: bool,
|
|
646
|
+
#[serde(default)]
|
|
647
|
+
pub issuer: Option<String>,
|
|
648
|
+
#[serde(default)]
|
|
649
|
+
pub client_id: Option<String>,
|
|
650
|
+
#[serde(default)]
|
|
651
|
+
pub authorization_endpoint: Option<String>,
|
|
652
|
+
#[serde(default)]
|
|
653
|
+
pub token_endpoint: Option<String>,
|
|
654
|
+
#[serde(default)]
|
|
655
|
+
pub redirect_uris: Vec<String>,
|
|
656
|
+
/// 服务端可能给 `"openid profile email"` 一个串,也可能给数组;统一收成列表
|
|
657
|
+
#[serde(default, deserialize_with = "string_or_list")]
|
|
658
|
+
#[specta(type = Vec<String>)]
|
|
659
|
+
pub scopes: Vec<String>,
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
fn string_or_list<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
|
|
663
|
+
#[derive(Deserialize)]
|
|
664
|
+
#[serde(untagged)]
|
|
665
|
+
enum StringOrList {
|
|
666
|
+
Text(String),
|
|
667
|
+
List(Vec<String>),
|
|
668
|
+
}
|
|
669
|
+
Ok(match StringOrList::deserialize(deserializer)? {
|
|
670
|
+
StringOrList::Text(text) => text.split_whitespace().map(str::to_string).collect(),
|
|
671
|
+
StringOrList::List(list) => list,
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/// `POST /auth/sso/exchange` 请求体。
|
|
676
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
677
|
+
#[serde(rename_all = "camelCase")]
|
|
678
|
+
pub struct SsoExchangeRequest {
|
|
679
|
+
pub id_token: String,
|
|
680
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
681
|
+
pub access_token: Option<String>,
|
|
682
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
683
|
+
pub device_name: Option<String>,
|
|
684
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
685
|
+
pub platform: Option<String>,
|
|
686
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
687
|
+
pub client_version: Option<String>,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
691
|
+
#[serde(rename_all = "camelCase")]
|
|
692
|
+
pub struct SsoUser {
|
|
693
|
+
pub user_id: RemoteId,
|
|
694
|
+
pub display_name: String,
|
|
695
|
+
#[serde(default)]
|
|
696
|
+
pub employee_no: Option<String>,
|
|
697
|
+
#[serde(default)]
|
|
698
|
+
pub email: Option<String>,
|
|
699
|
+
#[serde(default)]
|
|
700
|
+
pub feishu_open_id: Option<String>,
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/// `POST /auth/sso/exchange` 响应:带 deviceName 时 `deviceId` 有值且 `token` 是设备 token。
|
|
704
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
705
|
+
#[serde(rename_all = "camelCase")]
|
|
706
|
+
pub struct SsoExchangeResponse {
|
|
707
|
+
pub token: String,
|
|
708
|
+
pub user: SsoUser,
|
|
709
|
+
#[serde(default)]
|
|
710
|
+
pub device_id: Option<String>,
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ─── 6.7 协同 ────────────────────────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
/// `GET /files/{id}` 里协同要看的那一点:`canWrite`(镜像文件 / sync_enabled=false 项目为 false)。
|
|
716
|
+
/// 服务端目前尚未在文件详情里带这个字段,缺省时按根的 canWrite 判。
|
|
717
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
718
|
+
#[serde(rename_all = "camelCase")]
|
|
719
|
+
pub struct FileAccess {
|
|
720
|
+
#[serde(default)]
|
|
721
|
+
pub can_write: Option<bool>,
|
|
722
|
+
/// 文件所有者。「采纳建议替换」只对他显示;没有所有者的文件谁都不是所有者,不是缺省值
|
|
723
|
+
#[serde(default)]
|
|
724
|
+
pub owner_id: Option<RemoteId>,
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ─── 文件评论 ────────────────────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
/// 评论里出现的人:采纳者、以及 @ 到的人、`GET /users/search` 的搜人结果。服务端 `UserBrief` 原样。
|
|
730
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
731
|
+
#[serde(rename_all = "camelCase")]
|
|
732
|
+
pub struct UserBrief {
|
|
733
|
+
pub id: RemoteId,
|
|
734
|
+
#[serde(default)]
|
|
735
|
+
pub employee_no: Option<String>,
|
|
736
|
+
#[serde(default)]
|
|
737
|
+
pub name: Option<String>,
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/// `GET /files/{fileId}/comments` 的一项。**服务端直接给嵌套树**(根评论带 `replies`),
|
|
741
|
+
/// 客户端不按 `parentId` 自己拼。
|
|
742
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
743
|
+
#[serde(rename_all = "camelCase")]
|
|
744
|
+
pub struct FileComment {
|
|
745
|
+
pub id: RemoteId,
|
|
746
|
+
pub file_id: RemoteId,
|
|
747
|
+
#[serde(default)]
|
|
748
|
+
pub parent_id: Option<RemoteId>,
|
|
749
|
+
pub author_id: RemoteId,
|
|
750
|
+
#[serde(default)]
|
|
751
|
+
pub author_name: Option<String>,
|
|
752
|
+
#[serde(default)]
|
|
753
|
+
pub author_employee_no: Option<String>,
|
|
754
|
+
/// 软删除的墓碑(还有回复挂着)body / quote 都是 null,UI 显示「已删除」占位
|
|
755
|
+
#[serde(default)]
|
|
756
|
+
pub body: Option<String>,
|
|
757
|
+
#[serde(default)]
|
|
758
|
+
pub quote: Option<String>,
|
|
759
|
+
/// 引文在正文纯文本里的字符偏移;老评论没有。多处重复引文靠它消歧
|
|
760
|
+
#[serde(default)]
|
|
761
|
+
pub quote_offset: Option<i64>,
|
|
762
|
+
#[serde(default)]
|
|
763
|
+
pub is_ai: bool,
|
|
764
|
+
/// 这条评论 @ 到的人(服务端回的,带名字)。**显示名字不显示 id** —— 正文里本来就是
|
|
765
|
+
/// `@名字`,这份列表是给「谁被提到了」这类判断用的
|
|
766
|
+
#[serde(default)]
|
|
767
|
+
pub mentions: Option<Vec<UserBrief>>,
|
|
768
|
+
#[serde(default)]
|
|
769
|
+
pub deleted: bool,
|
|
770
|
+
pub created_at: String,
|
|
771
|
+
#[serde(default)]
|
|
772
|
+
pub updated_at: Option<String>,
|
|
773
|
+
#[serde(default)]
|
|
774
|
+
pub replies: Vec<FileComment>,
|
|
775
|
+
#[serde(default)]
|
|
776
|
+
pub suggested_replacement: Option<String>,
|
|
777
|
+
#[serde(default)]
|
|
778
|
+
pub applied_at: Option<String>,
|
|
779
|
+
#[serde(default)]
|
|
780
|
+
pub applied_by: Option<UserBrief>,
|
|
781
|
+
#[serde(default)]
|
|
782
|
+
pub applied_version_id: Option<RemoteId>,
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/// `POST /files/{fileId}/comments` 的请求体。
|
|
786
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
787
|
+
#[serde(rename_all = "camelCase")]
|
|
788
|
+
pub struct CreateCommentRequest {
|
|
789
|
+
pub body: String,
|
|
790
|
+
/// 这条评论 @ 到了谁。服务端(`FileCommentEntity.mentionedUserIds`)据此发通知。
|
|
791
|
+
///
|
|
792
|
+
/// **正文里同时留着 `@名字` 的字面文本**(与网页端同一做法):id 只用来通知,
|
|
793
|
+
/// 显示靠正文本身 —— 这样老客户端、纯文本导出、AI 读到的都是人话而不是一串数字。
|
|
794
|
+
/// 没 @ 任何人时发 `null`(**不是空数组**)。服务端把 null 与「字段不在」当同一回事 ——
|
|
795
|
+
/// 与同结构体里的 `parent_id` 同一处理。
|
|
796
|
+
/// (不能用 `skip_serializing_if`:这个结构体同时是命令入参,specta 的统一模式表达不了条件省略。)
|
|
797
|
+
pub mentioned_user_ids: Option<Vec<RemoteId>>,
|
|
798
|
+
/// 回复谁(根评论为 None)。服务端把 null 与「字段不在」当同一回事,所以照发 null
|
|
799
|
+
pub parent_id: Option<RemoteId>,
|
|
800
|
+
pub quote: Option<String>,
|
|
801
|
+
pub quote_offset: Option<i64>,
|
|
802
|
+
/// 建议替换文本:文件所有者可以「采纳」它生成新版本。有它就必须有 quote
|
|
803
|
+
pub suggested_replacement: Option<String>,
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/// 打开评论抽屉前要问清的三件事:这份文件在服务端是谁、我是谁、我能不能采纳建议。
|
|
807
|
+
/// 不在 Drive 里 / 没登录时命令返回 None —— 那时根本不该出现评论入口。
|
|
808
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
809
|
+
#[serde(rename_all = "camelCase")]
|
|
810
|
+
pub struct CommentContext {
|
|
811
|
+
pub file_id: RemoteId,
|
|
812
|
+
pub current_user_id: RemoteId,
|
|
813
|
+
/// 文件所有者本人 → 建议替换旁边出现「采纳」
|
|
814
|
+
pub is_owner: bool,
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ─── 6.2 变更推送 ────────────────────────────────────────────────────────────
|
|
818
|
+
|
|
819
|
+
/// `/ws/sync` 上的 JSON 帧,双向共用一个 tagged enum。
|
|
820
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
821
|
+
#[serde(tag = "type", rename_all = "snake_case")]
|
|
822
|
+
pub enum PushMessage {
|
|
823
|
+
Subscribe {
|
|
824
|
+
#[serde(rename = "projectIds")]
|
|
825
|
+
project_ids: Vec<RemoteId>,
|
|
826
|
+
},
|
|
827
|
+
/// 服务端对 `subscribe` 的确认(契约里没写,实测有);收到只记 debug,不做别的
|
|
828
|
+
Subscribed {
|
|
829
|
+
#[serde(rename = "projectIds", default)]
|
|
830
|
+
project_ids: Vec<RemoteId>,
|
|
831
|
+
},
|
|
832
|
+
FileEvents {
|
|
833
|
+
#[serde(rename = "projectId")]
|
|
834
|
+
project_id: RemoteId,
|
|
835
|
+
seq: i64,
|
|
836
|
+
},
|
|
837
|
+
/// 打开着评论抽屉的文件集(整份替换,不是增量)。
|
|
838
|
+
SubscribeFiles {
|
|
839
|
+
#[serde(rename = "fileIds")]
|
|
840
|
+
file_ids: Vec<RemoteId>,
|
|
841
|
+
},
|
|
842
|
+
/// 服务端对 `subscribe_files` 的确认:**没有 READ 权的 fileId 会被剔除**,
|
|
843
|
+
/// 所以回来的集合可能比发出去的小 —— 少掉的那些永远收不到 comment_event。
|
|
844
|
+
FilesSubscribed {
|
|
845
|
+
#[serde(rename = "fileIds", default)]
|
|
846
|
+
file_ids: Vec<RemoteId>,
|
|
847
|
+
},
|
|
848
|
+
/// 某文件的评论变了。**只是信号**:客户端据此重拉一次列表,不做增量拼装。
|
|
849
|
+
CommentEvent {
|
|
850
|
+
#[serde(rename = "fileId")]
|
|
851
|
+
file_id: RemoteId,
|
|
852
|
+
#[serde(rename = "commentId")]
|
|
853
|
+
comment_id: RemoteId,
|
|
854
|
+
/// `created` / `updated` / `deleted` / `applied`。保持字符串:新增动作时老客户端
|
|
855
|
+
/// 照样能收下并重拉列表,不必为了一个没见过的取值把整帧丢掉
|
|
856
|
+
action: String,
|
|
857
|
+
/// 服务端全局 NON_NULL:actorId / at 为空时整个字段不出现
|
|
858
|
+
#[serde(rename = "actorId", default)]
|
|
859
|
+
actor_id: Option<RemoteId>,
|
|
860
|
+
#[serde(default)]
|
|
861
|
+
at: Option<String>,
|
|
862
|
+
},
|
|
863
|
+
Ping,
|
|
864
|
+
Pong,
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ─── 6.8 新建项目向导 ────────────────────────────────────────────────────────
|
|
868
|
+
|
|
869
|
+
/// `GET /project-templates` 一项(服务端 `project_templates` 实体原样)。
|
|
870
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
871
|
+
#[serde(rename_all = "camelCase")]
|
|
872
|
+
pub struct ProjectTemplate {
|
|
873
|
+
pub id: RemoteId,
|
|
874
|
+
#[serde(default)]
|
|
875
|
+
pub code: Option<String>,
|
|
876
|
+
pub name: String,
|
|
877
|
+
#[serde(default)]
|
|
878
|
+
pub description: Option<String>,
|
|
879
|
+
#[serde(default)]
|
|
880
|
+
pub kind: Option<String>,
|
|
881
|
+
#[serde(default)]
|
|
882
|
+
pub icon: Option<String>,
|
|
883
|
+
#[serde(default)]
|
|
884
|
+
pub builtin: bool,
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/// `GET /departments` 一项(扁平列表)。
|
|
888
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
889
|
+
#[serde(rename_all = "camelCase")]
|
|
890
|
+
pub struct Department {
|
|
891
|
+
pub id: RemoteId,
|
|
892
|
+
#[serde(default)]
|
|
893
|
+
pub code: Option<String>,
|
|
894
|
+
pub name: String,
|
|
895
|
+
#[serde(default)]
|
|
896
|
+
pub parent_id: Option<RemoteId>,
|
|
897
|
+
#[serde(default, rename = "type")]
|
|
898
|
+
pub department_type: Option<String>,
|
|
899
|
+
/// 层级路径(`GET /departments?q=` 搜出来的带它,契约 §6.18);列表接口不给 → None
|
|
900
|
+
#[serde(default)]
|
|
901
|
+
pub path: Option<String>,
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/// `POST /projects/from-template` 请求体。
|
|
905
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
906
|
+
#[serde(rename_all = "camelCase")]
|
|
907
|
+
pub struct CreateProjectFromTemplateRequest {
|
|
908
|
+
pub template_id: RemoteId,
|
|
909
|
+
pub name: String,
|
|
910
|
+
pub scope: ProjectScope,
|
|
911
|
+
/// 不带就发 null(specta 不接受 skip_serializing_if 的命令参数)
|
|
912
|
+
#[serde(default)]
|
|
913
|
+
pub department_id: Option<RemoteId>,
|
|
914
|
+
#[serde(default)]
|
|
915
|
+
pub topic_id: Option<RemoteId>,
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/// `POST /projects/from-template` 响应:只取定位根所需的字段,其余原样忽略。
|
|
919
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
920
|
+
#[serde(rename_all = "camelCase")]
|
|
921
|
+
pub struct CreatedProject {
|
|
922
|
+
#[serde(alias = "projectId")]
|
|
923
|
+
pub id: RemoteId,
|
|
924
|
+
pub project_key: String,
|
|
925
|
+
pub name: String,
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// ─── 会话归档(上行专用根)/ 知识回流 / 版本保存 ───────────────────────────────
|
|
929
|
+
|
|
930
|
+
/// `GET /projects/by-key/{projectKey}`:只取定位归档项目所需的字段。
|
|
931
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
932
|
+
#[serde(rename_all = "camelCase")]
|
|
933
|
+
pub struct ProjectSummary {
|
|
934
|
+
#[serde(alias = "projectId")]
|
|
935
|
+
pub id: RemoteId,
|
|
936
|
+
#[serde(default)]
|
|
937
|
+
pub project_key: Option<String>,
|
|
938
|
+
#[serde(default)]
|
|
939
|
+
pub name: Option<String>,
|
|
940
|
+
#[serde(default)]
|
|
941
|
+
pub sync_enabled: Option<bool>,
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/// `GET /auth/me`:归档路径前缀用邮箱前缀(插件同款 `<email 前缀>/<YYYY-MM>/`)。
|
|
945
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
946
|
+
#[serde(rename_all = "camelCase")]
|
|
947
|
+
pub struct UserInfo {
|
|
948
|
+
#[serde(default)]
|
|
949
|
+
pub id: Option<RemoteId>,
|
|
950
|
+
#[serde(default)]
|
|
951
|
+
pub name: Option<String>,
|
|
952
|
+
#[serde(default)]
|
|
953
|
+
pub email: Option<String>,
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/// `GET /sync/me`:当前用户。契约钉死的只有 `{userId, displayName, employeeNo}`;部门字段
|
|
957
|
+
/// **各版本名字不一、也可能压根没有**,所以全按可选解析(客户端在 `client::parse_sync_me` 里
|
|
958
|
+
/// 把常见的几种写法都认一遍)。拿不到部门时 SKILL 自动分发退化成「只装公司级」——
|
|
959
|
+
/// 宁可少装,不可把别的部门的规范装到这台机器上。
|
|
960
|
+
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, specta::Type)]
|
|
961
|
+
#[serde(rename_all = "camelCase")]
|
|
962
|
+
pub struct SyncMe {
|
|
963
|
+
pub user_id: Option<RemoteId>,
|
|
964
|
+
pub display_name: Option<String>,
|
|
965
|
+
pub employee_no: Option<String>,
|
|
966
|
+
/// 服务端待补(见 docs/drive.md「待服务端」):有了它就能按 id 而不是显示名匹配部门
|
|
967
|
+
pub department_id: Option<RemoteId>,
|
|
968
|
+
pub dept_name: Option<String>,
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/// `POST /files/upload` 与 `POST /files/{id}/upload-version` 的响应(插件用的上传接口,归档回落路径)。
|
|
972
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
973
|
+
#[serde(rename_all = "camelCase")]
|
|
974
|
+
pub struct UploadedFile {
|
|
975
|
+
pub file_id: RemoteId,
|
|
976
|
+
#[serde(default)]
|
|
977
|
+
pub version_id: Option<RemoteId>,
|
|
978
|
+
#[serde(default)]
|
|
979
|
+
pub current_version_id: Option<RemoteId>,
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/// `POST /files/{id}/save-content` 的响应。
|
|
983
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
984
|
+
#[serde(rename_all = "camelCase")]
|
|
985
|
+
pub struct SavedContent {
|
|
986
|
+
#[serde(default)]
|
|
987
|
+
pub new_version_id: Option<RemoteId>,
|
|
988
|
+
#[serde(default)]
|
|
989
|
+
pub merged_with_previous: bool,
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/// `GET /ai/search` 一条命中(6.10;`mode` 缺省时 data 是数组,semantic / hybrid 时是 `{items,total}`,客户端两种都收)。
|
|
993
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
994
|
+
#[serde(rename_all = "camelCase")]
|
|
995
|
+
pub struct AiSearchHit {
|
|
996
|
+
pub file_id: RemoteId,
|
|
997
|
+
pub name: String,
|
|
998
|
+
#[serde(default)]
|
|
999
|
+
pub kind: Option<String>,
|
|
1000
|
+
#[serde(default)]
|
|
1001
|
+
pub project_name: Option<String>,
|
|
1002
|
+
#[serde(default)]
|
|
1003
|
+
pub folder_path: Option<String>,
|
|
1004
|
+
#[serde(default)]
|
|
1005
|
+
pub category: Option<String>,
|
|
1006
|
+
#[serde(default)]
|
|
1007
|
+
pub snippet: Option<String>,
|
|
1008
|
+
#[serde(default)]
|
|
1009
|
+
pub human_url: Option<String>,
|
|
1010
|
+
#[serde(default)]
|
|
1011
|
+
pub updated_at: Option<String>,
|
|
1012
|
+
#[serde(default)]
|
|
1013
|
+
pub score: Option<f64>,
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/// 设置页「已装的部门 Skill」的一条:SKILL 项目不进云盘列表(它是给机器读的),
|
|
1017
|
+
/// 但用户得能找到它装在哪。
|
|
1018
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1019
|
+
#[serde(rename_all = "camelCase")]
|
|
1020
|
+
pub struct SkillMirror {
|
|
1021
|
+
pub project_id: RemoteId,
|
|
1022
|
+
pub project_key: String,
|
|
1023
|
+
pub name: String,
|
|
1024
|
+
/// 公司级 / 本人部门的 SKILL 会**自动**装;别的部门的只有用户手动固定才装
|
|
1025
|
+
pub auto_installed: bool,
|
|
1026
|
+
pub pinned: bool,
|
|
1027
|
+
/// DEPT / TEAM 的部门名;公司级为空
|
|
1028
|
+
pub dept_name: Option<String>,
|
|
1029
|
+
/// `~/YUP Drive/<drivePath>`
|
|
1030
|
+
pub local_path: String,
|
|
1031
|
+
/// `~/.alwith/skills/.yup-drive/<projectKey>`;没配 skills_dir 时为空
|
|
1032
|
+
pub skills_path: Option<String>,
|
|
1033
|
+
/// 是否已经同步到本地(目录存在)
|
|
1034
|
+
pub installed: bool,
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/// 会话归档的状态快照(设置页「AI 对话归档」用)。`enabled` 由桌面侧按设置填。
|
|
1038
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, specta::Type)]
|
|
1039
|
+
#[serde(rename_all = "camelCase")]
|
|
1040
|
+
pub struct ArchiveStatus {
|
|
1041
|
+
pub enabled: bool,
|
|
1042
|
+
pub syncing: bool,
|
|
1043
|
+
/// 上次扫描时间(不论有没有上传)
|
|
1044
|
+
pub last_run_at: Option<String>,
|
|
1045
|
+
/// 上次真正上传了会话的时间
|
|
1046
|
+
pub last_upload_at: Option<String>,
|
|
1047
|
+
/// 已归档会话数(索引里的行数)
|
|
1048
|
+
pub synced: u32,
|
|
1049
|
+
/// 上次运行上传的条数
|
|
1050
|
+
pub last_uploaded: u32,
|
|
1051
|
+
pub last_error: Option<String>,
|
|
1052
|
+
/// `batch`(走 /sync/batch)或 `upload`(服务端对该项目 FORBIDDEN,回落 /files/upload)
|
|
1053
|
+
pub mode: Option<String>,
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/// 归属兜底防线的状态(见 `engine::LEGACY_PERSONAL_ROOT_LIMIT`)。
|
|
1057
|
+
///
|
|
1058
|
+
/// 老服务端不返回 `isMine` / `ownerId`,桌面端只能按 `scope == PERSONAL` 回落。对普通员工没问题
|
|
1059
|
+
/// (自己就一个个人库),对管理员是灾难 —— 他对全公司每个人的个人知识库都有读权限,回落会把几百个
|
|
1060
|
+
/// 同事的库全拉到本地磁盘(2026-09-04 生产联调实报 279 个)。这里把「归属判不出」这件事单独暴露给
|
|
1061
|
+
/// UI,让用户看到防线为什么生效、并能显式覆盖它。
|
|
1062
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, specta::Type)]
|
|
1063
|
+
#[serde(rename_all = "camelCase")]
|
|
1064
|
+
pub struct OwnershipGuard {
|
|
1065
|
+
/// 服务端既没给 `isMine` 也没给 `ownerId` 的 PERSONAL 根数量
|
|
1066
|
+
pub unknown_personal_roots: u32,
|
|
1067
|
+
/// 超过这个数量就只同步已固定的(`engine::LEGACY_PERSONAL_ROOT_LIMIT`)
|
|
1068
|
+
pub threshold: u32,
|
|
1069
|
+
/// 用户是否已显式点过「全部同步」覆盖这道防线
|
|
1070
|
+
pub sync_all_personal: bool,
|
|
1071
|
+
/// 防线是否正在生效:归属判不出 + 数量超阈值 + 用户没覆盖 → 只同步已固定的
|
|
1072
|
+
pub active: bool,
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/// 单个项目在同步队列里的处境。云盘面板的行内状态直接映射这四个值 ——
|
|
1076
|
+
/// 点一个还没落盘的项目后,用户要能一眼看出「在排队 / 在下 / 下完了 / 挂了」,
|
|
1077
|
+
/// 而不是等一句二十秒后才弹的 toast。
|
|
1078
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1079
|
+
#[serde(rename_all = "camelCase")]
|
|
1080
|
+
pub enum ProjectPhase {
|
|
1081
|
+
/// 在本轮同步的队列里等着(用户点过的排在队首)
|
|
1082
|
+
Queued,
|
|
1083
|
+
/// 正在下行 / 上行这个项目
|
|
1084
|
+
Syncing,
|
|
1085
|
+
/// 本轮跑完了(`failed > 0` 说明有文件没下来,但项目本身是完成的)
|
|
1086
|
+
Done,
|
|
1087
|
+
/// 整个项目这一轮失败(拉 snapshot / changes 失败之类),`message` 是原因
|
|
1088
|
+
Failed,
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/// 一个项目一轮同步的进度与结果。`total` 是本轮要处理的文件数(首轮 = snapshot 全量,
|
|
1092
|
+
/// 增量轮 = 变更事件数),`done` / `failed` 是已处理与失败的个数 —— 结束时这两个数字
|
|
1093
|
+
/// 就是「成功 N 个、失败 M 个」的汇总。
|
|
1094
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1095
|
+
#[serde(rename_all = "camelCase")]
|
|
1096
|
+
pub struct ProjectProgress {
|
|
1097
|
+
pub project_id: RemoteId,
|
|
1098
|
+
pub phase: ProjectPhase,
|
|
1099
|
+
/// 本轮已处理完的文件数
|
|
1100
|
+
pub done: u32,
|
|
1101
|
+
/// 本轮要处理的文件数;拿到 snapshot / changes 之前是 0
|
|
1102
|
+
pub total: u32,
|
|
1103
|
+
/// 本轮失败的文件数(单文件失败不拖垮项目,详情进 `StatusSnapshot::errors`)
|
|
1104
|
+
pub failed: u32,
|
|
1105
|
+
/// 失败原因摘要;完整错误在 `StatusSnapshot::errors` 里按 projectId 找
|
|
1106
|
+
pub message: Option<String>,
|
|
1107
|
+
pub at: String,
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/// 「清理未选中的本地副本」的一个候选目录:已落盘,但当前不在同步选择集里。
|
|
1111
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1112
|
+
#[serde(rename_all = "camelCase")]
|
|
1113
|
+
pub struct CleanupEntry {
|
|
1114
|
+
pub project_id: RemoteId,
|
|
1115
|
+
pub name: String,
|
|
1116
|
+
pub drive_path: String,
|
|
1117
|
+
pub local_path: String,
|
|
1118
|
+
/// 目录里的文件数(给用户一个体量感)
|
|
1119
|
+
pub files: u32,
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/// 必须跳过的理由。
|
|
1123
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1124
|
+
#[serde(rename_all = "camelCase")]
|
|
1125
|
+
pub enum CleanupSkipReason {
|
|
1126
|
+
/// 用户自己固定的项目 —— 哪怕当前没在同步范围内(如 `syncEnabled=false`)也不动
|
|
1127
|
+
Pinned,
|
|
1128
|
+
/// 目录里有还没上行的本地改动(新增 / 修改 / 删除 / pending / 冲突)
|
|
1129
|
+
PendingChanges,
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1133
|
+
#[serde(rename_all = "camelCase")]
|
|
1134
|
+
pub struct CleanupSkipped {
|
|
1135
|
+
pub name: String,
|
|
1136
|
+
pub drive_path: String,
|
|
1137
|
+
pub local_path: String,
|
|
1138
|
+
pub reason: CleanupSkipReason,
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/// 清理的扫描结果 / 执行结果。扫描阶段 `moved` 恒为空,执行阶段 `candidates` 是这一轮真正处理过的集合。
|
|
1142
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, specta::Type)]
|
|
1143
|
+
#[serde(rename_all = "camelCase")]
|
|
1144
|
+
pub struct CleanupReport {
|
|
1145
|
+
/// `<本地根>/../YUP Drive 暂存`
|
|
1146
|
+
pub staging_dir: String,
|
|
1147
|
+
/// 可以移走的目录
|
|
1148
|
+
pub candidates: Vec<CleanupEntry>,
|
|
1149
|
+
/// 必须跳过的目录与理由
|
|
1150
|
+
pub skipped: Vec<CleanupSkipped>,
|
|
1151
|
+
/// 真正移走的目录(扫描阶段为空)
|
|
1152
|
+
pub moved: Vec<CleanupEntry>,
|
|
1153
|
+
/// 移动失败的:`<drivePath>: <原因>`
|
|
1154
|
+
pub failed: Vec<String>,
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ─── 沉淀(condense)─────────────────────────────────────────────────────────
|
|
1158
|
+
//
|
|
1159
|
+
// 「资料自动沉淀为知识」是整个项目的核心卖点,但入口一直只在网页上的一个按钮 —— 而人干活是
|
|
1160
|
+
// 在 alwith 里。老板原话:「沉淀不是直接在 alwith 里做好了以后直接沉淀为知识的吗」。
|
|
1161
|
+
//
|
|
1162
|
+
// **沉淀是服务端的 job,不是本地跑 skill**(契约 §2):`POST /projects/{id}/condense` 建一条
|
|
1163
|
+
// condense job,执行器拉项目快照 zip、跑 kb-condense、产出草稿进知识库。客户端只负责发起、
|
|
1164
|
+
// 显示进度、把草稿领回来给人改。这一点必须守住:知识过期检测、`recondense`、网页那个
|
|
1165
|
+
// 「建议沉淀」列表全挂在这条链上,客户端另跑一份会分叉成两种质量的知识。
|
|
1166
|
+
|
|
1167
|
+
/// `GET /jobs/{id}` / `POST /projects/{id}/condense` 回的任务对象(契约 §2,只取客户端用得上的字段)。
|
|
1168
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1169
|
+
#[serde(rename_all = "camelCase")]
|
|
1170
|
+
pub struct Job {
|
|
1171
|
+
pub id: RemoteId,
|
|
1172
|
+
/// `condense` / `feishu_import` / …
|
|
1173
|
+
#[serde(default)]
|
|
1174
|
+
pub r#type: Option<String>,
|
|
1175
|
+
pub status: JobStatus,
|
|
1176
|
+
#[serde(default)]
|
|
1177
|
+
pub project_id: Option<RemoteId>,
|
|
1178
|
+
/// 0-100;服务端没给就当还没开始
|
|
1179
|
+
#[serde(default)]
|
|
1180
|
+
pub progress: Option<u32>,
|
|
1181
|
+
/// 执行器写的一句人话(「正在拉取快照」之类)
|
|
1182
|
+
#[serde(default)]
|
|
1183
|
+
pub message: Option<String>,
|
|
1184
|
+
/// 失败原因。**原样保留**,这是用户唯一能据以行动的东西
|
|
1185
|
+
#[serde(default)]
|
|
1186
|
+
pub error: Option<String>,
|
|
1187
|
+
/// 成功时的结果;condense 的形状见 `CondenseResult`
|
|
1188
|
+
#[serde(default)]
|
|
1189
|
+
pub result: Option<CondenseResult>,
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1193
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
1194
|
+
pub enum JobStatus {
|
|
1195
|
+
Pending,
|
|
1196
|
+
Claimed,
|
|
1197
|
+
Running,
|
|
1198
|
+
Succeeded,
|
|
1199
|
+
Failed,
|
|
1200
|
+
Cancelled,
|
|
1201
|
+
/// 夜间扫描建议的沉淀,等人批准才进队列(契约 §6.8)。客户端「主动提」读的就是它
|
|
1202
|
+
Proposed,
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
impl JobStatus {
|
|
1206
|
+
/// 还在路上(值得继续轮询)。
|
|
1207
|
+
pub fn is_live(self) -> bool {
|
|
1208
|
+
matches!(self, JobStatus::Pending | JobStatus::Claimed | JobStatus::Running)
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/// condense 成功后的结果(契约 §2)。
|
|
1213
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1214
|
+
#[serde(rename_all = "camelCase")]
|
|
1215
|
+
pub struct CondenseResult {
|
|
1216
|
+
pub knowledge_file_id: RemoteId,
|
|
1217
|
+
#[serde(default)]
|
|
1218
|
+
pub knowledge_title: Option<String>,
|
|
1219
|
+
#[serde(default)]
|
|
1220
|
+
pub sources_linked: Option<u32>,
|
|
1221
|
+
/// 网页上打开这份草稿的地址 —— 本地还没同步到那个知识库项目时的退路
|
|
1222
|
+
#[serde(default)]
|
|
1223
|
+
pub human_url: Option<String>,
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/// 沉淀前的对账结果:**这个项目还有什么没上云**。
|
|
1227
|
+
///
|
|
1228
|
+
/// 为什么必须有:执行器拉的是**云端**的 zip。他刚干完的活如果还没同步上去,沉淀就是在旧内容
|
|
1229
|
+
/// 上跑 —— 产出一份看起来没问题、实际漏了今天全部工作的草稿。**这比失败更糟**:失败看得见,
|
|
1230
|
+
/// 而这个看不见(2026-09-07)。
|
|
1231
|
+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1232
|
+
#[serde(rename_all = "camelCase")]
|
|
1233
|
+
pub struct UnsyncedSummary {
|
|
1234
|
+
/// 还没传上去的文件数(等着传的 + 传失败的)
|
|
1235
|
+
pub pending: u32,
|
|
1236
|
+
/// 其中传失败、需要人处置的
|
|
1237
|
+
pub failed: u32,
|
|
1238
|
+
/// 具体是哪些(云端相对路径,最多列 20 条 —— 用户要的是「哪几个」,不是一张长清单)
|
|
1239
|
+
pub paths: Vec<String>,
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/// `GET /client/config`(契约 §6.16)—— 登录后问一次,把该由服务端说了算的东西拿回来。
|
|
1243
|
+
///
|
|
1244
|
+
/// 老板原话:「配置的时候我认为只需要配置 token 就可以了」。300 个人手工粘三个地址一定会
|
|
1245
|
+
/// 出错 —— 已经被坑过一次(中继地址填成带 `/collab` 的,协同全废了大半天)。
|
|
1246
|
+
///
|
|
1247
|
+
/// 所有字段都可空:老服务端根本没有这个接口,新服务端也可能只配了一半。**拿不到不能让登录
|
|
1248
|
+
/// 失败**,安静回落到本地那套就是了。
|
|
1249
|
+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1250
|
+
#[serde(rename_all = "camelCase")]
|
|
1251
|
+
pub struct ClientConfig {
|
|
1252
|
+
/// 协同中继根地址(不带 `/collab`,房间路径由客户端拼)。
|
|
1253
|
+
/// **空 = 服务端没配中继** → 禁用协同并提示管理员,契约明写「不要回落到猜一个地址」
|
|
1254
|
+
#[serde(default)]
|
|
1255
|
+
pub collab_relay_url: Option<String>,
|
|
1256
|
+
/// 网页端地址(打开文件 / 分享链接用)
|
|
1257
|
+
#[serde(default)]
|
|
1258
|
+
pub web_base_url: Option<String>,
|
|
1259
|
+
#[serde(default)]
|
|
1260
|
+
pub server_version: Option<String>,
|
|
1261
|
+
/// 自动下载阈值的**服务端默认值**(不是配置,客户端可改)
|
|
1262
|
+
#[serde(default)]
|
|
1263
|
+
pub large_file_threshold_bytes: Option<u64>,
|
|
1264
|
+
/// 单文件上行上限(字节),服务端的硬上限。**0 = 不设限**;老服务端不给 → None,回落内置默认
|
|
1265
|
+
#[serde(default)]
|
|
1266
|
+
pub max_upload_bytes: Option<u64>,
|
|
1267
|
+
/// 特性开关。现在一个都没用上,但**解析时保留** —— 以后加开关不必再改一遍两边
|
|
1268
|
+
#[serde(default)]
|
|
1269
|
+
pub features: std::collections::HashMap<String, bool>,
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/// `POST /sessions/archive` 的响应(V61)。
|
|
1273
|
+
///
|
|
1274
|
+
/// 落点由**服务端**决定:带 `projectId` → 该项目的 `session/`;不带 → 调用者个人知识库的
|
|
1275
|
+
/// `session/`。文件打 `content_kind='SESSION'` 并强制 cloudOnly,不进 snapshot / changes,
|
|
1276
|
+
/// 所以永远不会同步到任何人的笔记本上。
|
|
1277
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
1278
|
+
#[serde(rename_all = "camelCase")]
|
|
1279
|
+
pub struct SessionArchive {
|
|
1280
|
+
pub file_id: RemoteId,
|
|
1281
|
+
#[serde(default)]
|
|
1282
|
+
pub version_id: Option<RemoteId>,
|
|
1283
|
+
pub project_id: RemoteId,
|
|
1284
|
+
#[serde(default)]
|
|
1285
|
+
pub project_name: Option<String>,
|
|
1286
|
+
/// 固定为 `session/<文件名>`
|
|
1287
|
+
pub path: String,
|
|
1288
|
+
/// 对话的稳定身份 —— 同一段对话续写用同一个,服务端据此做幂等 / 出新版本
|
|
1289
|
+
pub session_id: String,
|
|
1290
|
+
#[serde(default)]
|
|
1291
|
+
pub sha256: Option<String>,
|
|
1292
|
+
#[serde(default)]
|
|
1293
|
+
pub created: bool,
|
|
1294
|
+
#[serde(default)]
|
|
1295
|
+
pub deduplicated: bool,
|
|
1296
|
+
#[serde(default)]
|
|
1297
|
+
pub cloud_only: bool,
|
|
1298
|
+
/// true = 落在了调用者的个人空间(请求没带 projectId)
|
|
1299
|
+
#[serde(default)]
|
|
1300
|
+
pub personal: bool,
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/// 「磁盘占用排名」的一条:一个项目占了多少。
|
|
1304
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1305
|
+
#[serde(rename_all = "camelCase")]
|
|
1306
|
+
pub struct DiskUsageProject {
|
|
1307
|
+
pub project_id: RemoteId,
|
|
1308
|
+
pub name: String,
|
|
1309
|
+
pub drive_path: String,
|
|
1310
|
+
pub local_path: String,
|
|
1311
|
+
pub bytes: u64,
|
|
1312
|
+
pub files: u32,
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/// 「磁盘占用排名」的一条:一个文件占了多少。
|
|
1316
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1317
|
+
#[serde(rename_all = "camelCase")]
|
|
1318
|
+
pub struct DiskUsageFile {
|
|
1319
|
+
pub project_id: RemoteId,
|
|
1320
|
+
pub project_name: String,
|
|
1321
|
+
/// 项目内的云端相对路径
|
|
1322
|
+
pub path: String,
|
|
1323
|
+
pub local_path: String,
|
|
1324
|
+
pub bytes: u64,
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/// 本地云盘占了多少、都被谁占了。
|
|
1328
|
+
///
|
|
1329
|
+
/// **看不见就管不了**:真机上 2.9 GB 里 2.69 GB 是一个人目录下的原始数据表(1848 个文件),
|
|
1330
|
+
/// 真正的文档加起来才几兆 —— 不把这个摆出来,没人会想到问题出在「原始数据跟着文档一起下来了」。
|
|
1331
|
+
///
|
|
1332
|
+
/// 数字取自索引(每行都记着 size),不去遍历磁盘:几千个文件 stat 一遍会让设置页卡住,
|
|
1333
|
+
/// 而索引里的 size 就是那份文件的字节数。
|
|
1334
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, specta::Type)]
|
|
1335
|
+
#[serde(rename_all = "camelCase")]
|
|
1336
|
+
pub struct DiskUsage {
|
|
1337
|
+
pub total_bytes: u64,
|
|
1338
|
+
pub total_files: u32,
|
|
1339
|
+
/// 占用最多的项目,从多到少
|
|
1340
|
+
pub projects: Vec<DiskUsageProject>,
|
|
1341
|
+
/// 占用最多的单个文件,从多到少
|
|
1342
|
+
pub files: Vec<DiskUsageFile>,
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/// 「重新核对仅云端文件」的结果。
|
|
1346
|
+
///
|
|
1347
|
+
/// 为什么需要这个动作:`cloudOnly` 是**分类**不是内容。服务端把一批文件从 cloudOnly 改判成
|
|
1348
|
+
/// 正常(2026-09-05:「需走修改申请」的文件此前被误判成 cloudOnly),这些文件本身一个字节
|
|
1349
|
+
/// 都没变,于是不会产生任何 `file_events` —— 走 cursor 增量的客户端**永远**收不到通知,
|
|
1350
|
+
/// 已经同步过的机器会一直守着 111 个 `.yupcloud` 占位。只有拿 snapshot 整批重新比一遍分类
|
|
1351
|
+
/// 才能追上。反向(权限被收回 → 变成 cloudOnly)同理,而且那一侧是安全要求。
|
|
1352
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, specta::Type)]
|
|
1353
|
+
#[serde(rename_all = "camelCase")]
|
|
1354
|
+
pub struct CloudOnlyRecheck {
|
|
1355
|
+
/// 服务端已不再标 cloudOnly:真实内容已拉到本地、`.yupcloud` 占位已删
|
|
1356
|
+
pub restored: u32,
|
|
1357
|
+
/// 服务端新标成 cloudOnly:本地内容已从盘上删掉、换成 `.yupcloud` 占位
|
|
1358
|
+
pub hidden: u32,
|
|
1359
|
+
/// 分类没变,但盘上那个 `.yupcloud` 占位不见了(正文本来就不该有)→ 已把占位补回来。
|
|
1360
|
+
///
|
|
1361
|
+
/// 这不是小事:一份 cloudOnly 文件在盘上的正确终态是**只有占位、没有正文**。占位也没了的话,
|
|
1362
|
+
/// 它所在的目录就成了空目录,用户看到的是「这个文件夹里什么都没有」而不是「这些文件只能在
|
|
1363
|
+
/// 云端看」—— 真机上 111 个占位全部消失、索引里还留着 38 条 cloudOnly、盘上多出 149 个空
|
|
1364
|
+
/// 目录(2026-09-05)。
|
|
1365
|
+
pub repaired: u32,
|
|
1366
|
+
/// 云端有、这边**索引和盘上都没有**的文件 → 已经重新登记成「尚未下载」。
|
|
1367
|
+
///
|
|
1368
|
+
/// 这不是分类漂移,是下行漏了:一次下载失败之后 cursor 已经往前走,那条 change 事件不会
|
|
1369
|
+
/// 再来第二次,而索引里连一行都没有 —— 这份文件就此人间蒸发,服务端日后修好也不会自愈。
|
|
1370
|
+
/// 2026-09-07 老板机器上那批下不完的大文件正卡在这个形状里。开机核对顺路把它们登记回来,
|
|
1371
|
+
/// 之后按阈值与退避决定是自动拉、还是留个占位等他点。
|
|
1372
|
+
pub relisted: u32,
|
|
1373
|
+
/// 这一轮实际取过 snapshot 比对的项目数
|
|
1374
|
+
pub projects: u32,
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
impl CloudOnlyRecheck {
|
|
1378
|
+
pub fn is_empty(&self) -> bool {
|
|
1379
|
+
self.restored == 0 && self.hidden == 0 && self.repaired == 0 && self.relisted == 0
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// ─── 6.18 权限档位与协作者 ───────────────────────────────────────────────────
|
|
1384
|
+
|
|
1385
|
+
/// 档位接口**收**的主体类型。ROLE / TEAM / LINK_COMPANY 留给管理后台的规则接口,这里不收;
|
|
1386
|
+
/// 但列表里会出现它们的只读行,所以 `Grant::subject_type` 是开放的字符串,不是这个枚举。
|
|
1387
|
+
/// `All` 的 `subjectId` 传 0。
|
|
1388
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1389
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
1390
|
+
pub enum GrantSubjectType {
|
|
1391
|
+
User,
|
|
1392
|
+
Dept,
|
|
1393
|
+
All,
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/// 档位:一个主体在一个资源上的 ALLOW 动作集合的具名投影。**不是第二个判权维度**,
|
|
1397
|
+
/// 只在写入和显示时存在。`Custom` = 有 DENY 行或集合不等于三档之一,只读显示,不能从档位接口写。
|
|
1398
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1399
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
1400
|
+
pub enum GrantLevel {
|
|
1401
|
+
View,
|
|
1402
|
+
Edit,
|
|
1403
|
+
Manage,
|
|
1404
|
+
Custom,
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/// 这一行来自哪一层。
|
|
1408
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1409
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
1410
|
+
pub enum GrantSource {
|
|
1411
|
+
File,
|
|
1412
|
+
Folder,
|
|
1413
|
+
Space,
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/// 档位接口作用在哪种资源上:文件与目录各一套路径,形状完全相同。
|
|
1417
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1418
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
1419
|
+
pub enum GrantResourceKind {
|
|
1420
|
+
File,
|
|
1421
|
+
Folder,
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/// 右键那一行对应的云端资源。目录靠 `POST /folders/ensure` 换 id(索引里只记目录路径不记 id)。
|
|
1425
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
|
1426
|
+
#[serde(rename_all = "camelCase")]
|
|
1427
|
+
pub struct GrantTarget {
|
|
1428
|
+
pub kind: GrantResourceKind,
|
|
1429
|
+
pub id: RemoteId,
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/// `GET .../grants` 的一行:一主体一行,同一主体多层有行时只列真正生效的那一层。
|
|
1433
|
+
///
|
|
1434
|
+
/// 服务端还带 `effective` / `overriddenBy`(与规则列表同口径),面板用不着,解析时不收。
|
|
1435
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1436
|
+
#[serde(rename_all = "camelCase")]
|
|
1437
|
+
pub struct Grant {
|
|
1438
|
+
/// **开放枚举**:除 USER / DEPT / ALL 外还会有 ROLE / TEAM / LINK_COMPANY 的只读行(服务端 fe8485d),
|
|
1439
|
+
/// 认不出的一律按只读画。能不能画操作按钮看 `editable`,不看这个也不看 `level`
|
|
1440
|
+
pub subject_type: String,
|
|
1441
|
+
pub subject_id: RemoteId,
|
|
1442
|
+
#[serde(default)]
|
|
1443
|
+
pub subject_display: Option<String>,
|
|
1444
|
+
pub level: GrantLevel,
|
|
1445
|
+
/// CUSTOM 时靠它解释;其余档位也带
|
|
1446
|
+
#[serde(default)]
|
|
1447
|
+
pub actions: Vec<String>,
|
|
1448
|
+
#[serde(default)]
|
|
1449
|
+
pub source: Option<GrantSource>,
|
|
1450
|
+
#[serde(default)]
|
|
1451
|
+
pub source_resource_id: Option<RemoteId>,
|
|
1452
|
+
#[serde(default)]
|
|
1453
|
+
pub source_display: Option<String>,
|
|
1454
|
+
/// source != 本资源。继承来的不能在这一层删,只能去源头改
|
|
1455
|
+
#[serde(default)]
|
|
1456
|
+
pub inherited: bool,
|
|
1457
|
+
/// 直接行且调用者有 MANAGE_PERMISSION 才 true
|
|
1458
|
+
#[serde(default)]
|
|
1459
|
+
pub editable: bool,
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/// 项目角色带来的隐含权限(管理员 / 所有者),`grants` 里不造行,另列一段。
|
|
1463
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1464
|
+
#[serde(rename_all = "camelCase")]
|
|
1465
|
+
pub struct ImplicitGrant {
|
|
1466
|
+
#[serde(default)]
|
|
1467
|
+
pub subject_display: Option<String>,
|
|
1468
|
+
pub level: GrantLevel,
|
|
1469
|
+
/// `PROJECT_MANAGER | OWNER | FOLDER_CREATOR | GLOBAL_ADMIN`(服务端 fe8485d);认不出的按「隐含权限」画
|
|
1470
|
+
#[serde(default)]
|
|
1471
|
+
pub reason: Option<String>,
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/// `GET .../grants` 的响应:顶层是对象不是数组。
|
|
1475
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1476
|
+
#[serde(rename_all = "camelCase")]
|
|
1477
|
+
pub struct GrantList {
|
|
1478
|
+
#[serde(default)]
|
|
1479
|
+
pub grants: Vec<Grant>,
|
|
1480
|
+
#[serde(default)]
|
|
1481
|
+
pub implicit: Vec<ImplicitGrant>,
|
|
1482
|
+
/// 调用者自己在此资源的档位。**不是 MANAGE 就整个面板只读,不画添加区**。
|
|
1483
|
+
/// 服务端没给 → None,按只读处理(宁可少画一个入口,也不给一个点了会 403 的)
|
|
1484
|
+
#[serde(default)]
|
|
1485
|
+
pub caller_level: Option<GrantLevel>,
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/// `POST .../grants`:幂等的「设成这个档位」,不是「追加一条」。再 POST 同一主体 = 改档位。
|
|
1489
|
+
/// `Custom` 不能写(服务端会拒),前端也不给选。
|
|
1490
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1491
|
+
#[serde(rename_all = "camelCase")]
|
|
1492
|
+
pub struct SetGrantRequest {
|
|
1493
|
+
pub subject_type: GrantSubjectType,
|
|
1494
|
+
pub subject_id: RemoteId,
|
|
1495
|
+
pub level: GrantLevel,
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/// 409 `INHERITED_GRANT` 带回来的「这条行在哪一层」:`{source, resourceId, projectKey, display}`,
|
|
1499
|
+
/// 在响应体**顶层**(服务端 fe8485d)。每个都可空 —— 缺了也只是引导语少一截,不能让删除整个报错。
|
|
1500
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, specta::Type)]
|
|
1501
|
+
#[serde(rename_all = "camelCase")]
|
|
1502
|
+
pub struct InheritedFrom {
|
|
1503
|
+
#[serde(default)]
|
|
1504
|
+
pub source: Option<GrantSource>,
|
|
1505
|
+
#[serde(default)]
|
|
1506
|
+
pub resource_id: Option<RemoteId>,
|
|
1507
|
+
#[serde(default)]
|
|
1508
|
+
pub project_key: Option<String>,
|
|
1509
|
+
#[serde(default)]
|
|
1510
|
+
pub display: Option<String>,
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/// `POST` / `DELETE .../grants` 的三种结局。后两种是**规则不是故障**,所以不走 `Err`:
|
|
1514
|
+
/// - `Inherited`(只有 DELETE):该主体在此资源上只有继承来的行 → 引导去源头改。服务端明说
|
|
1515
|
+
/// 「不能在子节点静默造一条 DENY 去抵消」;
|
|
1516
|
+
/// - `Custom`(POST / DELETE 都有):该主体有直接 DENY 行 → 档位接口不碰,去网页管理后台改。
|
|
1517
|
+
///
|
|
1518
|
+
/// `Applied` 带回来的就是 GET 同一个对象(服务端 fe8485d),直接拿它刷新列表,不必再 GET 一趟。
|
|
1519
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1520
|
+
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
|
1521
|
+
pub enum GrantWriteOutcome {
|
|
1522
|
+
Applied { list: GrantList },
|
|
1523
|
+
Inherited { inherited_from: InheritedFrom },
|
|
1524
|
+
Custom,
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// ─── 「分享给我的」(服务端 GrantService#sharedWithMe) ────────────────────────
|
|
1528
|
+
|
|
1529
|
+
/// `GET /folders/{id}` 里我们要的那点东西。
|
|
1530
|
+
///
|
|
1531
|
+
/// 只为一件事存在:`/shared-with-me` 给了 `projectId` 却**不给路径**,而桌面端要把人送到
|
|
1532
|
+
/// 本地那个目录,不知道它在项目里的哪一层就只能停在项目根(网页端正是停在那儿,见
|
|
1533
|
+
/// `pages/shared/index.vue` 的「打开所在项目」)。
|
|
1534
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
1535
|
+
#[serde(rename_all = "camelCase")]
|
|
1536
|
+
pub struct FolderInfo {
|
|
1537
|
+
#[serde(default)]
|
|
1538
|
+
pub id: Option<RemoteId>,
|
|
1539
|
+
#[serde(default)]
|
|
1540
|
+
pub project_id: Option<RemoteId>,
|
|
1541
|
+
/// 数据库里形如 `/a/b/`(首尾都有斜杠);项目根是 `/`。用 `rel_path()` 转成契约那套相对路径
|
|
1542
|
+
#[serde(default)]
|
|
1543
|
+
pub full_path: Option<String>,
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
impl FolderInfo {
|
|
1547
|
+
/// `/a/b/` → `a/b`;`/` 或缺省 → 空串(项目根)。
|
|
1548
|
+
///
|
|
1549
|
+
/// 与服务端 `SyncPaths.folderRelPath` 同一套规则。**斜杠必须两头都削**:留着首斜杠去
|
|
1550
|
+
/// `PathBuf::join` 的话,在 Unix 上得到的是 `/a/b` 这个绝对路径 —— 项目根被整个丢掉,
|
|
1551
|
+
/// 人被送到磁盘根目录下一个不存在的地方。
|
|
1552
|
+
pub fn rel_path(&self) -> String {
|
|
1553
|
+
let raw = self.full_path.as_deref().unwrap_or_default().replace('\\', "/");
|
|
1554
|
+
raw.trim_matches('/').to_string()
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/// `GET /shared-with-me` 的一行:**直接授给本人**的一个文件或目录。
|
|
1559
|
+
///
|
|
1560
|
+
/// 口径是服务端定的(`GrantService#sharedWithMe`):只有 USER 主体的直接授权,部门 / 全公司
|
|
1561
|
+
/// 的集体授权不算 —— 那是底权限不是「分享给我」,三百人共有的东西列进来这个视图第一天就没法看。
|
|
1562
|
+
/// 已删除的资源服务端已经跳过。
|
|
1563
|
+
///
|
|
1564
|
+
/// 末尾两个字段**服务端不返回,是引擎补的**(见 `Engine::shared_with_me`):服务端那条记录只说
|
|
1565
|
+
/// 「哪个资源、在哪个项目」,而桌面端要回答的是「它在我这台机器的哪个路径」。
|
|
1566
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
|
|
1567
|
+
#[serde(rename_all = "camelCase")]
|
|
1568
|
+
pub struct SharedItem {
|
|
1569
|
+
/// `FILE` | `FOLDER`。与 §6.18 档位接口的资源种类同一个枚举,拿它能直接拼出 `GrantTarget`
|
|
1570
|
+
pub resource_type: GrantResourceKind,
|
|
1571
|
+
pub resource_id: RemoteId,
|
|
1572
|
+
pub name: String,
|
|
1573
|
+
/// 理论上可空(资源行的 `projectId` 为 null)。没有项目就无处可去,行仍然列出来但点不动
|
|
1574
|
+
#[serde(default)]
|
|
1575
|
+
pub project_id: Option<RemoteId>,
|
|
1576
|
+
#[serde(default)]
|
|
1577
|
+
pub project_name: Option<String>,
|
|
1578
|
+
/// VIEW / EDIT / MANAGE;动作集合凑不成三档之一时服务端给 CUSTOM
|
|
1579
|
+
pub level: GrantLevel,
|
|
1580
|
+
#[serde(default)]
|
|
1581
|
+
pub granted_by_name: Option<String>,
|
|
1582
|
+
/// ISO 时间串。**不解析成时间类型**:只用来显示,而各版本服务端的格式未必一致
|
|
1583
|
+
#[serde(default)]
|
|
1584
|
+
pub granted_at: Option<String>,
|
|
1585
|
+
/// 引擎补:FOLDER 行在项目里的相对路径(`GET /folders/{id}` 的 `fullPath` 归一化);
|
|
1586
|
+
/// FILE 行与查不到时为 null
|
|
1587
|
+
#[serde(default)]
|
|
1588
|
+
pub folder_path: Option<String>,
|
|
1589
|
+
/// 引擎补:这条在本地磁盘上的绝对路径。**不保证已经存在** —— 项目还没同步下来时它是
|
|
1590
|
+
/// 「同步完会落在哪」,前端据此在拉取完成后定位;项目不在索引里(见 `Engine::shared_with_me`)
|
|
1591
|
+
/// 则为 null
|
|
1592
|
+
#[serde(default)]
|
|
1593
|
+
pub local_path: Option<String>,
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, specta::Type)]
|
|
1597
|
+
#[serde(rename_all = "camelCase")]
|
|
1598
|
+
pub struct CloudFolder {
|
|
1599
|
+
/// 项目内相对路径,例如 `01_用户增长/02_增长策略_Wee`
|
|
1600
|
+
pub path: String,
|
|
1601
|
+
pub folder_id: RemoteId,
|
|
1602
|
+
}
|