boot.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. const api = window.yaoyaoDesktop
  2. const $ = id => document.getElementById(id)
  3. let registering = false
  4. let state, initialized = false, pending = false, serverDirty = false, generation = 0, actionError = ''
  5. let draftMode = '', previousStep
  6. let theme = window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
  7. try { theme = localStorage.getItem('yaoyao-desktop-theme') || theme } catch { /* Storage can be unavailable in embedded previews. */ }
  8. function applyTheme() {
  9. document.documentElement.dataset.theme = theme
  10. $('theme-toggle').setAttribute('aria-label', theme === 'dark' ? '切换浅色主题' : '切换深色主题')
  11. $('theme-toggle').querySelector('use').setAttribute('href', `boot-icons.svg#${theme === 'dark' ? 'sun' : 'moon'}`)
  12. }
  13. applyTheme()
  14. $('theme-toggle').onclick = () => { theme = theme === 'dark' ? 'light' : 'dark'; try { localStorage.setItem('yaoyao-desktop-theme', theme) } catch {} applyTheme() }
  15. function display(next) {
  16. state = next
  17. const restoring = state.restoring === true || state.phase === 'complete' || state.mode === 'local' && state.phase === 'entering'
  18. $('onboarding').hidden = restoring
  19. $('session-loading').hidden = !restoring
  20. document.title = restoring ? '夭夭' : '开始使用夭夭'
  21. if (restoring) return
  22. if (!initialized) {
  23. $('server').value = state.serverURL || ''
  24. $('remember').checked = state.remember
  25. initialized = true
  26. }
  27. const busy = pending || state.busy
  28. if (state.mode) draftMode = state.mode
  29. const clientOnly = state.supportedModes && !state.supportedModes.includes('server')
  30. $('step-choice').hidden = Boolean(clientOnly)
  31. if (clientOnly) {
  32. $('prepare-title').firstElementChild.textContent = '01'
  33. $('account-title').firstElementChild.textContent = '02'
  34. $('step-prepare').querySelector('b').textContent = '1'
  35. $('step-account').querySelector('b').textContent = '2'
  36. $('help').firstElementChild.hidden = true
  37. $('help').lastElementChild.textContent = '更换服务器不会迁移原服务器上的数据。连接失败时请检查服务器地址和网络。'
  38. }
  39. const local = state.mode === 'local'
  40. const ready = !local && ['ready', 'authenticating', 'entering'].includes(state.phase) && !serverDirty
  41. const setup = ready && state.setupRequired
  42. const confirm = setup || registering
  43. const accountTitle = setup ? '创建管理员' : registering ? '注册子账号' : '登录账号'
  44. $('register-toggle').hidden = !ready || setup || state.authenticated
  45. $('register-toggle').disabled = busy
  46. $('register-toggle').textContent = registering ? '已有账号?返回登录' : '没有账号?注册子账号'
  47. $('registration-notice').textContent = state.registrationNotice || ''
  48. $('registration-notice').hidden = !ready || !state.registrationNotice
  49. $('mode-options').disabled = busy
  50. for (const input of document.querySelectorAll('[name=mode]')) input.checked = input.value === (state.mode || draftMode)
  51. $('continue').disabled = busy || !draftMode
  52. $('remember').disabled = busy
  53. $('prepare-section').classList.toggle('pending', !state.mode)
  54. $('prepare-section').classList.toggle('local-ready', local && ready)
  55. $('prepare-heading').textContent = local ? '准备本机服务' : state.mode ? '连接服务器' : '准备服务'
  56. $('choose-hint').hidden = !!state.mode
  57. $('local-panel').hidden = !local
  58. $('connection-form').hidden = state.mode !== 'remote'
  59. $('server').disabled = busy
  60. $('detect').disabled = busy || !$('server').value.trim()
  61. $('detect').textContent = busy && !ready ? '正在检测…' : '检测并继续'
  62. $('prepare-local').hidden = !local || state.phase !== 'idle'
  63. $('prepare-local').disabled = busy
  64. $('actions').hidden = !local || !(state.phase === 'error' || state.error)
  65. $('retry').disabled = busy
  66. $('force-sync').hidden = !state.canForceSync
  67. $('force-sync').disabled = busy
  68. const preparationError = !ready && !serverDirty && (actionError || state.error)
  69. $('status').textContent = preparationError || (busy && !ready ? local ? state.message : '正在检测服务器连接…' : serverDirty ? '' : state.message)
  70. $('status').setAttribute('role', preparationError ? 'alert' : 'status')
  71. $('status').classList.toggle('success', ready && !preparationError)
  72. $('prepare-section').setAttribute('aria-busy', String(busy && !ready))
  73. $('account-heading').textContent = accountTitle
  74. $('account-step-title').textContent = accountTitle
  75. $('account-section').classList.toggle('pending', !ready)
  76. $('account-section').setAttribute('aria-busy', String(busy && ready))
  77. $('account-hint').textContent = !ready ? ''
  78. : state.authenticated ? `已登录${state.username ? `:${state.username}` : ''},可以继续进入夭夭。`
  79. : setup ? '首次使用,请设置管理员账号和密码。密码至少 8 个字符。' : registering ? '子账号仅可使用 Bot 模式,注册后需管理员分配机器人并开通。' : '登录所选服务器的夭夭账号。'
  80. $('login-form').hidden = !ready
  81. $('credentials').hidden = state.authenticated
  82. $('confirmation-field').hidden = !confirm
  83. $('username').disabled = busy || state.authenticated || !ready
  84. $('password').disabled = busy || state.authenticated || !ready
  85. $('confirmation').disabled = busy || !confirm || state.authenticated
  86. $('confirmation').required = confirm && !state.authenticated
  87. $('password').minLength = confirm ? 8 : 1
  88. $('password').autocomplete = confirm ? 'new-password' : 'current-password'
  89. $('toggle-password').disabled = busy
  90. $('submit').disabled = busy
  91. $('submit').querySelector('span').textContent = state.phase === 'authenticating' || pending && ready ? (confirm ? '正在提交…' : '正在登录…')
  92. : state.phase === 'entering' ? '正在进入…' : state.authenticated ? '进入夭夭' : setup ? '创建并进入夭夭' : registering ? '注册子账号' : '登录并进入夭夭'
  93. $('login-error').textContent = ready ? actionError || state.error : ''
  94. $('login-error').hidden = !$('login-error').textContent
  95. $('waiting').hidden = !busy || ready
  96. $('waiting').textContent = local ? '正在准备服务,请稍候' : '正在检测连接,请稍候'
  97. const activeStep = ready ? 2 : state.mode ? 1 : 0
  98. $('choice-section').hidden = activeStep !== 0 || Boolean(clientOnly)
  99. $('prepare-section').hidden = activeStep !== 1
  100. $('account-section').hidden = activeStep !== 2
  101. $('step-account').hidden = local
  102. $('back').hidden = activeStep === 0 || Boolean(clientOnly && activeStep === 1)
  103. $('back').disabled = busy
  104. $('change-server').disabled = busy
  105. let serverLabel = state.serverURL || ''
  106. try { serverLabel = new URL(serverLabel).host } catch { /* Editing an unvalidated saved address is still possible. */ }
  107. $('server-summary').textContent = `已连接 · ${serverLabel}`
  108. $('page-title').textContent = activeStep === 0 ? '开始使用夭夭' : activeStep === 1 ? local ? '准备本机服务' : '连接你的服务器' : registering ? '注册子账号' : '登录夭夭'
  109. $('page-description').textContent = activeStep === 0 ? '选择适合你的使用方式。' : activeStep === 1 ? local ? '准备完成后,以管理员身份直接进入。' : '输入服务器地址,确认连接后继续。' : registering ? '提交后需管理员分配机器人并开通,才能登录使用。' : '使用此服务器的账号继续。'
  110. const stepCount = clientOnly || local ? 2 : 3
  111. $('step-caption').textContent = `第 ${activeStep + (clientOnly ? 0 : 1)} 步,共 ${stepCount} 步`
  112. $('onboarding').dataset.step = String(activeStep)
  113. if (previousStep !== activeStep) { previousStep = activeStep; $('help').hidden = true; $('help-button').setAttribute('aria-expanded', 'false'); $('page-title').focus() }
  114. for (const [index, id] of ['step-choice', 'step-prepare', 'step-account'].entries()) {
  115. $(id).classList.toggle('complete', index < activeStep)
  116. if (index === activeStep) $(id).setAttribute('aria-current', 'step')
  117. else $(id).removeAttribute('aria-current')
  118. }
  119. const stages = ['checking', 'installing', 'starting']
  120. const current = stages.indexOf(state.stage)
  121. for (const [index, row] of [...document.querySelectorAll('[data-stage]')].entries()) {
  122. const complete = ready || (state.phase !== 'idle' && index < current)
  123. const active = !complete && state.phase === 'preparing' && index === current
  124. row.classList.toggle('complete', complete)
  125. row.classList.toggle('active', active)
  126. row.querySelector('use').setAttribute('href', `boot-icons.svg#${complete ? 'check' : active ? 'loader' : 'circle'}`)
  127. row.querySelector('small').textContent = complete ? '已完成' : active ? '进行中' : state.phase === 'error' && index === current ? '未完成' : '等待中'
  128. }
  129. $('install-progress').hidden = !local || state.phase !== 'preparing'
  130. document.querySelector('.install-steps').hidden = local && ready
  131. const history = state.history?.join('\n') || '准备开始'
  132. if ($('install-log').textContent !== history) $('install-log').textContent = history
  133. }
  134. async function action(run, { resetServer = false, focusAccount = false } = {}) {
  135. if (pending) return
  136. pending = true; actionError = ''; generation++
  137. if (state) display(state)
  138. try {
  139. const next = await run()
  140. if (resetServer) {
  141. if (next.phase === 'ready') $('server').value = next.serverURL || ''
  142. serverDirty = false
  143. }
  144. display(next)
  145. } catch (error) {
  146. actionError = error.message || '操作未完成,请重试。'
  147. } finally {
  148. pending = false
  149. if (state) display(state)
  150. if (focusAccount && state?.phase === 'ready' && !state.authenticated) $('username').focus()
  151. }
  152. }
  153. for (const input of document.querySelectorAll('[name=mode]')) input.addEventListener('change', () => {
  154. draftMode = input.value
  155. display(state)
  156. })
  157. $('continue').onclick = () => {
  158. $('login-form').reset(); $('password').type = 'password'
  159. $('toggle-password').setAttribute('aria-pressed', 'false'); $('toggle-password').setAttribute('aria-label', '显示密码')
  160. $('password').removeAttribute('aria-invalid'); $('confirmation').removeAttribute('aria-invalid')
  161. serverDirty = false; registering = false
  162. void action(() => api.selectServer(draftMode))
  163. }
  164. function back() {
  165. if (pending || state.busy) return
  166. const mode = ['ready', 'authenticating', 'entering'].includes(state.phase) ? state.mode : null
  167. $('password').value = ''; $('confirmation').value = ''; registering = false; serverDirty = false
  168. void action(() => api.selectServer(mode))
  169. }
  170. $('back').onclick = back
  171. $('change-server').onclick = back
  172. $('server').addEventListener('input', () => {
  173. serverDirty = true; registering = false; actionError = ''
  174. // Credentials belong to the checked server, never carry them to a new address.
  175. $('login-form').reset(); $('password').type = 'password'
  176. $('toggle-password').setAttribute('aria-pressed', 'false'); $('toggle-password').setAttribute('aria-label', '显示密码')
  177. if (state) display(state)
  178. })
  179. $('connection-form').addEventListener('submit', event => {
  180. event.preventDefault()
  181. void action(() => api.prepareServer({ serverURL: $('server').value, remember: $('remember').checked }), { resetServer: true, focusAccount: true })
  182. })
  183. $('prepare-local').onclick = () => action(() => api.prepareServer({ remember: $('remember').checked }), { focusAccount: true })
  184. $('retry').onclick = () => action(() => api.prepareServer({ remember: $('remember').checked }), { focusAccount: true })
  185. $('force-sync').onclick = () => action(() => api.forceSync(), { focusAccount: true })
  186. $('logs').onclick = async () => { try { await api.logs() } catch (error) { actionError = error.message; display(state) } }
  187. $('toggle-password').onclick = () => {
  188. const show = $('password').type === 'password'
  189. $('password').type = show ? 'text' : 'password'
  190. $('toggle-password').setAttribute('aria-label', show ? '隐藏密码' : '显示密码')
  191. $('toggle-password').setAttribute('aria-pressed', String(show))
  192. }
  193. $('confirmation').addEventListener('blur', () => {
  194. const mismatch = $('confirmation').value && $('confirmation').value !== $('password').value
  195. $('confirmation').setAttribute('aria-invalid', String(!!mismatch))
  196. actionError = mismatch ? '两次输入的密码不一致' : ''
  197. display(state)
  198. })
  199. $('login-form').addEventListener('submit', event => {
  200. event.preventDefault()
  201. if (serverDirty || pending) return
  202. if ((state.setupRequired || registering) && !state.authenticated && $('confirmation').value !== $('password').value) {
  203. actionError = '两次输入的密码不一致'; $('confirmation').setAttribute('aria-invalid', 'true'); display(state); $('confirmation').focus(); return
  204. }
  205. void action(async () => {
  206. const next = await api.login({ username: $('username').value, password: $('password').value,
  207. confirmation: $('confirmation').value, register: registering, remember: $('remember').checked })
  208. $('password').value = ''; $('confirmation').value = ''
  209. if (registering && !next.error) registering = false
  210. return next
  211. })
  212. })
  213. $('register-toggle').onclick = () => {
  214. if (!state.registrationAvailable) { actionError = '当前服务器尚不支持子账号注册,请联系管理员升级。'; display(state); return }
  215. registering = !registering; actionError = ''; state.error = ''; state.registrationNotice = ''
  216. $('password').value = ''; $('confirmation').value = ''; display(state); $('username').focus()
  217. }
  218. $('help-button').onclick = () => {
  219. $('help').hidden = !$('help').hidden
  220. $('help-button').setAttribute('aria-expanded', String(!$('help').hidden))
  221. }
  222. async function render() {
  223. const request = generation
  224. try { const next = await window.yaoyaoDesktop.status(); if (request === generation) display(next) }
  225. catch {
  226. if (!state) {
  227. $('session-loading-message').textContent = '无法读取启动状态,请重新打开应用。'
  228. $('session-loading').setAttribute('role', 'alert')
  229. $('session-loading').setAttribute('aria-busy', 'false')
  230. }
  231. }
  232. }
  233. void render()
  234. const poll = setInterval(() => { void render() }, 500)
  235. window.addEventListener('pagehide', () => clearInterval(poll), { once: true })