@waterplus-ai/waterbuddy 0.1.12 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/lib/client/index.js +423 -35
- package/lib/host/index.js +184 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,9 +8,18 @@ unchanged.
|
|
|
8
8
|
|
|
9
9
|
- WaterBuddy branding, favicon, and page title.
|
|
10
10
|
- Water-industry assistant persona for internal users.
|
|
11
|
-
-
|
|
11
|
+
- Login panel with two modes: WaterPlus "水务之窗" QR-code login and WaterPlus account login.
|
|
12
|
+
- Account registration (email verification code) and password reset inside the same panel.
|
|
12
13
|
- User profile and avatar display after login.
|
|
13
|
-
- Xiaojia MCP access with the current
|
|
14
|
+
- Xiaojia MCP access with the current session token attached by a local proxy.
|
|
15
|
+
|
|
16
|
+
## Sign-in modes
|
|
17
|
+
|
|
18
|
+
The sidebar user entry opens one panel with `扫码登录` and `账号登录` tabs. The account tab mirrors the
|
|
19
|
+
WaterPlus web sign-in contract: account and password login, `注册账号` with an emailed verification
|
|
20
|
+
code, and `忘记密码?` password reset. The client only talks to local `/api/waterbuddy/*` routes; the
|
|
21
|
+
Host performs every upstream call and keeps the token in process memory, so no credential or token
|
|
22
|
+
reaches the browser bundle.
|
|
14
23
|
|
|
15
24
|
## Install
|
|
16
25
|
|
package/lib/client/index.js
CHANGED
|
@@ -63,14 +63,362 @@ window.__ModuleLoader__.load({
|
|
|
63
63
|
: jsx('span', { style: { ...style, display: 'grid', placeItems: 'center', background: 'linear-gradient(135deg, #1d9bf0, #2563eb)', color: '#fff', fontSize: Math.max(14, size * 0.42), fontWeight: 700 }, children: user ? name.slice(0, 1) : '🔐' })
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function plainInputStyle() {
|
|
67
|
+
return { minWidth: 0, flex: '1 1 auto', height: 46, border: 0, outline: 'none', background: 'transparent', color: '#162033', fontSize: 14 }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fieldIcon(text) {
|
|
71
|
+
return jsx('span', { 'aria-hidden': 'true', style: { flex: '0 0 auto', width: 18, textAlign: 'center', color: '#9aa5b4', fontSize: 13 }, children: text })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function errorBox(message, onRetry) {
|
|
75
|
+
return jsxs('div', { role: 'alert', style: { display: 'flex', alignItems: 'flex-start', gap: 8, marginTop: 12, padding: '9px 12px', borderRadius: 10, background: '#fef2f2', border: '1px solid #fecaca', color: '#b91c1c', fontSize: 12.5, lineHeight: '18px', textAlign: 'left' }, children: [
|
|
76
|
+
jsx('span', { 'aria-hidden': 'true', style: { flex: '0 0 auto' }, children: '⚠' }),
|
|
77
|
+
jsx('span', { style: { minWidth: 0, flex: '1 1 auto', wordBreak: 'break-word' }, children: message }),
|
|
78
|
+
onRetry ? jsx('button', { type: 'button', onClick: onRetry, style: { flex: '0 0 auto', border: 0, background: 'transparent', color: '#b91c1c', fontSize: 12.5, fontWeight: 600, cursor: 'pointer', textDecoration: 'underline', padding: 0 }, children: '重试' }) : null,
|
|
79
|
+
] })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function primaryButtonStyle(disabled) {
|
|
83
|
+
return { width: '100%', height: 46, border: 0, borderRadius: 10, background: disabled ? '#9dc2e8' : '#1677d2', color: '#fff', fontSize: 15, fontWeight: 600, cursor: disabled ? 'not-allowed' : 'pointer', transition: 'background .15s ease' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function linkButtonStyle() {
|
|
87
|
+
return { border: 0, background: 'transparent', color: '#1677d2', fontSize: 12.5, cursor: 'pointer', padding: 0 }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function agreementNotice() {
|
|
91
|
+
const linkStyle = { color: '#1677d2', textDecoration: 'none' }
|
|
92
|
+
return jsxs('div', { style: { display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: 7, marginTop: 16, color: '#8a94a6', fontSize: 11.5, lineHeight: '17px', textAlign: 'left' }, children: [
|
|
93
|
+
jsx('input', { type: 'checkbox', checked: true, readOnly: true, tabIndex: -1, 'aria-label': '已阅读并同意服务协议和隐私政策', style: { flex: '0 0 auto', width: 13, height: 13, margin: '2px 0 0', accentColor: '#1677d2' } }),
|
|
94
|
+
jsxs('span', { style: { minWidth: 0 }, children: [
|
|
95
|
+
'已阅读并同意',
|
|
96
|
+
jsx('a', { href: 'https://ai.shuiwujia.com/agreement', target: '_blank', rel: 'noreferrer', style: linkStyle, children: '《服务协议》' }),
|
|
97
|
+
'和',
|
|
98
|
+
jsx('a', { href: 'https://ai.shuiwujia.com/privacy', target: '_blank', rel: 'noreferrer', style: linkStyle, children: '《隐私政策》' }),
|
|
99
|
+
] }),
|
|
100
|
+
] })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function passwordField(extra) {
|
|
104
|
+
return { display: 'flex', alignItems: 'center', gap: 8, height: 48, padding: '0 12px', border: '1px solid #e3e8ef', borderRadius: 10, background: '#f7f8fa', boxSizing: 'border-box', ...(extra || {}) }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function plainInputStyle() {
|
|
108
|
+
return { minWidth: 0, flex: '1 1 auto', height: 46, border: 0, outline: 'none', background: 'transparent', color: '#162033', fontSize: 14 }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fieldIcon(text) {
|
|
112
|
+
return jsx('span', { 'aria-hidden': 'true', style: { flex: '0 0 auto', width: 18, textAlign: 'center', color: '#9aa5b4', fontSize: 13 }, children: text })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function errorBox(message) {
|
|
116
|
+
return jsxs('div', { role: 'alert', style: { display: 'flex', alignItems: 'flex-start', gap: 8, padding: '9px 12px', borderRadius: 10, background: '#fef2f2', border: '1px solid #fecaca', color: '#b91c1c', fontSize: 12.5, lineHeight: '18px', textAlign: 'left' }, children: [
|
|
117
|
+
jsx('span', { 'aria-hidden': 'true', style: { flex: '0 0 auto' }, children: '⚠' }),
|
|
118
|
+
jsx('span', { style: { minWidth: 0, flex: '1 1 auto', wordBreak: 'break-word' }, children: message }),
|
|
119
|
+
] })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function primaryButtonStyle(disabled) {
|
|
123
|
+
return { width: '100%', height: 46, border: 0, borderRadius: 10, background: disabled ? '#9dc2e8' : '#1677d2', color: '#fff', fontSize: 15, fontWeight: 600, cursor: disabled ? 'not-allowed' : 'pointer', transition: 'background .15s ease' }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function linkButtonStyle() {
|
|
127
|
+
return { border: 0, background: 'transparent', color: '#1677d2', fontSize: 12.5, cursor: 'pointer', padding: 0 }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function agreementNotice() {
|
|
131
|
+
const linkStyle = { color: '#1677d2', textDecoration: 'none' }
|
|
132
|
+
return jsxs('div', { style: { display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: 7, color: '#8a94a6', fontSize: 11.5, lineHeight: '17px', textAlign: 'left' }, children: [
|
|
133
|
+
jsx('input', { type: 'checkbox', checked: true, readOnly: true, tabIndex: -1, 'aria-label': '已阅读并同意服务协议和隐私政策', style: { flex: '0 0 auto', width: 13, height: 13, margin: '2px 0 0', accentColor: '#1677d2' } }),
|
|
134
|
+
jsxs('span', { style: { minWidth: 0 }, children: [
|
|
135
|
+
'已阅读并同意',
|
|
136
|
+
jsx('a', { href: 'https://ai.shuiwujia.com/agreement', target: '_blank', rel: 'noreferrer', style: linkStyle, children: '《服务协议》' }),
|
|
137
|
+
'和',
|
|
138
|
+
jsx('a', { href: 'https://ai.shuiwujia.com/privacy', target: '_blank', rel: 'noreferrer', style: linkStyle, children: '《隐私政策》' }),
|
|
139
|
+
] }),
|
|
140
|
+
] })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function useCountdown() {
|
|
144
|
+
const { useEffect, useRef, useState } = require('react')
|
|
145
|
+
const [countdown, setCountdown] = useState(0)
|
|
146
|
+
const timer = useRef(null)
|
|
147
|
+
const stop = () => {
|
|
148
|
+
if (timer.current !== null) { clearInterval(timer.current); timer.current = null }
|
|
149
|
+
}
|
|
150
|
+
const start = () => {
|
|
151
|
+
stop()
|
|
152
|
+
setCountdown(60)
|
|
153
|
+
timer.current = setInterval(() => {
|
|
154
|
+
setCountdown(value => {
|
|
155
|
+
if (value <= 1) { stop(); return 0 }
|
|
156
|
+
return value - 1
|
|
157
|
+
})
|
|
158
|
+
}, 1000)
|
|
159
|
+
}
|
|
160
|
+
useEffect(() => stop, [])
|
|
161
|
+
return [countdown, start]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function QrLogin({ session, onRestart, onClose }) {
|
|
165
|
+
const { qrCode, status, error, loading } = session
|
|
166
|
+
const statusText = { WAITING: '等待扫码', DEALING: '已扫码,请在手机上确认', SUCCESS: '登录成功', EXPIRED: '二维码已过期' }[status] || status
|
|
167
|
+
const tone = error ? '#dc2626' : status === 'SUCCESS' ? '#16a34a' : '#64748b'
|
|
168
|
+
return jsxs('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }, children: [
|
|
169
|
+
jsx('div', { style: { fontSize: 13.5, color: '#7b8799' }, children: `使用${SSO.title}扫码,安全登录 WaterBuddy` }),
|
|
170
|
+
qrCode
|
|
171
|
+
? jsx('img', { src: qrCode, alt: `${SSO.title}登录二维码`, style: { display: 'block', width: 206, height: 206, boxSizing: 'border-box', objectFit: 'contain', margin: '14px auto 10px', padding: 10, border: '1px solid #e5ebf3', borderRadius: 16, background: '#fff' } })
|
|
172
|
+
: jsx('div', { style: { width: 206, height: 206, boxSizing: 'border-box', margin: '14px auto 10px', display: 'grid', placeItems: 'center', border: '1px solid #e5ebf3', borderRadius: 16, background: '#f8fafc', color: '#7b8799', fontSize: 13 }, children: loading ? '二维码加载中…' : '二维码暂不可用' }),
|
|
173
|
+
jsx('div', { style: { minHeight: 22, color: tone, fontSize: 13 }, children: error || statusText || '请扫码登录' }),
|
|
174
|
+
jsxs('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', marginTop: 14 }, children: [
|
|
175
|
+
jsx('button', { type: 'button', onClick: onRestart, disabled: loading, style: { height: 38, padding: '0 20px', border: 0, borderRadius: 10, background: '#1677d2', color: '#fff', fontSize: 13, fontWeight: 600, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? .65 : 1 }, children: '刷新二维码' }),
|
|
176
|
+
jsx('button', { type: 'button', onClick: onClose, style: { height: 38, padding: '0 20px', border: '1px solid #d9e1ec', borderRadius: 10, background: '#fff', color: '#475569', fontSize: 13, fontWeight: 600, cursor: 'pointer' }, children: '关闭' }),
|
|
177
|
+
] }),
|
|
178
|
+
] })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function postAuth(path, payload) {
|
|
182
|
+
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
183
|
+
const body = await response.json().catch(() => ({}))
|
|
184
|
+
if (!response.ok) throw new Error(body.error || '请求失败,请重试')
|
|
185
|
+
return body
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function VerificationCodeField({ name, emailRef, sendPath, onError, onNotice }) {
|
|
189
|
+
const { useRef, useState } = require('react')
|
|
190
|
+
const codeRef = useRef(null)
|
|
191
|
+
const [countdown, startCountdown] = useCountdown()
|
|
192
|
+
const [sending, setSending] = useState(false)
|
|
193
|
+
|
|
194
|
+
const send = async () => {
|
|
195
|
+
const email = emailRef.current?.value?.trim() || ''
|
|
196
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return onError('请输入正确的邮箱')
|
|
197
|
+
setSending(true)
|
|
198
|
+
onError('')
|
|
199
|
+
if (onNotice) onNotice('')
|
|
200
|
+
try {
|
|
201
|
+
const body = await postAuth(sendPath, { email })
|
|
202
|
+
startCountdown()
|
|
203
|
+
if (onNotice) onNotice(body.message || '验证码已发送')
|
|
204
|
+
} catch (failure) {
|
|
205
|
+
onError(failure.message)
|
|
206
|
+
} finally {
|
|
207
|
+
setSending(false)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return jsxs('label', { style: passwordField({ padding: '0 6px 0 12px' }), children: [
|
|
212
|
+
fieldIcon('🛡'),
|
|
213
|
+
jsx('input', { ref: codeRef, name, inputMode: 'numeric', autoComplete: 'one-time-code', placeholder: '请输入验证码', style: plainInputStyle(), 'data-waterbuddy-code': 'true' }),
|
|
214
|
+
jsx('button', { type: 'button', disabled: countdown > 0 || sending, onClick: send, style: { flex: '0 0 auto', height: 36, padding: '0 14px', border: '1px solid #d9e1ec', borderRadius: 8, background: countdown > 0 || sending ? '#f1f5f9' : '#fff', color: countdown > 0 || sending ? '#9aa5b4' : '#1677d2', fontSize: 12.5, fontWeight: 600, cursor: countdown > 0 || sending ? 'not-allowed' : 'pointer' }, children: sending ? '发送中…' : countdown > 0 ? `${countdown}秒后重发` : '获取验证码' }),
|
|
215
|
+
] })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function AccountLoginForm({ onLogin, onSwitch }) {
|
|
219
|
+
const { useRef, useState } = require('react')
|
|
220
|
+
const loginId = useRef(null)
|
|
221
|
+
const password = useRef(null)
|
|
222
|
+
const [loading, setLoading] = useState(false)
|
|
223
|
+
const [error, setError] = useState('')
|
|
224
|
+
|
|
225
|
+
const submit = async event => {
|
|
226
|
+
if (event && event.preventDefault) event.preventDefault()
|
|
227
|
+
const id = loginId.current?.value?.trim() || ''
|
|
228
|
+
const secret = password.current?.value || ''
|
|
229
|
+
if (!id) return setError('请输入账号')
|
|
230
|
+
if (!secret) return setError('请输入密码')
|
|
231
|
+
setLoading(true); setError('')
|
|
232
|
+
try {
|
|
233
|
+
const body = await postAuth('/api/waterbuddy/auth/login', { loginId: id, password: secret })
|
|
234
|
+
return onLogin(body.user)
|
|
235
|
+
} catch (failure) {
|
|
236
|
+
setError(failure.message)
|
|
237
|
+
} finally {
|
|
238
|
+
setLoading(false)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return jsxs('form', { onSubmit: submit, style: { display: 'grid', gap: 14, textAlign: 'left' }, children: [
|
|
243
|
+
jsxs('label', { style: passwordField(), children: [
|
|
244
|
+
fieldIcon('👤'),
|
|
245
|
+
jsx('input', { ref: loginId, name: 'loginId', type: 'text', autoComplete: 'username', placeholder: '请输入账号', style: plainInputStyle(), onKeyDown: event => { if (event.key === 'Enter') submit(event) } }),
|
|
246
|
+
] }),
|
|
247
|
+
jsxs('label', { style: passwordField(), children: [
|
|
248
|
+
fieldIcon('🔒'),
|
|
249
|
+
jsx('input', { ref: password, name: 'password', type: 'password', autoComplete: 'current-password', placeholder: '请输入密码', style: plainInputStyle(), onKeyDown: event => { if (event.key === 'Enter') submit(event) } }),
|
|
250
|
+
] }),
|
|
251
|
+
jsx('div', { style: { marginTop: -4, textAlign: 'right' }, children: jsx('button', { type: 'button', onClick: () => onSwitch('password'), style: linkButtonStyle(), children: '忘记密码?' }) }),
|
|
252
|
+
error ? errorBox(error) : null,
|
|
253
|
+
jsx('button', { type: 'submit', disabled: loading, style: primaryButtonStyle(loading), children: loading ? '正在登录…' : '立即登录' }),
|
|
254
|
+
agreementNotice(),
|
|
255
|
+
jsxs('div', { style: { textAlign: 'center', color: '#8a94a6', fontSize: 12.5 }, children: [
|
|
256
|
+
'还没有账号?',
|
|
257
|
+
jsx('button', { type: 'button', onClick: () => onSwitch('register'), style: linkButtonStyle(), children: '注册账号' }),
|
|
258
|
+
] }),
|
|
259
|
+
] })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function RegisterForm({ onLogin, onSwitch }) {
|
|
263
|
+
const { useRef, useState } = require('react')
|
|
264
|
+
const email = useRef(null)
|
|
265
|
+
const code = useRef(null)
|
|
266
|
+
const password = useRef(null)
|
|
267
|
+
const name = useRef(null)
|
|
268
|
+
const [loading, setLoading] = useState(false)
|
|
269
|
+
const [error, setError] = useState('')
|
|
270
|
+
const [notice, setNotice] = useState('')
|
|
271
|
+
|
|
272
|
+
const onCodeError = message => setError(message || '')
|
|
273
|
+
const onCodeNotice = message => setNotice(message || '')
|
|
274
|
+
|
|
275
|
+
const submit = async event => {
|
|
276
|
+
if (event && event.preventDefault) event.preventDefault()
|
|
277
|
+
const mail = email.current?.value?.trim() || ''
|
|
278
|
+
const verification = code.current?.value?.trim() || ''
|
|
279
|
+
const secret = password.current?.value || ''
|
|
280
|
+
const displayName = name.current?.value?.trim() || ''
|
|
281
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(mail)) return setError('请输入正确的邮箱')
|
|
282
|
+
if (!/^\d{4,6}$/.test(verification)) return setError('验证码格式不正确')
|
|
283
|
+
if (!secret) return setError('请输入密码')
|
|
284
|
+
setLoading(true); setError(''); setNotice('')
|
|
285
|
+
try {
|
|
286
|
+
const body = await postAuth('/api/waterbuddy/auth/register', { email: mail, verifyCode: verification, password: secret, ...(displayName ? { name: displayName } : {}) })
|
|
287
|
+
return onLogin(body.user, body.message || '注册成功')
|
|
288
|
+
} catch (failure) {
|
|
289
|
+
setError(failure.message)
|
|
290
|
+
} finally {
|
|
291
|
+
setLoading(false)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return jsxs('form', { onSubmit: submit, style: { display: 'grid', gap: 11, textAlign: 'left' }, children: [
|
|
296
|
+
jsxs('label', { style: passwordField(), children: [
|
|
297
|
+
fieldIcon('✉'),
|
|
298
|
+
jsx('input', { ref: email, name: 'email', type: 'email', autoComplete: 'email', placeholder: '请输入邮箱', style: plainInputStyle() }),
|
|
299
|
+
] }),
|
|
300
|
+
jsx(VerificationCodeField, { name: 'verifyCode', emailRef: email, sendPath: '/api/waterbuddy/auth/register/code', onError: onCodeError, onNotice: onCodeNotice }),
|
|
301
|
+
jsxs('label', { style: passwordField(), children: [
|
|
302
|
+
fieldIcon('🔒'),
|
|
303
|
+
jsx('input', { ref: password, name: 'password', type: 'password', autoComplete: 'new-password', placeholder: '请输入密码', style: plainInputStyle() }),
|
|
304
|
+
] }),
|
|
305
|
+
jsxs('label', { style: passwordField(), children: [
|
|
306
|
+
fieldIcon('👤'),
|
|
307
|
+
jsx('input', { ref: name, name: 'name', type: 'text', placeholder: '请输入用户名(选填)', style: plainInputStyle() }),
|
|
308
|
+
] }),
|
|
309
|
+
error ? errorBox(error) : null,
|
|
310
|
+
notice ? jsx('div', { style: { color: '#16a34a', fontSize: 12.5 }, children: notice }) : null,
|
|
311
|
+
jsxs('div', { style: { display: 'flex', gap: 10, marginTop: 2 }, children: [
|
|
312
|
+
jsx('button', { type: 'submit', disabled: loading, style: { ...primaryButtonStyle(loading), flex: '1 1 auto' }, children: loading ? '注册中…' : '注册账号' }),
|
|
313
|
+
jsx('button', { type: 'button', onClick: () => onSwitch('account'), 'aria-label': '返回登录', title: '返回登录', style: { flex: '0 0 auto', width: 46, height: 46, border: '1px solid #d9e1ec', borderRadius: 10, background: '#fff', color: '#475569', fontSize: 16, cursor: 'pointer' }, children: '←' }),
|
|
314
|
+
] }),
|
|
315
|
+
agreementNotice(),
|
|
316
|
+
] })
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function PasswordResetForm({ onSwitch }) {
|
|
320
|
+
const { useRef, useState } = require('react')
|
|
321
|
+
const email = useRef(null)
|
|
322
|
+
const code = useRef(null)
|
|
323
|
+
const password = useRef(null)
|
|
324
|
+
const confirm = useRef(null)
|
|
325
|
+
const [loading, setLoading] = useState(false)
|
|
326
|
+
const [error, setError] = useState('')
|
|
327
|
+
const [notice, setNotice] = useState('')
|
|
328
|
+
|
|
329
|
+
const onCodeError = message => setError(message || '')
|
|
330
|
+
const onCodeNotice = message => setNotice(message || '')
|
|
331
|
+
|
|
332
|
+
const submit = async event => {
|
|
333
|
+
if (event && event.preventDefault) event.preventDefault()
|
|
334
|
+
const mail = email.current?.value?.trim() || ''
|
|
335
|
+
const verification = code.current?.value?.trim() || ''
|
|
336
|
+
const secret = password.current?.value || ''
|
|
337
|
+
const repeated = confirm.current?.value || ''
|
|
338
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(mail)) return setError('请输入正确的邮箱')
|
|
339
|
+
if (!/^\d{4,6}$/.test(verification)) return setError('验证码格式不正确')
|
|
340
|
+
if (!secret) return setError('请输入新密码')
|
|
341
|
+
if (secret !== repeated) return setError('两次输入的密码不一致')
|
|
342
|
+
setLoading(true); setError(''); setNotice('')
|
|
343
|
+
try {
|
|
344
|
+
const body = await postAuth('/api/waterbuddy/auth/password/change', { email: mail, verifyCode: verification, password: secret })
|
|
345
|
+
setNotice(body.message || '密码修改成功')
|
|
346
|
+
onSwitch('account', { loginId: mail })
|
|
347
|
+
} catch (failure) {
|
|
348
|
+
setError(failure.message)
|
|
349
|
+
} finally {
|
|
350
|
+
setLoading(false)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return jsxs('form', { onSubmit: submit, style: { display: 'grid', gap: 11, textAlign: 'left' }, children: [
|
|
355
|
+
jsxs('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [
|
|
356
|
+
jsx('strong', { style: { color: '#162033', fontSize: 14 }, children: '重置密码' }),
|
|
357
|
+
jsx('button', { type: 'button', onClick: () => onSwitch('account'), style: linkButtonStyle(), children: '← 返回登录' }),
|
|
358
|
+
] }),
|
|
359
|
+
jsxs('label', { style: passwordField(), children: [
|
|
360
|
+
fieldIcon('✉'),
|
|
361
|
+
jsx('input', { ref: email, name: 'email', type: 'email', autoComplete: 'email', placeholder: '请输入邮箱', style: plainInputStyle() }),
|
|
362
|
+
] }),
|
|
363
|
+
jsx(VerificationCodeField, { name: 'verifyCode', emailRef: email, sendPath: '/api/waterbuddy/auth/password/code', onError: onCodeError, onNotice: onCodeNotice }),
|
|
364
|
+
jsxs('label', { style: passwordField(), children: [
|
|
365
|
+
fieldIcon('🔒'),
|
|
366
|
+
jsx('input', { ref: password, name: 'password', type: 'password', autoComplete: 'new-password', placeholder: '请输入新密码', style: plainInputStyle() }),
|
|
367
|
+
] }),
|
|
368
|
+
jsxs('label', { style: passwordField(), children: [
|
|
369
|
+
fieldIcon('🔒'),
|
|
370
|
+
jsx('input', { ref: confirm, name: 'confirmPassword', type: 'password', autoComplete: 'new-password', placeholder: '请再次输入新密码', style: plainInputStyle() }),
|
|
371
|
+
] }),
|
|
372
|
+
error ? errorBox(error) : null,
|
|
373
|
+
notice ? jsx('div', { style: { color: '#16a34a', fontSize: 12.5 }, children: notice }) : null,
|
|
374
|
+
jsx('button', { type: 'submit', disabled: loading, style: { ...primaryButtonStyle(loading), marginTop: 2 }, children: loading ? '提交中…' : '修改密码' }),
|
|
375
|
+
] })
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function LoginPanel({ session, onLogin, onRestart, onClose }) {
|
|
379
|
+
const { useState } = require('react')
|
|
380
|
+
const [mode, setMode] = useState('qr')
|
|
381
|
+
const [loginId, setLoginId] = useState('')
|
|
382
|
+
const [notice, setNotice] = useState('')
|
|
383
|
+
|
|
384
|
+
const switchMode = (next, options) => {
|
|
385
|
+
setMode(next)
|
|
386
|
+
setNotice(options?.loginId || '')
|
|
387
|
+
}
|
|
388
|
+
const handleLogin = (user, message) => {
|
|
389
|
+
setNotice(message || '')
|
|
390
|
+
onLogin(user)
|
|
391
|
+
}
|
|
392
|
+
const tabs = [{ key: 'qr', title: '扫码登录' }, { key: 'account', title: '账号登录' }]
|
|
393
|
+
const activeTab = mode === 'qr' ? 'qr' : 'account'
|
|
394
|
+
|
|
395
|
+
return jsxs('div', { style: { padding: '18px 26px 24px' }, children: [
|
|
396
|
+
jsxs('div', { style: { display: 'flex', borderBottom: '1px solid #e8edf4' }, children: tabs.map(tab => jsxs('button', {
|
|
397
|
+
key: tab.key,
|
|
398
|
+
type: 'button',
|
|
399
|
+
onClick: () => switchMode(tab.key),
|
|
400
|
+
style: { position: 'relative', flex: '1 1 0', height: 46, border: 0, background: 'transparent', color: activeTab === tab.key ? '#162033' : '#7b8799', fontSize: 15, fontWeight: 600, cursor: 'pointer', transition: 'color .15s ease' },
|
|
401
|
+
children: [
|
|
402
|
+
tab.title,
|
|
403
|
+
activeTab === tab.key ? jsx('span', { style: { position: 'absolute', left: '50%', bottom: -1, width: 32, height: 3, marginLeft: -16, borderRadius: 2, background: '#1677d2' } }) : null,
|
|
404
|
+
],
|
|
405
|
+
})) }),
|
|
406
|
+
notice ? jsx('div', { style: { marginTop: 12, padding: '8px 12px', borderRadius: 10, background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#15803d', fontSize: 12.5, textAlign: 'center' }, children: notice }) : null,
|
|
407
|
+
jsx('div', { style: { paddingTop: 18, maxHeight: 'min(58vh, 470px)', overflowY: 'auto' }, children: mode === 'qr'
|
|
408
|
+
? jsx(QrLogin, { session, onRestart, onClose })
|
|
409
|
+
: mode === 'register'
|
|
410
|
+
? jsx(RegisterForm, { onLogin: handleLogin, onSwitch: switchMode })
|
|
411
|
+
: mode === 'password'
|
|
412
|
+
? jsx(PasswordResetForm, { onSwitch: switchMode })
|
|
413
|
+
: jsx(AccountLoginForm, { onLogin: handleLogin, onSwitch: switchMode }) }),
|
|
414
|
+
] })
|
|
415
|
+
}
|
|
416
|
+
|
|
66
417
|
function UserAction({ wide }) {
|
|
67
418
|
const { useEffect, useState } = require('react')
|
|
68
419
|
const [open, setOpen] = useState(false)
|
|
69
|
-
const [loading, setLoading] = useState(false)
|
|
70
|
-
const [qr, setQr] = useState(null)
|
|
71
|
-
const [status, setStatus] = useState('')
|
|
72
420
|
const [user, setUser] = useState(null)
|
|
73
|
-
const [
|
|
421
|
+
const [session, setSession] = useState({ qrCode: '', status: '', error: '', loading: false })
|
|
74
422
|
|
|
75
423
|
useEffect(() => {
|
|
76
424
|
let cancelled = false
|
|
@@ -80,55 +428,79 @@ window.__ModuleLoader__.load({
|
|
|
80
428
|
return () => { cancelled = true }
|
|
81
429
|
}, [])
|
|
82
430
|
|
|
431
|
+
const startLogin = async () => {
|
|
432
|
+
setSession({ qrCode: '', status: '二维码加载中', error: '', loading: true })
|
|
433
|
+
try {
|
|
434
|
+
const response = await fetch('/api/waterbuddy/auth/qr', { method: 'POST' })
|
|
435
|
+
const body = await response.json()
|
|
436
|
+
if (!response.ok) throw new Error(body.error || '二维码获取失败')
|
|
437
|
+
setSession({ qrCode: body.qrCode, status: 'WAITING', error: '', loading: false, sessionId: body.sessionId })
|
|
438
|
+
} catch (failure) {
|
|
439
|
+
setSession({ qrCode: '', status: '', error: failure.message, loading: false })
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
83
443
|
useEffect(() => {
|
|
84
|
-
if (!open || !
|
|
444
|
+
if (!open || !session.sessionId || user) return undefined
|
|
85
445
|
let stopped = false
|
|
86
446
|
let timer
|
|
87
447
|
const poll = async () => {
|
|
88
448
|
try {
|
|
89
|
-
const response = await fetch(`/api/waterbuddy/auth/status?sessionId=${encodeURIComponent(
|
|
449
|
+
const response = await fetch(`/api/waterbuddy/auth/status?sessionId=${encodeURIComponent(session.sessionId)}`)
|
|
90
450
|
const body = await response.json()
|
|
91
451
|
if (stopped) return
|
|
92
|
-
setStatus(body.status || '')
|
|
93
452
|
if (body.status === 'SUCCESS') {
|
|
94
453
|
setUser(body.user || null)
|
|
95
|
-
|
|
454
|
+
setSession(current => ({ ...current, status: 'SUCCESS', error: '' }))
|
|
96
455
|
return
|
|
97
456
|
}
|
|
98
457
|
if (body.status === 'EXPIRED') {
|
|
99
|
-
|
|
100
|
-
setLoading(false)
|
|
458
|
+
setSession(current => ({ ...current, status: 'EXPIRED', error: '二维码已过期,请重新获取' }))
|
|
101
459
|
return
|
|
102
460
|
}
|
|
461
|
+
setSession(current => ({ ...current, status: body.status || 'WAITING' }))
|
|
103
462
|
} catch (_) {
|
|
104
|
-
if (!stopped)
|
|
463
|
+
if (!stopped) setSession(current => ({ ...current, error: '查询扫码状态失败,请重试' }))
|
|
105
464
|
}
|
|
106
465
|
if (!stopped) timer = setTimeout(poll, 2000)
|
|
107
466
|
}
|
|
108
467
|
poll()
|
|
109
468
|
return () => { stopped = true; if (timer) clearTimeout(timer) }
|
|
110
|
-
}, [open,
|
|
111
|
-
|
|
112
|
-
const startLogin = async () => {
|
|
113
|
-
setLoading(true); setError(''); setStatus('二维码加载中'); setQr(null)
|
|
114
|
-
try {
|
|
115
|
-
const response = await fetch('/api/waterbuddy/auth/qr', { method: 'POST' })
|
|
116
|
-
const body = await response.json()
|
|
117
|
-
if (!response.ok) throw new Error(body.error || '二维码获取失败')
|
|
118
|
-
setQr(body); setStatus('等待扫码')
|
|
119
|
-
} catch (failure) {
|
|
120
|
-
setError(failure.message); setLoading(false)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
469
|
+
}, [open, session.sessionId, user])
|
|
123
470
|
|
|
124
|
-
const openPanel = () => { setOpen(true); if (!user) startLogin() }
|
|
125
|
-
const logout = async () => { await fetch('/api/waterbuddy/logout', { method: 'POST' }); setUser(null); setOpen(false) }
|
|
126
|
-
const statusText = { WAITING: '等待扫码', DEALING: '已扫码,请在手机上确认', SUCCESS: '登录成功', EXPIRED: '二维码已过期' }[status] || status
|
|
471
|
+
const openPanel = () => { setOpen(true); if (!user && !session.sessionId) startLogin() }
|
|
472
|
+
const logout = async () => { await fetch('/api/waterbuddy/logout', { method: 'POST' }); setUser(null); setOpen(false); setSession({ qrCode: '', status: '', error: '', loading: false }) }
|
|
127
473
|
const name = userName(user)
|
|
128
|
-
const account = user?.loginId || (user?.id ? `${user.id.slice(0, 8)}…` : '水务之窗账号')
|
|
129
|
-
const isAdmin = user?.isAdmin === 1 || user?.isAdmin === true
|
|
130
474
|
const buttonStyle = { borderRadius: 10, padding: '9px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }
|
|
131
475
|
|
|
476
|
+
function userPanel() {
|
|
477
|
+
const account = user?.loginId || (user?.id ? `${String(user.id).slice(0, 8)}…` : '水务之窗账号')
|
|
478
|
+
const isAdmin = user?.isAdmin === 1 || user?.isAdmin === true
|
|
479
|
+
return jsxs(Fragment, { children: [
|
|
480
|
+
jsx('div', { style: { position: 'relative', padding: '28px 28px 44px', textAlign: 'center', color: '#fff', background: 'linear-gradient(135deg, #0f6fd6 0%, #1d9bf0 58%, #38bdf8 100%)' }, children: [
|
|
481
|
+
jsx('button', { type: 'button', onClick: () => setOpen(false), 'aria-label': '关闭', style: { position: 'absolute', top: 14, right: 16, width: 30, height: 30, border: 0, borderRadius: '50%', background: 'rgba(255,255,255,.18)', color: '#fff', fontSize: 20, lineHeight: 1, cursor: 'pointer' }, children: '×' }),
|
|
482
|
+
jsx('div', { style: { fontSize: 12, opacity: .82, letterSpacing: 1 }, children: 'WATERBUDDY' }),
|
|
483
|
+
jsx('div', { style: { marginTop: 8, fontSize: 15, fontWeight: 600 }, children: 'WaterBuddy' }),
|
|
484
|
+
jsx('div', { style: { position: 'absolute', left: '50%', bottom: -35, transform: 'translateX(-50%)', padding: 5, borderRadius: '50%', background: '#fff', boxShadow: '0 6px 18px rgba(15,23,42,.18)' }, children: jsx(UserAvatar, { user, size: 62 }) }),
|
|
485
|
+
] }),
|
|
486
|
+
jsxs('div', { style: { padding: '48px 28px 26px', textAlign: 'center' }, children: [
|
|
487
|
+
jsx('h3', { style: { margin: 0, color: '#162033', fontSize: 22 }, children: name }),
|
|
488
|
+
jsx('p', { style: { margin: '7px 0 18px', color: '#7b8799', fontSize: 13 }, children: user.organization || user.department || '水务之窗' }),
|
|
489
|
+
jsxs('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, textAlign: 'left' }, children: [
|
|
490
|
+
jsxs('div', { style: { padding: '12px 14px', borderRadius: 12, background: '#f5f8fc' }, children: [
|
|
491
|
+
jsx('small', { style: { color: '#8a94a6', fontSize: 11 }, children: '账号' }),
|
|
492
|
+
jsx('div', { style: { marginTop: 4, color: '#334155', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: account }),
|
|
493
|
+
] }),
|
|
494
|
+
jsxs('div', { style: { padding: '12px 14px', borderRadius: 12, background: '#f5f8fc' }, children: [
|
|
495
|
+
jsx('small', { style: { color: '#8a94a6', fontSize: 11 }, children: '身份' }),
|
|
496
|
+
jsx('div', { style: { marginTop: 4, color: '#334155', fontSize: 13, fontWeight: 600 }, children: isAdmin ? '管理员' : '内部成员' }),
|
|
497
|
+
] }),
|
|
498
|
+
] }),
|
|
499
|
+
jsx('button', { type: 'button', onClick: logout, style: { ...buttonStyle, width: '100%', marginTop: 18, border: '1px solid #d9e1ec', background: '#fff', color: '#475569' }, children: '退出登录' }),
|
|
500
|
+
] }),
|
|
501
|
+
] })
|
|
502
|
+
}
|
|
503
|
+
|
|
132
504
|
return jsxs(Fragment, {
|
|
133
505
|
children: [
|
|
134
506
|
jsx('button', {
|
|
@@ -136,12 +508,27 @@ window.__ModuleLoader__.load({
|
|
|
136
508
|
onClick: openPanel,
|
|
137
509
|
title: user ? name : '登录 WaterBuddy',
|
|
138
510
|
style: { display: 'flex', alignItems: 'center', gap: 8, width: wide ? 'auto' : 40, maxWidth: wide ? 150 : 40, minHeight: 38, padding: wide ? '5px 9px 5px 6px' : 3, border: user ? '1px solid #e1eafa' : '1px solid transparent', borderRadius: 12, background: user ? '#f5f8ff' : 'transparent', color: 'var(--dsw-text-secondary, #586174)', cursor: 'pointer', textAlign: 'left', transition: 'background .15s ease, border-color .15s ease' },
|
|
139
|
-
children: [user ? jsx(UserAvatar, { user, size: 28 }) : jsx('img', { src: SSO.image, alt: SSO.title, style: { width: 28, height: 28, borderRadius: 8, objectFit: 'contain', padding: 2, boxSizing: 'border-box', flex: '0 0 auto', background: '#fff', border: '1px solid #dce9fb' } }), wide ? jsxs('span', { style: { minWidth: 0, display: 'grid', gap: 1 }, children: [jsx('strong', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12.5, lineHeight: '16px', color: 'var(--dsw-text-primary, #1f2937)' }, children: user ? name : SSO.title }), jsx('small', { style: { color: '#8a94a6', fontSize: 10.5, lineHeight: '14px' }, children: user ? '水务之窗' : '
|
|
511
|
+
children: [user ? jsx(UserAvatar, { user, size: 28 }) : jsx('img', { src: SSO.image, alt: SSO.title, style: { width: 28, height: 28, borderRadius: 8, objectFit: 'contain', padding: 2, boxSizing: 'border-box', flex: '0 0 auto', background: '#fff', border: '1px solid #dce9fb' } }), wide ? jsxs('span', { style: { minWidth: 0, display: 'grid', gap: 1 }, children: [jsx('strong', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12.5, lineHeight: '16px', color: 'var(--dsw-text-primary, #1f2937)' }, children: user ? name : SSO.title }), jsx('small', { style: { color: '#8a94a6', fontSize: 10.5, lineHeight: '14px' }, children: user ? '水务之窗' : '扫码或账号登录' })] }) : null],
|
|
140
512
|
}),
|
|
141
|
-
open ? jsx('div', { style: { position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15, 23, 42, .42)', backdropFilter: 'blur(5px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: event => { if (event.target === event.currentTarget) setOpen(false) }, children: jsx('div', { style: { width: 420, maxWidth: 'calc(100vw - 32px)', borderRadius: 22, background: '#fff', overflow: 'hidden', boxShadow: '0 24px 80px rgba(15,23,42,.28)' }, children: user ?
|
|
513
|
+
open ? jsx('div', { style: { position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15, 23, 42, .42)', backdropFilter: 'blur(5px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: event => { if (event.target === event.currentTarget) setOpen(false) }, children: jsx('div', { style: { width: 420, maxWidth: 'calc(100vw - 32px)', borderRadius: 22, background: '#fff', overflow: 'hidden', boxShadow: '0 24px 80px rgba(15,23,42,.28)' }, children: user ? userPanel() : jsxs('div', { children: [
|
|
514
|
+
jsxs('div', { style: { position: 'relative', padding: '22px 26px 0', textAlign: 'center' }, children: [
|
|
515
|
+
jsx('button', { type: 'button', onClick: () => setOpen(false), 'aria-label': '关闭', style: { position: 'absolute', top: 14, right: 16, width: 30, height: 30, border: 0, borderRadius: '50%', background: '#f1f5f9', color: '#64748b', fontSize: 20, lineHeight: 1, cursor: 'pointer' }, children: '×' }),
|
|
516
|
+
jsx('img', { src: SSO.image, alt: SSO.title, style: { display: 'block', width: 52, height: 52, objectFit: 'contain', margin: '0 auto' } }),
|
|
517
|
+
jsx('h3', { style: { margin: '10px 0 4px', color: '#162033', fontSize: 21 }, children: 'WaterBuddy' }),
|
|
518
|
+
jsx('p', { style: { margin: 0, color: '#7b8799', fontSize: 12.5 }, children: '登录后同步水务之窗账号与知识库权限' }),
|
|
519
|
+
] }),
|
|
520
|
+
jsx(LoginPanel, { session, onLogin: nextUser => setUser(nextUser || null), onRestart: startLogin, onClose: () => setOpen(false) }),
|
|
521
|
+
] }) }) }) : null,
|
|
142
522
|
],
|
|
143
523
|
})
|
|
144
524
|
}
|
|
525
|
+
function SkipWelcomeNotice({ complete }) {
|
|
526
|
+
const { useEffect } = require('react')
|
|
527
|
+
useEffect(() => {
|
|
528
|
+
complete()
|
|
529
|
+
}, [complete])
|
|
530
|
+
return null
|
|
531
|
+
}
|
|
145
532
|
|
|
146
533
|
const inject = ['slots']
|
|
147
534
|
function apply(ctx) {
|
|
@@ -167,10 +554,10 @@ window.__ModuleLoader__.load({
|
|
|
167
554
|
const style = document.createElement('style')
|
|
168
555
|
style.dataset.waterbuddy = 'sidebar-footer-layout'
|
|
169
556
|
style.textContent = [
|
|
170
|
-
'.dshp-
|
|
171
|
-
'.dshp-
|
|
172
|
-
'.dshp-
|
|
173
|
-
'.dshp-
|
|
557
|
+
'.dshp-panel.dshp-panel--wide .dshp-panel__foot-area{flex-direction:row;align-items:center;gap:8px}',
|
|
558
|
+
'.dshp-panel.dshp-panel--wide .dshp-panel__settings-area{order:1;flex:0 0 auto;width:auto;min-width:0}',
|
|
559
|
+
'.dshp-panel.dshp-panel--wide .dshp-panel__footer-actions{order:2;flex:1 1 auto;width:auto;min-width:0;justify-content:flex-end}',
|
|
560
|
+
'.dshp-panel.dshp-panel--wide .dshp-panel__footer-actions>div{display:flex;justify-content:flex-end;min-width:0}',
|
|
174
561
|
].join('')
|
|
175
562
|
document.head.append(style)
|
|
176
563
|
return () => { style.remove() }
|
|
@@ -181,6 +568,7 @@ window.__ModuleLoader__.load({
|
|
|
181
568
|
yield ctx.slots.register({ name: 'sidebar.brand.name', priority: -10 }, WaterBuddyName)
|
|
182
569
|
}))
|
|
183
570
|
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'waterbuddy-user', order: 90, label: '登录 WaterBuddy' }, UserAction))
|
|
571
|
+
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ name: 'settings.onboarding', id: 'welcome-notice', priority: -1000, order: -100 }, SkipWelcomeNotice))
|
|
184
572
|
}
|
|
185
573
|
|
|
186
574
|
exports.inject = inject
|
package/lib/host/index.js
CHANGED
|
@@ -8,26 +8,45 @@ const WATERPLUS_ICON = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC
|
|
|
8
8
|
const CLIENT_ID = '2CC7F925-07AE-4AB9-9F17-75B7378D5890'
|
|
9
9
|
const PLATFORM_URL = 'https://web.shuiwujia.com'
|
|
10
10
|
const AI_URL = 'https://ai.shuiwujia.com'
|
|
11
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
12
|
+
const VERIFY_CODE_PATTERN = /^\d{4,6}$/
|
|
11
13
|
const sessions = new Map()
|
|
12
|
-
const authFile = path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'waterbuddy-auth.json')
|
|
13
14
|
let persistedAuth
|
|
14
15
|
let currentUser
|
|
16
|
+
let upstreamTransport
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Test seam only. Production always uses `https.request`; regression tests install a
|
|
20
|
+
* scripted transport so the platform contract can be asserted without network access.
|
|
21
|
+
*/
|
|
22
|
+
function setUpstreamTransport(transport) {
|
|
23
|
+
upstreamTransport = transport
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function dshHome() {
|
|
27
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function authFile() {
|
|
31
|
+
return path.join(dshHome(), 'waterbuddy-auth.json')
|
|
32
|
+
}
|
|
15
33
|
|
|
16
34
|
function readPersistedAuth() {
|
|
17
35
|
try {
|
|
18
|
-
const value = JSON.parse(fs.readFileSync(authFile, 'utf8'))
|
|
36
|
+
const value = JSON.parse(fs.readFileSync(authFile(), 'utf8'))
|
|
19
37
|
if (value?.token && value?.user) {
|
|
20
|
-
|
|
21
|
-
|
|
38
|
+
currentUser = normalizeUser(value.user)
|
|
39
|
+
persistedAuth = { ...value, user: currentUser }
|
|
22
40
|
}
|
|
23
41
|
} catch {}
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
function savePersistedAuth(auth) {
|
|
27
45
|
try {
|
|
28
|
-
|
|
29
|
-
fs.
|
|
30
|
-
|
|
46
|
+
const file = authFile()
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
48
|
+
fs.writeFileSync(file, JSON.stringify(auth), { mode: 0o600 })
|
|
49
|
+
try { fs.chmodSync(file, 0o600) } catch {}
|
|
31
50
|
} catch (error) {
|
|
32
51
|
console.warn('[WaterBuddy] failed to persist login:', error.message)
|
|
33
52
|
}
|
|
@@ -35,7 +54,7 @@ function savePersistedAuth(auth) {
|
|
|
35
54
|
|
|
36
55
|
function clearPersistedAuth() {
|
|
37
56
|
persistedAuth = undefined
|
|
38
|
-
try { fs.rmSync(authFile, { force: true }) } catch {}
|
|
57
|
+
try { fs.rmSync(authFile(), { force: true }) } catch {}
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
function isSupportedAvatarUrl(url) {
|
|
@@ -46,9 +65,17 @@ function normalizeAvatar(value) {
|
|
|
46
65
|
if (typeof value !== 'string' || !value) return undefined
|
|
47
66
|
if (value.startsWith('data:')) return value
|
|
48
67
|
try {
|
|
49
|
-
|
|
68
|
+
let source = value
|
|
69
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
70
|
+
const candidate = new URL(source, 'http://127.0.0.1')
|
|
71
|
+
if (candidate.pathname !== '/api/waterbuddy/avatar') break
|
|
72
|
+
const nested = candidate.searchParams.get('url')
|
|
73
|
+
if (!nested) return undefined
|
|
74
|
+
source = nested
|
|
75
|
+
}
|
|
76
|
+
const url = new URL(source, AI_URL)
|
|
50
77
|
if (!isSupportedAvatarUrl(url)) return undefined
|
|
51
|
-
return url.href
|
|
78
|
+
return `/api/waterbuddy/avatar?url=${encodeURIComponent(url.href)}`
|
|
52
79
|
} catch { return undefined }
|
|
53
80
|
}
|
|
54
81
|
|
|
@@ -71,17 +98,23 @@ function queryUrl(path, params) {
|
|
|
71
98
|
return url.href
|
|
72
99
|
}
|
|
73
100
|
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
function aiUrl(path, params) {
|
|
102
|
+
const url = new URL(path, AI_URL)
|
|
103
|
+
for (const [key, value] of Object.entries(params || {})) url.searchParams.set(key, value)
|
|
104
|
+
return url.href
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function joinCookies(...values) {
|
|
108
|
+
return values.filter(Boolean).join('; ')
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
async function requestJson(url, options = {}) {
|
|
80
112
|
const parsed = new URL(url)
|
|
81
113
|
const headers = { plaform: 'PC', ...(options.headers || {}) }
|
|
82
114
|
const body = options.body
|
|
115
|
+
const send = options.request || upstreamTransport || https.request
|
|
83
116
|
return await new Promise((resolve, reject) => {
|
|
84
|
-
const request =
|
|
117
|
+
const request = send(parsed, { method: options.method || 'GET', headers, insecureHTTPParser: true }, response => {
|
|
85
118
|
const chunks = []
|
|
86
119
|
response.on('data', chunk => chunks.push(chunk))
|
|
87
120
|
response.on('end', () => {
|
|
@@ -98,13 +131,135 @@ async function requestJson(url, options = {}) {
|
|
|
98
131
|
})
|
|
99
132
|
}
|
|
100
133
|
|
|
134
|
+
function postAiJson(path, payload, options = {}) {
|
|
135
|
+
return requestJson(aiUrl(path, options.query), {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'content-type': 'application/json', ...(options.headers || {}) },
|
|
138
|
+
body: JSON.stringify(payload),
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractToken(result) {
|
|
143
|
+
if (typeof result === 'string') return result
|
|
144
|
+
if (!result || typeof result !== 'object') return ''
|
|
145
|
+
for (const key of ['token', 'accessToken', 'access_token']) {
|
|
146
|
+
if (typeof result[key] === 'string' && result[key]) return result[key]
|
|
147
|
+
}
|
|
148
|
+
return ''
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function fetchUserInfo(token) {
|
|
152
|
+
const profile = await requestJson(aiUrl('/swj-waterplusai-api/client/user/info'), { headers: { Authorization: `Bearer ${token}` } })
|
|
153
|
+
return normalizeUser(profile.body.result)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function finalizeLogin(token) {
|
|
157
|
+
if (!token) throw new AuthUpstreamError('登录凭证无效')
|
|
158
|
+
const user = await fetchUserInfo(token)
|
|
159
|
+
currentUser = user
|
|
160
|
+
persistedAuth = { token, user }
|
|
161
|
+
savePersistedAuth(persistedAuth)
|
|
162
|
+
return { token, user }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A rejected local payload: a retry cannot succeed, so the route answers 400. */
|
|
166
|
+
class InvalidAuthRequest extends Error {}
|
|
167
|
+
|
|
168
|
+
/** A failed upstream call: the platform may recover, so the route answers 502. */
|
|
169
|
+
class AuthUpstreamError extends Error {}
|
|
170
|
+
|
|
171
|
+
function requireString(value, message) {
|
|
172
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
173
|
+
if (!text) throw new InvalidAuthRequest(message)
|
|
174
|
+
return text
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isLocalValidation(error) {
|
|
178
|
+
return error instanceof InvalidAuthRequest
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function accountLogin(body) {
|
|
182
|
+
const loginId = requireString(body.loginId, '请输入账号')
|
|
183
|
+
const password = requireString(body.password, '请输入密码')
|
|
184
|
+
const response = await requestJson(aiUrl('/swj-waterplusai-api/client/user/login'), {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'content-type': 'application/json' },
|
|
187
|
+
body: JSON.stringify({ loginId, password }),
|
|
188
|
+
})
|
|
189
|
+
const { user } = await finalizeLogin(extractToken(response.body.result))
|
|
190
|
+
return { status: 'SUCCESS', user }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function sendRegisterCode(body) {
|
|
194
|
+
const email = requireString(body.email, '请输入邮箱')
|
|
195
|
+
if (!EMAIL_PATTERN.test(email)) throw new InvalidAuthRequest('邮箱格式不正确')
|
|
196
|
+
const response = await postAiJson('/swj-waterplusai-api/client/user/register/code', { email })
|
|
197
|
+
return { ok: true, message: response.body.message || '验证码已发送' }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function registerAccount(body) {
|
|
201
|
+
const email = requireString(body.email, '请输入邮箱')
|
|
202
|
+
if (!EMAIL_PATTERN.test(email)) throw new InvalidAuthRequest('邮箱格式不正确')
|
|
203
|
+
const verifyCode = requireString(body.verifyCode, '请输入验证码')
|
|
204
|
+
if (!VERIFY_CODE_PATTERN.test(verifyCode)) throw new InvalidAuthRequest('验证码格式不正确')
|
|
205
|
+
const password = requireString(body.password, '请输入密码')
|
|
206
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
207
|
+
const payload = { email, verifyCode, password, ...(name ? { name } : {}) }
|
|
208
|
+
const response = await postAiJson('/swj-waterplusai-api/client/user/register', payload)
|
|
209
|
+
const { user } = await finalizeLogin(extractToken(response.body.result))
|
|
210
|
+
return { status: 'SUCCESS', user, message: response.body.message || '注册成功' }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function sendPasswordCode(body) {
|
|
214
|
+
const email = requireString(body.email, '请输入邮箱')
|
|
215
|
+
if (!EMAIL_PATTERN.test(email)) throw new InvalidAuthRequest('邮箱格式不正确')
|
|
216
|
+
const response = await postAiJson('/swj-waterplusai-api/client/user/password/code', { email })
|
|
217
|
+
return { ok: true, message: response.body.message || '验证码已发送' }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function changePassword(body) {
|
|
221
|
+
const email = requireString(body.email, '请输入邮箱')
|
|
222
|
+
if (!EMAIL_PATTERN.test(email)) throw new InvalidAuthRequest('邮箱格式不正确')
|
|
223
|
+
const verifyCode = requireString(body.verifyCode, '请输入验证码')
|
|
224
|
+
if (!VERIFY_CODE_PATTERN.test(verifyCode)) throw new InvalidAuthRequest('验证码格式不正确')
|
|
225
|
+
const password = requireString(body.password, '请输入密码')
|
|
226
|
+
const response = await postAiJson('/swj-waterplusai-api/client/user/password/change', { email, verifyCode, password })
|
|
227
|
+
return { ok: true, message: response.body.message || '密码修改成功' }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readJsonBody(req) {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
const chunks = []
|
|
233
|
+
req.on('data', chunk => chunks.push(chunk))
|
|
234
|
+
req.on('end', () => {
|
|
235
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
236
|
+
if (!text) return resolve({})
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(text)
|
|
239
|
+
resolve(parsed && typeof parsed === 'object' ? parsed : {})
|
|
240
|
+
} catch { reject(new InvalidAuthRequest('请求参数格式不正确')) }
|
|
241
|
+
})
|
|
242
|
+
req.on('error', reject)
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function handleAuthAction(req, res, action) {
|
|
247
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method-not-allowed' })
|
|
248
|
+
try {
|
|
249
|
+
const body = await readJsonBody(req)
|
|
250
|
+
json(res, 200, await action(body))
|
|
251
|
+
} catch (error) {
|
|
252
|
+
json(res, isLocalValidation(error) ? 400 : 502, { error: error.message })
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
101
256
|
async function createLogin() {
|
|
102
257
|
const login = await requestJson(queryUrl('/swj-platform-api/waterplusUser/login', { orgId: CLIENT_ID }))
|
|
103
258
|
const scene = login.body.result?.scene
|
|
104
259
|
if (!scene) throw new Error('登录场景创建失败')
|
|
105
260
|
const qr = await requestJson(`${PLATFORM_URL}/swj-platform-api/platformWxappUser/getQRCode`, { method: 'POST', headers: { 'content-type': 'application/json', ...(login.cookie ? { cookie: login.cookie } : {}) }, body: JSON.stringify({ page: 'main/home/index', scene, check_path: true, env_version: 'release' }) })
|
|
106
261
|
const sessionId = crypto.randomUUID()
|
|
107
|
-
sessions.set(sessionId, { scene, cookie:
|
|
262
|
+
sessions.set(sessionId, { scene, cookie: joinCookies(login.cookie, qr.cookie), createdAt: Date.now() })
|
|
108
263
|
const encoded = String(qr.body.result)
|
|
109
264
|
return { sessionId, qrCode: `data:${encoded.startsWith('/9j/') ? 'image/jpeg' : 'image/png'};base64,${encoded}`, status: 'WAITING' }
|
|
110
265
|
}
|
|
@@ -113,19 +268,15 @@ async function pollLogin(session) {
|
|
|
113
268
|
if (Date.now() - session.createdAt > 5 * 60_000) return { status: 'EXPIRED' }
|
|
114
269
|
const statusResponse = await requestJson(queryUrl('/swj-platform-api/platformWxappUser/findRegisterStatus', { scene: session.scene }), { headers: session.cookie ? { cookie: session.cookie } : {} })
|
|
115
270
|
const status = statusResponse.body.result || 'WAITING'
|
|
116
|
-
session.cookie =
|
|
271
|
+
session.cookie = joinCookies(session.cookie, statusResponse.cookie)
|
|
117
272
|
if (status !== 'SUCCESS') return { status }
|
|
118
273
|
if (session.user) return { status: 'SUCCESS', user: session.user }
|
|
119
274
|
const auth = await requestJson(queryUrl('/swj-platform-api/platformUser/authAI', { clientId: CLIENT_ID }), { headers: session.cookie ? { cookie: session.cookie } : {} })
|
|
120
275
|
const validated = await requestJson(`${AI_URL}/swj-waterplusai-api/client/user/sso/validate?authInfo=${encodeURIComponent(String(auth.body.result).replace(/ /g, '+'))}&clientId=${CLIENT_ID}`)
|
|
121
|
-
const token = validated.body.result
|
|
122
|
-
const profile = await requestJson(`${AI_URL}/swj-waterplusai-api/client/user/info`, { headers: { Authorization: `Bearer ${token}` } })
|
|
276
|
+
const { token, user } = await finalizeLogin(extractToken(validated.body.result))
|
|
123
277
|
session.token = token
|
|
124
|
-
session.user =
|
|
125
|
-
|
|
126
|
-
persistedAuth = { token, user: currentUser }
|
|
127
|
-
savePersistedAuth(persistedAuth)
|
|
128
|
-
return { status: 'SUCCESS', user: currentUser }
|
|
278
|
+
session.user = user
|
|
279
|
+
return { status: 'SUCCESS', user }
|
|
129
280
|
}
|
|
130
281
|
|
|
131
282
|
async function proxyMcp(req, res) {
|
|
@@ -148,12 +299,15 @@ async function proxyAvatar(req, res) {
|
|
|
148
299
|
let target
|
|
149
300
|
try { target = new URL(value) } catch { return json(res, 400, { error: 'invalid-avatar-url' }) }
|
|
150
301
|
if (!isSupportedAvatarUrl(target)) return json(res, 400, { error: 'avatar-url-not-supported' })
|
|
151
|
-
|
|
302
|
+
const response = await fetch(target)
|
|
303
|
+
if (!response.ok || !response.body) return json(res, response.status || 502, { error: 'avatar-fetch-failed' })
|
|
304
|
+
res.writeHead(response.status, { 'content-type': response.headers.get('content-type') || 'image/jpeg', 'cache-control': 'private, max-age=3600' })
|
|
305
|
+
for await (const chunk of response.body) res.write(chunk)
|
|
152
306
|
res.end()
|
|
153
307
|
}
|
|
154
308
|
|
|
155
309
|
export const inject = ['webServer', 'systemPrompt']
|
|
156
|
-
export { normalizeAvatar }
|
|
310
|
+
export { normalizeAvatar, setUpstreamTransport }
|
|
157
311
|
|
|
158
312
|
export function apply(ctx) {
|
|
159
313
|
readPersistedAuth()
|
|
@@ -163,6 +317,11 @@ export function apply(ctx) {
|
|
|
163
317
|
{ kind: 'exact', path: '/manifest.webmanifest', handler: (_req, res) => { res.writeHead(200, { 'content-type': 'application/manifest+json; charset=utf-8', 'cache-control': 'public, max-age=3600' }); res.end(MANIFEST) } },
|
|
164
318
|
{ kind: 'exact', path: '/api/waterbuddy/auth/qr', handler: async (req, res) => { if (req.method !== 'POST') return json(res, 405, { error: 'method-not-allowed' }); try { json(res, 200, await createLogin()) } catch (error) { json(res, 502, { error: error.message }) } } },
|
|
165
319
|
{ kind: 'exact', path: '/api/waterbuddy/auth/status', handler: async (req, res) => { if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' }); const sessionId = new URL(req.url, 'http://127.0.0.1').searchParams.get('sessionId'); const session = sessionId && sessions.get(sessionId); if (!session) return json(res, 404, { error: 'session-not-found' }); try { json(res, 200, await pollLogin(session)) } catch (error) { json(res, 502, { error: error.message }) } } },
|
|
320
|
+
{ kind: 'exact', path: '/api/waterbuddy/auth/login', handler: (req, res) => handleAuthAction(req, res, accountLogin) },
|
|
321
|
+
{ kind: 'exact', path: '/api/waterbuddy/auth/register/code', handler: (req, res) => handleAuthAction(req, res, sendRegisterCode) },
|
|
322
|
+
{ kind: 'exact', path: '/api/waterbuddy/auth/register', handler: (req, res) => handleAuthAction(req, res, registerAccount) },
|
|
323
|
+
{ kind: 'exact', path: '/api/waterbuddy/auth/password/code', handler: (req, res) => handleAuthAction(req, res, sendPasswordCode) },
|
|
324
|
+
{ kind: 'exact', path: '/api/waterbuddy/auth/password/change', handler: (req, res) => handleAuthAction(req, res, changePassword) },
|
|
166
325
|
{ kind: 'exact', path: '/api/waterbuddy/user', handler: (_req, res) => json(res, 200, { user: currentUser || null }) },
|
|
167
326
|
{ kind: 'exact', path: '/api/waterbuddy/avatar', handler: async (req, res) => { try { await proxyAvatar(req, res) } catch (error) { json(res, 502, { error: error.message }) } } },
|
|
168
327
|
{ kind: 'exact', path: '/api/waterbuddy/logout', handler: (_req, res) => { currentUser = undefined; for (const session of sessions.values()) delete session.token; clearPersistedAuth(); json(res, 200, { ok: true }) } },
|