diff --git a/docs/使用手册.md b/docs/使用手册.md index cb19c3b..0058ba7 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -435,7 +435,9 @@ 成绩相同(或手动名次相同)即视为并列,显示同一名次,例如两个「3」。积分按规则包里的并列口径计算,默认是「顺延」:并列第 3 各得第 3 名的分,无第 4 名,下一名是第 5 名。奖牌也按并列处理,并列第 1 各记 1 金、无银牌。 **Q:为什么「发布」按钮是灰的?** -可能原因:当前是访客未登录、服务端连接中断、处于示例数据模式,或校验存在错误。页面会给出对应提示。 +可能原因:当前是访客未登录(只读)、处于示例数据模式,或校验存在错误。页面会给出对应提示。 + +> 顶部显示「连接中断」不会再禁用按钮:实时推送断了只意味着数据可能不是最新,写操作照常提交并由服务端校验。 **Q:为什么页面显示「示例数据」?** 说明前端连不上后端或接口未就绪,此时操作不会写入真实赛事。请检查 Console 里服务端是否为「运行中」,恢复后刷新页面。 diff --git a/docs/常见问题.md b/docs/常见问题.md index 29c164d..888a4fb 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -126,7 +126,9 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 不需要。完全离线运行,不依赖外网,也不需要在现场安装任何运行时。 **Q34:页面提示「示例数据」是什么意思?** -表示后端未就绪或连接中断,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。 +表示后端未就绪,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。 + +> 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。 **Q35:为什么登录 / 写操作偶尔被拒绝?** 系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。 diff --git a/web/src/admin/components/baseData/useBaseData.ts b/web/src/admin/components/baseData/useBaseData.ts index 50f926c..7ffc7d5 100644 --- a/web/src/admin/components/baseData/useBaseData.ts +++ b/web/src/admin/components/baseData/useBaseData.ts @@ -272,7 +272,6 @@ export interface BaseDataController { export function useBaseData(): BaseDataController { const auth = useAuthStore() - const shared = useEventStore() return { teams, @@ -288,8 +287,8 @@ export function useBaseData(): BaseDataController { mockLabel: computed(() => baseDataFallback.value || mockMode.value ? `示例数据(${mockReason.value ?? '服务端未就绪'})` : null, ), - // 示例数据模式允许本地写入,否则整页只能看不能改,交互无从验证。 - canWrite: computed(() => !auth.readOnly && (baseDataFallback.value || shared.canWrite)), + // 写权限只看角色:写操作走 HTTP、服务端会做完整校验,实时连接断线不该让整页变只读。 + canWrite: computed(() => !auth.readOnly), load, loadEventDetail, selectEvent, diff --git a/web/src/admin/layouts/AdminLayout.vue b/web/src/admin/layouts/AdminLayout.vue index 98dc99a..813a03f 100644 --- a/web/src/admin/layouts/AdminLayout.vue +++ b/web/src/admin/layouts/AdminLayout.vue @@ -85,7 +85,7 @@ const connection = computed(() => { return { tone: 'success', text: '实时在线', hint: data.serverVersion === null ? '' : `版本 ${data.serverVersion}` } } if (data.realtimeStatus === 'connecting') return { tone: 'info', text: '连接中…', hint: '' } - return { tone: 'error', text: '连接中断', hint: '写操作已停用' } + return { tone: 'error', text: '连接中断', hint: '数据可能不是最新' } }) async function refresh(): Promise { diff --git a/web/src/admin/stores/event.ts b/web/src/admin/stores/event.ts index 3f4bc4b..77200b5 100644 --- a/web/src/admin/stores/event.ts +++ b/web/src/admin/stores/event.ts @@ -44,10 +44,13 @@ export const useEventStore = defineStore('admin-event', () => { ) const online = computed(() => realtimeStatus.value === 'online') /** - * 写操作门槛:真实后端需实时在线;示例数据模式允许本地写入以便走通交互。 - * 登录态由 auth store 叠加判断,这里只管连接。 + * 写操作门槛:写路径是 HTTP,服务端会做完整校验,因此这里恒为 true; + * 角色判断由各页面叠加 `auth.readOnly` 完成。 + * + * 曾经写成「实时在线才可写」,结果 WS 一断(服务端重启、网络瞬断)整站变只读, + * 现场代价太大;实时连接状态只用于顶部「连接中断」提示,不再拦截写操作。 */ - const canWrite = computed(() => mockMode.value || realtimeStatus.value === 'online') + const canWrite = computed(() => true) function applySnapshot(snapshot: SnapshotMessage): void { settings.value = snapshot.settings diff --git a/web/src/admin/tests/base-data.test.ts b/web/src/admin/tests/base-data.test.ts new file mode 100644 index 0000000..17510db --- /dev/null +++ b/web/src/admin/tests/base-data.test.ts @@ -0,0 +1,27 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createPinia, setActivePinia } from 'pinia' +import type { CurrentUser } from '@/shared' +import { useAuthStore } from '../stores/auth' +import { useEventStore } from '../stores/event' +import { useBaseData } from '../components/baseData/useBaseData' + +// 写权限只看角色:实时连接断开(服务端重启、网络瞬断)不得让整页变只读。 +// 曾经 canWrite 依赖 realtimeStatus === 'online',WS 一断连「保存全部」都是灰的。 +test('实时连接离线时仍可写,只读访客不可写', () => { + setActivePinia(createPinia()) + const auth = useAuthStore() + const event = useEventStore() + const data = useBaseData() + + assert.equal(event.realtimeStatus, 'idle', '未连接时状态应为 idle') + assert.equal(event.canWrite, true, '离线不应拦截写操作') + assert.equal(data.canWrite.value, false, '未登录(访客)不可写') + + auth.user = { id: 1, username: 'admin', displayName: '管理员', role: 'admin' } as unknown as CurrentUser + assert.equal(auth.readOnly, false) + assert.equal(data.canWrite.value, true, '登录后即使实时连接离线也应可写') + + auth.user = null + assert.equal(data.canWrite.value, false, '退出后回到只读访客') +})