SystemManagementPanel.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. <script setup lang="ts">
  2. import { computed, ref, watch } from 'vue'
  3. import AppIcon from '@/components/common/AppIcon.vue'
  4. import {
  5. createUser,
  6. deleteUser,
  7. getAllowedHostsSettings,
  8. getUpstreamConnectionStatus,
  9. listUsers,
  10. saveAllowedHostsSettings,
  11. setUpstreamCredentials,
  12. updateUser,
  13. type AllowedHostsSettings,
  14. type ManagedUser,
  15. type UpstreamConnectionStatus,
  16. } from '@/api/admin'
  17. import {
  18. getPushSystemStatus,
  19. saveFCMPushSystemConfig,
  20. savePushSystemConfig,
  21. type APNsEnvironment,
  22. type PushSystemStatus,
  23. } from '@/api/push'
  24. type SystemManagementSection = 'users' | 'connection' | 'push'
  25. const props = withDefaults(defineProps<{
  26. section: SystemManagementSection
  27. active?: boolean
  28. upstreamReady?: boolean
  29. upstreamError?: string
  30. }>(), {
  31. active: true,
  32. upstreamReady: false,
  33. upstreamError: '',
  34. })
  35. const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
  36. const users = ref<ManagedUser[]>([])
  37. const username = ref('')
  38. const password = ref('')
  39. const upstreamUsername = ref('')
  40. const upstreamPassword = ref('')
  41. const connectionBaseline = ref({ username: '', password: '' })
  42. const connectionStatus = ref<UpstreamConnectionStatus>()
  43. const allowedHostsSettings = ref<AllowedHostsSettings>()
  44. const allowedHostsText = ref('')
  45. const allowedHostsBaseline = ref('')
  46. const allowedHostsBusy = ref(false)
  47. const allowedHostsError = ref('')
  48. const allowedHostsNotice = ref('')
  49. const busy = ref(false)
  50. const error = ref('')
  51. const pushStatus = ref<PushSystemStatus>()
  52. const pushStatusLoadError = ref('')
  53. const pushBusy = ref(false)
  54. const pushError = ref('')
  55. const pushNotice = ref('')
  56. const pushKeyFile = ref('')
  57. const pushKeyId = ref('')
  58. const pushTeamId = ref('')
  59. const pushTopic = ref('cn.samien.yaoyao.hermes')
  60. const pushEnvironments = ref<APNsEnvironment[]>(['development', 'production'])
  61. const fcmServiceAccountFile = ref('')
  62. const fcmProjectId = ref('')
  63. const fcmPackageName = ref('cn.samien.yaoyao.hermes')
  64. const fcmBusy = ref(false)
  65. const fcmError = ref('')
  66. const fcmNotice = ref('')
  67. const currentAccessHost = window.location.hostname || 'localhost'
  68. const effectiveUpstreamReady = computed(() => connectionStatus.value?.ready ?? props.upstreamReady)
  69. const effectiveUpstreamError = computed(() => connectionStatus.value?.error || props.upstreamError)
  70. const passwordAuthentication = computed(() => !['loopback-token', 'loopback-direct'].includes(connectionStatus.value?.authMode ?? 'unknown'))
  71. const authModeLabel = computed(() => {
  72. if (connectionStatus.value?.authMode === 'loopback-token') return '本机 Session Token(自动)'
  73. if (connectionStatus.value?.authMode === 'loopback-direct') return '本机直连(兼容模式)'
  74. if (connectionStatus.value?.authMode === 'password') return '用户名和密码'
  75. return '等待检测'
  76. })
  77. const verifiedAtLabel = computed(() => {
  78. const value = connectionStatus.value?.lastVerifiedAt
  79. return value === undefined ? '尚未成功验证' : new Date(value).toLocaleString()
  80. })
  81. interface APNsFormSnapshot {
  82. keyFile: string
  83. keyId: string
  84. teamId: string
  85. topic: string
  86. environments: string
  87. }
  88. interface FCMFormSnapshot {
  89. serviceAccountFile: string
  90. projectId: string
  91. packageName: string
  92. }
  93. const apnsBaseline = ref<APNsFormSnapshot>()
  94. const fcmBaseline = ref<FCMFormSnapshot>()
  95. const pushManagedByEnvironment = computed(() => pushStatus.value?.source === 'environment')
  96. const fcmStatus = computed(() => pushStatus.value?.providers?.fcm)
  97. const fcmManagedByEnvironment = computed(() => fcmStatus.value?.source === 'environment')
  98. const fcmCanSave = computed(() => Boolean(
  99. fcmStatus.value?.managementAvailable && fcmStatus.value.editable && !fcmBusy.value && !pushBusy.value,
  100. ))
  101. const fcmStatusText = computed(() => {
  102. if (!fcmStatus.value) return '当前版本未提供 Android 推送状态'
  103. if (!fcmStatus.value.configured && fcmStatus.value.lastError) return `FCM 配置无效:${fcmStatus.value.lastError}`
  104. if (!fcmStatus.value.configured) return '尚未配置 FCM 服务账号文件'
  105. if (fcmStatus.value.healthy) return 'FCM 推送服务正常'
  106. return fcmStatus.value.lastError || 'FCM 推送服务异常'
  107. })
  108. const pushCanSave = computed(() => Boolean(
  109. pushStatus.value?.managementAvailable
  110. && pushStatus.value.editable
  111. && !pushBusy.value
  112. && !fcmBusy.value,
  113. ))
  114. const pushStatusText = computed(() => {
  115. if (!pushStatus.value) return pushStatusLoadError.value || '当前版本未提供推送状态'
  116. if (!pushStatus.value.configured && pushStatus.value.lastError) return `APNs 配置无效:${pushStatus.value.lastError}`
  117. if (!pushStatus.value.configured) return '尚未配置 APNs 密钥文件'
  118. if (pushStatus.value.healthy) return 'APNs 推送服务正常'
  119. return pushStatus.value.lastError || 'APNs 推送服务异常'
  120. })
  121. function apnsSnapshot(): APNsFormSnapshot {
  122. return {
  123. keyFile: pushKeyFile.value,
  124. keyId: pushKeyId.value,
  125. teamId: pushTeamId.value,
  126. topic: pushTopic.value,
  127. environments: [...pushEnvironments.value].sort().join(','),
  128. }
  129. }
  130. function fcmSnapshot(): FCMFormSnapshot {
  131. return {
  132. serviceAccountFile: fcmServiceAccountFile.value,
  133. projectId: fcmProjectId.value,
  134. packageName: fcmPackageName.value,
  135. }
  136. }
  137. const dirty = computed(() => {
  138. if (props.section === 'users') return Boolean(username.value.trim() || password.value)
  139. if (props.section === 'connection') {
  140. return upstreamUsername.value !== connectionBaseline.value.username
  141. || upstreamPassword.value !== connectionBaseline.value.password
  142. || allowedHostsText.value !== allowedHostsBaseline.value
  143. }
  144. if (props.section !== 'push') return false
  145. const apnsDirty = Boolean(apnsBaseline.value) && Object.keys(apnsSnapshot()).some(key => apnsSnapshot()[key as keyof APNsFormSnapshot] !== apnsBaseline.value?.[key as keyof APNsFormSnapshot])
  146. const fcmDirty = Boolean(fcmBaseline.value) && Object.keys(fcmSnapshot()).some(key => fcmSnapshot()[key as keyof FCMFormSnapshot] !== fcmBaseline.value?.[key as keyof FCMFormSnapshot])
  147. return apnsDirty || fcmDirty
  148. })
  149. watch(dirty, value => emit('dirty-change', value), { immediate: true })
  150. function hydrateAPNsForm(status: PushSystemStatus) {
  151. pushKeyFile.value = status.keyFile ?? ''
  152. pushKeyId.value = status.keyId ?? ''
  153. pushTeamId.value = status.teamId ?? ''
  154. pushTopic.value = status.topic || 'cn.samien.yaoyao.hermes'
  155. pushEnvironments.value = status.environments.length
  156. ? [...status.environments]
  157. : ['development', 'production']
  158. apnsBaseline.value = apnsSnapshot()
  159. }
  160. function hydrateFCMForm(status: PushSystemStatus) {
  161. const fcm = status.providers?.fcm
  162. fcmServiceAccountFile.value = fcm?.serviceAccountFile ?? ''
  163. fcmProjectId.value = fcm?.projectId ?? ''
  164. fcmPackageName.value = fcm?.packageName || 'cn.samien.yaoyao.hermes'
  165. fcmBaseline.value = fcmSnapshot()
  166. }
  167. function hydratePushForm(status: PushSystemStatus) {
  168. hydrateAPNsForm(status)
  169. hydrateFCMForm(status)
  170. }
  171. async function refresh() {
  172. error.value = ''
  173. if (props.section === 'users') {
  174. users.value = await listUsers()
  175. return
  176. }
  177. if (props.section === 'connection') {
  178. const [settings, status] = await Promise.all([
  179. getAllowedHostsSettings(),
  180. getUpstreamConnectionStatus(),
  181. ])
  182. allowedHostsSettings.value = settings
  183. connectionStatus.value = status
  184. allowedHostsText.value = settings.editableHosts.join('\n')
  185. allowedHostsBaseline.value = allowedHostsText.value
  186. return
  187. }
  188. if (props.section !== 'push') return
  189. pushStatusLoadError.value = ''
  190. const next = await getPushSystemStatus().catch(cause => {
  191. if ((cause as { status?: unknown })?.status !== 404) {
  192. pushStatusLoadError.value = `无法读取 APNs 状态:${cause instanceof Error ? cause.message : '请求失败'}`
  193. }
  194. return undefined
  195. })
  196. pushStatus.value = next
  197. if (next) hydratePushForm(next)
  198. else {
  199. apnsBaseline.value = undefined
  200. fcmBaseline.value = undefined
  201. }
  202. }
  203. defineExpose({ refresh })
  204. async function run(action: () => Promise<void>, refreshAfter = true) {
  205. if (busy.value) return
  206. busy.value = true
  207. error.value = ''
  208. try {
  209. await action()
  210. if (refreshAfter) await refresh()
  211. } catch (cause) {
  212. error.value = cause instanceof Error ? cause.message : '操作失败'
  213. } finally {
  214. busy.value = false
  215. }
  216. }
  217. function add() {
  218. void run(async () => {
  219. await createUser(username.value.trim(), password.value)
  220. username.value = ''
  221. password.value = ''
  222. })
  223. }
  224. function toggle(user: ManagedUser) {
  225. void run(async () => { await updateUser(user.id, { enabled: !user.enabled }) })
  226. }
  227. function reset(user: ManagedUser) {
  228. const value = window.prompt(`为“${user.username}”设置临时密码(至少 8 个字符)`)
  229. if (value) void run(async () => { await updateUser(user.id, { password: value }) })
  230. }
  231. function remove(user: ManagedUser) {
  232. if (window.confirm(`删除用户“${user.username}”?`)) {
  233. void run(async () => { await deleteUser(user.id) })
  234. }
  235. }
  236. async function saveUpstream() {
  237. if (busy.value) return
  238. const submittedUsername = upstreamUsername.value.trim()
  239. const submittedPassword = upstreamPassword.value
  240. await run(async () => {
  241. await setUpstreamCredentials(submittedUsername, submittedPassword)
  242. upstreamUsername.value = submittedUsername
  243. upstreamPassword.value = ''
  244. connectionBaseline.value = { username: submittedUsername, password: '' }
  245. })
  246. }
  247. function parsedAllowedHosts(): string[] {
  248. return allowedHostsText.value
  249. .split(/[\s,]+/)
  250. .map(value => value.trim())
  251. .filter(Boolean)
  252. }
  253. async function saveAllowedHosts() {
  254. if (allowedHostsBusy.value) return
  255. allowedHostsBusy.value = true
  256. allowedHostsError.value = ''
  257. allowedHostsNotice.value = ''
  258. try {
  259. const settings = await saveAllowedHostsSettings(parsedAllowedHosts())
  260. allowedHostsSettings.value = settings
  261. allowedHostsText.value = settings.editableHosts.join('\n')
  262. allowedHostsBaseline.value = allowedHostsText.value
  263. allowedHostsNotice.value = '允许的访问地址已保存并立即生效'
  264. } catch (cause) {
  265. allowedHostsError.value = cause instanceof Error ? cause.message : '无法保存允许的访问地址'
  266. } finally {
  267. allowedHostsBusy.value = false
  268. }
  269. }
  270. async function savePush() {
  271. if (!pushStatus.value?.editable || pushManagedByEnvironment.value || pushBusy.value || fcmBusy.value) return
  272. const keyFile = pushKeyFile.value.trim()
  273. const keyId = pushKeyId.value.trim()
  274. const teamId = pushTeamId.value.trim()
  275. const topic = pushTopic.value.trim()
  276. const environments = (['development', 'production'] as const)
  277. .filter(environment => pushEnvironments.value.includes(environment))
  278. pushError.value = ''
  279. pushNotice.value = ''
  280. if (!keyFile.startsWith('/')) {
  281. pushError.value = '请输入 15300 所在机器上的 .p8 文件绝对路径'
  282. return
  283. }
  284. if (!/^[A-Za-z0-9]{1,128}$/.test(keyId)) {
  285. pushError.value = 'Key ID 仅支持 1–128 位字母或数字'
  286. return
  287. }
  288. if (!/^[A-Za-z0-9]{1,128}$/.test(teamId)) {
  289. pushError.value = 'Team ID 仅支持 1–128 位字母或数字'
  290. return
  291. }
  292. if (topic.length > 255 || !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(topic)) {
  293. pushError.value = 'Topic 必须是有效的 App Bundle ID'
  294. return
  295. }
  296. if (!environments.length) {
  297. pushError.value = '至少选择 Sandbox 或 Production'
  298. return
  299. }
  300. pushBusy.value = true
  301. try {
  302. const next = await savePushSystemConfig({ keyFile, keyId, teamId, topic, environments })
  303. pushStatus.value = next
  304. hydrateAPNsForm(next)
  305. const environmentText = environments.length === 2
  306. ? 'Sandbox 与 Production'
  307. : environments[0] === 'development' ? 'Sandbox' : 'Production'
  308. pushNotice.value = `${environmentText} 验证通过,APNs 已启用`
  309. } catch (cause) {
  310. pushError.value = cause instanceof Error ? cause.message : '无法验证并启用 APNs'
  311. } finally {
  312. pushBusy.value = false
  313. }
  314. }
  315. async function saveFCM() {
  316. if (!fcmStatus.value?.editable || fcmManagedByEnvironment.value || fcmBusy.value || pushBusy.value) return
  317. const serviceAccountFile = fcmServiceAccountFile.value.trim()
  318. const projectId = fcmProjectId.value.trim()
  319. const packageName = fcmPackageName.value.trim()
  320. fcmError.value = ''
  321. fcmNotice.value = ''
  322. if (!serviceAccountFile.startsWith('/')) {
  323. fcmError.value = '请输入 15300 所在机器上的服务账号 JSON 文件绝对路径'
  324. return
  325. }
  326. if (!/^[a-z][a-z0-9-]{4,61}[a-z0-9]$/.test(projectId)) {
  327. fcmError.value = '请输入有效的 Firebase Project ID'
  328. return
  329. }
  330. if (packageName !== 'cn.samien.yaoyao.hermes') {
  331. fcmError.value = 'Android 包名必须是 cn.samien.yaoyao.hermes'
  332. return
  333. }
  334. fcmBusy.value = true
  335. try {
  336. const next = await saveFCMPushSystemConfig({ serviceAccountFile, projectId, packageName })
  337. pushStatus.value = next
  338. hydrateFCMForm(next)
  339. fcmNotice.value = 'Firebase 权限与项目验证通过,FCM 已启用'
  340. } catch (cause) {
  341. fcmError.value = cause instanceof Error ? cause.message : '无法验证并启用 FCM'
  342. } finally {
  343. fcmBusy.value = false
  344. }
  345. }
  346. watch(() => [props.active, props.section] as const, ([active, section]) => {
  347. if (!active) return
  348. if (section === 'push') {
  349. pushError.value = ''
  350. pushNotice.value = ''
  351. fcmError.value = ''
  352. fcmNotice.value = ''
  353. }
  354. if (section === 'connection') {
  355. allowedHostsError.value = ''
  356. allowedHostsNotice.value = ''
  357. }
  358. void refresh().catch(cause => {
  359. error.value = cause instanceof Error ? cause.message : '读取系统设置失败'
  360. })
  361. }, { immediate: true })
  362. </script>
  363. <template>
  364. <section class="system-management-panel" :data-section="section">
  365. <p v-if="error" class="error" role="alert">{{ error }}</p>
  366. <div v-if="section === 'users'" class="block">
  367. <h3>用户</h3>
  368. <div v-for="user in users" :key="user.id" class="user">
  369. <span>
  370. <b>{{ user.username }}</b>
  371. <small>{{ user.role === 'admin' ? '管理员' : user.enabled ? (user.mustChangePassword ? '等待修改临时密码' : '普通用户') : '已禁用' }}</small>
  372. </span>
  373. <template v-if="user.role !== 'admin'">
  374. <button type="button" :aria-label="`为用户 ${user.username} 重置密码`" :disabled="busy" @click="reset(user)">重置密码</button>
  375. <button type="button" :aria-label="`${user.enabled ? '禁用' : '启用'}用户 ${user.username}`" :disabled="busy" @click="toggle(user)">{{ user.enabled ? '禁用' : '启用' }}</button>
  376. <button class="danger" type="button" :aria-label="`删除用户 ${user.username}`" :disabled="busy" @click="remove(user)">删除</button>
  377. </template>
  378. </div>
  379. <form class="user-create-form" aria-label="创建用户" @submit.prevent="add">
  380. <label><span>新用户名</span><input v-model="username" name="managed-username" autocomplete="off" :disabled="busy" /></label>
  381. <label><span>临时密码</span><input v-model="password" name="managed-temporary-password" type="password" autocomplete="new-password" :disabled="busy" /><small>至少 8 位</small></label>
  382. <button class="solid-button" :disabled="busy || !username.trim() || password.length < 8">创建用户</button>
  383. </form>
  384. </div>
  385. <div v-else-if="section === 'connection'" class="block">
  386. <h3>Hermes 连接</h3>
  387. <p :class="{ ok: effectiveUpstreamReady }">{{ effectiveUpstreamReady ? '9119 连接正常' : effectiveUpstreamError || '尚未验证上游连接' }}</p>
  388. <div class="connection-summary" aria-label="Hermes 连接状态">
  389. <span><small>9119 地址</small><b>{{ connectionStatus?.endpoint || '等待检测' }}</b></span>
  390. <span><small>认证模式</small><b>{{ authModeLabel }}</b></span>
  391. <span><small>15300 网络范围</small><b>{{ connectionStatus?.webNetworkScope === 'local' ? '仅本机' : '局域网可访问' }}</b></span>
  392. <span><small>9119 网络范围</small><b>{{ connectionStatus?.networkScope === 'network' ? '网络可访问' : '仅本机' }}</b></span>
  393. <span class="wide"><small>最近验证</small><b>{{ verifiedAtLabel }}</b></span>
  394. </div>
  395. <p v-if="connectionStatus?.authMode === 'loopback-token'" class="connection-mode-note">9119 使用本机临时 Token,15300 会自动读取并在 9119 重启后刷新;Token 不会在设置中显示。</p>
  396. <p v-else-if="connectionStatus?.authMode === 'loopback-direct'" class="connection-mode-note">当前 9119 允许本机直接访问,不需要账号或 Token;该兼容模式只允许回环地址使用。</p>
  397. <p class="network-description">直连本机 127.0.0.1 或 ::1 且 Hermes 启用本机授权时,自动使用临时会话令牌,无需配置用户名和密码。远程上游或已启用账号鉴权的 9119 仍需服务账号;15300 登录保护保持不变,不会自动修改 Hermes 账号配置。</p>
  398. <h4 v-if="passwordAuthentication">服务账号(按需配置)</h4>
  399. <form v-if="passwordAuthentication" @submit.prevent="saveUpstream">
  400. <label><span>9119 用户名</span><input v-model="upstreamUsername" name="upstream-username" autocomplete="off" :disabled="busy" /></label>
  401. <label><span>9119 密码</span><input v-model="upstreamPassword" name="upstream-password" type="password" autocomplete="new-password" :disabled="busy" /></label>
  402. <button class="solid-button" :disabled="busy || !upstreamUsername.trim() || !upstreamPassword">验证并保存</button>
  403. </form>
  404. <div class="block network-access-block">
  405. <h3>外网访问地址</h3>
  406. <p class="network-description">允许通过指定域名或公网 IP 访问 15300。每行填写一个地址,不要包含 <code>http://</code>、端口或路径。</p>
  407. <form class="allowed-hosts-form" @submit.prevent="saveAllowedHosts">
  408. <label>
  409. <span>允许的域名和 IP</span>
  410. <textarea v-model="allowedHostsText" name="allowed-hosts" rows="5" :disabled="allowedHostsBusy" spellcheck="false" placeholder="yaoyao.example.com&#10;203.0.113.10&#10;2001:db8::10"></textarea>
  411. <small>当前访问地址:{{ currentAccessHost }}。保存后立即生效,无需重启 Web。</small>
  412. </label>
  413. <div v-if="allowedHostsSettings?.environmentHosts.length" class="environment-hosts">
  414. <small>环境变量保留地址(Web 中不能移除)</small>
  415. <code>{{ allowedHostsSettings.environmentHosts.join(', ') }}</code>
  416. </div>
  417. <p v-if="allowedHostsSettings?.configurationError" class="allowed-hosts-error" role="alert">现有配置异常:{{ allowedHostsSettings.configurationError }}</p>
  418. <p v-if="allowedHostsError" class="allowed-hosts-error" role="alert">{{ allowedHostsError }}</p>
  419. <p v-else-if="allowedHostsNotice" class="allowed-hosts-notice" role="status">{{ allowedHostsNotice }}</p>
  420. <footer><button class="solid-button" :disabled="allowedHostsBusy || allowedHostsText === allowedHostsBaseline">{{ allowedHostsBusy ? '保存中…' : '保存访问地址' }}</button></footer>
  421. </form>
  422. </div>
  423. </div>
  424. <template v-else>
  425. <div class="block push-block">
  426. <h3>iOS 消息推送</h3>
  427. <p class="push-health" :class="{ ok: pushStatus?.configured && pushStatus?.healthy }">{{ pushStatusText }}</p>
  428. <div v-if="pushStatus" class="push-status">
  429. <span><small>Topic</small><b>{{ pushStatus.topic || '—' }}</b></span>
  430. <span><small>注册设备</small><b>{{ pushStatus.registrationCount }}</b></span>
  431. <span><small>待发送</small><b>{{ pushStatus.pendingCount }}</b></span>
  432. </div>
  433. <template v-if="pushStatus?.managementAvailable">
  434. <p v-if="pushManagedByEnvironment" class="push-managed-note">当前 APNs 配置由服务环境变量管理,Web 仅可查看。请修改 LaunchAgent、Docker 或进程环境后重启服务。</p>
  435. <p v-else-if="!pushStatus.editable" class="push-managed-note">当前 APNs 配置为只读,请在 15300 服务端修改后重启服务。</p>
  436. <form class="push-config-form" @submit.prevent="savePush">
  437. <label class="wide"><span>.p8 本地路径</span><input v-model="pushKeyFile" name="push-key-file" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" placeholder="/Users/…/AuthKey_XXXXXXXXXX.p8" /><small>填写 15300 所在机器上的绝对路径,私钥内容不会经过浏览器。</small></label>
  438. <label><span>Key ID</span><input v-model="pushKeyId" name="push-key-id" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" /></label>
  439. <label><span>Team ID</span><input v-model="pushTeamId" name="push-team-id" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" /></label>
  440. <label class="wide"><span>Topic(Bundle ID)</span><input v-model="pushTopic" name="push-topic" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" /></label>
  441. <fieldset class="wide" :disabled="!pushStatus.editable || pushBusy || fcmBusy">
  442. <legend>推送环境</legend>
  443. <label><input v-model="pushEnvironments" type="checkbox" value="development" />Sandbox(开发)</label>
  444. <label><input v-model="pushEnvironments" type="checkbox" value="production" />Production(TestFlight / App Store)</label>
  445. </fieldset>
  446. <div v-if="pushStatus.warnings.length" class="wide push-warnings" aria-live="polite">
  447. <p v-for="warning in pushStatus.warnings" :key="`${warning.code}:${warning.message}`" class="push-warning" role="status"><AppIcon name="alert" :size="14" />{{ warning.message }}</p>
  448. </div>
  449. <p v-if="pushError" class="wide push-error" role="alert">{{ pushError }}</p>
  450. <p v-else-if="pushNotice" class="wide push-notice" role="status">{{ pushNotice }}</p>
  451. <footer v-if="pushStatus.editable" class="wide"><button class="solid-button" :disabled="!pushCanSave">{{ pushBusy ? '验证中…' : '验证并启用' }}</button></footer>
  452. </form>
  453. </template>
  454. <p v-else-if="pushStatus" class="push-managed-note">当前服务版本只提供推送状态,不能在 Web 中修改配置。</p>
  455. </div>
  456. <div class="block push-block">
  457. <h3>Android 消息推送</h3>
  458. <p class="push-health" :class="{ ok: fcmStatus?.configured && fcmStatus?.healthy }">{{ fcmStatusText }}</p>
  459. <div v-if="fcmStatus" class="push-status">
  460. <span><small>Firebase Project</small><b>{{ fcmStatus.projectId || '—' }}</b></span>
  461. <span><small>注册设备</small><b>{{ fcmStatus.registrationCount }}</b></span>
  462. <span><small>待发送</small><b>{{ fcmStatus.pendingCount }}</b></span>
  463. </div>
  464. <template v-if="fcmStatus?.managementAvailable">
  465. <p v-if="fcmManagedByEnvironment" class="push-managed-note">当前 FCM 配置由服务环境变量管理,Web 仅可查看。请修改 LaunchAgent、Docker 或进程环境后重启服务。</p>
  466. <p v-else-if="!fcmStatus.editable" class="push-managed-note">当前 FCM 配置为只读,请在 15300 服务端修改后重启服务。</p>
  467. <form class="push-config-form" @submit.prevent="saveFCM">
  468. <label class="wide"><span>服务账号 JSON 本地路径</span><input v-model="fcmServiceAccountFile" name="fcm-service-account-file" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" placeholder="/Users/…/firebase-service-account.json" /><small>只填写 15300 所在机器上的绝对路径;JSON 内容和私钥不会经过浏览器,也不会写入夭夭配置。</small></label>
  469. <label><span>Firebase Project ID</span><input v-model="fcmProjectId" name="fcm-project-id" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" /></label>
  470. <label><span>Android 包名</span><input v-model="fcmPackageName" name="fcm-package-name" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" /></label>
  471. <div v-if="fcmStatus.warnings.length" class="wide push-warnings" aria-live="polite">
  472. <p v-for="warning in fcmStatus.warnings" :key="`${warning.code}:${warning.message}`" class="push-warning" role="status"><AppIcon name="alert" :size="14" />{{ warning.message }}</p>
  473. </div>
  474. <p v-if="fcmError" class="wide push-error" role="alert">{{ fcmError }}</p>
  475. <p v-else-if="fcmNotice" class="wide push-notice" role="status">{{ fcmNotice }}</p>
  476. <footer v-if="fcmStatus.editable" class="wide"><button class="solid-button" :disabled="!fcmCanSave">{{ fcmBusy ? '验证中…' : '验证并启用' }}</button></footer>
  477. </form>
  478. </template>
  479. <p v-else-if="fcmStatus" class="push-managed-note">当前服务版本只提供 FCM 状态,不能在 Web 中修改配置。</p>
  480. </div>
  481. </template>
  482. </section>
  483. </template>
  484. <style scoped>
  485. .system-management-panel { display: grid; }
  486. .block { min-width: 0; margin: 0; padding: 0; }
  487. .block + .block { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--line); }
  488. .block h3 { margin: 0 0 14px; font-size: 16px; }
  489. .block p { color: var(--danger); font-size: 13px; }
  490. .block p.ok { color: var(--success, #21845b); }
  491. .connection-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 14px; }
  492. .connection-summary span { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 12px; border-radius: 9px; background: var(--surface-soft); }
  493. .connection-summary span.wide { grid-column: 1 / -1; }
  494. .connection-summary small { color: var(--text-muted); font-size: 11px; }
  495. .connection-summary b { overflow-wrap: anywhere; color: var(--text-primary); font-size: 13px; }
  496. .block p.connection-mode-note { margin: 12px 0 0; padding: 9px 10px; border-radius: 9px; background: var(--surface-soft); color: var(--text-muted); line-height: 1.55; }
  497. .network-description { margin: 0; color: var(--text-muted) !important; line-height: 1.6; }
  498. .network-access-block { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--line); }
  499. .network-description code { color: var(--text-secondary); }
  500. .allowed-hosts-form { display: grid; grid-template-columns: 1fr; align-items: stretch; }
  501. .allowed-hosts-form textarea { min-width: 0; resize: vertical; padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-raised); color: var(--text-primary); font: 13px/1.55 var(--font-mono, monospace); }
  502. .allowed-hosts-form textarea:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
  503. .environment-hosts { display: grid; gap: 5px; padding: 10px 11px; border-radius: 9px; background: var(--surface-soft); }
  504. .environment-hosts small { color: var(--text-muted); font-size: 11px; }
  505. .environment-hosts code { overflow-wrap: anywhere; color: var(--text-secondary); font-size: 12px; }
  506. .allowed-hosts-error,.allowed-hosts-notice { margin: 0; padding: 9px 10px; border-radius: 9px; line-height: 1.55; }
  507. .allowed-hosts-error { background: color-mix(in srgb, var(--danger) 8%, transparent); }
  508. .allowed-hosts-notice { background: color-mix(in srgb, var(--success, #21845b) 9%, transparent); color: var(--success, #21845b) !important; }
  509. .allowed-hosts-form footer { display: flex; justify-content: flex-end; }
  510. .error { margin: 0 0 12px; color: var(--danger); font-size: 13px; }
  511. .push-status { display: grid; grid-template-columns: minmax(0, 2fr) 1fr 1fr; gap: 8px; }
  512. .push-status span { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 12px; border-radius: 9px; background: var(--surface-soft); }
  513. .push-status small { color: var(--text-muted); font-size: 11px; }
  514. .push-status b { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
  515. .push-config-form { grid-template-columns: 1fr 1fr; margin-top: 16px; padding: 16px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); }
  516. .push-config-form .wide { grid-column: 1 / -1; }
  517. .push-config-form > label { display: grid; min-width: 0; gap: 6px; color: var(--text-secondary); font-size: 12px; font-weight: 650; }
  518. .push-config-form > label small { color: var(--text-muted); font-size: 11px; font-weight: 400; line-height: 1.5; }
  519. .push-config-form input[readonly] { opacity: .72; cursor: default; }
  520. .push-config-form fieldset { display: flex; flex-wrap: wrap; gap: 12px; margin: 0; padding: 9px 10px; border: 1px solid var(--line); border-radius: 9px; }
  521. .push-config-form legend { padding: 0 4px; color: var(--text-secondary); font-size: 12px; font-weight: 650; }
  522. .push-config-form fieldset label { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 12px; }
  523. .push-config-form fieldset input { width: 14px; height: 14px; accent-color: var(--accent); }
  524. .push-managed-note { margin: 10px 0 0; padding: 9px 10px; border-radius: 9px; background: var(--surface-soft); color: var(--text-muted) !important; line-height: 1.55; }
  525. .push-warnings { display: grid; gap: 6px; }
  526. .push-warning { display: flex; align-items: flex-start; gap: 6px; margin: 0; padding: 9px 10px; border-radius: 9px; background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary) !important; line-height: 1.55; }
  527. .push-error, .push-notice { margin: 0; padding: 9px 10px; border-radius: 9px; line-height: 1.55; }
  528. .push-error { background: color-mix(in srgb, var(--danger) 8%, transparent); }
  529. .push-notice { background: color-mix(in srgb, var(--success, #21845b) 9%, transparent); color: var(--success, #21845b) !important; }
  530. .push-config-form footer { display: flex; justify-content: flex-end; }
  531. .user { display: flex; min-height: 58px; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }
  532. .user span { display: flex; min-width: 0; flex: 1; flex-direction: column; }
  533. .user b { font-size: 14px; }
  534. .user small { color: var(--text-muted); font-size: 12px; }
  535. .user button { min-height: 34px; padding: 0 8px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; }
  536. .user button:hover { background: var(--surface-soft); }
  537. .user button.danger { color: var(--danger); }
  538. form { display: grid; grid-template-columns: 1fr 1fr auto; align-items: end; gap: 10px; margin-top: 16px; }
  539. form > label { display: grid; min-width: 0; gap: 6px; color: var(--text-secondary); font-size: 12px; font-weight: 650; }
  540. form > label small { color: var(--text-muted); font-size: 11px; font-weight: 400; }
  541. .user-create-form { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 12px; margin-top: 18px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); }
  542. .user-create-form .solid-button { grid-column: 1 / -1; min-width: 104px; justify-self: end; }
  543. input:not([type="checkbox"]) { min-width: 0; height: 42px; padding: 0 11px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-raised); color: var(--text-primary); font: 13px var(--font-ui); }
  544. input:not([type="checkbox"]):focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
  545. .solid-button { min-height: 42px; padding: 0 14px; border: 0; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font: 600 13px var(--font-ui); }
  546. .solid-button:disabled,.user button:disabled { cursor: not-allowed; opacity: .5; }
  547. @media (max-width: 600px) {
  548. form { grid-template-columns: 1fr; }
  549. .user-create-form { grid-template-columns: 1fr; }
  550. .user { flex-wrap: wrap; padding: 8px 0; }
  551. .user span { flex-basis: 100%; }
  552. .push-status { grid-template-columns: 1fr; }
  553. .push-config-form { grid-template-columns: 1fr; }
  554. .push-config-form .wide { grid-column: 1; }
  555. .connection-summary { grid-template-columns: 1fr; }
  556. .connection-summary span.wide { grid-column: 1; }
  557. }
  558. </style>