dsh-calendar 0.5.4 → 0.7.0
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/CHANGELOG.md +59 -31
- package/README.en.md +39 -1
- package/README.md +41 -1
- package/lib/client.js +1412 -0
- package/lib/index.d.ts +28 -0
- package/lib/index.js +102 -1
- package/lib/settings.d.ts +83 -0
- package/lib/settings.js +139 -0
- package/lib/store.d.ts +10 -0
- package/lib/store.js +42 -0
- package/lib/tools.d.ts +11 -1
- package/lib/tools.js +16 -14
- package/lib/web.d.ts +134 -0
- package/lib/web.js +453 -0
- package/package.json +10 -1
- package/scripts/google-oauth.mjs +276 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1412 @@
|
|
|
1
|
+
// NOTE(维护者请看这里):这个文件就是插件网页端的**源码**,不经过 tsc —— 仓库里没有
|
|
2
|
+
// src/client.ts,pnpm run build 也不会重写它。改设置面板请直接改这里,改完用
|
|
3
|
+
// node --check lib/client.js 自检(CI 里也会跑这一步)。
|
|
4
|
+
//
|
|
5
|
+
// 面板只做三件事:把 CalDAV 上的日程画成月/周/议程三种视图、把日程详情讲清楚、
|
|
6
|
+
// 把增删改发回插件自己的 /_dsh/dsh-calendar/settings 路由。它不缓存业务数据——
|
|
7
|
+
// CalDAV 服务器才是唯一真相,服务器变了刷新即见。
|
|
8
|
+
window.__ModuleLoader__.load({ id: "dsh-calendar", factory: (require) => {
|
|
9
|
+
var module = { exports: {} }; var exports = module.exports;
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const React = require("react");
|
|
13
|
+
const { useState, useEffect, useMemo, useRef, useCallback } = React;
|
|
14
|
+
const h = React.createElement;
|
|
15
|
+
|
|
16
|
+
const ROUTE = "/_dsh/dsh-calendar/settings";
|
|
17
|
+
|
|
18
|
+
/** 三视图;order 决定 tab 顺序。 */
|
|
19
|
+
const VIEWS = ["month", "week", "agenda"];
|
|
20
|
+
|
|
21
|
+
const WEEKDAYS_ZH = ["一", "二", "三", "四", "五", "六", "日"];
|
|
22
|
+
|
|
23
|
+
/** 上次用的视图:用户下次打开面板不该又跳回「月」。localStorage 不可用(隐私模式)就退回 default。 */
|
|
24
|
+
const VIEW_STORAGE_KEY = "dsh-calendar:view";
|
|
25
|
+
function loadStoredView() {
|
|
26
|
+
try {
|
|
27
|
+
const stored = window.localStorage.getItem(VIEW_STORAGE_KEY);
|
|
28
|
+
return VIEWS.indexOf(stored) === -1 ? "month" : stored;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return "month";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function storeView(view) {
|
|
34
|
+
try { window.localStorage.setItem(VIEW_STORAGE_KEY, view); } catch (error) { /* 写不进去就算了 */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* @ui-css-start */
|
|
38
|
+
const CSS = [
|
|
39
|
+
".dshc-root{color:var(--dsw-alias-label-primary,#111);font-size:13px;line-height:1.5}",
|
|
40
|
+
// 面板可能出现在两个宽度差很大的容器里(对话页浮层 ~760px、设置页更窄)。
|
|
41
|
+
// 用容器查询按「面板自己的宽度」适配:窄的时候藏掉时间前缀与重复的标题,
|
|
42
|
+
// 让芯片至少能把日程标题露出来。
|
|
43
|
+
".dshc-shell{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.08));border-radius:14px;padding:14px 16px 16px;container-type:inline-size}",
|
|
44
|
+
"@container (max-width: 620px){.dshc-title{display:none}.dshc-chip .t{display:none}.dshc-toolbar{gap:6px}.dshc-monthhead{min-width:0}}",
|
|
45
|
+
".dshc-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px}",
|
|
46
|
+
".dshc-title{font-size:15px;font-weight:600;margin:0}",
|
|
47
|
+
".dshc-sub{color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}",
|
|
48
|
+
".dshc-spacer{flex:1}",
|
|
49
|
+
".dshc-btn{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);border-radius:9px;padding:5px 11px;font-size:12px;cursor:pointer;transition:background .12s,border-color .12s}",
|
|
50
|
+
".dshc-btn:hover:not(:disabled){background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.05))}",
|
|
51
|
+
".dshc-btn:disabled{opacity:.5;cursor:not-allowed}",
|
|
52
|
+
".dshc-btn.primary{background:var(--dsw-alias-button-primary-fill,#2563eb);border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff)}",
|
|
53
|
+
".dshc-btn.primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#1d4ed8)}",
|
|
54
|
+
".dshc-btn.danger{color:var(--dsw-alias-state-error-primary,#dc2626);border-color:var(--dsw-alias-state-error-primary,#dc2626)}",
|
|
55
|
+
".dshc-btn.ghost{border-color:transparent;background:transparent}",
|
|
56
|
+
".dshc-tabs{display:inline-flex;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.05));border-radius:10px;padding:2px;gap:2px}",
|
|
57
|
+
".dshc-tab{border:0;background:transparent;border-radius:8px;padding:4px 10px;font-size:12px;cursor:pointer;color:var(--dsw-alias-label-tertiary,#6b7280)}",
|
|
58
|
+
".dshc-tab.on{background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);box-shadow:var(--dsw-elevation-prominent,0 1px 2px rgba(0,0,0,.12))}",
|
|
59
|
+
".dshc-monthhead{text-align:center;font-size:13px;font-weight:600;min-width:104px}",
|
|
60
|
+
".dshc-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}",
|
|
61
|
+
".dshc-dow{text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);padding:2px 0 4px}",
|
|
62
|
+
".dshc-cell{display:block;width:100%;min-height:88px;box-sizing:border-box;border-radius:10px;padding:5px 5px 6px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03));border:1px solid transparent;cursor:pointer;text-align:left;font:inherit;color:inherit;transition:background .12s,border-color .12s;overflow:hidden}",
|
|
63
|
+
".dshc-cell:hover{background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.07))}",
|
|
64
|
+
".dshc-cell.out{opacity:.45}",
|
|
65
|
+
".dshc-cell.today{border-color:var(--dsw-alias-state-business-primary,#2563eb)}",
|
|
66
|
+
".dshc-daynum{font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);margin:0 0 3px;display:flex;justify-content:space-between}",
|
|
67
|
+
".dshc-cell.today .dshc-daynum b{background:var(--dsw-alias-state-business-primary,#2563eb);color:var(--dsw-alias-label-primary-foreground,#fff);border-radius:999px;padding:0 6px}",
|
|
68
|
+
".dshc-cell.today .dshc-daynum b{font-weight:600}",
|
|
69
|
+
".dshc-chip{display:block;width:100%;text-align:left;border:0;border-left:3px solid var(--dsw-alias-state-business-primary,#2563eb);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);border-radius:7px;padding:3px 6px;margin-bottom:3px;font-size:12px;line-height:1.35;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
70
|
+
".dshc-chip:hover{background:var(--dsw-alias-button-elevated-fill,rgba(0,0,0,.06))}",
|
|
71
|
+
".dshc-chip.allday{border-left-color:var(--dsw-alias-state-success-primary,#16a34a)}",
|
|
72
|
+
".dshc-chip.recurring{border-left-color:var(--dsw-alias-state-warn-primary,#f59e0b)}",
|
|
73
|
+
".dshc-chip .t{color:var(--dsw-alias-label-tertiary,#9ca3af);margin-right:4px}",
|
|
74
|
+
".dshc-more{font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);padding-left:3px}",
|
|
75
|
+
".dshc-week{display:grid;grid-template-columns:56px repeat(7,minmax(0,1fr));gap:4px;max-height:520px;overflow:auto}",
|
|
76
|
+
".dshc-hour{font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);text-align:right;padding-right:4px;height:44px}",
|
|
77
|
+
".dshc-daycol{position:relative;border-left:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.07));min-height:44px}",
|
|
78
|
+
".dshc-daycol.today{background:var(--dsw-alias-bg-mask-3,rgba(37,99,235,.06))}",
|
|
79
|
+
".dshc-dayhead{position:sticky;top:0;background:var(--dsw-alias-bg-layer-1,#fff);z-index:2;text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);padding-bottom:4px}",
|
|
80
|
+
".dshc-dayhead b{color:var(--dsw-alias-label-primary,#111);font-weight:600;font-size:13px}",
|
|
81
|
+
".dshc-slot{position:absolute;left:2px;right:2px;border-radius:7px;padding:3px 5px;font-size:11px;line-height:1.3;overflow:hidden;cursor:pointer;background:var(--dsw-alias-state-business-primary,#2563eb);color:var(--dsw-alias-label-primary-foreground,#fff);border:0;text-align:left}",
|
|
82
|
+
".dshc-slot.allday{background:var(--dsw-alias-state-success-primary,#16a34a)}",
|
|
83
|
+
".dshc-slot.recurring{background:var(--dsw-alias-state-warn-primary,#f59e0b);color:#1f2937}",
|
|
84
|
+
".dshc-agenda{display:flex;flex-direction:column;gap:14px;max-height:560px;overflow:auto;padding-right:4px}",
|
|
85
|
+
".dshc-daygroup{display:flex;flex-direction:column;gap:6px}",
|
|
86
|
+
".dshc-daylabel{display:flex;align-items:baseline;gap:8px;position:sticky;top:0;background:var(--dsw-alias-bg-layer-1,#fff);padding:2px 0}",
|
|
87
|
+
".dshc-daylabel b{font-size:13px}",
|
|
88
|
+
".dshc-row{display:flex;gap:10px;align-items:flex-start;padding:8px 10px;border-radius:10px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03));cursor:pointer;border-left:3px solid var(--dsw-alias-state-business-primary,#2563eb)}",
|
|
89
|
+
".dshc-row:hover{background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.07))}",
|
|
90
|
+
".dshc-row.allday{border-left-color:var(--dsw-alias-state-success-primary,#16a34a)}",
|
|
91
|
+
".dshc-row.recurring{border-left-color:var(--dsw-alias-state-warn-primary,#f59e0b)}",
|
|
92
|
+
".dshc-time{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#6b7280);min-width:96px;font-size:12px;padding-top:1px}",
|
|
93
|
+
".dshc-main{flex:1;min-width:0}",
|
|
94
|
+
".dshc-name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
95
|
+
".dshc-meta{color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
96
|
+
".dshc-tag{display:inline-block;font-size:11px;padding:1px 6px;border-radius:999px;background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.07));color:var(--dsw-alias-label-tertiary,#6b7280);margin-left:6px}",
|
|
97
|
+
".dshc-empty{padding:26px 18px;text-align:center;color:var(--dsw-alias-label-tertiary,#6b7280)}",
|
|
98
|
+
".dshc-empty h3{color:var(--dsw-alias-label-primary,#111);margin:0 0 6px;font-size:14px}",
|
|
99
|
+
".dshc-empty code{background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.05));padding:1px 5px;border-radius:5px;font-size:12px}",
|
|
100
|
+
".dshc-alert{display:flex;gap:8px;align-items:flex-start;border-radius:10px;padding:8px 10px;font-size:12px;margin-bottom:10px;background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.05))}",
|
|
101
|
+
".dshc-alert.error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#dc2626) 12%,transparent);color:var(--dsw-alias-state-error-primary,#dc2626)}",
|
|
102
|
+
".dshc-alert.warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 16%,transparent)}",
|
|
103
|
+
".dshc-alert.demo{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#2563eb) 12%,transparent)}",
|
|
104
|
+
".dshc-skel{height:88px;border-radius:10px;background:linear-gradient(90deg,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 25%,var(--dsw-alias-bg-mask-3,rgba(0,0,0,.08)) 37%,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 63%);background-size:400% 100%;animation:dshc-shimmer 1.4s ease infinite}",
|
|
105
|
+
"@keyframes dshc-shimmer{0%{background-position:100% 0}100%{background-position:0 0}}",
|
|
106
|
+
".dshc-overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-mask-3,rgba(15,23,42,.42));display:flex;justify-content:flex-end;z-index:60}",
|
|
107
|
+
".dshc-drawer{width:min(420px,92vw);height:100%;background:var(--dsw-alias-bg-layer-1,#fff);border-left:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.08));padding:18px;overflow:auto;box-shadow:var(--dsw-elevation-prominent,0 10px 30px rgba(0,0,0,.2))}",
|
|
108
|
+
".dshc-modalwrap{position:fixed;inset:0;background:var(--dsw-alias-bg-mask-3,rgba(15,23,42,.42));display:flex;align-items:center;justify-content:center;z-index:70;padding:20px}",
|
|
109
|
+
".dshc-modal{width:min(460px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-layer-1,#fff);border-radius:14px;padding:18px;box-shadow:var(--dsw-elevation-prominent,0 10px 30px rgba(0,0,0,.25))}",
|
|
110
|
+
".dshc-field{margin-bottom:10px}",
|
|
111
|
+
".dshc-field label{display:block;font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280);margin-bottom:4px}",
|
|
112
|
+
".dshc-input,select.dshc-input,textarea.dshc-input{width:100%;box-sizing:border-box;background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03)));border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:9px;padding:7px 9px;font:inherit;color:inherit}",
|
|
113
|
+
"textarea.dshc-input{min-height:72px;resize:vertical}",
|
|
114
|
+
".dshc-two{display:grid;grid-template-columns:1fr 1fr;gap:8px}",
|
|
115
|
+
".dshc-check{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-primary,#111)}",
|
|
116
|
+
".dshc-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}",
|
|
117
|
+
".dshc-kv{display:grid;grid-template-columns:72px 1fr;gap:6px 10px;font-size:12px;margin:12px 0 0}",
|
|
118
|
+
".dshc-kv dt{color:var(--dsw-alias-label-tertiary,#6b7280)}",
|
|
119
|
+
".dshc-kv dd{margin:0;word-break:break-all}",
|
|
120
|
+
".dshc-drawertitle{font-size:16px;font-weight:600;margin:0 0 4px}",
|
|
121
|
+
".dshc-mono{font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280);word-break:break-all}",
|
|
122
|
+
".dshc-close{position:absolute;top:10px;right:12px}",
|
|
123
|
+
".dshc-footer{margin-top:12px;display:flex;align-items:center;gap:8px;justify-content:space-between;color:var(--dsw-alias-label-tertiary,#9ca3af);font-size:11px}"
|
|
124
|
+
].join("");
|
|
125
|
+
/* @ui-css-end */
|
|
126
|
+
|
|
127
|
+
const UI = {
|
|
128
|
+
zh: {
|
|
129
|
+
"nav.title": "日历",
|
|
130
|
+
"section.title": "日历",
|
|
131
|
+
"section.intro": "在设置页直接查看和编辑 CalDAV 上的日程:月/周/议程三种视图,点一下就能改时间、换标题、加重复规则。",
|
|
132
|
+
"view.month": "月", "view.week": "周", "view.agenda": "议程",
|
|
133
|
+
"action.today": "今天", "action.prev": "上", "action.next": "下", "action.new": "新建日程",
|
|
134
|
+
"action.refresh": "刷新", "action.retry": "重试", "action.edit": "编辑", "action.delete": "删除",
|
|
135
|
+
"action.save": "保存", "action.cancel": "取消", "action.confirmDelete": "确认删除",
|
|
136
|
+
"action.open": "打开", "action.copy": "复制 uid", "action.copied": "已复制",
|
|
137
|
+
"action.demo": "查看示例", "action.exitDemo": "退出示例",
|
|
138
|
+
"state.loading": "正在读取日历…", "state.empty": "这段时间没有日程。",
|
|
139
|
+
"state.notConfigured": "还没配置 CalDAV 账号",
|
|
140
|
+
"state.notConfiguredHint": "在 profile 的 cordis.patch.yml 里给 dsh-calendar 填上 provider / caldavUrl / username / password(或 OAuth 四件套)后重启即可。现在可以先看看示例面板。",
|
|
141
|
+
"state.readonly": "只读",
|
|
142
|
+
"demo.banner": "示例数据(不是你的真实日历)—— 点右上角「退出示例」回到真实数据。",
|
|
143
|
+
"field.summary": "标题", "field.date": "日期", "field.start": "开始", "field.end": "结束",
|
|
144
|
+
"field.allDay": "全天", "field.location": "地点", "field.description": "备注",
|
|
145
|
+
"field.repeat": "重复", "field.uid": "事件 uid", "field.when": "时间", "field.duration": "时长",
|
|
146
|
+
"repeat.none": "不重复", "repeat.daily": "每天", "repeat.weekly": "每周", "repeat.monthly": "每月", "repeat.yearly": "每年", "repeat.custom": "自定义 RRULE",
|
|
147
|
+
"repeat.weeklyEvery": "每周({n} 次)", "repeat.recurring": "重复", "repeat.occurrence": "系列中的一次",
|
|
148
|
+
"conflict.title": "这个时段已有 {n} 个日程,会重叠:", "conflict.hint": "仍然保存?",
|
|
149
|
+
"error.title": "操作失败", "error.load": "读取日历失败",
|
|
150
|
+
"confirm.delete": "删除「{name}」?这个操作会同步到你的日历服务器。",
|
|
151
|
+
"form.newTitle": "新建日程", "form.editTitle": "编辑日程",
|
|
152
|
+
"form.required": "标题和时间都要填。",
|
|
153
|
+
"day.today": "今天", "day.tomorrow": "明天", "day.yesterday": "昨天",
|
|
154
|
+
"count.events": "{n} 个日程", "count.occurrences": "含 {n} 次重复展开",
|
|
155
|
+
"format.duration.hm": "{h} 小时 {m} 分", "format.duration.h": "{h} 小时", "format.duration.m": "{m} 分钟", "format.duration.zero": "0 分钟",
|
|
156
|
+
"format.allday": "全天",
|
|
157
|
+
"conn.button": "连接设置",
|
|
158
|
+
"conn.title": "CalDAV 连接设置",
|
|
159
|
+
"conn.intro": "填完点「测试连接」,通了才会保存。密码只存在本机(不进日志、不进导出)。",
|
|
160
|
+
"conn.provider": "服务商",
|
|
161
|
+
"conn.caldavUrl": "日历集合 URL",
|
|
162
|
+
"conn.username": "账号",
|
|
163
|
+
"conn.password": "密码 / 应用专用密码",
|
|
164
|
+
"conn.host": "服务器地址",
|
|
165
|
+
"conn.user": "用户名",
|
|
166
|
+
"conn.calendar": "日历名(如 personal)",
|
|
167
|
+
"conn.calendarId": "日历 ID(通常是你的邮箱)",
|
|
168
|
+
"conn.clientId": "clientId",
|
|
169
|
+
"conn.clientSecret": "clientSecret",
|
|
170
|
+
"conn.refreshToken": "refreshToken",
|
|
171
|
+
"conn.tokenUrl": "令牌地址(可留空)",
|
|
172
|
+
"conn.proxyUrl": "代理地址(可留空)",
|
|
173
|
+
"conn.keep": "已保存,留空不改",
|
|
174
|
+
"conn.test": "测试连接",
|
|
175
|
+
"conn.testing": "正在连接…",
|
|
176
|
+
"conn.save": "保存并启用",
|
|
177
|
+
"conn.saving": "保存中…",
|
|
178
|
+
"conn.ok": "连接成功:未来 30 天读到 {n} 个日程",
|
|
179
|
+
"conn.okSample": "(例如:{sample})",
|
|
180
|
+
"conn.fail": "连接失败",
|
|
181
|
+
"conn.savedToast": "连接已保存,工具下一次调用就用它",
|
|
182
|
+
"conn.noSettings": "当前宿主的 settings 服务不可用,面板不能保存:请在 profile 的 cordis.patch.yml 里配置 dsh-calendar 后重启。",
|
|
183
|
+
"conn.storageFallback": "连接保存在插件自己的文件:~/.dsh/data/dsh-calendar/connection.json({reason},功能不受影响)。",
|
|
184
|
+
"conn.needProvider": "先选一个服务商",
|
|
185
|
+
"conn.current": "当前:{summary}",
|
|
186
|
+
"conn.steps": "怎么拿到这些值?(点开看步骤)",
|
|
187
|
+
"link.googleConsole": "打开 Google Cloud 凭据页 ↗",
|
|
188
|
+
"link.googlePlayground": "打开 OAuth Playground ↗",
|
|
189
|
+
"link.appleId": "打开 Apple ID 设置 ↗",
|
|
190
|
+
"hint.calendarId.google": "通常就是你的 Gmail 地址",
|
|
191
|
+
"hint.clientId.google": "OAuth 客户端 ID,类型选「桌面应用」即可",
|
|
192
|
+
"hint.refreshToken.google": "用下面步骤里的 OAuth Playground 换一次,长期有效",
|
|
193
|
+
"hint.tokenUrl": "留空即用 Google 默认地址",
|
|
194
|
+
"hint.caldavUrl.icloud": "形如 https://caldav.icloud.com/<数字ID>/calendars/<日历ID>/ —— 数字 ID 在与日历同级的设置里能看到",
|
|
195
|
+
"hint.username.icloud": "你的 Apple ID 邮箱",
|
|
196
|
+
"hint.password.icloud": "必须用 App 专用密码,不是 Apple ID 登录密码",
|
|
197
|
+
"hint.host.nextcloud": "含 https://,结尾不要带 /",
|
|
198
|
+
"hint.user.nextcloud": "拼 CalDAV 地址用的用户名(通常与登录名相同)",
|
|
199
|
+
"hint.username.nextcloud": "用于认证的账号名;与上一栏不同时才需要分别填",
|
|
200
|
+
"hint.calendar.nextcloud": "日历名,个人日历默认是 personal",
|
|
201
|
+
"hint.caldavUrl.custom": "完整日历集合 URL,结尾的 / 别丢",
|
|
202
|
+
"hint.username.custom": "登录账号 / 邮箱",
|
|
203
|
+
"steps.google": ["在 Google Cloud 控制台新建项目,并启用「Google Calendar API」", "「凭据」→ 创建凭据 → OAuth 客户端 ID,类型选「桌面应用」,拿到 clientId 与 clientSecret", "打开 OAuth Playground(齿轮里勾 Use your own OAuth credentials,填上面的 clientId / clientSecret),授权 scope https://www.googleapis.com/auth/calendar,换取 refreshToken", "日历 ID 一般就是你的 Gmail 地址;四格填完点「测试连接」"],
|
|
204
|
+
"steps.icloud": ["到 appleid.apple.com → 登录与安全 → App 专用密码,生成一个(形如 xxxx-xxxx-xxxx-xxxx)", "iCloud 网页版日历 → 左下角齿轮 → 日历设置,找到你要连的那本日历的 CalDAV 地址", "账号填 Apple ID 邮箱,密码填上一步的 App 专用密码"],
|
|
205
|
+
"steps.nextcloud": ["服务器地址填 https://cloud.example.com(结尾不带 /)", "用户名 = 登录名;日历名在「日历」应用左侧能看到,个人日历默认叫 personal", "密码用 App 密码或登录密码,填完点「测试连接」"],
|
|
206
|
+
"steps.custom": ["在服务商后台找到你日历的集合 URL(以 / 结尾),例如 https://cloud.example.com/remote.php/dav/calendars/用户名/日历名/", "账号填登录名,密码填授权码 / App 密码,填完点「测试连接」"],
|
|
207
|
+
"conn.googleHint": "Google 必须用 OAuth:clientSecret 与 refreshToken 由一次授权换成,README 里有换取步骤;Google 不接受应用专用密码。",
|
|
208
|
+
"conn.icloudHint": "iCloud 用 App 专用密码(appleid.apple.com → 登录与安全);日历集合 URL 形如 https://caldav.icloud.com/<数字ID>/calendars/<日历ID>/,可在 iCloud 网页版日历设置里找到。",
|
|
209
|
+
"conn.nextcloudHint": "Nextcloud 填服务器地址 + 用户名 + 日历名即可,密码用 App 密码或登录密码。",
|
|
210
|
+
"conn.customHint": "自建 CalDAV 直接给完整日历集合 URL(结尾的 / 别丢),形如 https://cloud.example.com/remote.php/dav/calendars/用户名/日历名/。"
|
|
211
|
+
},
|
|
212
|
+
en: {
|
|
213
|
+
"nav.title": "Calendar",
|
|
214
|
+
"section.title": "Calendar",
|
|
215
|
+
"section.intro": "View and edit your CalDAV calendar right here: month, week and agenda views; click any event to retitle it, move it, or add a recurrence rule.",
|
|
216
|
+
"view.month": "Month", "view.week": "Week", "view.agenda": "Agenda",
|
|
217
|
+
"action.today": "Today", "action.prev": "Prev", "action.next": "Next", "action.new": "New event",
|
|
218
|
+
"action.refresh": "Refresh", "action.retry": "Retry", "action.edit": "Edit", "action.delete": "Delete",
|
|
219
|
+
"action.save": "Save", "action.cancel": "Cancel", "action.confirmDelete": "Delete anyway",
|
|
220
|
+
"action.open": "Open", "action.copy": "Copy uid", "action.copied": "Copied",
|
|
221
|
+
"action.demo": "See a sample", "action.exitDemo": "Exit sample",
|
|
222
|
+
"state.loading": "Loading your calendar…", "state.empty": "Nothing scheduled in this range.",
|
|
223
|
+
"state.notConfigured": "No CalDAV account configured yet",
|
|
224
|
+
"state.notConfiguredHint": "Fill in provider / caldavUrl / username / password (or the four OAuth fields) for dsh-calendar in your profile's cordis.patch.yml and restart. Meanwhile you can look at a sample panel.",
|
|
225
|
+
"state.readonly": "read-only",
|
|
226
|
+
"demo.banner": "Sample data (not your real calendar) — hit「Exit sample」to go back.",
|
|
227
|
+
"field.summary": "Title", "field.date": "Date", "field.start": "Start", "field.end": "End",
|
|
228
|
+
"field.allDay": "All day", "field.location": "Location", "field.description": "Notes",
|
|
229
|
+
"field.repeat": "Repeats", "field.uid": "Event uid", "field.when": "When", "field.duration": "Duration",
|
|
230
|
+
"repeat.none": "Does not repeat", "repeat.daily": "Daily", "repeat.weekly": "Weekly", "repeat.monthly": "Monthly", "repeat.yearly": "Yearly", "repeat.custom": "Custom RRULE",
|
|
231
|
+
"repeat.weeklyEvery": "Weekly ({n} times)", "repeat.recurring": "Recurring", "repeat.occurrence": "One occurrence of a series",
|
|
232
|
+
"conflict.title": "This slot already has {n} event(s); they overlap:", "conflict.hint": "Save anyway?",
|
|
233
|
+
"error.title": "Something failed", "error.load": "Could not load the calendar",
|
|
234
|
+
"confirm.delete": "Delete「{name}」? This is pushed to your calendar server.",
|
|
235
|
+
"form.newTitle": "New event", "form.editTitle": "Edit event",
|
|
236
|
+
"form.required": "A title and a time range are required.",
|
|
237
|
+
"day.today": "Today", "day.tomorrow": "Tomorrow", "day.yesterday": "Yesterday",
|
|
238
|
+
"count.events": "{n} events", "count.occurrences": "{n} expanded occurrences",
|
|
239
|
+
"format.duration.hm": "{h}h {m}m", "format.duration.h": "{h}h", "format.duration.m": "{m}m", "format.duration.zero": "0m",
|
|
240
|
+
"format.allday": "All day",
|
|
241
|
+
"conn.button": "Connection",
|
|
242
|
+
"conn.title": "CalDAV connection",
|
|
243
|
+
"conn.intro": "Fill this in and hit「Test connection」— nothing is saved until it works. The password stays on this machine only (no logs, no exports).",
|
|
244
|
+
"conn.provider": "Provider",
|
|
245
|
+
"conn.caldavUrl": "Calendar collection URL",
|
|
246
|
+
"conn.username": "Account",
|
|
247
|
+
"conn.password": "Password / app-specific password",
|
|
248
|
+
"conn.host": "Server",
|
|
249
|
+
"conn.user": "Username",
|
|
250
|
+
"conn.calendar": "Calendar name (e.g. personal)",
|
|
251
|
+
"conn.calendarId": "Calendar ID (usually your email)",
|
|
252
|
+
"conn.clientId": "clientId",
|
|
253
|
+
"conn.clientSecret": "clientSecret",
|
|
254
|
+
"conn.refreshToken": "refreshToken",
|
|
255
|
+
"conn.tokenUrl": "Token URL (optional)",
|
|
256
|
+
"conn.proxyUrl": "Proxy URL (optional)",
|
|
257
|
+
"conn.keep": "saved — leave blank to keep",
|
|
258
|
+
"conn.test": "Test connection",
|
|
259
|
+
"conn.testing": "Connecting…",
|
|
260
|
+
"conn.save": "Save and enable",
|
|
261
|
+
"conn.saving": "Saving…",
|
|
262
|
+
"conn.ok": "Connected: {n} events in the next 30 days",
|
|
263
|
+
"conn.okSample": " (e.g. {sample})",
|
|
264
|
+
"conn.fail": "Connection failed",
|
|
265
|
+
"conn.savedToast": "Saved — the tools pick it up on their next call",
|
|
266
|
+
"conn.noSettings": "This host has no settings service, so the panel cannot save: configure dsh-calendar in the profile's cordis.patch.yml and restart.",
|
|
267
|
+
"conn.storageFallback": "Saved in the plugin's own file: ~/.dsh/data/dsh-calendar/connection.json ({reason}; nothing else changes).",
|
|
268
|
+
"conn.needProvider": "Pick a provider first",
|
|
269
|
+
"conn.current": "Now: {summary}",
|
|
270
|
+
"conn.steps": "Where do these values come from?",
|
|
271
|
+
"link.googleConsole": "Open Google Cloud credentials ↗",
|
|
272
|
+
"link.googlePlayground": "Open OAuth Playground ↗",
|
|
273
|
+
"link.appleId": "Open Apple ID settings ↗",
|
|
274
|
+
"hint.calendarId.google": "usually your Gmail address",
|
|
275
|
+
"hint.clientId.google": "an OAuth client ID; the desktop-app type works",
|
|
276
|
+
"hint.refreshToken.google": "exchange it once in the OAuth Playground step below; it lasts",
|
|
277
|
+
"hint.tokenUrl": "leave empty to use Google's default",
|
|
278
|
+
"hint.caldavUrl.icloud": "looks like https://caldav.icloud.com/<numeric id>/calendars/<calendar id>/",
|
|
279
|
+
"hint.username.icloud": "your Apple ID email",
|
|
280
|
+
"hint.password.icloud": "an app-specific password, not your Apple ID password",
|
|
281
|
+
"hint.host.nextcloud": "include https:// and drop any trailing /",
|
|
282
|
+
"hint.user.nextcloud": "the username used to build the CalDAV URL",
|
|
283
|
+
"hint.username.nextcloud": "the account used to authenticate; fill it separately only when it differs",
|
|
284
|
+
"hint.calendar.nextcloud": "calendar name; the personal one is usually called personal",
|
|
285
|
+
"hint.caldavUrl.custom": "the full collection URL — keep the trailing slash",
|
|
286
|
+
"hint.username.custom": "login account / email",
|
|
287
|
+
"steps.google": ["Create a project in the Google Cloud console and enable the Google Calendar API", "Credentials → Create credentials → OAuth client ID, type Desktop app; copy clientId and clientSecret", "Open the OAuth Playground (gear icon: Use your own OAuth credentials, paste clientId/clientSecret), authorize scope https://www.googleapis.com/auth/calendar and exchange a refreshToken", "The calendar ID is usually your Gmail address; then hit Test connection"],
|
|
288
|
+
"steps.icloud": ["Go to appleid.apple.com → Sign-In and Security → App-Specific Passwords and create one (xxxx-xxxx-xxxx-xxxx)", "In iCloud web Calendar → gear → Calendar settings, find the CalDAV URL of the calendar you want", "Fill your Apple ID email and that app-specific password"],
|
|
289
|
+
"steps.nextcloud": ["Server: https://cloud.example.com (no trailing slash)", "Username = your login; the calendar name is shown in the Calendar app (personal by default)", "Use an app password or your login password, then hit Test connection"],
|
|
290
|
+
"steps.custom": ["Find your calendar collection URL in the provider's settings (it ends with /), e.g. https://cloud.example.com/remote.php/dav/calendars/user/calendar/", "Fill the login account and an app password / token, then Test connection"],
|
|
291
|
+
"conn.googleHint": "Google requires OAuth: exchange clientSecret and refreshToken once (see the README); Google does not accept app passwords.",
|
|
292
|
+
"conn.icloudHint": "iCloud uses an app-specific password (appleid.apple.com → Sign-In and Security); the collection URL looks like https://caldav.icloud.com/<numeric id>/calendars/<calendar id>/.",
|
|
293
|
+
"conn.nextcloudHint": "Nextcloud needs the server URL, username and calendar name; any app password works.",
|
|
294
|
+
"conn.customHint": "For a self-hosted CalDAV give the full collection URL (keep the trailing slash), e.g. https://cloud.example.com/remote.php/dav/calendars/user/calendar/."
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
var UI_LANG = "zh";
|
|
299
|
+
var relocalize = null;
|
|
300
|
+
|
|
301
|
+
/** 宿主 locale 服务:get('locale') 是正规读法,ctx.locale 兜旧宿主,都没有就是没有。 */
|
|
302
|
+
function localeServiceOf(ctx) {
|
|
303
|
+
try {
|
|
304
|
+
if (typeof ctx.get === "function") {
|
|
305
|
+
const viaGet = ctx.get("locale");
|
|
306
|
+
if (viaGet !== undefined && viaGet !== null) return viaGet;
|
|
307
|
+
}
|
|
308
|
+
} catch (e) { /* 落到下面那条路 */ }
|
|
309
|
+
const direct = ctx.locale;
|
|
310
|
+
return direct === undefined || direct === null ? null : direct;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function localeToLang(active) {
|
|
314
|
+
const text = typeof active === "string" ? active.toLowerCase() : "";
|
|
315
|
+
return text.indexOf("en") === 0 ? "en" : "zh";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 订阅语言切换;服务缺失或形状不同都只是留在 zh,绝不把面板拖垮。 */
|
|
319
|
+
function subscribeLocale(ctx) {
|
|
320
|
+
const service = localeServiceOf(ctx);
|
|
321
|
+
if (service === null) return () => {};
|
|
322
|
+
const apply = () => {
|
|
323
|
+
try {
|
|
324
|
+
if (typeof service.getSnapshot === "function") {
|
|
325
|
+
UI_LANG = localeToLang(service.getSnapshot().active);
|
|
326
|
+
}
|
|
327
|
+
} catch (e) { UI_LANG = "zh"; }
|
|
328
|
+
if (typeof relocalize === "function") {
|
|
329
|
+
try { relocalize(); } catch (e) { /* 重绘失败不该断订阅链 */ }
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
apply();
|
|
333
|
+
try {
|
|
334
|
+
if (typeof service.subscribe !== "function") return () => {};
|
|
335
|
+
const off = service.subscribe(apply);
|
|
336
|
+
return typeof off === "function" ? off : () => {};
|
|
337
|
+
} catch (e) { return () => {}; }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** 文案:{name} 形式的占位符按 params 替换,缺参时保留原样(宁可露出花括号也不吞字)。 */
|
|
341
|
+
function t(key, params) {
|
|
342
|
+
const dict = UI[UI_LANG] || UI.zh;
|
|
343
|
+
let text = dict[key];
|
|
344
|
+
if (text === undefined) text = (UI.zh[key] !== undefined ? UI.zh[key] : key);
|
|
345
|
+
if (params === undefined) return text;
|
|
346
|
+
return text.replace(/\{(\w+)\}/g, (whole, name) =>
|
|
347
|
+
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : whole);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** 调插件自己的路由;错误码原样带出,方便上层区分「没配置」与「服务器拒绝」。 */
|
|
351
|
+
async function api(action, payload) {
|
|
352
|
+
const init = { credentials: "same-origin", method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ action }, payload)) };
|
|
353
|
+
const res = await fetch(ROUTE, init);
|
|
354
|
+
let body = null;
|
|
355
|
+
try { body = await res.json(); } catch (e) { body = null; }
|
|
356
|
+
if (!res.ok || !body || body.ok !== true) {
|
|
357
|
+
const message = body && body.error && body.error.message ? body.error.message : ("HTTP " + res.status);
|
|
358
|
+
const error = new Error(message);
|
|
359
|
+
error.status = res.status;
|
|
360
|
+
error.code = body && body.error ? body.error.code : undefined;
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
return body.value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ───────────────────────────── 时间与格式 ─────────────────────────────
|
|
367
|
+
|
|
368
|
+
/** 事件时间是「全天 = YYYY-MM-DD」或「定时 = UTC ISO」,两种都要能解析。 */
|
|
369
|
+
function isDateOnly(value) { return /^\d{4}-\d{2}-\d{2}$/.test(String(value || "")); }
|
|
370
|
+
function startDate(event) { return isDateOnly(event.start) ? new Date(event.start + "T00:00:00") : new Date(event.start); }
|
|
371
|
+
function endDate(event) { return isDateOnly(event.end) ? new Date(event.end + "T00:00:00") : new Date(event.end); }
|
|
372
|
+
function dayKey(date) {
|
|
373
|
+
return date.getFullYear() + "-" + pad2(date.getMonth() + 1) + "-" + pad2(date.getDate());
|
|
374
|
+
}
|
|
375
|
+
function pad2(n) { return n < 10 ? "0" + n : String(n); }
|
|
376
|
+
function hhmm(date) { return pad2(date.getHours()) + ":" + pad2(date.getMinutes()); }
|
|
377
|
+
function startOfDay(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); }
|
|
378
|
+
function addDays(date, n) { return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n); }
|
|
379
|
+
function addMonths(date, n) { return new Date(date.getFullYear(), date.getMonth() + n, 1); }
|
|
380
|
+
/** 周一为一周之首:JS 的 getDay() 里周日是 0,先掰成 1..7。 */
|
|
381
|
+
function isoWeekday(date) { const d = date.getDay(); return d === 0 ? 7 : d; }
|
|
382
|
+
function startOfWeek(date) { return addDays(startOfDay(date), -(isoWeekday(date) - 1)); }
|
|
383
|
+
function daysBetween(a, b) { return Math.round((startOfDay(b) - startOfDay(a)) / 86400000); }
|
|
384
|
+
|
|
385
|
+
function formatDay(date, opts) {
|
|
386
|
+
const withYear = opts && opts.withYear;
|
|
387
|
+
return date.getFullYear() + " 年 " + (date.getMonth() + 1) + " 月 " + date.getDate() + " 日" + (withYear ? "" : "");
|
|
388
|
+
}
|
|
389
|
+
function formatMonthTitle(date) { return date.getFullYear() + " 年 " + (date.getMonth() + 1) + " 月"; }
|
|
390
|
+
function formatWeekTitle(date) {
|
|
391
|
+
const from = startOfWeek(date), to = addDays(from, 6);
|
|
392
|
+
return formatMonthTitle(from) + " " + from.getDate() + "–" + (to.getMonth() === from.getMonth() ? "" : (to.getMonth() + 1) + " 月 ") + to.getDate() + " 日";
|
|
393
|
+
}
|
|
394
|
+
function relativeDay(date) {
|
|
395
|
+
const diff = daysBetween(new Date(), date);
|
|
396
|
+
if (diff === 0) return t("day.today");
|
|
397
|
+
if (diff === 1) return t("day.tomorrow");
|
|
398
|
+
if (diff === -1) return t("day.yesterday");
|
|
399
|
+
return "周" + WEEKDAYS_ZH[isoWeekday(date) - 1];
|
|
400
|
+
}
|
|
401
|
+
function formatWhen(event) {
|
|
402
|
+
if (event.allDay) return t("format.allday") + " · " + formatDay(startDate(event));
|
|
403
|
+
const s = startDate(event), e = endDate(event);
|
|
404
|
+
const sameDay = dayKey(s) === dayKey(e);
|
|
405
|
+
return formatDay(s) + " " + hhmm(s) + "–" + (sameDay ? "" : formatDay(e) + " ") + hhmm(e);
|
|
406
|
+
}
|
|
407
|
+
function formatDuration(event) {
|
|
408
|
+
if (event.allDay) {
|
|
409
|
+
const days = Math.max(1, daysBetween(startDate(event), endDate(event)));
|
|
410
|
+
return days === 1 ? t("format.allday") : days + " 天";
|
|
411
|
+
}
|
|
412
|
+
const minutes = Math.max(0, Math.round((endDate(event) - startDate(event)) / 60000));
|
|
413
|
+
const hours = Math.floor(minutes / 60), rest = minutes % 60;
|
|
414
|
+
if (hours > 0 && rest > 0) return t("format.duration.hm", { h: hours, m: rest });
|
|
415
|
+
if (hours > 0) return t("format.duration.h", { h: hours });
|
|
416
|
+
if (rest > 0) return t("format.duration.m", { m: rest });
|
|
417
|
+
return t("format.duration.zero");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** RRULE 说人话:只翻译常见的 FREQ/INTERVAL/COUNT/UNTIL,认不出就原样返回。 */
|
|
421
|
+
function humanRepeat(rrule) {
|
|
422
|
+
if (!rrule) return "";
|
|
423
|
+
const parts = {};
|
|
424
|
+
String(rrule).split(";").forEach((piece) => {
|
|
425
|
+
const i = piece.indexOf("=");
|
|
426
|
+
if (i > 0) parts[piece.slice(0, i).toUpperCase()] = piece.slice(i + 1);
|
|
427
|
+
});
|
|
428
|
+
const freq = parts.FREQ;
|
|
429
|
+
const base = freq === "DAILY" ? "每天" : freq === "WEEKLY" ? "每周" : freq === "MONTHLY" ? "每月" : freq === "YEARLY" ? "每年" : "";
|
|
430
|
+
if (base === "") return rrule;
|
|
431
|
+
const interval = Number(parts.INTERVAL || "1");
|
|
432
|
+
const every = interval > 1 ? "每 " + interval + (freq === "DAILY" ? " 天" : freq === "WEEKLY" ? " 周" : freq === "MONTHLY" ? " 个月" : " 年") : base;
|
|
433
|
+
if (parts.COUNT) return every + "(共 " + parts.COUNT + " 次)";
|
|
434
|
+
if (parts.UNTIL) {
|
|
435
|
+
const raw = String(parts.UNTIL);
|
|
436
|
+
const iso = raw.length >= 8 ? raw.slice(0, 4) + "-" + raw.slice(4, 6) + "-" + raw.slice(6, 8) : raw;
|
|
437
|
+
return every + ",到 " + iso + " 为止";
|
|
438
|
+
}
|
|
439
|
+
return every;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** 两个事件是否真的重叠(全天事件按当天整天算)。 */
|
|
443
|
+
function overlaps(a, b) {
|
|
444
|
+
const as = startDate(a).getTime(), ae = endDate(a).getTime() || as;
|
|
445
|
+
const bs = startDate(b).getTime(), be = endDate(b).getTime() || bs;
|
|
446
|
+
return as < be && bs < ae;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 月视图的 6×7 网格起点:所在周的周一,往前补到整周。 */
|
|
450
|
+
function monthGridDays(anchor) {
|
|
451
|
+
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
|
452
|
+
const start = startOfWeek(first);
|
|
453
|
+
const days = [];
|
|
454
|
+
for (let i = 0; i < 42; i++) days.push(addDays(start, i));
|
|
455
|
+
return days;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 一个事件落在哪几天(跨天事件在月视图里逐天显示)。 */
|
|
459
|
+
function daysOfEvent(event) {
|
|
460
|
+
const days = [];
|
|
461
|
+
if (event.allDay) {
|
|
462
|
+
const from = startDate(event);
|
|
463
|
+
// 全天事件的 DTEND 是「次日零点」(iCal 语义),显示上要退一天。
|
|
464
|
+
const to = addDays(endDate(event), -1);
|
|
465
|
+
for (let d = startOfDay(from); d <= to; d = addDays(d, 1)) days.push(d);
|
|
466
|
+
return days.length > 0 ? days : [from];
|
|
467
|
+
}
|
|
468
|
+
const from = startOfDay(startDate(event)), to = startOfDay(endDate(event));
|
|
469
|
+
for (let d = from; d <= to; d = addDays(d, 1)) days.push(d);
|
|
470
|
+
return days;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 事件排序:全天在前,然后按开始时间。 */
|
|
474
|
+
function sortForDisplay(events) {
|
|
475
|
+
return events.slice().sort((a, b) => {
|
|
476
|
+
if (a.allDay !== b.allDay) return a.allDay ? -1 : 1;
|
|
477
|
+
return startDate(a) - startDate(b);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ───────────────────────────── 宿主 UI 组件(可选) ─────────────────────────────
|
|
482
|
+
// 平台的 seed 模块表里有 ui-primitives;老宿主没有时退回同名自绘控件,
|
|
483
|
+
// 面板不会因为「组件库不在」整块消失。
|
|
484
|
+
var PRIM = null;
|
|
485
|
+
try { PRIM = require("@deepseek-ai/dsh-client-ui-primitives"); } catch (e) { PRIM = null; }
|
|
486
|
+
|
|
487
|
+
/** Button:有官方组件就用官方(variant/tokens 一致),否则退回 .dshc-btn。 */
|
|
488
|
+
function Btn(props) {
|
|
489
|
+
const { variant, size, icon, className, children, ...rest } = props || {};
|
|
490
|
+
if (PRIM && typeof PRIM.Button === "function") {
|
|
491
|
+
return h(PRIM.Button, Object.assign({ variant: variant || "ghost", size: size || "sm" }, rest, { className }), children);
|
|
492
|
+
}
|
|
493
|
+
const cls = ["dshc-btn", variant === "primary" ? "primary" : "", variant === "danger" ? "danger" : "", variant === "ghost" ? "ghost" : "", className || ""].join(" ").trim();
|
|
494
|
+
return h("button", Object.assign({ type: "button" }, rest, { className: cls }), children);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Input:官方版是「带图标的 span 包 input」,属性透传,这里只用其属性面。 */
|
|
498
|
+
function Txt(props) {
|
|
499
|
+
const { className, ...rest } = props || {};
|
|
500
|
+
if (PRIM && typeof PRIM.Input === "function") return h(PRIM.Input, Object.assign({}, rest, { className }));
|
|
501
|
+
return h("input", Object.assign({ type: "text" }, rest, { className: ["dshc-input", className || ""].join(" ").trim() }));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Checkbox:回到原生勾选框 + 文案,语义与官方一致(label 既是可见名也是无障碍名)。 */
|
|
505
|
+
function Chk(props) {
|
|
506
|
+
if (PRIM && typeof PRIM.Checkbox === "function") return h(PRIM.Checkbox, props);
|
|
507
|
+
return h("label", { className: "dshc-check" }, [
|
|
508
|
+
h("input", { type: "checkbox", checked: props.checked === true, disabled: props.disabled === true, onChange: (e) => props.onChange(e.target.checked) }),
|
|
509
|
+
h("span", null, props.label)
|
|
510
|
+
]);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Modal:官方版是 portal + 遮罩 + Esc 语义;没有它时用同一套 class 自绘。 */
|
|
514
|
+
function Dlg(props) {
|
|
515
|
+
if (PRIM && typeof PRIM.Modal === "function") {
|
|
516
|
+
return h(PRIM.Modal, {
|
|
517
|
+
open: props.open,
|
|
518
|
+
onClose: props.onClose,
|
|
519
|
+
title: props.title,
|
|
520
|
+
closeLabel: props.closeLabel || props.title,
|
|
521
|
+
children: props.children,
|
|
522
|
+
footer: props.footer
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (!props.open) return null;
|
|
526
|
+
return h("div", { className: "dshc-modalwrap", onMouseDown: (e) => { if (e.target === e.currentTarget) props.onClose(); } }, [
|
|
527
|
+
h("div", { className: "dshc-modal" }, [
|
|
528
|
+
h("div", { style: { position: "relative" } }, [
|
|
529
|
+
h("div", { className: "dshc-drawertitle" }, props.title),
|
|
530
|
+
h("button", { type: "button", className: "dshc-btn ghost dshc-close", onClick: props.onClose, "aria-label": props.closeLabel || props.title }, "\u2715")
|
|
531
|
+
]),
|
|
532
|
+
props.children,
|
|
533
|
+
props.footer
|
|
534
|
+
])
|
|
535
|
+
]);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 复制 uid:优先官方 writeClipboard(它处理了非 https 的降级),否则走 navigator。 */
|
|
539
|
+
async function copyText(text) {
|
|
540
|
+
try {
|
|
541
|
+
if (PRIM && typeof PRIM.writeClipboard === "function") { await PRIM.writeClipboard(text); return true; }
|
|
542
|
+
} catch (e) { /* 落到下面 */ }
|
|
543
|
+
try {
|
|
544
|
+
if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return true; }
|
|
545
|
+
} catch (e) { /* 忽略 */ }
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ───────────────────────────── 小部件 ─────────────────────────────
|
|
550
|
+
|
|
551
|
+
/** 事件属于哪一类:影响左侧色条(全天绿 / 重复黄 / 普通蓝)。 */
|
|
552
|
+
function eventClass(event) {
|
|
553
|
+
return event.allDay ? "allday" : (event.rrule || event.isOccurrence ? "recurring" : "");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function Chip(props) {
|
|
557
|
+
const { event, onClick } = props;
|
|
558
|
+
return h("button", {
|
|
559
|
+
type: "button",
|
|
560
|
+
className: "dshc-chip " + eventClass(event),
|
|
561
|
+
title: event.summary,
|
|
562
|
+
onClick: (e) => { e.stopPropagation(); onClick(event); }
|
|
563
|
+
}, [
|
|
564
|
+
event.allDay ? null : h("span", { className: "t" }, hhmm(startDate(event))),
|
|
565
|
+
h("span", null, event.summary)
|
|
566
|
+
]);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** 议程里的一行:时间列 + 标题/地点 + 重复角标。 */
|
|
570
|
+
function Row(props) {
|
|
571
|
+
const { event, onClick } = props;
|
|
572
|
+
const meta = [];
|
|
573
|
+
if (event.location) meta.push(event.location);
|
|
574
|
+
if (event.rrule) meta.push(humanRepeat(event.rrule));
|
|
575
|
+
else if (event.isOccurrence) meta.push(t("repeat.occurrence"));
|
|
576
|
+
return h("div", { className: "dshc-row " + eventClass(event), onClick: () => onClick(event), role: "button", tabIndex: 0,
|
|
577
|
+
onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") onClick(event); } }, [
|
|
578
|
+
h("div", { className: "dshc-time" }, event.allDay ? t("format.allday") : (hhmm(startDate(event)) + "–" + hhmm(endDate(event)))),
|
|
579
|
+
h("div", { className: "dshc-main" }, [
|
|
580
|
+
h("div", { className: "dshc-name" }, event.summary),
|
|
581
|
+
meta.length > 0 ? h("div", { className: "dshc-meta" }, meta.join(" · ")) : null
|
|
582
|
+
])
|
|
583
|
+
]);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** 月视图:6×7 网格;每格最多 3 条,多的收成「+N」。 */
|
|
587
|
+
function MonthView(props) {
|
|
588
|
+
const { anchor, events, onPick, onCreateAt } = props;
|
|
589
|
+
const days = monthGridDays(anchor);
|
|
590
|
+
const today = dayKey(new Date());
|
|
591
|
+
const byDay = {};
|
|
592
|
+
for (const event of events) {
|
|
593
|
+
for (const day of daysOfEvent(event)) {
|
|
594
|
+
const key = dayKey(day);
|
|
595
|
+
if (!byDay[key]) byDay[key] = [];
|
|
596
|
+
byDay[key].push(event);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const cells = [];
|
|
600
|
+
for (const day of days) {
|
|
601
|
+
const key = dayKey(day);
|
|
602
|
+
const list = sortForDisplay(byDay[key] || []);
|
|
603
|
+
const shown = list.slice(0, 3);
|
|
604
|
+
const rest = list.length - shown.length;
|
|
605
|
+
// 格子必须是 div 而不是 button:里面的日程芯片本身是按钮,而按钮不能嵌套按钮 ——
|
|
606
|
+
// 嵌套时浏览器会把外层按钮提前闭合,芯片就被挤到网格外面去了(真被用户抓到过)。
|
|
607
|
+
cells.push(h("div", {
|
|
608
|
+
key: key,
|
|
609
|
+
role: "button",
|
|
610
|
+
tabIndex: 0,
|
|
611
|
+
className: "dshc-cell" + (day.getMonth() !== anchor.getMonth() ? " out" : "") + (key === today ? " today" : ""),
|
|
612
|
+
onClick: () => onCreateAt(day),
|
|
613
|
+
onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCreateAt(day); } },
|
|
614
|
+
title: t("action.new")
|
|
615
|
+
}, [
|
|
616
|
+
h("div", { className: "dshc-daynum" }, [key === today ? h("b", null, String(day.getDate())) : h("span", null, String(day.getDate()))]),
|
|
617
|
+
shown.map((event) => h(Chip, { key: event.uid + "@" + key, event, onClick: onPick })),
|
|
618
|
+
rest > 0 ? h("div", { className: "dshc-more" }, "+" + rest) : null
|
|
619
|
+
]));
|
|
620
|
+
}
|
|
621
|
+
return h("div", null, [
|
|
622
|
+
h("div", { className: "dshc-grid", style: { marginBottom: "4px" } }, WEEKDAYS_ZH.map((d) => h("div", { className: "dshc-dow", key: d }, "周" + d))),
|
|
623
|
+
h("div", { className: "dshc-grid" }, cells)
|
|
624
|
+
]);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** 周视图:7 列 × 24 小时网格,事件按分钟定位;同一时段重叠的事件并排显示。 */
|
|
628
|
+
function WeekView(props) {
|
|
629
|
+
const { anchor, events, onPick, onCreateAt } = props;
|
|
630
|
+
const HOUR_PX = 44;
|
|
631
|
+
const start = startOfWeek(anchor);
|
|
632
|
+
const days = [];
|
|
633
|
+
for (let i = 0; i < 7; i++) days.push(addDays(start, i));
|
|
634
|
+
const today = dayKey(new Date());
|
|
635
|
+
const allDay = sortForDisplay(events.filter((e) => e.allDay));
|
|
636
|
+
const timed = events.filter((e) => !e.allDay);
|
|
637
|
+
|
|
638
|
+
// 默认落在 07:00:半夜那几格基本没人看,但需要时还能往上滚(不裁剪任何事件)。
|
|
639
|
+
const scroller = useRef(null);
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
const node = scroller.current;
|
|
642
|
+
if (node === null || node === undefined) return;
|
|
643
|
+
node.scrollTop = 7 * HOUR_PX;
|
|
644
|
+
}, [dayKey(anchor)]);
|
|
645
|
+
|
|
646
|
+
const hoursColumn = h("div", { key: "__hours" }, [h("div", { className: "dshc-dayhead", key: "h" }, "\u00a0")].concat(
|
|
647
|
+
Array.from({ length: 24 }, (_, hour) => h("div", { className: "dshc-hour", key: "h" + hour }, hour === 0 ? "" : pad2(hour) + ":00"))
|
|
648
|
+
));
|
|
649
|
+
|
|
650
|
+
const dayColumns = days.map((day) => {
|
|
651
|
+
const key = dayKey(day);
|
|
652
|
+
const dayTimed = timed.filter((event) => daysOfEvent(event).some((d) => dayKey(d) === key));
|
|
653
|
+
// 重叠事件分轨:按开始时间排好,逐个找第一条已经结束的轨道;没有就新开一条。
|
|
654
|
+
const laneEnds = [];
|
|
655
|
+
const placed = dayTimed.slice().sort((a, b) => startDate(a) - startDate(b)).map((event) => {
|
|
656
|
+
const from = startDate(event), to = endDate(event);
|
|
657
|
+
let lane = laneEnds.findIndex((endMs) => endMs <= from.getTime());
|
|
658
|
+
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0); }
|
|
659
|
+
laneEnds[lane] = to.getTime();
|
|
660
|
+
return { event, lane };
|
|
661
|
+
});
|
|
662
|
+
const laneCount = Math.max(1, laneEnds.length);
|
|
663
|
+
const blocks = placed.map(({ event, lane }) => {
|
|
664
|
+
const from = startDate(event), to = endDate(event);
|
|
665
|
+
const top = (from.getHours() * 60 + from.getMinutes()) * (HOUR_PX / 60);
|
|
666
|
+
const height = Math.max(18, ((to - from) / 60000) * (HOUR_PX / 60));
|
|
667
|
+
const width = "calc(" + (100 / laneCount) + "% - 4px)";
|
|
668
|
+
const left = "calc(" + (lane * (100 / laneCount)) + "% + 2px)";
|
|
669
|
+
return h("button", {
|
|
670
|
+
key: event.uid,
|
|
671
|
+
type: "button",
|
|
672
|
+
className: "dshc-slot " + eventClass(event),
|
|
673
|
+
style: { top: top + "px", height: height + "px", left, width },
|
|
674
|
+
title: event.summary + " " + hhmm(from) + "\u2013" + hhmm(to),
|
|
675
|
+
onClick: (e) => { e.stopPropagation(); onPick(event); }
|
|
676
|
+
}, [h("div", null, event.summary), h("div", { style: { opacity: .75 } }, hhmm(from) + "\u2013" + hhmm(to))]);
|
|
677
|
+
});
|
|
678
|
+
return h("div", { key, style: { minWidth: 0 } }, [
|
|
679
|
+
h("div", { className: "dshc-dayhead" }, [
|
|
680
|
+
h("b", null, String(day.getDate())),
|
|
681
|
+
" ",
|
|
682
|
+
relativeDay(day)
|
|
683
|
+
]),
|
|
684
|
+
h("div", {
|
|
685
|
+
className: "dshc-daycol" + (key === today ? " today" : ""),
|
|
686
|
+
style: { height: (24 * HOUR_PX) + "px" },
|
|
687
|
+
onClick: () => onCreateAt(day),
|
|
688
|
+
title: t("action.new")
|
|
689
|
+
}, blocks)
|
|
690
|
+
]);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
return h("div", null, [
|
|
694
|
+
allDay.length > 0
|
|
695
|
+
? h("div", { style: { marginBottom: "8px" } }, allDay.map((event) => h(Row, { key: event.uid, event, onClick: onPick })))
|
|
696
|
+
: null,
|
|
697
|
+
h("div", { className: "dshc-week", ref: scroller }, [hoursColumn].concat(dayColumns))
|
|
698
|
+
]);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** 议程视图:从今天起按天分组,最像「接下来要干什么」。 */
|
|
702
|
+
function AgendaView(props) {
|
|
703
|
+
const { events, onPick, onCreateAt } = props;
|
|
704
|
+
const groups = [];
|
|
705
|
+
const index = {};
|
|
706
|
+
for (const event of sortForDisplay(events)) {
|
|
707
|
+
const day = startOfDay(startDate(event));
|
|
708
|
+
const key = dayKey(day);
|
|
709
|
+
if (index[key] === undefined) { index[key] = groups.length; groups.push({ day, key, items: [] }); }
|
|
710
|
+
groups[index[key]].items.push(event);
|
|
711
|
+
}
|
|
712
|
+
if (groups.length === 0) return h("div", { className: "dshc-empty" }, t("state.empty"));
|
|
713
|
+
return h("div", { className: "dshc-agenda" }, groups.map((group) => h("div", { className: "dshc-daygroup", key: group.key }, [
|
|
714
|
+
h("div", { className: "dshc-daylabel" }, [
|
|
715
|
+
h("b", null, formatDay(group.day)),
|
|
716
|
+
h("span", { className: "dshc-sub" }, relativeDay(group.day)),
|
|
717
|
+
h("span", { className: "dshc-spacer" }),
|
|
718
|
+
h("button", { type: "button", className: "dshc-btn ghost", onClick: () => onCreateAt(group.day) }, t("action.new"))
|
|
719
|
+
]),
|
|
720
|
+
group.items.map((event) => h(Row, { key: event.uid, event, onClick: onPick }))
|
|
721
|
+
])));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ───────────────────────────── 悬浮面板的样式补充 ─────────────────────────────
|
|
725
|
+
const CSS_EXTRA = [
|
|
726
|
+
".dshc-fab{position:fixed;right:18px;bottom:18px;z-index:55;width:38px;height:38px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);box-shadow:var(--dsw-elevation-prominent,0 6px 18px rgba(0,0,0,.18));cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:.62;transition:opacity .15s,transform .15s}",
|
|
727
|
+
".dshc-fab:hover{opacity:1;transform:translateY(-1px)}",
|
|
728
|
+
".dshc-fab.on{opacity:1;background:var(--dsw-alias-button-primary-fill,#2563eb);color:var(--dsw-alias-label-primary-foreground,#fff);border-color:transparent}",
|
|
729
|
+
".dshc-float{position:fixed;right:18px;bottom:66px;z-index:56;width:min(760px,94vw);max-height:min(78vh,720px);overflow:auto;background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:16px;box-shadow:var(--dsw-elevation-prominent,0 18px 48px rgba(0,0,0,.28));padding:14px}",
|
|
730
|
+
".dshc-float .dshc-shell{border:0;padding:0;background:transparent}",
|
|
731
|
+
".dshc-steps{margin:2px 0 10px;font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280)}",
|
|
732
|
+
".dshc-steps summary{cursor:pointer;color:var(--dsw-alias-state-business-primary,#2563eb)}",
|
|
733
|
+
".dshc-steps ol{margin:6px 0 0;padding-left:18px;line-height:1.6}",
|
|
734
|
+
".dshc-link{color:var(--dsw-alias-state-business-primary,#2563eb);text-decoration:none}",
|
|
735
|
+
".dshc-toast{position:fixed;right:18px;bottom:112px;z-index:57;background:var(--dsw-alias-label-primary,#111);color:var(--dsw-alias-label-primary-foreground,#fff);border-radius:10px;padding:8px 12px;font-size:12px;box-shadow:var(--dsw-elevation-prominent,0 8px 20px rgba(0,0,0,.25))}"
|
|
736
|
+
].join("");
|
|
737
|
+
|
|
738
|
+
// ───────────────────────────── 数据 ─────────────────────────────
|
|
739
|
+
|
|
740
|
+
/** 去掉毫秒:后端的 assertIsoTime 只认秒级 ISO,浏览器 toISOString 带 .000Z。 */
|
|
741
|
+
function isoOf(date) { return date.toISOString().replace(/\.\d{3}Z$/, "Z"); }
|
|
742
|
+
|
|
743
|
+
function rangeOf(view, anchor) {
|
|
744
|
+
if (view === "week") {
|
|
745
|
+
const from = startOfWeek(anchor);
|
|
746
|
+
return { start: isoOf(from), end: isoOf(addDays(from, 7)) };
|
|
747
|
+
}
|
|
748
|
+
if (view === "agenda") {
|
|
749
|
+
const from = startOfDay(new Date());
|
|
750
|
+
return { start: isoOf(from), end: isoOf(addDays(from, 30)) };
|
|
751
|
+
}
|
|
752
|
+
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
|
753
|
+
const from = startOfWeek(first);
|
|
754
|
+
return { start: isoOf(from), end: isoOf(addDays(from, 42)) };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** 示例数据:只在「没配置」或用户主动点「查看示例」时用,不冒充真实日历。 */
|
|
758
|
+
function demoEvents() {
|
|
759
|
+
const base = startOfDay(new Date());
|
|
760
|
+
const at = (offset, hour, minute) => { const d = addDays(base, offset); d.setHours(hour, minute || 0, 0, 0); return isoOf(d); };
|
|
761
|
+
const dateOnly = (offset) => { const d = addDays(base, offset); return dayKey(d); };
|
|
762
|
+
return [
|
|
763
|
+
{ uid: "demo://allday", summary: "📦 发布日:dsh-email 0.13.1 + dsh-suite 0.1.5", start: dateOnly(0), end: dateOnly(1), allDay: true, description: "两个包都已发布并通过 tarball 内容核对", location: "npm registry", isOccurrence: false },
|
|
764
|
+
{ uid: "demo://review", summary: "产品评审会", start: at(0, 10, 0), end: at(0, 11, 0), allDay: false, location: "会议室 A", description: "议程:0.13.1 反馈、下载数据复盘", isOccurrence: false },
|
|
765
|
+
{ uid: "demo://one-on-one", summary: "1:1 同步", start: at(0, 15, 0), end: at(0, 15, 30), allDay: false, location: "线上", isOccurrence: false },
|
|
766
|
+
{ uid: "demo://standup", summary: "每周站会", start: at(1, 9, 30), end: at(1, 9, 50), allDay: false, rrule: "FREQ=WEEKLY;COUNT=6", location: "线上", isOccurrence: false },
|
|
767
|
+
{ uid: "demo://standup@2", summary: "每周站会", start: at(8, 9, 30), end: at(8, 9, 50), allDay: false, location: "线上", isOccurrence: true, seriesStart: at(1, 9, 30) },
|
|
768
|
+
{ uid: "demo://gurio", summary: "与 gurio-wine 同步", start: at(2, 16, 0), end: at(2, 16, 30), allDay: false, location: "线上", description: "OAuth2 内置客户端后续", isOccurrence: false }
|
|
769
|
+
];
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** 拉一个时间窗的事件;demo 为真时用示例数据,不打网络。 */
|
|
773
|
+
function useCalendarData(range, reloadToken, demo) {
|
|
774
|
+
const [state, setState] = useState({ loading: true, error: null, events: [], configured: true });
|
|
775
|
+
useEffect(() => {
|
|
776
|
+
if (demo === true) {
|
|
777
|
+
setState({ loading: false, error: null, events: demoEvents(), configured: true });
|
|
778
|
+
return () => {};
|
|
779
|
+
}
|
|
780
|
+
let alive = true;
|
|
781
|
+
setState((prev) => ({ loading: true, error: null, events: prev.events, configured: prev.configured }));
|
|
782
|
+
api("list", { start: range.start, end: range.end, expand: true, maxOccurrences: 300 })
|
|
783
|
+
.then((value) => { if (alive) setState({ loading: false, error: null, events: (value && value.events) || [], configured: true }); })
|
|
784
|
+
.catch((error) => { if (alive) setState({ loading: false, error, events: [], configured: error && error.code !== "not-configured" }); });
|
|
785
|
+
return () => { alive = false; };
|
|
786
|
+
}, [range.start, range.end, reloadToken, demo]);
|
|
787
|
+
return state;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ───────────────────────────── 表单 ─────────────────────────────
|
|
791
|
+
|
|
792
|
+
/** 事件 → 表单草稿(日期与时间都按浏览器本地时区拆开给 datetime-local 用)。 */
|
|
793
|
+
function draftOfEvent(event) {
|
|
794
|
+
const allDay = event.allDay === true;
|
|
795
|
+
const s = startDate(event);
|
|
796
|
+
const rawEnd = endDate(event);
|
|
797
|
+
// 全天事件的 DTEND 是「次日零点」:编辑时退一天,否则用户会看到莫名多一天。
|
|
798
|
+
const e = allDay ? addDays(rawEnd, -1) : rawEnd;
|
|
799
|
+
return {
|
|
800
|
+
uid: event.uid,
|
|
801
|
+
summary: event.summary || "",
|
|
802
|
+
date: dayKey(s),
|
|
803
|
+
startTime: pad2(s.getHours()) + ":" + pad2(s.getMinutes()),
|
|
804
|
+
endTime: pad2(e.getHours()) + ":" + pad2(e.getMinutes()),
|
|
805
|
+
allDay,
|
|
806
|
+
location: event.location || "",
|
|
807
|
+
description: event.description || "",
|
|
808
|
+
repeat: repeatKeyOf(event.rrule),
|
|
809
|
+
rrule: event.rrule || ""
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** 新建表单:默认落在被点的那一天,上午 10:00–11:00(日历产品的惯例)。 */
|
|
814
|
+
function draftAt(date, hour) {
|
|
815
|
+
const d = date || new Date();
|
|
816
|
+
const startHour = typeof hour === "number" ? hour : 10;
|
|
817
|
+
return {
|
|
818
|
+
uid: undefined,
|
|
819
|
+
summary: "",
|
|
820
|
+
date: dayKey(d),
|
|
821
|
+
startTime: pad2(startHour) + ":00",
|
|
822
|
+
endTime: pad2(Math.min(23, startHour + 1)) + ":00",
|
|
823
|
+
allDay: false,
|
|
824
|
+
location: "",
|
|
825
|
+
description: "",
|
|
826
|
+
repeat: "",
|
|
827
|
+
rrule: ""
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function repeatKeyOf(rrule) {
|
|
832
|
+
if (!rrule) return "";
|
|
833
|
+
const freq = /FREQ=([A-Z]+)/i.exec(rrule);
|
|
834
|
+
if (!freq) return "custom";
|
|
835
|
+
const value = freq[1].toUpperCase();
|
|
836
|
+
const simple = rrule.replace(/FREQ=[A-Z]+;?/i, "").replace(/^;|;$/g, "");
|
|
837
|
+
if (simple !== "") return "custom";
|
|
838
|
+
if (value === "DAILY") return "daily";
|
|
839
|
+
if (value === "WEEKLY") return "weekly";
|
|
840
|
+
if (value === "MONTHLY") return "monthly";
|
|
841
|
+
if (value === "YEARLY") return "yearly";
|
|
842
|
+
return "custom";
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function rruleForRepeat(repeat) {
|
|
846
|
+
if (repeat === "daily") return "FREQ=DAILY";
|
|
847
|
+
if (repeat === "weekly") return "FREQ=WEEKLY";
|
|
848
|
+
if (repeat === "monthly") return "FREQ=MONTHLY";
|
|
849
|
+
if (repeat === "yearly") return "FREQ=YEARLY";
|
|
850
|
+
return "";
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** 草稿 → 后端字段(全天走 DATE,定时走本地时间换算出的 UTC 秒级 ISO)。 */
|
|
854
|
+
function fieldsOfDraft(draft) {
|
|
855
|
+
const fields = { summary: draft.summary.trim(), location: draft.location.trim(), description: draft.description.trim() };
|
|
856
|
+
if (draft.allDay === true) {
|
|
857
|
+
fields.start = draft.date;
|
|
858
|
+
fields.end = dayKey(addDays(new Date(draft.date + "T00:00:00"), 1));
|
|
859
|
+
fields.allDay = true;
|
|
860
|
+
} else {
|
|
861
|
+
const start = new Date(draft.date + "T" + (draft.startTime || "00:00") + ":00");
|
|
862
|
+
const end = new Date(draft.date + "T" + (draft.endTime || "00:00") + ":00");
|
|
863
|
+
fields.start = isoOf(start);
|
|
864
|
+
fields.end = isoOf(end);
|
|
865
|
+
fields.allDay = false;
|
|
866
|
+
}
|
|
867
|
+
const rrule = draft.repeat === "custom" ? (draft.rrule || "").trim() : rruleForRepeat(draft.repeat);
|
|
868
|
+
if (rrule !== "") fields.rrule = rrule;
|
|
869
|
+
else if (draft.uid !== undefined) fields.rrule = "";
|
|
870
|
+
return fields;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** 草稿算出来的临时事件,用来做冲突检测(不落盘)。 */
|
|
874
|
+
function asPseudoEvent(draft) {
|
|
875
|
+
const fields = fieldsOfDraft(draft);
|
|
876
|
+
return { uid: "__draft", summary: fields.summary || "(未命名)", start: fields.start, end: fields.end, allDay: draft.allDay === true };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function EventForm(props) {
|
|
880
|
+
const { draft, events, onCancel, onSubmit, busy, error } = props;
|
|
881
|
+
const [form, setForm] = useState(draft);
|
|
882
|
+
useEffect(() => { setForm(draft); }, [draft.uid, draft.date, draft.startTime, draft.summary]);
|
|
883
|
+
const patch = (fields) => setForm((prev) => Object.assign({}, prev, fields));
|
|
884
|
+
const isEdit = draft.uid !== undefined;
|
|
885
|
+
const pseudo = asPseudoEvent(form);
|
|
886
|
+
const conflicts = form.summary.trim() === "" ? [] : events.filter((event) => overlaps(event, pseudo) && event.uid !== form.uid);
|
|
887
|
+
const canSave = form.summary.trim() !== "" && (form.allDay === true || form.startTime <= form.endTime);
|
|
888
|
+
|
|
889
|
+
const body = h("div", null, [
|
|
890
|
+
error ? h("div", { className: "dshc-alert error" }, error) : null,
|
|
891
|
+
h("div", { className: "dshc-field" }, [
|
|
892
|
+
h("label", null, t("field.summary")),
|
|
893
|
+
h(Txt, { value: form.summary, autoFocus: true, onChange: (e) => patch({ summary: e.target.value }), placeholder: t("form.newTitle") })
|
|
894
|
+
]),
|
|
895
|
+
h("div", { className: "dshc-field" }, [
|
|
896
|
+
h("label", null, t("field.date")),
|
|
897
|
+
h(Txt, { type: "date", value: form.date, onChange: (e) => patch({ date: e.target.value }) })
|
|
898
|
+
]),
|
|
899
|
+
form.allDay === true ? null : h("div", { className: "dshc-two" }, [
|
|
900
|
+
h("div", { className: "dshc-field" }, [h("label", null, t("field.start")), h(Txt, { type: "time", value: form.startTime, onChange: (e) => patch({ startTime: e.target.value }) })]),
|
|
901
|
+
h("div", { className: "dshc-field" }, [h("label", null, t("field.end")), h(Txt, { type: "time", value: form.endTime, onChange: (e) => patch({ endTime: e.target.value }) })])
|
|
902
|
+
]),
|
|
903
|
+
h("div", { className: "dshc-field" }, [h(Chk, { checked: form.allDay === true, label: t("field.allDay"), onChange: (next) => patch({ allDay: next }) })]),
|
|
904
|
+
h("div", { className: "dshc-field" }, [
|
|
905
|
+
h("label", null, t("field.repeat")),
|
|
906
|
+
h("select", { className: "dshc-input", value: form.repeat, onChange: (e) => patch({ repeat: e.target.value }) }, [
|
|
907
|
+
h("option", { value: "", key: "none" }, t("repeat.none")),
|
|
908
|
+
h("option", { value: "daily", key: "d" }, t("repeat.daily")),
|
|
909
|
+
h("option", { value: "weekly", key: "w" }, t("repeat.weekly")),
|
|
910
|
+
h("option", { value: "monthly", key: "m" }, t("repeat.monthly")),
|
|
911
|
+
h("option", { value: "yearly", key: "y" }, t("repeat.yearly")),
|
|
912
|
+
h("option", { value: "custom", key: "c" }, t("repeat.custom"))
|
|
913
|
+
])
|
|
914
|
+
]),
|
|
915
|
+
form.repeat === "custom" ? h("div", { className: "dshc-field" }, [
|
|
916
|
+
h("label", null, "RRULE"),
|
|
917
|
+
h(Txt, { value: form.rrule, placeholder: "FREQ=WEEKLY;COUNT=10", onChange: (e) => patch({ rrule: e.target.value }) })
|
|
918
|
+
]) : null,
|
|
919
|
+
h("div", { className: "dshc-field" }, [h("label", null, t("field.location")), h(Txt, { value: form.location, onChange: (e) => patch({ location: e.target.value }) })]),
|
|
920
|
+
h("div", { className: "dshc-field" }, [
|
|
921
|
+
h("label", null, t("field.description")),
|
|
922
|
+
h("textarea", { className: "dshc-input", value: form.description, onChange: (e) => patch({ description: e.target.value }) })
|
|
923
|
+
]),
|
|
924
|
+
conflicts.length > 0 ? h("div", { className: "dshc-alert warn" }, [
|
|
925
|
+
h("div", null, [
|
|
926
|
+
h("div", null, t("conflict.title", { n: conflicts.length })),
|
|
927
|
+
conflicts.slice(0, 3).map((event) => h("div", { key: event.uid }, "\u2022 " + formatWhen(event) + " " + event.summary)),
|
|
928
|
+
h("div", { className: "dshc-sub" }, t("conflict.hint"))
|
|
929
|
+
])
|
|
930
|
+
]) : null
|
|
931
|
+
]);
|
|
932
|
+
|
|
933
|
+
const footer = h("div", { className: "dshc-actions" }, [
|
|
934
|
+
h(Btn, { key: "cancel", variant: "ghost", onClick: onCancel, disabled: busy === true }, t("action.cancel")),
|
|
935
|
+
h(Btn, { key: "save", variant: "primary", onClick: () => onSubmit(form), disabled: busy === true || canSave !== true }, busy === true ? "\u2026" : t("action.save"))
|
|
936
|
+
]);
|
|
937
|
+
|
|
938
|
+
return h(Dlg, {
|
|
939
|
+
open: true,
|
|
940
|
+
onClose: onCancel,
|
|
941
|
+
title: isEdit ? t("form.editTitle") : t("form.newTitle"),
|
|
942
|
+
children: body,
|
|
943
|
+
footer: footer
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ───────────────────────────── 详情抽屉 ─────────────────────────────
|
|
948
|
+
|
|
949
|
+
function EventDrawer(props) {
|
|
950
|
+
const { event, onClose, onEdit, onDelete, busy, confirmDelete, setConfirmDelete, events } = props;
|
|
951
|
+
const [copied, setCopied] = useState(false);
|
|
952
|
+
const seriesCount = event.isOccurrence ? events.filter((e) => e.seriesStart === event.seriesStart).length : 0;
|
|
953
|
+
return h("div", { className: "dshc-overlay", onMouseDown: (e) => { if (e.target === e.currentTarget) onClose(); } }, [
|
|
954
|
+
h("div", { className: "dshc-drawer", style: { position: "relative" } }, [
|
|
955
|
+
h("button", { type: "button", className: "dshc-btn ghost dshc-close", onClick: onClose, "aria-label": "close" }, "\u2715"),
|
|
956
|
+
h("div", { className: "dshc-drawertitle" }, event.summary),
|
|
957
|
+
h("div", { className: "dshc-sub" }, [
|
|
958
|
+
formatWhen(event),
|
|
959
|
+
h("span", { className: "dshc-tag" }, formatDuration(event))
|
|
960
|
+
].filter(Boolean)),
|
|
961
|
+
h("dl", { className: "dshc-kv" }, [
|
|
962
|
+
event.rrule ? h("dt", { key: "rdt" }, t("repeat.recurring")) : null,
|
|
963
|
+
event.rrule ? h("dd", { key: "rdd" }, humanRepeat(event.rrule) + " · " + event.rrule) : null,
|
|
964
|
+
event.isOccurrence && !event.rrule ? h("dt", { key: "odt" }, t("repeat.occurrence")) : null,
|
|
965
|
+
event.isOccurrence && !event.rrule ? h("dd", { key: "odd" }, (seriesCount > 0 ? seriesCount + " / " : "") + t("repeat.occurrence")) : null,
|
|
966
|
+
event.location ? h("dt", { key: "ldt" }, t("field.location")) : null,
|
|
967
|
+
event.location ? h("dd", { key: "ldd" }, event.location) : null,
|
|
968
|
+
event.description ? h("dt", { key: "ddt" }, t("field.description")) : null,
|
|
969
|
+
event.description ? h("dd", { key: "ddd", style: { whiteSpace: "pre-wrap" } }, event.description) : null,
|
|
970
|
+
h("dt", { key: "udt" }, t("field.uid")),
|
|
971
|
+
h("dd", { key: "udd" }, h("span", { className: "dshc-mono" }, event.uid))
|
|
972
|
+
].filter(Boolean)),
|
|
973
|
+
h("div", { className: "dshc-actions" }, [
|
|
974
|
+
h(Btn, { key: "copy", onClick: async () => { const ok = await copyText(event.uid); if (ok) { setCopied(true); setTimeout(() => setCopied(false), 1600); } } }, copied ? t("action.copied") : t("action.copy")),
|
|
975
|
+
h("span", { className: "dshc-spacer" }),
|
|
976
|
+
confirmDelete
|
|
977
|
+
? h(Btn, { key: "confirm", variant: "primary", disabled: busy === true, onClick: () => onDelete(event) }, t("action.confirmDelete"))
|
|
978
|
+
: h(Btn, { key: "delete", variant: "danger", onClick: () => setConfirmDelete(true), disabled: busy === true }, t("action.delete")),
|
|
979
|
+
h(Btn, { key: "edit", variant: "outline", onClick: () => onEdit(event), disabled: busy === true }, t("action.edit"))
|
|
980
|
+
]),
|
|
981
|
+
confirmDelete ? h("div", { className: "dshc-sub", style: { textAlign: "right", marginTop: "6px" } }, t("confirm.delete", { name: event.summary })) : null
|
|
982
|
+
])
|
|
983
|
+
]);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ───────────────────────────── 连接设置 ─────────────────────────────
|
|
987
|
+
|
|
988
|
+
/** 每个服务商要填哪些格子。顺序就是表单里的顺序。 */
|
|
989
|
+
const PROVIDER_FIELDS = {
|
|
990
|
+
google: ["calendarId", "clientId", "clientSecret", "refreshToken", "tokenUrl", "proxyUrl"],
|
|
991
|
+
icloud: ["caldavUrl", "username", "password", "proxyUrl"],
|
|
992
|
+
nextcloud: ["host", "user", "username", "calendar", "password", "proxyUrl"],
|
|
993
|
+
custom: ["caldavUrl", "username", "password", "proxyUrl"]
|
|
994
|
+
};
|
|
995
|
+
const FIELD_LABEL = {
|
|
996
|
+
caldavUrl: "conn.caldavUrl", username: "conn.username", password: "conn.password",
|
|
997
|
+
host: "conn.host", user: "conn.user", calendar: "conn.calendar", calendarId: "conn.calendarId",
|
|
998
|
+
clientId: "conn.clientId", clientSecret: "conn.clientSecret", refreshToken: "conn.refreshToken",
|
|
999
|
+
tokenUrl: "conn.tokenUrl", proxyUrl: "conn.proxyUrl"
|
|
1000
|
+
};
|
|
1001
|
+
const SECRET_FIELDS = ["password", "clientSecret", "refreshToken"];
|
|
1002
|
+
const PROVIDER_HINT = { google: "conn.googleHint", icloud: "conn.icloudHint", nextcloud: "conn.nextcloudHint", custom: "conn.customHint" };
|
|
1003
|
+
/** 字段级小字提示:provider -> 字段 -> 文案 key(没有就不显示)。 */
|
|
1004
|
+
const FIELD_HINT = {
|
|
1005
|
+
google: { calendarId: "hint.calendarId.google", refreshToken: "hint.refreshToken.google", clientId: "hint.clientId.google", tokenUrl: "hint.tokenUrl" },
|
|
1006
|
+
icloud: { caldavUrl: "hint.caldavUrl.icloud", username: "hint.username.icloud", password: "hint.password.icloud" },
|
|
1007
|
+
nextcloud: { host: "hint.host.nextcloud", user: "hint.user.nextcloud", username: "hint.username.nextcloud", calendar: "hint.calendar.nextcloud" },
|
|
1008
|
+
custom: { caldavUrl: "hint.caldavUrl.custom", username: "hint.username.custom" }
|
|
1009
|
+
};
|
|
1010
|
+
/** 「怎么拿到这些值」的步骤 + 外链(两边语言各一份文案,链接是共用的)。 */
|
|
1011
|
+
const PROVIDER_LINKS = {
|
|
1012
|
+
google: [
|
|
1013
|
+
{ href: "https://console.cloud.google.com/apis/credentials", key: "link.googleConsole" },
|
|
1014
|
+
{ href: "https://developers.google.com/oauthplayground", key: "link.googlePlayground" }
|
|
1015
|
+
],
|
|
1016
|
+
icloud: [{ href: "https://appleid.apple.com/account/manage", key: "link.appleId" }],
|
|
1017
|
+
nextcloud: [],
|
|
1018
|
+
custom: []
|
|
1019
|
+
};
|
|
1020
|
+
const PROVIDER_NAMES = ["google", "icloud", "nextcloud", "custom"];
|
|
1021
|
+
|
|
1022
|
+
/** 面板上的连接摘要 → 表单草稿。密钥一律留空,靠 placeholder 说明「已保存」。 */
|
|
1023
|
+
function connectionDraftOf(connection) {
|
|
1024
|
+
const out = { provider: "" };
|
|
1025
|
+
for (const key of Object.keys(FIELD_LABEL)) out[key] = "";
|
|
1026
|
+
if (connection) {
|
|
1027
|
+
for (const key of Object.keys(out)) {
|
|
1028
|
+
if (typeof connection[key] === "string") out[key] = connection[key];
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return out;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** 「当前:icloud · 你@icloud.com」里的那半句。 */
|
|
1035
|
+
function connectionSummary(connection) {
|
|
1036
|
+
if (connection === null || connection === undefined) return "";
|
|
1037
|
+
const provider = connection.provider || "custom";
|
|
1038
|
+
const account = connection.calendarId || connection.username || connection.user
|
|
1039
|
+
|| (connection.host ? String(connection.host).replace(/^https?:\/\//, "") : "")
|
|
1040
|
+
|| connection.caldavUrl || "";
|
|
1041
|
+
return account === "" ? provider : provider + " · " + account;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* 连接设置表单:填 → 测试 → 保存。保存前服务端还会再测一次(test:true),
|
|
1046
|
+
* 所以「保存成功」等价于「这套凭据刚才真的连上了」。
|
|
1047
|
+
*/
|
|
1048
|
+
function ConnectionForm(props) {
|
|
1049
|
+
const { connection, onClose, onSaved } = props;
|
|
1050
|
+
const [form, setForm] = useState(() => connectionDraftOf(connection));
|
|
1051
|
+
const [busy, setBusy] = useState("");
|
|
1052
|
+
const [result, setResult] = useState(null);
|
|
1053
|
+
const maySave = connection === null || connection.settingsAvailable !== false;
|
|
1054
|
+
const patch = (fields) => setForm((prev) => Object.assign({}, prev, fields));
|
|
1055
|
+
const visible = PROVIDER_FIELDS[form.provider] || [];
|
|
1056
|
+
const payload = () => {
|
|
1057
|
+
const out = {};
|
|
1058
|
+
for (const key of Object.keys(FIELD_LABEL)) {
|
|
1059
|
+
const value = form[key];
|
|
1060
|
+
if (typeof value !== "string") continue;
|
|
1061
|
+
// 密钥留空 = 保持已存的那一份;其余字段空串服务端也按「保持不变」处理。
|
|
1062
|
+
if (SECRET_FIELDS.indexOf(key) !== -1 && value.trim() === "") continue;
|
|
1063
|
+
out[key] = value.trim();
|
|
1064
|
+
}
|
|
1065
|
+
if (form.provider !== "") out.provider = form.provider;
|
|
1066
|
+
if (form.provider === "google") out.authMethod = "oauth";
|
|
1067
|
+
return out;
|
|
1068
|
+
};
|
|
1069
|
+
const test = async () => {
|
|
1070
|
+
setBusy("test");
|
|
1071
|
+
setResult(null);
|
|
1072
|
+
try {
|
|
1073
|
+
const value = await api("testConnection", payload());
|
|
1074
|
+
const sample = (value.sample || []).join("、");
|
|
1075
|
+
setResult({ ok: true, text: t("conn.ok", { n: value.count }) + (sample === "" ? "" : t("conn.okSample", { sample })) });
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
setResult({ ok: false, text: error && error.message ? error.message : String(error) });
|
|
1078
|
+
} finally {
|
|
1079
|
+
setBusy("");
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
const save = async () => {
|
|
1083
|
+
setBusy("save");
|
|
1084
|
+
setResult(null);
|
|
1085
|
+
try {
|
|
1086
|
+
onSaved(await api("saveConnection", Object.assign({}, payload(), { test: true })));
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
setResult({ ok: false, text: error && error.message ? error.message : String(error) });
|
|
1089
|
+
} finally {
|
|
1090
|
+
setBusy("");
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
const body = h("div", null, [
|
|
1094
|
+
h("div", { className: "dshc-sub", style: { marginBottom: "10px" } }, t("conn.intro")),
|
|
1095
|
+
maySave !== true
|
|
1096
|
+
? h("div", { className: "dshc-alert warn" }, t("conn.noSettings"))
|
|
1097
|
+
: null,
|
|
1098
|
+
h("div", { className: "dshc-field" }, [
|
|
1099
|
+
h("label", null, t("conn.provider")),
|
|
1100
|
+
h("select", { className: "dshc-input", value: form.provider, onChange: (e) => patch({ provider: e.target.value }) },
|
|
1101
|
+
[h("option", { value: "", key: "__none" }, "— " + t("conn.needProvider") + " —")].concat(
|
|
1102
|
+
PROVIDER_NAMES.map((name) => h("option", { value: name, key: name }, name === "custom" ? "自建 CalDAV" : name === "icloud" ? "iCloud" : name === "nextcloud" ? "Nextcloud" : "Google"))
|
|
1103
|
+
))
|
|
1104
|
+
]),
|
|
1105
|
+
PROVIDER_HINT[form.provider] ? h("div", { className: "dshc-sub", style: { marginBottom: "8px" } }, t(PROVIDER_HINT[form.provider])) : null,
|
|
1106
|
+
form.provider === "" ? null : h("details", { className: "dshc-steps" }, [
|
|
1107
|
+
h("summary", { key: "sum" }, t("conn.steps")),
|
|
1108
|
+
h("ol", { key: "list" }, (UI[UI_LANG]["steps." + form.provider] || []).map((line, index) => h("li", { key: "s" + index }, line))),
|
|
1109
|
+
(PROVIDER_LINKS[form.provider] || []).length === 0 ? null : h("div", { key: "links", style: { marginTop: "6px", display: "flex", gap: "10px", flexWrap: "wrap" } },
|
|
1110
|
+
(PROVIDER_LINKS[form.provider] || []).map((link) => h("a", { key: link.href, href: link.href, target: "_blank", rel: "noreferrer", className: "dshc-link" }, t(link.key))))
|
|
1111
|
+
]),
|
|
1112
|
+
visible.map((key) => {
|
|
1113
|
+
const secret = SECRET_FIELDS.indexOf(key) !== -1;
|
|
1114
|
+
const stored = connection !== null && connection !== undefined && (
|
|
1115
|
+
(key === "password" && connection.hasPassword === true)
|
|
1116
|
+
|| (key === "clientSecret" && connection.hasClientSecret === true)
|
|
1117
|
+
|| (key === "refreshToken" && connection.hasRefreshToken === true));
|
|
1118
|
+
const hintKey = FIELD_HINT[form.provider] ? FIELD_HINT[form.provider][key] : undefined;
|
|
1119
|
+
return h("div", { className: "dshc-field", key }, [
|
|
1120
|
+
h("label", null, t(FIELD_LABEL[key])),
|
|
1121
|
+
h(Txt, {
|
|
1122
|
+
type: secret ? "password" : "text",
|
|
1123
|
+
value: form[key] || "",
|
|
1124
|
+
placeholder: stored ? t("conn.keep") : "",
|
|
1125
|
+
onChange: (e) => patch({ [key]: e.target.value })
|
|
1126
|
+
}),
|
|
1127
|
+
hintKey ? h("div", { className: "dshc-sub", style: { marginTop: "3px" } }, t(hintKey)) : null
|
|
1128
|
+
]);
|
|
1129
|
+
}),
|
|
1130
|
+
result !== null
|
|
1131
|
+
? h("div", { className: "dshc-alert " + (result.ok === true ? "demo" : "error") }, (result.ok === true ? "✓ " : t("conn.fail") + ":") + result.text)
|
|
1132
|
+
: null,
|
|
1133
|
+
h("div", { className: "dshc-actions" }, [
|
|
1134
|
+
h(Btn, { key: "cancel", onClick: onClose, disabled: busy !== "" }, t("action.cancel")),
|
|
1135
|
+
h(Btn, { key: "test", onClick: test, disabled: busy !== "" || form.provider === "" }, busy === "test" ? t("conn.testing") : t("conn.test")),
|
|
1136
|
+
h(Btn, { key: "save", variant: "primary", onClick: save, disabled: busy !== "" || form.provider === "" || maySave !== true }, busy === "save" ? t("conn.saving") : t("conn.save"))
|
|
1137
|
+
]),
|
|
1138
|
+
maySave !== true || (connection !== null && connection !== undefined && connection.settingsKind === "file")
|
|
1139
|
+
? h("div", { className: "dshc-sub", style: { marginTop: "10px" } },
|
|
1140
|
+
connection !== null && connection !== undefined && connection.settingsKind === "file"
|
|
1141
|
+
? t("conn.storageFallback", { reason: connection.settingsReason || "插件未注入宿主 settings 服务" })
|
|
1142
|
+
: t("conn.current", { summary: connectionSummary(connection) }))
|
|
1143
|
+
: null
|
|
1144
|
+
]);
|
|
1145
|
+
return h(Dlg, { open: true, onClose, title: t("conn.title"), children: body });
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// ───────────────────────────── 主面板 ─────────────────────────────
|
|
1149
|
+
|
|
1150
|
+
function CalendarPanel(props) {
|
|
1151
|
+
const compact = props.compact === true;
|
|
1152
|
+
const [view, setView] = useState(loadStoredView);
|
|
1153
|
+
const [anchor, setAnchor] = useState(() => new Date());
|
|
1154
|
+
const [demo, setDemo] = useState(false);
|
|
1155
|
+
const [reloadToken, setReloadToken] = useState(0);
|
|
1156
|
+
const [selected, setSelected] = useState(null);
|
|
1157
|
+
const [draft, setDraft] = useState(null);
|
|
1158
|
+
const [busy, setBusy] = useState(false);
|
|
1159
|
+
const [formError, setFormError] = useState(null);
|
|
1160
|
+
const [banner, setBanner] = useState(null);
|
|
1161
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
1162
|
+
|
|
1163
|
+
const [configured, setConfigured] = useState(null);
|
|
1164
|
+
const [conn, setConn] = useState(null);
|
|
1165
|
+
const [connOpen, setConnOpen] = useState(false);
|
|
1166
|
+
useEffect(() => {
|
|
1167
|
+
let alive = true;
|
|
1168
|
+
api("connection", {}).then((value) => { if (alive) { setConn(value); setConfigured(value && value.configured !== false); } })
|
|
1169
|
+
.catch(() => { if (alive) setConfigured(true); }); // 状态都问不到时按「已配置」处理,让错误条去解释
|
|
1170
|
+
return () => { alive = false; };
|
|
1171
|
+
}, [reloadToken]);
|
|
1172
|
+
|
|
1173
|
+
const range = useMemo(() => rangeOf(view, anchor), [view, anchor.getFullYear(), anchor.getMonth(), anchor.getDate()]);
|
|
1174
|
+
const data = useCalendarData(range, reloadToken, demo);
|
|
1175
|
+
const reload = () => setReloadToken((n) => n + 1);
|
|
1176
|
+
|
|
1177
|
+
const notify = (text) => { setBanner(text); setTimeout(() => setBanner((current) => (current === text ? null : current)), 2200); };
|
|
1178
|
+
|
|
1179
|
+
const title = view === "week" ? formatWeekTitle(anchor) : view === "agenda" ? t("view.agenda") : formatMonthTitle(anchor);
|
|
1180
|
+
const step = (delta) => setAnchor((current) => (view === "week" ? addDays(current, delta * 7) : addMonthShift(current, delta)));
|
|
1181
|
+
|
|
1182
|
+
const submit = async (form) => {
|
|
1183
|
+
setBusy(true);
|
|
1184
|
+
setFormError(null);
|
|
1185
|
+
try {
|
|
1186
|
+
const fields = fieldsOfDraft(form);
|
|
1187
|
+
if (form.uid === undefined) await api("create", fields);
|
|
1188
|
+
else await api("update", Object.assign({ uid: form.uid }, fields));
|
|
1189
|
+
setDraft(null);
|
|
1190
|
+
setSelected(null);
|
|
1191
|
+
reload();
|
|
1192
|
+
notify(form.uid === undefined ? "已创建" : "已保存");
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
setFormError(error && error.message ? error.message : String(error));
|
|
1195
|
+
} finally {
|
|
1196
|
+
setBusy(false);
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
const remove = async (event) => {
|
|
1201
|
+
setBusy(true);
|
|
1202
|
+
try {
|
|
1203
|
+
await api("delete", { uid: event.uid });
|
|
1204
|
+
setSelected(null);
|
|
1205
|
+
setConfirmDelete(false);
|
|
1206
|
+
reload();
|
|
1207
|
+
notify("已删除");
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
setBanner(error && error.message ? error.message : String(error));
|
|
1210
|
+
} finally {
|
|
1211
|
+
setBusy(false);
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const toolbar = h("div", { className: "dshc-toolbar" }, [
|
|
1216
|
+
h("h3", { className: "dshc-title", key: "title" }, t("section.title")),
|
|
1217
|
+
h("div", { className: "dshc-tabs", key: "tabs" }, VIEWS.map((name) => h("button", {
|
|
1218
|
+
key: name, type: "button", className: "dshc-tab" + (view === name ? " on" : ""),
|
|
1219
|
+
onClick: () => { setView(name); storeView(name); }
|
|
1220
|
+
}, t("view." + name)))),
|
|
1221
|
+
h("span", { className: "dshc-spacer", key: "spacer" }),
|
|
1222
|
+
h("span", { className: "dshc-monthhead", key: "period" }, title),
|
|
1223
|
+
h(Btn, { key: "prev", onClick: () => step(-1), "aria-label": t("action.prev") }, "\u2039"),
|
|
1224
|
+
h(Btn, { key: "today", onClick: () => setAnchor(new Date()) }, t("action.today")),
|
|
1225
|
+
h(Btn, { key: "next", onClick: () => step(1), "aria-label": t("action.next") }, "\u203a"),
|
|
1226
|
+
h(Btn, { key: "conn", onClick: () => setConnOpen(true) }, t("conn.button")),
|
|
1227
|
+
demo === true
|
|
1228
|
+
? h(Btn, { key: "exitdemo", onClick: () => setDemo(false) }, t("action.exitDemo"))
|
|
1229
|
+
: h(Btn, { key: "reload", onClick: reload }, t("action.refresh")),
|
|
1230
|
+
h(Btn, { key: "new", variant: "primary", onClick: () => setDraft(draftAt(anchor)) }, t("action.new"))
|
|
1231
|
+
]);
|
|
1232
|
+
|
|
1233
|
+
const body = () => {
|
|
1234
|
+
if (demo === false && configured === false) {
|
|
1235
|
+
return h("div", { className: "dshc-empty" }, [
|
|
1236
|
+
h("h3", null, t("state.notConfigured")),
|
|
1237
|
+
h("p", null, t("state.notConfiguredHint")),
|
|
1238
|
+
h("div", { style: { marginTop: "12px", display: "flex", gap: "8px", justifyContent: "center" } }, [
|
|
1239
|
+
h(Btn, { key: "cfg", variant: "primary", onClick: () => setConnOpen(true) }, t("conn.button")),
|
|
1240
|
+
h(Btn, { key: "demo", onClick: () => setDemo(true) }, t("action.demo"))
|
|
1241
|
+
])
|
|
1242
|
+
]);
|
|
1243
|
+
}
|
|
1244
|
+
if (configured === null) return h("div", { className: "dshc-grid" }, Array.from({ length: 7 }, (_, i) => h("div", { className: "dshc-skel", key: i })));
|
|
1245
|
+
if (data.loading === true && data.events.length === 0) {
|
|
1246
|
+
return h("div", { className: "dshc-grid" }, Array.from({ length: 14 }, (_, i) => h("div", { className: "dshc-skel", key: i })));
|
|
1247
|
+
}
|
|
1248
|
+
if (view === "week") return h(WeekView, { anchor, events: data.events, onPick: openEvent, onCreateAt: createAt });
|
|
1249
|
+
if (view === "agenda") return h(AgendaView, { events: data.events, onPick: openEvent, onCreateAt: createAt });
|
|
1250
|
+
return h(MonthView, { anchor, events: data.events, onPick: openEvent, onCreateAt: createAt });
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
const openEvent = (event) => { setConfirmDelete(false); setSelected(event); };
|
|
1254
|
+
const createAt = (day, hour) => setDraft(draftAt(day, hour));
|
|
1255
|
+
|
|
1256
|
+
return h("div", { className: "dshc-root" }, [
|
|
1257
|
+
h("div", { className: "dshc-shell" }, [
|
|
1258
|
+
banner !== null ? h("div", { className: "dshc-alert demo" }, banner) : null,
|
|
1259
|
+
demo === true ? h("div", { className: "dshc-alert demo" }, t("demo.banner")) : null,
|
|
1260
|
+
data.error !== null && data.error !== undefined && demo === false && configured !== false
|
|
1261
|
+
? h("div", { className: "dshc-alert error" }, [
|
|
1262
|
+
h("span", null, (data.error.message || t("error.load"))),
|
|
1263
|
+
h("span", { className: "dshc-spacer" }),
|
|
1264
|
+
h(Btn, { onClick: reload }, t("action.retry"))
|
|
1265
|
+
])
|
|
1266
|
+
: null,
|
|
1267
|
+
compact === true ? toolbar : h("div", null, [toolbar]),
|
|
1268
|
+
body(),
|
|
1269
|
+
h("div", { className: "dshc-footer" }, [
|
|
1270
|
+
h("span", null, t("count.events", { n: data.events.length }) + (data.events.some((e) => e.isOccurrence) ? " · " + t("count.occurrences", { n: data.events.filter((e) => e.isOccurrence).length }) : "")),
|
|
1271
|
+
h("span", null, demo === true ? "" : (conn !== null && conn !== undefined && conn.configured === true ? t("conn.current", { summary: connectionSummary(conn) }) : ""))
|
|
1272
|
+
])
|
|
1273
|
+
]),
|
|
1274
|
+
selected !== null
|
|
1275
|
+
? h(EventDrawer, {
|
|
1276
|
+
event: selected,
|
|
1277
|
+
events: data.events,
|
|
1278
|
+
busy,
|
|
1279
|
+
confirmDelete,
|
|
1280
|
+
setConfirmDelete,
|
|
1281
|
+
onClose: () => { setSelected(null); setConfirmDelete(false); },
|
|
1282
|
+
onEdit: (event) => { setSelected(null); setDraft(draftOfEvent(event)); },
|
|
1283
|
+
onDelete: remove
|
|
1284
|
+
})
|
|
1285
|
+
: null,
|
|
1286
|
+
draft !== null
|
|
1287
|
+
? h(EventForm, { draft, events: data.events, busy, error: formError, onCancel: () => { setDraft(null); setFormError(null); }, onSubmit: submit })
|
|
1288
|
+
: null,
|
|
1289
|
+
connOpen === true
|
|
1290
|
+
? h(ConnectionForm, {
|
|
1291
|
+
connection: conn,
|
|
1292
|
+
onClose: () => setConnOpen(false),
|
|
1293
|
+
onSaved: (value) => {
|
|
1294
|
+
if (value && value.connection) setConn(value.connection);
|
|
1295
|
+
setConnOpen(false);
|
|
1296
|
+
setDemo(false);
|
|
1297
|
+
reload();
|
|
1298
|
+
notify(t("conn.savedToast"));
|
|
1299
|
+
}
|
|
1300
|
+
})
|
|
1301
|
+
: null
|
|
1302
|
+
]);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** 月份平移:先跳到本月 1 号再加月,避免 1 月 31 日 +1 个月变成 3 月 3 日。 */
|
|
1306
|
+
function addMonthShift(date, delta) {
|
|
1307
|
+
const first = new Date(date.getFullYear(), date.getMonth(), 1);
|
|
1308
|
+
const shifted = new Date(first.getFullYear(), first.getMonth() + delta, 1);
|
|
1309
|
+
const lastDay = new Date(shifted.getFullYear(), shifted.getMonth() + 1, 0).getDate();
|
|
1310
|
+
return new Date(shifted.getFullYear(), shifted.getMonth(), Math.min(date.getDate(), lastDay));
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/** 设置页里的那一节。 */
|
|
1314
|
+
function CalendarSection() {
|
|
1315
|
+
return h("div", null, [
|
|
1316
|
+
h("h2", null, t("section.title")),
|
|
1317
|
+
h("p", { className: "dshc-sub" }, t("section.intro")),
|
|
1318
|
+
h(CalendarPanel, {})
|
|
1319
|
+
]);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** 对话页右下角的小按钮 + 悬浮面板(自己挂 root,不占用宿主槽位)。 */
|
|
1323
|
+
function Launcher() {
|
|
1324
|
+
const [open, setOpen] = useState(false);
|
|
1325
|
+
useEffect(() => {
|
|
1326
|
+
if (open !== true) return () => {};
|
|
1327
|
+
const onKey = (event) => { if (event.key === "Escape") setOpen(false); };
|
|
1328
|
+
document.addEventListener("keydown", onKey);
|
|
1329
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
1330
|
+
}, [open]);
|
|
1331
|
+
const calendarIcon = h("svg", { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" }, [
|
|
1332
|
+
h("rect", { key: "r", x: 3, y: 5, width: 18, height: 16, rx: 3 }),
|
|
1333
|
+
h("path", { key: "l", d: "M3 10h18M8 3v4M16 3v4" })
|
|
1334
|
+
]);
|
|
1335
|
+
return h("div", null, [
|
|
1336
|
+
h("button", {
|
|
1337
|
+
type: "button",
|
|
1338
|
+
className: "dshc-fab" + (open === true ? " on" : ""),
|
|
1339
|
+
title: t("nav.title"),
|
|
1340
|
+
"aria-label": t("nav.title"),
|
|
1341
|
+
onClick: () => setOpen((value) => !value)
|
|
1342
|
+
}, calendarIcon),
|
|
1343
|
+
open === true ? h("div", { className: "dshc-float" }, h(CalendarPanel, { compact: true })) : null
|
|
1344
|
+
]);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// ───────────────────────────── 挂载 ─────────────────────────────
|
|
1348
|
+
|
|
1349
|
+
const inject = ["slots", "locale"];
|
|
1350
|
+
|
|
1351
|
+
function apply(ctx) {
|
|
1352
|
+
// 语言要在任何 t() 之前定下来(与 dsh-email 同款契约)。
|
|
1353
|
+
try { ctx.effect(() => subscribeLocale(ctx), "dsh-calendar: locale"); } catch (e) { /* 没有 effect 的宿主不该拖垮面板 */ }
|
|
1354
|
+
|
|
1355
|
+
try {
|
|
1356
|
+
ctx.effect(() => {
|
|
1357
|
+
const id = "dsh-calendar/client";
|
|
1358
|
+
if (document.querySelector('style[data-plugin-css="' + id + '"]')) return () => {};
|
|
1359
|
+
const style = document.createElement("style");
|
|
1360
|
+
style.dataset.plugin = "dsh-calendar";
|
|
1361
|
+
style.dataset.pluginCss = id;
|
|
1362
|
+
style.textContent = CSS + CSS_EXTRA;
|
|
1363
|
+
document.head.appendChild(style);
|
|
1364
|
+
return () => { style.remove(); };
|
|
1365
|
+
}, "dsh-calendar: styles");
|
|
1366
|
+
} catch (e) { /* 无 DOM 环境(纯 node 侧冒烟)时跳过 */ }
|
|
1367
|
+
|
|
1368
|
+
try {
|
|
1369
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
1370
|
+
name: "settings.section",
|
|
1371
|
+
id: "dsh-calendar",
|
|
1372
|
+
order: 50,
|
|
1373
|
+
label: () => t("nav.title"),
|
|
1374
|
+
inject: () => ({})
|
|
1375
|
+
}, CalendarSection));
|
|
1376
|
+
} catch (e) { /* 没有 slots 服务:仍是合法的插件,只是没有设置页 */ }
|
|
1377
|
+
|
|
1378
|
+
try {
|
|
1379
|
+
ctx.effect(() => mountLauncher(), "dsh-calendar: floating panel");
|
|
1380
|
+
} catch (e) { /* 同上 */ }
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** 把悬浮面板挂到页面上;已经挂过(HMR / 重复 apply)就复用。 */
|
|
1384
|
+
function mountLauncher() {
|
|
1385
|
+
if (typeof document === "undefined" || document.body === null) return () => {};
|
|
1386
|
+
if (document.getElementById("dsh-calendar-root") !== null) return () => {};
|
|
1387
|
+
const host = document.createElement("div");
|
|
1388
|
+
host.id = "dsh-calendar-root";
|
|
1389
|
+
document.body.appendChild(host);
|
|
1390
|
+
let root = null;
|
|
1391
|
+
try {
|
|
1392
|
+
const ReactDomClient = require("react-dom/client");
|
|
1393
|
+
root = ReactDomClient.createRoot(host);
|
|
1394
|
+
root.render(h(Launcher, {}));
|
|
1395
|
+
} catch (e) {
|
|
1396
|
+
// 老宿主没有 react-dom/client 时不要静默留下一个空气泡:整个按钮一起撤掉。
|
|
1397
|
+
host.remove();
|
|
1398
|
+
return () => {};
|
|
1399
|
+
}
|
|
1400
|
+
return () => {
|
|
1401
|
+
try { if (root !== null) root.unmount(); } catch (e) { /* 卸载失败也要把节点摘掉 */ }
|
|
1402
|
+
host.remove();
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
relocalize = () => { /* 面板是 React 树,语言切换由 subscribeLocale 触发的重渲染覆盖 */ };
|
|
1407
|
+
|
|
1408
|
+
exports.apply = apply;
|
|
1409
|
+
exports.inject = inject;
|
|
1410
|
+
|
|
1411
|
+
return module.exports;
|
|
1412
|
+
}});
|