@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 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
- - In-page WaterBuddy SSO QR-code login.
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 SSO token attached by a local proxy.
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
 
@@ -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 [error, setError] = useState('')
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 || !qr?.sessionId) return undefined
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(qr.sessionId)}`)
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
- setLoading(false)
454
+ setSession(current => ({ ...current, status: 'SUCCESS', error: '' }))
96
455
  return
97
456
  }
98
457
  if (body.status === 'EXPIRED') {
99
- setError('二维码已过期,请重新获取')
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) setError('查询扫码状态失败,请重试')
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, qr])
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 ? '水务之窗' : '扫码登录' })] }) : null],
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 ? jsxs(Fragment, { children: [jsx('div', { style: { position: 'relative', padding: '28px 28px 44px', textAlign: 'center', color: '#fff', background: 'linear-gradient(135deg, #0f6fd6 0%, #1d9bf0 58%, #38bdf8 100%)' }, children: [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: '×' }), jsx('div', { style: { fontSize: 12, opacity: .82, letterSpacing: 1 }, children: 'WATERBUDDY' }), jsx('div', { style: { marginTop: 8, fontSize: 15, fontWeight: 600 }, children: 'WaterBuddy' }), 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 }) })] }), jsxs('div', { style: { padding: '48px 28px 26px', textAlign: 'center' }, children: [jsx('h3', { style: { margin: 0, color: '#162033', fontSize: 22 }, children: name }), jsx('p', { style: { margin: '7px 0 18px', color: '#7b8799', fontSize: 13 }, children: user.organization || user.department || '水务之窗' }), jsxs('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, textAlign: 'left' }, children: [jsxs('div', { style: { padding: '12px 14px', borderRadius: 12, background: '#f5f8fc' }, children: [jsx('small', { style: { color: '#8a94a6', fontSize: 11 }, children: '账号' }), jsx('div', { style: { marginTop: 4, color: '#334155', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: account })] }), jsxs('div', { style: { padding: '12px 14px', borderRadius: 12, background: '#f5f8fc' }, children: [jsx('small', { style: { color: '#8a94a6', fontSize: 11 }, children: '身份' }), jsx('div', { style: { marginTop: 4, color: '#334155', fontSize: 13, fontWeight: 600 }, children: isAdmin ? '管理员' : '内部成员' })] })] }), jsx('button', { type: 'button', onClick: logout, style: { ...buttonStyle, width: '100%', marginTop: 18, border: '1px solid #d9e1ec', background: '#fff', color: '#475569' }, children: '退出登录' })] })] }) : jsxs('div', { style: { padding: 28, textAlign: 'center' }, children: [jsx('button', { type: 'button', onClick: () => setOpen(false), 'aria-label': '关闭', style: { display: 'block', marginLeft: 'auto', width: 30, height: 30, border: 0, borderRadius: '50%', background: '#f1f5f9', color: '#64748b', fontSize: 20, lineHeight: 1, cursor: 'pointer' }, children: '×' }), jsx('img', { src: SSO.image, alt: SSO.title, style: { display: 'block', width: 52, height: 52, objectFit: 'contain', margin: '0 auto 12px' } }), jsx('h3', { style: { margin: '14px 0 6px', color: '#162033', fontSize: 21 }, children: 'WaterBuddy' }), jsx('p', { style: { margin: 0, color: '#7b8799', fontSize: 13 }, children: `使用${SSO.title}扫码,安全登录 WaterBuddy` }), qr?.qrCode ? jsx('img', { src: qr.qrCode, alt: '水务之窗登录二维码', style: { display: 'block', width: 220, height: 220, boxSizing: 'border-box', objectFit: 'contain', margin: '20px auto 12px', padding: 10, border: '1px solid #e5ebf3', borderRadius: 16, background: '#fff' } }) : jsx('div', { style: { width: 220, height: 220, boxSizing: 'border-box', margin: '20px auto 12px', display: 'grid', placeItems: 'center', border: '1px solid #e5ebf3', borderRadius: 16, background: '#f8fafc', color: '#7b8799', fontSize: 13 }, children: loading ? '二维码加载中…' : '二维码暂不可用' }), jsx('div', { style: { minHeight: 24, color: error ? '#dc2626' : status === 'SUCCESS' ? '#16a34a' : '#64748b', fontSize: 13 }, children: error || statusText || '请扫码登录' }), jsxs('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', marginTop: 18 }, children: [jsx('button', { type: 'button', onClick: startLogin, disabled: loading, style: { ...buttonStyle, border: 0, background: '#1677d2', color: '#fff', opacity: loading ? .65 : 1 }, children: '刷新二维码' }), jsx('button', { type: 'button', onClick: () => setOpen(false), style: { ...buttonStyle, border: '1px solid #d9e1ec', background: '#fff', color: '#475569' }, children: '关闭' })] })] }) }) }) : null,
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-root.dshp-wide .dshp-footArea{flex-direction:row;align-items:center;gap:8px}',
171
- '.dshp-root.dshp-wide .dshp-settingsArea{order:1;flex:0 0 auto;width:auto;min-width:0}',
172
- '.dshp-root.dshp-wide .dshp-footerActions{order:2;flex:1 1 auto;width:auto;min-width:0;justify-content:flex-end}',
173
- '.dshp-root.dshp-wide .dshp-footerActions>div{display:flex;justify-content:flex-end;min-width:0}',
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
- persistedAuth = value
21
- currentUser = value.user
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
- fs.mkdirSync(path.dirname(authFile), { recursive: true })
29
- fs.writeFileSync(authFile, JSON.stringify(auth), { mode: 0o600 })
30
- try { fs.chmodSync(authFile, 0o600) } catch {}
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
- const url = new URL(value, AI_URL)
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 cookieHeader(response) {
75
- if (typeof response.headers.getSetCookie !== 'function') return ''
76
- return response.headers.getSetCookie().map(value => value.split(';', 1)[0]).join('; ')
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 = https.request(parsed, { method: options.method || 'GET', headers, insecureHTTPParser: true }, response => {
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: [login.cookie, qr.cookie].filter(Boolean).join('; '), createdAt: Date.now() })
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 = [session.cookie, statusResponse.cookie].filter(Boolean).join('; ')
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 = normalizeUser(profile.body.result)
125
- currentUser = session.user
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
- res.writeHead(302, { location: target.href, 'cache-control': 'private, max-age=3600' })
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 }) } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waterplus-ai/waterbuddy",
3
- "version": "0.1.12",
3
+ "version": "0.1.21",
4
4
  "description": "WaterBuddy branding and Web profile bundle for DeepSeek Harness",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",