@waterplus-ai/waterbuddy 0.1.17 → 0.1.34

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,363 @@ 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
- const { useEffect, useState } = require('react')
418
+ const { useEffect, useRef, 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 [llm, setLlm] = useState(null)
422
+ const [session, setSession] = useState({ qrCode: '', status: '', error: '', loading: false })
74
423
 
75
424
  useEffect(() => {
76
425
  let cancelled = false
@@ -80,54 +429,127 @@ window.__ModuleLoader__.load({
80
429
  return () => { cancelled = true }
81
430
  }, [])
82
431
 
432
+ // 额度轮询:依赖 user,登录/登出后会立刻重新取值(Host 此时已换过 key)。
433
+ useEffect(() => {
434
+ let cancelled = false
435
+ const load = async () => {
436
+ try {
437
+ const response = await fetch('/api/waterbuddy/llm/status')
438
+ const body = await response.json()
439
+ if (!cancelled) setLlm(body.llm || null)
440
+ } catch (_) {}
441
+ }
442
+ load()
443
+ const timer = setInterval(load, 15000)
444
+ return () => { cancelled = true; clearInterval(timer) }
445
+ }, [user])
446
+
447
+ const startLogin = async () => {
448
+ setSession({ qrCode: '', status: '二维码加载中', error: '', loading: true })
449
+ try {
450
+ const response = await fetch('/api/waterbuddy/auth/qr', { method: 'POST' })
451
+ const body = await response.json()
452
+ if (!response.ok) throw new Error(body.error || '二维码获取失败')
453
+ setSession({ qrCode: body.qrCode, status: 'WAITING', error: '', loading: false, sessionId: body.sessionId })
454
+ } catch (failure) {
455
+ setSession({ qrCode: '', status: '', error: failure.message, loading: false })
456
+ }
457
+ }
458
+
83
459
  useEffect(() => {
84
- if (!open || !qr?.sessionId) return undefined
460
+ if (!open || !session.sessionId || user) return undefined
85
461
  let stopped = false
86
462
  let timer
87
463
  const poll = async () => {
88
464
  try {
89
- const response = await fetch(`/api/waterbuddy/auth/status?sessionId=${encodeURIComponent(qr.sessionId)}`)
465
+ const response = await fetch(`/api/waterbuddy/auth/status?sessionId=${encodeURIComponent(session.sessionId)}`)
90
466
  const body = await response.json()
91
467
  if (stopped) return
92
- setStatus(body.status || '')
93
468
  if (body.status === 'SUCCESS') {
94
469
  setUser(body.user || null)
95
- setLoading(false)
470
+ setSession(current => ({ ...current, status: 'SUCCESS', error: '' }))
96
471
  return
97
472
  }
98
473
  if (body.status === 'EXPIRED') {
99
- setError('二维码已过期,请重新获取')
100
- setLoading(false)
474
+ setSession(current => ({ ...current, status: 'EXPIRED', error: '二维码已过期,请重新获取' }))
101
475
  return
102
476
  }
477
+ setSession(current => ({ ...current, status: body.status || 'WAITING' }))
103
478
  } catch (_) {
104
- if (!stopped) setError('查询扫码状态失败,请重试')
479
+ if (!stopped) setSession(current => ({ ...current, error: '查询扫码状态失败,请重试' }))
105
480
  }
106
481
  if (!stopped) timer = setTimeout(poll, 2000)
107
482
  }
108
483
  poll()
109
484
  return () => { stopped = true; if (timer) clearTimeout(timer) }
110
- }, [open, qr])
485
+ }, [open, session.sessionId, user])
111
486
 
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)
487
+ const openPanel = () => { setOpen(true); if (!user && !session.sessionId) startLogin() }
488
+ // 额度耗尽是**主动**提示:模型调用由 Harness 自己发起,插件看不到那次 403
489
+ // (harness 还会把它归类成「API 密钥无效」,文案完全误导),只能靠额度轮询
490
+ // 自己发现并把登录面板推给用户。
491
+ //
492
+ // 必须走 openPanel 而不是裸 setOpen:裸开会得到一个「二维码暂不可用」的空面板,
493
+ // 因为扫码会话根本没发起(实测踩过)。
494
+ const exhaustedNotified = useRef(false)
495
+ useEffect(() => {
496
+ if (!llm || llm.managesQuota !== true || user) {
497
+ exhaustedNotified.current = false
498
+ return
121
499
  }
122
- }
123
-
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
500
+ if (llm.exhausted === true && !exhaustedNotified.current) {
501
+ exhaustedNotified.current = true
502
+ openPanel()
503
+ return
504
+ }
505
+ if (llm.exhausted !== true) exhaustedNotified.current = false
506
+ }, [llm, user])
507
+ const logout = async () => { await fetch('/api/waterbuddy/logout', { method: 'POST' }); setUser(null); setOpen(false); setSession({ qrCode: '', status: '', error: '', loading: false }) }
127
508
  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
509
  const buttonStyle = { borderRadius: 10, padding: '9px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }
510
+ // 余额单位是 sub2api 的美元,不是人民币(见 SUB2API-TOKEN-QUOTA-PLAN §2)。
511
+ const balanceText = `$${Number(llm?.balance || 0).toFixed(2)}`
512
+ // 用户把默认模型换成别人的提供方时,消耗的不是我们的额度,一律不展示额度。
513
+ const managesQuota = llm?.managesQuota === true
514
+ const exhausted = managesQuota && llm?.exhausted === true
515
+ // 未登录时展示试用额度,登录后展示账号额度;耗尽一律优先提示
516
+ const subtitle = !managesQuota
517
+ ? (user ? '水务之窗' : '扫码或账号登录')
518
+ : exhausted
519
+ ? '额度已用完'
520
+ : (user ? `额度 ${balanceText}` : `试用额度 ${balanceText}`)
521
+
522
+ function userPanel() {
523
+ const account = user?.loginId || (user?.id ? `${String(user.id).slice(0, 8)}…` : '水务之窗账号')
524
+ const isAdmin = user?.isAdmin === 1 || user?.isAdmin === true
525
+ return jsxs(Fragment, { children: [
526
+ jsx('div', { style: { position: 'relative', padding: '28px 28px 44px', textAlign: 'center', color: '#fff', background: 'linear-gradient(135deg, #0f6fd6 0%, #1d9bf0 58%, #38bdf8 100%)' }, children: [
527
+ 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: '×' }),
528
+ jsx('div', { style: { fontSize: 12, opacity: .82, letterSpacing: 1 }, children: 'WATERBUDDY' }),
529
+ jsx('div', { style: { marginTop: 8, fontSize: 15, fontWeight: 600 }, children: 'WaterBuddy' }),
530
+ 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 }) }),
531
+ ] }),
532
+ jsxs('div', { style: { padding: '48px 28px 26px', textAlign: 'center' }, children: [
533
+ jsx('h3', { style: { margin: 0, color: '#162033', fontSize: 22 }, children: name }),
534
+ jsx('p', { style: { margin: '7px 0 18px', color: '#7b8799', fontSize: 13 }, children: user.organization || user.department || '水务之窗' }),
535
+ jsxs('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, textAlign: 'left' }, children: [
536
+ jsxs('div', { style: { padding: '12px 14px', borderRadius: 12, background: '#f5f8fc' }, children: [
537
+ jsx('small', { style: { color: '#8a94a6', fontSize: 11 }, children: '账号' }),
538
+ jsx('div', { style: { marginTop: 4, color: '#334155', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: account }),
539
+ ] }),
540
+ jsxs('div', { style: { padding: '12px 14px', borderRadius: 12, background: '#f5f8fc' }, children: [
541
+ jsx('small', { style: { color: '#8a94a6', fontSize: 11 }, children: '身份' }),
542
+ jsx('div', { style: { marginTop: 4, color: '#334155', fontSize: 13, fontWeight: 600 }, children: isAdmin ? '管理员' : '内部成员' }),
543
+ ] }),
544
+ managesQuota ? jsxs('div', { style: { padding: '12px 14px', borderRadius: 12, background: exhausted ? '#fff7ed' : '#f5f8fc' }, children: [
545
+ jsx('small', { style: { color: '#8a94a6', fontSize: 11 }, children: '剩余额度' }),
546
+ jsx('div', { style: { marginTop: 4, color: exhausted ? '#b45309' : '#334155', fontSize: 13, fontWeight: 600 }, children: balanceText }),
547
+ ] }) : null,
548
+ ] }),
549
+ jsx('button', { type: 'button', onClick: logout, style: { ...buttonStyle, width: '100%', marginTop: 18, border: '1px solid #d9e1ec', background: '#fff', color: '#475569' }, children: '退出登录' }),
550
+ ] }),
551
+ ] })
552
+ }
131
553
 
132
554
  return jsxs(Fragment, {
133
555
  children: [
@@ -136,13 +558,21 @@ window.__ModuleLoader__.load({
136
558
  onClick: openPanel,
137
559
  title: user ? name : '登录 WaterBuddy',
138
560
  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],
561
+ 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: exhausted ? '#b45309' : '#8a94a6', fontSize: 10.5, lineHeight: '14px' }, children: subtitle })] }) : null],
140
562
  }),
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,
563
+ 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: [
564
+ jsxs('div', { style: { position: 'relative', padding: '22px 26px 0', textAlign: 'center' }, children: [
565
+ 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: '×' }),
566
+ jsx('img', { src: SSO.image, alt: SSO.title, style: { display: 'block', width: 52, height: 52, objectFit: 'contain', margin: '0 auto' } }),
567
+ jsx('h3', { style: { margin: '10px 0 4px', color: '#162033', fontSize: 21 }, children: 'WaterBuddy' }),
568
+ jsx('p', { style: { margin: 0, color: '#7b8799', fontSize: 12.5 }, children: '登录后同步水务之窗账号与知识库权限' }),
569
+ exhausted ? jsx('div', { style: { margin: '14px 26px 0', padding: '10px 12px', borderRadius: 10, background: '#fff7ed', border: '1px solid #fed7aa', color: '#b45309', fontSize: 12.5, lineHeight: 1.5 }, children: '试用额度已用完,登录后即可继续使用(额度升为账号档)' }) : null,
570
+ ] }),
571
+ jsx(LoginPanel, { session, onLogin: nextUser => setUser(nextUser || null), onRestart: startLogin, onClose: () => setOpen(false) }),
572
+ ] }) }) }) : null,
142
573
  ],
143
574
  })
144
575
  }
145
-
146
576
  function SkipWelcomeNotice({ complete }) {
147
577
  const { useEffect } = require('react')
148
578
  useEffect(() => {
@@ -175,10 +605,10 @@ window.__ModuleLoader__.load({
175
605
  const style = document.createElement('style')
176
606
  style.dataset.waterbuddy = 'sidebar-footer-layout'
177
607
  style.textContent = [
178
- '.dshp-root.dshp-wide .dshp-footArea{flex-direction:row;align-items:center;gap:8px}',
179
- '.dshp-root.dshp-wide .dshp-settingsArea{order:1;flex:0 0 auto;width:auto;min-width:0}',
180
- '.dshp-root.dshp-wide .dshp-footerActions{order:2;flex:1 1 auto;width:auto;min-width:0;justify-content:flex-end}',
181
- '.dshp-root.dshp-wide .dshp-footerActions>div{display:flex;justify-content:flex-end;min-width:0}',
608
+ '.dshp-panel.dshp-panel--wide .dshp-panel__foot-area{flex-direction:row;align-items:center;gap:8px}',
609
+ '.dshp-panel.dshp-panel--wide .dshp-panel__settings-area{order:1;flex:0 0 auto;width:auto;min-width:0}',
610
+ '.dshp-panel.dshp-panel--wide .dshp-panel__footer-actions{order:2;flex:1 1 auto;width:auto;min-width:0;justify-content:flex-end}',
611
+ '.dshp-panel.dshp-panel--wide .dshp-panel__footer-actions>div{display:flex;justify-content:flex-end;min-width:0}',
182
612
  ].join('')
183
613
  document.head.append(style)
184
614
  return () => { style.remove() }