fix(entry): 回到录入页时记住上次的小项(按 id);feat: 新增「清空本组 / 本批成绩」;版本 dev_0.5.12
## 一、修「改小项显示名称后,撤销发布按钮消失」 **根因不是改名字。** 我一开始怀疑「某处按 name 关联」,验下来那条路是干净的: 加载录入页报名行的查询、成绩查询、引擎读数据全是按 event_id / category_id / code 关联的 (并补了一条「小项名 + 组别名一起改」的回归用例,证明录入页状态 / 流水 / 榜单逐字节不变)。 真机制:**进入录入页时不记得上次录的是哪个小项**,兜底挑「第一个**待录入**(pending)的小项」。 而**刚发布完的小项状态已经是 published**,恰好被这条兜底排除 → 回去时右侧换成了**另一个小项**, 而「撤销发布」按钮只按「当前表里有没有已发布的行」显示,自然就不在。 ⇒ 现场看到的「左栏仍显示已发布、单位成绩仍有加分、但按钮没了」**三条本来不矛盾 —— 它们说的是两个不同的小项**。 **修法**:`pickInitialEventId()` 记住上次录入的小项(**按 id** 存 localStorage), 优先级 = URL 查询参数 → 上次录入的(**不排除已发布**)→ 第一个 pending → 列表第一个; 记忆失效(小项被删、隐私模式读不到存储)一律退回原兜底,不把人挡在录入页外。 **纯前端,零数据迁移。** 真机复现与复验(CDP 驱动真实浏览器): - 修前:「离开再回来 → 换成了别的小项、没有撤销发布按钮」; - 修后:「离开再回来 → 仍是那个小项、按钮在」。 ## 二、新增「清空本组 / 本批成绩」(危险操作) `POST /api/results/clear`(recorder 及以上 / CSRF / 幂等): - `heat` **省略 = 清整组**、数字 = 该批次、显式 `null` = 只清未分批的行(三种要能分清); - `confirm` 必须为 `true`(**服务端兜底**「没勾选不让提交」,不只是前端拦); - **不提供「清空整个小项」** —— 爆炸半径太大; - 语义 = **先撤销发布 + 再清内容**:成绩行整行归零 → **清整组时一并清掉「团体名次」** (否则会出现「清空了、榜上还有分」)→ 作废旧流水并按剩余已发布内容重写 → **小项状态对称退回**(只在原为 published 时降级,正好避免「左栏写着已发布却没有已发布成绩」) → 写审计 + 推榜。 界面:工具栏「清空本组」常驻;「清空本批」只在筛了批次时出现(没筛批次时它等于清整组,不重复给); 两粒都是危险色,示例数据模式 / 访客只读时禁用并说明原因。 **与「撤销发布」的区别**(界面上都写清了,很容易混): - **撤销发布**:成绩内容**保留**,只把分收回; - **清空**:成绩内容**一起清掉**,分也收回。 ## 三、顺带:按名字关联的同类扫描 扫了 13 处「按名字关联」的地方并逐条判定,结论: **数据关联一律按 id/code**;只有当「人写的名字要在表里对齐」时才按名字 (导入报名表里的组别名、Excel 工作表名、缺证件号时的同名兜底)—— 那几处是**有意为之**、报错也能照着改,**未改**。 ## 四、验证 - 全量自检两轮**全绿、退出码 0**:服务端 14 包 / 前端 **974 例** 0 fail / Console 96 例 0 fail。 - 新增用例:`event_rename_publish_test.go`(改名后状态/流水/榜单逐字节不变)、 `entry-initial-event.test.ts`(8 例)、`results_clear_test.go`、`entry-clear.test.ts`。 ## 版本 dev_0.5.11 → dev_0.5.12(无数据迁移)。
This commit is contained in:
1 parent
7a64e83ecf
commit
72362239c6
20 files changed
+1448
-18
No files matched your search
@@ -4,6 +4,8 @@ import type { AthletePurgeResult, DisplayInstance, EntryBoard } from '@/shared/a
|
||||
import type {
|
||||
AthleteStanding,
|
||||
AuditLog,
|
||||
ClearResultsPayload,
|
||||
ClearResultsResult,
|
||||
CurrentUser,
|
||||
DisplayCommand,
|
||||
EntryHeatPayload,
|
||||
@@ -237,6 +239,15 @@ export const adminApi = {
|
||||
reopenResult: (id: number, payload: ReopenResultPayload): Promise<Result> =>
|
||||
callWrite(() => client.results.reopen(id, payload)),
|
||||
|
||||
/**
|
||||
* 清空「当前组 / 批次」的成绩(= 撤销发布 + 清空成绩内容,危险操作)。
|
||||
*
|
||||
* 与发布 / 撤销发布同一档:写操作不降级(示例数据模式下页面先看 data.isMock 拦下),
|
||||
* 请求体里的 `confirm: true` 是「勾选了『我知道这会清掉已发布的分数』」在协议上的落点。
|
||||
*/
|
||||
clearResults: (payload: ClearResultsPayload): Promise<ClearResultsResult> =>
|
||||
callWrite(() => client.results.clearResults(payload)),
|
||||
|
||||
standings: (): Promise<StandingRow[]> =>
|
||||
call(() => client.standings.teams(), () => mockDb.standings()),
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ import MxIcon from './MxIcon.vue'
|
||||
* `reasonRequired` 打开时,弹窗里多出一个必填原因输入框(作废成绩 / 退回草稿 / 作废破纪录登记),
|
||||
* 原因随 confirm 事件一起 emit 给调用方,由调用方写进请求体(服务端再写审计)。
|
||||
* 原因留空时**不 emit confirm**:这类操作都改榜单,宁可多拦一次,也不能带着空原因落库。
|
||||
*
|
||||
* `ackRequired` 是另一道更轻的门:勾选一句话(例如「我知道这会清掉已发布的分数」)才允许确认。
|
||||
* 用于**后果需要被读到**的操作(清空成绩):光有「确认 / 取消」两颗按钮,
|
||||
* 现场很容易连点两下把一整组成绩清掉;勾选强制多读一句话。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -24,6 +28,10 @@ const props = withDefaults(
|
||||
reasonRequired?: boolean
|
||||
reasonLabel?: string
|
||||
reasonPlaceholder?: string
|
||||
/** true 时要求勾选下面的确认句才允许点确认键。 */
|
||||
ackRequired?: boolean
|
||||
/** 勾选框旁的说明;缺省时给一句中性的,具体语义由调用方写清。 */
|
||||
ackLabel?: string
|
||||
}>(),
|
||||
{
|
||||
details: () => [],
|
||||
@@ -34,6 +42,8 @@ const props = withDefaults(
|
||||
reasonRequired: false,
|
||||
reasonLabel: '原因',
|
||||
reasonPlaceholder: '必填,会写入审计日志',
|
||||
ackRequired: false,
|
||||
ackLabel: '我已确认这次操作',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -43,8 +53,12 @@ const emit = defineEmits<{ confirm: [reason: string]; cancel: [] }>()
|
||||
const reason = ref('')
|
||||
/** 只有点过确认键(或按键提交)之后才亮红字,避免刚打开就一片报错。 */
|
||||
const reasonTouched = ref(false)
|
||||
/** 勾选状态;同样在每次弹窗打开时复位——上一次的勾选不能替这一次背书。 */
|
||||
const acked = ref(false)
|
||||
|
||||
const reasonMissing = computed(() => props.reasonRequired && reason.value.trim() === '')
|
||||
/** 勾选框没勾上:确认键据此禁用(原因必填与它互相独立)。 */
|
||||
const ackMissing = computed(() => props.ackRequired && !acked.value)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -52,6 +66,7 @@ watch(
|
||||
if (!open) return
|
||||
reason.value = ''
|
||||
reasonTouched.value = false
|
||||
acked.value = false
|
||||
},
|
||||
)
|
||||
|
||||
@@ -67,13 +82,14 @@ function requestCancel(): void {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
/** 确认键:先过原因必填,再 emit。 */
|
||||
/** 确认键:先过原因必填与勾选,再 emit。 */
|
||||
function requestConfirm(): void {
|
||||
if (props.pending) return
|
||||
if (reasonMissing.value) {
|
||||
reasonTouched.value = true
|
||||
return
|
||||
}
|
||||
if (ackMissing.value) return
|
||||
emit('confirm', reason.value.trim())
|
||||
}
|
||||
|
||||
@@ -118,6 +134,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
></textarea>
|
||||
<span v-if="reasonTouched && reasonMissing" class="confirm-reason-error">原因必填</span>
|
||||
</label>
|
||||
<!--
|
||||
勾选确认:光有「确认 / 取消」两颗按钮,现场很容易连点两下把一整组成绩清掉。
|
||||
勾上才放行确认键(requestConfirm 里再挡一次,键盘回车/空格也过不来)。
|
||||
-->
|
||||
<label v-if="ackRequired" class="confirm-ack">
|
||||
<input v-model="acked" type="checkbox" :disabled="pending" />
|
||||
<span>{{ ackLabel }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mx-modal-foot">
|
||||
<button class="mx-btn mx-btn-secondary" type="button" :disabled="pending" @click="requestCancel">
|
||||
@@ -127,7 +151,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
class="mx-btn"
|
||||
:class="tone === 'danger' ? 'mx-btn-danger' : 'mx-btn-primary'"
|
||||
type="button"
|
||||
:disabled="pending"
|
||||
:disabled="pending || ackMissing"
|
||||
@click="requestConfirm"
|
||||
>
|
||||
{{ pending ? '处理中…' : confirmText }}
|
||||
@@ -172,4 +196,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
font-size: 12px;
|
||||
color: var(--mx-red);
|
||||
}
|
||||
.confirm-ack {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.confirm-ack input {
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -118,7 +118,26 @@ import {
|
||||
type HeatAllocation,
|
||||
type HeatFilter,
|
||||
} from './entryHeat'
|
||||
import {
|
||||
CLEAR_ACK_LABEL,
|
||||
CLEAR_BATCH_BUTTON_TITLE,
|
||||
CLEAR_BATCH_LABEL,
|
||||
CLEAR_BUTTON_TITLE,
|
||||
CLEAR_GROUP_LABEL,
|
||||
CLEAR_RESULT_TITLE,
|
||||
clearConfirmDetails,
|
||||
clearPayload,
|
||||
clearScopeOf,
|
||||
clearSummary,
|
||||
type ClearScope,
|
||||
} from './entryClear'
|
||||
import { adminApi, sampleDataBlockedNotice } from '../api/client'
|
||||
import {
|
||||
entryMemoryStorage,
|
||||
pickInitialEventId,
|
||||
readLastEntryEventId,
|
||||
rememberEntryEventId,
|
||||
} from './entryInitialEvent'
|
||||
import {
|
||||
FINAL_PUBLISH_LABEL,
|
||||
FINAL_PUBLISH_TITLE,
|
||||
@@ -238,6 +257,16 @@ const publishing = ref(false)
|
||||
const unpublishing = ref(false)
|
||||
const publishDialog = ref(false)
|
||||
const unpublishDialog = ref(false)
|
||||
/**
|
||||
* 清空(危险操作)的状态。
|
||||
*
|
||||
* scope 在打开弹窗时就定下来(`本组` / `本批` 两个入口各走一次),提交时按它组请求体;
|
||||
* 中途筛选变了也不会悄悄改变作用范围——弹窗里写给操作员看的那句话必须就是即将执行的那件事。
|
||||
*/
|
||||
const clearDialog = ref(false)
|
||||
/** 打开弹窗时定下的作用范围(与表格的批次筛子同一套取值)。 */
|
||||
const clearFilter = ref<HeatFilter>('all')
|
||||
const clearing = ref(false)
|
||||
/**
|
||||
* 「以决赛成绩发布」勾选状态,**默认不勾**(不勾 = 本次只更新名次)。
|
||||
*
|
||||
@@ -1088,6 +1117,9 @@ async function loadEvent(id: number): Promise<void> {
|
||||
selectedHeat.value = 'all'
|
||||
selectedEntryIds.value = []
|
||||
allRows.value = board.entries.map(toRow)
|
||||
// 记下「上次录入的是哪个小项」:下次进录入页要选回它(按 id,改名不影响)。
|
||||
// 只在真的加载成功之后写——加载失败还留着旧项目,不该把记忆改掉。
|
||||
rememberEntryEventId(entryMemoryStorage(), id)
|
||||
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
|
||||
recordForEvent.value = record === null ? null : { resultText: record.resultText, resultValue: record.resultValue }
|
||||
// 组别已定、随行视图已归位,这时候取团体名次:有随行数据就走缓存,否则发一次 GET。
|
||||
@@ -1831,6 +1863,95 @@ async function confirmUnpublish(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 清空当前组 / 批次成绩(危险操作) ─────────────────────────────────────────
|
||||
|
||||
/** 弹窗里那一句话要用的读数:范围里的条数、其中已发布多少(与表格看到的行同源)。 */
|
||||
const clearPreview = computed(() => clearSummary(filterRowsByHeat(categoryRows.value, clearFilter.value)))
|
||||
|
||||
/** 该组已录入的团体名次条数:清整组时会一并清掉,弹窗里要点名。 */
|
||||
const clearTeamRankCount = computed(() => teamRanksPayload(teamRankRows.value).ranks.length)
|
||||
|
||||
/** 弹窗明细:小项 / 组别 / 范围 / 条数 / 已发布多少 / 会不会动团体名次。 */
|
||||
const clearDetails = computed(() =>
|
||||
clearConfirmDetails({
|
||||
eventName: selectedEvent.value?.name ?? '',
|
||||
categoryName: currentCategoryName.value,
|
||||
scope: clearScopeOf(clearFilter.value),
|
||||
heat: clearFilter.value === 'all' || clearFilter.value === 'none' ? null : clearFilter.value,
|
||||
cleared: clearPreview.value.cleared,
|
||||
published: clearPreview.value.published,
|
||||
teamRanks: clearTeamRankCount.value,
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* 清空入口的两个粒度:`本组` = 当前小项 + 当前组别的全部成绩;
|
||||
* `本批` = 只清当前批次(只在确实筛了批次时出现——没筛批次时它没有意义)。
|
||||
*
|
||||
* 打开前先把「这一范围根本没成绩」挡掉并说明原因:进了弹窗再报错会让人以为是清空失败。
|
||||
*/
|
||||
function openClear(scope: ClearScope): void {
|
||||
if (data.isMock) {
|
||||
// 与发布 / 撤销发布同一口径:示例数据模式下写不进真实赛事,提示成功等于谎报。
|
||||
toast.error('示例数据模式不能清空成绩', '后端未就绪,清空不会写入真实赛事')
|
||||
return
|
||||
}
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可清空成绩', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
}
|
||||
if (selectedEventId.value === null || selectedCategoryId.value === null) {
|
||||
toast.error('请先选择项目与组别')
|
||||
return
|
||||
}
|
||||
// 范围在打开弹窗时定下来:中途改筛选不该悄悄改变即将执行的作用范围。
|
||||
const filter: HeatFilter = scope === 'group' ? 'all' : selectedHeat.value
|
||||
if (filter === 'all' && scope !== 'group') {
|
||||
// 「清空本批」在没筛批次时等于清整组,那不是这颗按钮的意思。
|
||||
toast.error('请先在上方选择批次', '「清空本批」只清当前批次;要清整组请用「清空本组」')
|
||||
return
|
||||
}
|
||||
if (clearSummary(filterRowsByHeat(categoryRows.value, filter)).cleared === 0) {
|
||||
toast.error('这一范围没有成绩可清空', '还没录过成绩,或当前批次是空的')
|
||||
return
|
||||
}
|
||||
clearFilter.value = filter
|
||||
clearDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空确认:整组 / 本批 / 未分批三种范围走同一个接口,靠请求体里 heat 的有无与取值区分
|
||||
* (见 entryClear.clearPayload)。
|
||||
*
|
||||
* 请求体一律带 confirm:true(这颗按钮只有勾选后才点得动,服务端再兜一次);
|
||||
* 成功后与发布 / 撤销发布同一条路刷新(成绩表 + 榜单 + 项目列表 + 结算状态),
|
||||
* 因为这一下同时改了成绩内容与积分流水,只刷新其中一样就会留下自相矛盾的界面。
|
||||
*/
|
||||
async function confirmClear(): Promise<void> {
|
||||
const eventId = selectedEventId.value
|
||||
const categoryId = selectedCategoryId.value
|
||||
if (eventId === null || categoryId === null) return
|
||||
clearing.value = true
|
||||
try {
|
||||
const result = await adminApi.clearResults(
|
||||
clearPayload({ eventId, categoryId, filter: clearFilter.value, reason: '' }),
|
||||
)
|
||||
clearDialog.value = false
|
||||
await reloadAfterSettlementChange()
|
||||
const extra = result.teamRanks > 0 ? `,团体名次 ${result.teamRanks} 条一并清空` : ''
|
||||
toast.warning(
|
||||
`已清空 ${result.cleared} 条成绩`,
|
||||
result.published > 0
|
||||
? `其中已发布 ${result.published} 条,收回积分流水 ${result.voidedLedger} 条${extra}`
|
||||
: `这一范围没有已发布的成绩,榜单未变${extra}`,
|
||||
)
|
||||
} catch (caught) {
|
||||
toast.error('清空成绩失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 分批结算(延迟结算下的「榜单还没反映这批」提示 + 人工兜底) ────────────────
|
||||
|
||||
/** 读当前 (小项, 组别) 的结算状态;失败时按「没有提示」处理,不打断录入。 */
|
||||
@@ -2342,22 +2463,24 @@ function moveFocusBy(delta: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入录入页时默认选中小项:URL 带 eventId 时优先,否则取第一个未开始(pending)的项目,
|
||||
* 都没有就取列表第一个。
|
||||
* 进入录入页时默认选中小项:URL 带 eventId 时优先,其次是上次录入过的那个小项,
|
||||
* 再取第一个未开始(pending)的项目,都没有就取列表第一个(判定见 entryInitialEvent)。
|
||||
*
|
||||
* 注意口径:这里**没有**「上次编辑的小项」这种记忆——前端从未持久化过录入位置,
|
||||
* 所以换人换班后回到录入页看到的是兜底结论,不是他离开时的那个项目。
|
||||
* 「上次录入的小项」是必需的:兜底挑的第一个 pending 小项恰好排除了**刚发布完的那个**
|
||||
* (发布后它的状态已是 published),于是去基础数据改个名称再回来,右侧就换成了别的小项,
|
||||
* 「撤销发布」按钮跟着消失——左栏那个小项还写着已发布、榜单上也还有它的分,三处自相矛盾。
|
||||
* 记忆按 id 存,改显示名称不影响它。
|
||||
*/
|
||||
async function pickInitialEvent(): Promise<void> {
|
||||
if (selectedEventId.value !== null) return
|
||||
const query = route.query.eventId
|
||||
const fromQuery = typeof query === 'string' ? Number(query) : Number.NaN
|
||||
if (Number.isFinite(fromQuery) && data.eventById(fromQuery) !== undefined) {
|
||||
selectEvent(fromQuery)
|
||||
return
|
||||
}
|
||||
const fallback = data.events.find((event) => event.status === 'pending') ?? data.events[0]
|
||||
if (fallback !== undefined) selectEvent(fallback.id)
|
||||
const id = pickInitialEventId({
|
||||
fromQuery: Number.isFinite(fromQuery) ? fromQuery : null,
|
||||
lastId: readLastEntryEventId(entryMemoryStorage()),
|
||||
events: data.events,
|
||||
})
|
||||
if (id !== null) selectEvent(id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -2536,6 +2659,36 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
>
|
||||
撤销发布
|
||||
</button>
|
||||
<!--
|
||||
清空成绩:危险操作(连成绩内容一起清掉,不只是收回分数),所以与「撤销发布」
|
||||
分开两颗按钮、颜色同为危险色、并且必须勾选确认才能提交。
|
||||
「清空本批」只在确实筛了批次时出现——没筛批次时它等于清整组,会误导。
|
||||
-->
|
||||
<button
|
||||
class="mx-btn mx-btn-danger mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!canWrite || data.isMock"
|
||||
:title="
|
||||
data.isMock
|
||||
? '示例数据模式下不能清空成绩'
|
||||
: auth.readOnly
|
||||
? '需要登录后才能清空成绩'
|
||||
: CLEAR_BUTTON_TITLE
|
||||
"
|
||||
@click="openClear('group')"
|
||||
>
|
||||
{{ CLEAR_GROUP_LABEL }}
|
||||
</button>
|
||||
<button
|
||||
v-if="heatScoped"
|
||||
class="mx-btn mx-btn-danger mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!canWrite || data.isMock"
|
||||
:title="CLEAR_BATCH_BUTTON_TITLE"
|
||||
@click="openClear('batch')"
|
||||
>
|
||||
{{ CLEAR_BATCH_LABEL }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3436,6 +3589,24 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
清空成绩(危险操作):说清范围、条数、已发布的分会被收回,并要求勾选确认句。
|
||||
它与「撤销发布」的区别写在明细最后一行——两者只差「成绩内容留不留」这一步。
|
||||
-->
|
||||
<ConfirmDialog
|
||||
:open="clearDialog"
|
||||
tone="danger"
|
||||
:title="CLEAR_RESULT_TITLE"
|
||||
message="清空 = 先撤销发布(收回分数、作废该范围的积分流水),再把成绩内容清掉(名次、成绩值、备注),回到「没录过」的状态。"
|
||||
:details="clearDetails"
|
||||
confirm-text="清空"
|
||||
:ack-required="true"
|
||||
:ack-label="CLEAR_ACK_LABEL"
|
||||
:pending="clearing"
|
||||
@confirm="confirmClear"
|
||||
@cancel="clearDialog = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="unpublishDialog"
|
||||
tone="danger"
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ClearResultsPayload } from '@/shared'
|
||||
import type { HeatFilter } from './entryHeat'
|
||||
|
||||
/**
|
||||
* 「清空当前组 / 批次成绩」的判定与文案。
|
||||
*
|
||||
* 抽出来的原因:`.vue` 组件跑不进 `node --test`,而这里最容易写错的两件事恰好都在
|
||||
* 「看不见的地方」——作用范围与请求体形状(整组 / 某批 / 未分批在服务端是三条不同的路),
|
||||
* 以及危险操作必须说清楚的后果(清多少条、哪一组、已发布的分会不会被收回)。
|
||||
*
|
||||
* 语义(主人定的):「清空」= **先撤销发布**(已发布的收回、该组产生的积分流水作废)
|
||||
* **+ 把成绩内容清空**(名次 / 成绩值 / 备注都清,回到「没录过」)。
|
||||
* 与「撤销发布」的区别要一直写在界面上:撤销发布保留成绩内容、只把分数收回。
|
||||
*/
|
||||
|
||||
export const CLEAR_GROUP_LABEL = '清空本组'
|
||||
export const CLEAR_BATCH_LABEL = '清空本批'
|
||||
export const CLEAR_BUTTON_TITLE = '清空这一组的成绩(含已发布的分),回到「没录过」的状态'
|
||||
export const CLEAR_BATCH_BUTTON_TITLE = '只清空当前批次的成绩(含已发布的分),其它批次不动'
|
||||
export const CLEAR_RESULT_TITLE = '清空该组成绩'
|
||||
export const CLEAR_ACK_LABEL = '我知道这会清掉已发布的分数'
|
||||
|
||||
/** 作用范围:整组 / 指定批次 / 只清未分批的行。 */
|
||||
export type ClearScope = 'group' | 'batch' | 'unbatched'
|
||||
|
||||
/** 批次筛选值 → 清空作用范围(与表格看到的行保持同一个筛子)。 */
|
||||
export function clearScopeOf(filter: HeatFilter): ClearScope {
|
||||
if (filter === 'all') return 'group'
|
||||
if (filter === 'none') return 'unbatched'
|
||||
return 'batch'
|
||||
}
|
||||
|
||||
/** 提示与明细里的称呼。 */
|
||||
export function clearScopeWord(scope: ClearScope, heat: number | null): string {
|
||||
if (scope === 'batch') return heat === null ? '当前批次' : `第 ${heat} 批`
|
||||
if (scope === 'unbatched') return '未分批'
|
||||
return '本组'
|
||||
}
|
||||
|
||||
export interface ClearPayloadInput {
|
||||
eventId: number
|
||||
categoryId: number | null
|
||||
filter: HeatFilter
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 组请求体。
|
||||
*
|
||||
* `heat` 这个键**只在该带的时候带**:服务端按「请求体里有没有 heat」分三条路——
|
||||
* 没有 = 整个组别(含未分批的行)、数字 = 只清这一批、null = 只清未分批的行。
|
||||
* 一律写成 `heat: x` 会把「清整组」悄悄降级成「清未分批」。
|
||||
*
|
||||
* confirm 恒为 true:它对应界面上那个必须勾选的框,服务端也拿它兜底(没勾选一律 400)。
|
||||
*/
|
||||
export function clearPayload(input: ClearPayloadInput): ClearResultsPayload {
|
||||
const reason = input.reason.trim()
|
||||
const base: ClearResultsPayload = {
|
||||
eventId: input.eventId,
|
||||
categoryId: input.categoryId ?? undefined,
|
||||
confirm: true,
|
||||
reason: reason === '' ? undefined : reason,
|
||||
}
|
||||
if (input.filter === 'all') return base
|
||||
return { ...base, heat: input.filter === 'none' ? null : input.filter }
|
||||
}
|
||||
|
||||
/** 确认框里的条数:清掉多少条、其中多少条原先已发布。 */
|
||||
export function clearSummary(rows: readonly { resultState: string | null }[]): {
|
||||
cleared: number
|
||||
published: number
|
||||
} {
|
||||
return {
|
||||
cleared: rows.length,
|
||||
published: rows.filter((row) => row.resultState === 'published').length,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClearDetailsInput {
|
||||
eventName: string
|
||||
categoryName: string
|
||||
scope: ClearScope
|
||||
heat: number | null
|
||||
cleared: number
|
||||
published: number
|
||||
/** 该组别已录入的团体手工名次条数(0 = 这一项没有记团体名次,或清的是批次)。 */
|
||||
teamRanks: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认框明细:谁都能照着念——哪个小项哪个组 / 批、清多少条、其中多少条已发布、
|
||||
* 已发布的分会被收回。
|
||||
*/
|
||||
export function clearConfirmDetails(input: ClearDetailsInput): string[] {
|
||||
const scope = clearScopeWord(input.scope, input.heat)
|
||||
const details = [
|
||||
`小项「${input.eventName}」· 组别「${input.categoryName}」· 范围:${scope}`,
|
||||
`将清空 ${input.cleared} 条成绩(其中已发布 ${input.published} 条)`,
|
||||
]
|
||||
if (input.published > 0) {
|
||||
details.push(`这 ${input.published} 条已发布成绩产生的分会一并收回,积分榜与大屏跟着回退`)
|
||||
} else {
|
||||
details.push('当前没有已发布的成绩,这一步不涉及积分榜变化')
|
||||
}
|
||||
if (input.scope === 'group' && input.teamRanks > 0) {
|
||||
details.push(`该组按单位录入的团体名次(${input.teamRanks} 条)会一并清空`)
|
||||
}
|
||||
details.push('清空后这一组回到「没录过」:名次、成绩值、备注都清掉')
|
||||
details.push(keepPublishedNote())
|
||||
return details
|
||||
}
|
||||
|
||||
/** 与「撤销发布」的区别:两者只差一步,界面上必须说清。 */
|
||||
export function keepPublishedNote(): string {
|
||||
return '只想收回分数、保留成绩内容,请改用「撤销发布」'
|
||||
}
|
||||
|
||||
/** 没勾选确认不许提交(确认键据此禁用)。 */
|
||||
export function canSubmitClear(acked: boolean): boolean {
|
||||
return acked
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 进入成绩录入页时选哪个小项。
|
||||
*
|
||||
* 抽出来的原因:`.vue` 组件跑不进 `node --test`,而这条选择规则踩过一次真坑——
|
||||
* 页面**不记得上次录的是哪个小项**,兜底挑「第一个待录入(pending)的小项」,
|
||||
* 而刚发布完的小项状态已经是 published,恰好被这条兜底排除掉:去基础数据改个名称
|
||||
* 再回录入页,右侧换成了别的小项,「撤销发布」按钮自然不在(左栏那个小项还写着已发布)。
|
||||
*
|
||||
* 记忆按 **id** 存:改小项显示名称不该影响任何功能,名字从头到尾不参与这里的判断。
|
||||
* 记忆只是「优先」,不是「唯一」——小项被删掉、存储读不出来(隐私模式)时一律退回
|
||||
* 原来的兜底,绝不让一条坏记忆把人挡在录入页外面。
|
||||
*/
|
||||
|
||||
/** 上次录入的小项在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */
|
||||
export const LAST_ENTRY_EVENT_KEY = 'scoreboard.entry.lastEventId'
|
||||
|
||||
/** 只用到这两件事;注入假实现后,node 里也能把存取与脏数据这两条路走一遍。 */
|
||||
export interface EntryMemoryStorage {
|
||||
getItem(key: string): string | null
|
||||
setItem(key: string, value: string): void
|
||||
}
|
||||
|
||||
/** 浏览器的 localStorage;隐私模式 / 禁用存储时**读属性本身就抛**,所以整段包在 try 里。 */
|
||||
export function entryMemoryStorage(): EntryMemoryStorage | null {
|
||||
try {
|
||||
return (globalThis as { localStorage?: EntryMemoryStorage }).localStorage ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 记住上次成功加载过的小项(null = 清掉记忆)。存不进去不影响录入。 */
|
||||
export function rememberEntryEventId(storage: EntryMemoryStorage | null, id: number | null): void {
|
||||
if (storage === null) return
|
||||
try {
|
||||
storage.setItem(LAST_ENTRY_EVENT_KEY, id === null ? '' : String(id))
|
||||
} catch {
|
||||
/* 存储写不进去(配额满 / 隐私模式)时静默放弃:录入不靠这条记忆 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读出记忆的小项 id。
|
||||
*
|
||||
* 只认正整数:空串、负数、小数、非数字一律当成「没有记忆」。
|
||||
* 把脏值原样喂给 `events.find(id === ...)` 本身无害,但「有没有记忆」这件事会在
|
||||
* 日志与调试里被反复读,宁可在这里就把它收敛成 null / 正整数两种可能。
|
||||
*/
|
||||
export function readLastEntryEventId(storage: EntryMemoryStorage | null): number | null {
|
||||
if (storage === null) return null
|
||||
let raw: string | null
|
||||
try {
|
||||
raw = storage.getItem(LAST_ENTRY_EVENT_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (raw === null) return null
|
||||
const text = raw.trim()
|
||||
if (!/^[1-9]\d*$/.test(text)) return null
|
||||
const value = Number(text)
|
||||
return Number.isSafeInteger(value) ? value : null
|
||||
}
|
||||
|
||||
/** 选择时只需要 id 与进度状态;名字不参与(改名不该影响任何功能)。 */
|
||||
export interface InitialEventCandidate {
|
||||
id: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface InitialEventInput {
|
||||
/** URL 里的 ?eventId=;非数字 / 缺省时为 null。 */
|
||||
fromQuery: number | null
|
||||
/** 上次录入的小项 id。 */
|
||||
lastId: number | null
|
||||
events: readonly InitialEventCandidate[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 选哪个小项进页面,按优先级:
|
||||
* 1. URL 带 eventId(从仪表盘「继续录入」这类深链进来,对方的意图最明确);
|
||||
* 2. 上次录入过的那个小项(**不排除已发布的小项**——发布完回来接着看的正是它);
|
||||
* 3. 第一个待录入(pending)的小项(改动前的兜底,保留);
|
||||
* 4. 列表第一个。
|
||||
*
|
||||
* 前两条都要在小项列表里真的存在才算数:小项被删掉之后,记忆里那个 id 不该把页面锁死。
|
||||
*/
|
||||
export function pickInitialEventId(input: InitialEventInput): number | null {
|
||||
const exists = (id: number | null): id is number =>
|
||||
id !== null && input.events.some((event) => event.id === id)
|
||||
if (exists(input.fromQuery)) return input.fromQuery
|
||||
if (exists(input.lastId)) return input.lastId
|
||||
const pending = input.events.find((event) => event.status === 'pending')
|
||||
if (pending !== undefined) return pending.id
|
||||
return input.events[0]?.id ?? null
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
CLEAR_ACK_LABEL,
|
||||
CLEAR_BATCH_LABEL,
|
||||
CLEAR_GROUP_LABEL,
|
||||
CLEAR_RESULT_TITLE,
|
||||
clearConfirmDetails,
|
||||
clearPayload,
|
||||
clearScopeOf,
|
||||
clearScopeWord,
|
||||
clearSummary,
|
||||
canSubmitClear,
|
||||
keepPublishedNote,
|
||||
} from '../pages/entryClear'
|
||||
|
||||
/**
|
||||
* 「清空当前组 / 批次成绩」的判定与文案(危险操作)。
|
||||
*
|
||||
* 语义(主人定的):「清空」= **先撤销发布**(已发布的收回、该组产生的积分流水作废)
|
||||
* **+ 把成绩内容清空**(名次 / 成绩值 / 备注都清,回到「没录过」)。
|
||||
* 与「撤销发布」必须分得清:撤销发布**保留成绩内容**、只把分数收回;清空连内容一起清掉。
|
||||
*
|
||||
* 所以这里钉三件事:
|
||||
* 1. 作用范围与请求体:整组**不带** heat 键,批次带数字,未分批带 null(三者在服务端是三条路);
|
||||
* 2. 确认框要写明「清多少条、哪个小项哪个组 / 批、已发布的分会被收回」;
|
||||
* 3. 没勾选「我知道这会清掉已发布的分数」不许提交。
|
||||
*/
|
||||
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
const DIALOG_SOURCE = readFileSync(new URL('../components/ConfirmDialog.vue', import.meta.url), 'utf8')
|
||||
|
||||
const row = (heat: number | null, state: string | null) => ({ heat, resultState: state })
|
||||
|
||||
test('作用范围:全部 = 整组,数字 = 该批次,未分批 = 只清未分批的行', () => {
|
||||
assert.equal(clearScopeOf('all'), 'group')
|
||||
assert.equal(clearScopeOf(2), 'batch')
|
||||
assert.equal(clearScopeOf('none'), 'unbatched')
|
||||
})
|
||||
|
||||
test('请求体:整组不带 heat 键,批次带数字,未分批带 null', () => {
|
||||
const group = clearPayload({ eventId: 4, categoryId: 3, filter: 'all', reason: '' })
|
||||
assert.equal('heat' in group, false, '整组不能带 heat:服务端把「有 heat 键」当批次处理')
|
||||
assert.equal(group.eventId, 4)
|
||||
assert.equal(group.categoryId, 3)
|
||||
|
||||
const batch = clearPayload({ eventId: 4, categoryId: 3, filter: 2, reason: '现场重赛' })
|
||||
assert.equal(batch.heat, 2)
|
||||
assert.equal(batch.reason, '现场重赛')
|
||||
|
||||
const unbatched = clearPayload({ eventId: 4, categoryId: 3, filter: 'none', reason: '' })
|
||||
assert.equal('heat' in unbatched, true)
|
||||
assert.equal(unbatched.heat, null)
|
||||
})
|
||||
|
||||
test('请求体一律带 confirm:true —— 服务端也要求勾选,界面禁用只是第一道', () => {
|
||||
const payload = clearPayload({ eventId: 1, categoryId: 2, filter: 'all', reason: '' })
|
||||
assert.equal(payload.confirm, true)
|
||||
})
|
||||
|
||||
test('条数统计:按当前可见行算,已发布条数单独数出来', () => {
|
||||
const rows = [row(1, 'published'), row(1, 'draft'), row(2, 'published')]
|
||||
assert.deepEqual(clearSummary(rows), { cleared: 3, published: 2 })
|
||||
assert.deepEqual(clearSummary([]), { cleared: 0, published: 0 })
|
||||
})
|
||||
|
||||
test('称呼:整组说「本组」,批次说「第 N 批」,未分批说「未分批」', () => {
|
||||
assert.equal(clearScopeWord('group', null), '本组')
|
||||
assert.equal(clearScopeWord('batch', 2), '第 2 批')
|
||||
assert.equal(clearScopeWord('unbatched', null), '未分批')
|
||||
})
|
||||
|
||||
test('按钮文案逐字:两个粒度各一个入口,且点明是「清空」不是「撤销发布」', () => {
|
||||
assert.equal(CLEAR_GROUP_LABEL, '清空本组')
|
||||
assert.equal(CLEAR_BATCH_LABEL, '清空本批')
|
||||
assert.equal(CLEAR_RESULT_TITLE, '清空该组成绩')
|
||||
assert.notEqual(CLEAR_GROUP_LABEL, '撤销发布')
|
||||
})
|
||||
|
||||
test('确认框明细:点名小项与组别、清多少条、其中多少条已发布、分数被收回', () => {
|
||||
const details = clearConfirmDetails({
|
||||
eventName: '100米跑',
|
||||
categoryName: '男子',
|
||||
scope: 'batch',
|
||||
heat: 2,
|
||||
cleared: 8,
|
||||
published: 8,
|
||||
teamRanks: 0,
|
||||
})
|
||||
const text = details.join('\n')
|
||||
assert.match(text, /100米跑/)
|
||||
assert.match(text, /男子/)
|
||||
assert.match(text, /第 2 批/)
|
||||
assert.match(text, /8 条/)
|
||||
assert.match(text, /已发布 8 条/)
|
||||
assert.match(text, /收回/)
|
||||
// 与「撤销发布」的区别必须写在框里,不能让人以为两者一样。
|
||||
assert.match(text, /撤销发布/)
|
||||
})
|
||||
|
||||
test('清整组时会点名「团体名次一并清空」,清批次时不会', () => {
|
||||
const group = clearConfirmDetails({
|
||||
eventName: '篮球', categoryName: '男子', scope: 'group', heat: null,
|
||||
cleared: 4, published: 4, teamRanks: 6,
|
||||
}).join('\n')
|
||||
assert.match(group, /团体名次/)
|
||||
const batch = clearConfirmDetails({
|
||||
eventName: '篮球', categoryName: '男子', scope: 'batch', heat: 1,
|
||||
cleared: 4, published: 4, teamRanks: 0,
|
||||
}).join('\n')
|
||||
assert.doesNotMatch(batch, /团体名次/)
|
||||
})
|
||||
|
||||
test('一条已发布都没有时如实说「不涉及榜单变化」,不吓唬人也不谎报', () => {
|
||||
const details = clearConfirmDetails({
|
||||
eventName: '100米跑', categoryName: '男子', scope: 'group', heat: null,
|
||||
cleared: 3, published: 0, teamRanks: 0,
|
||||
}).join('\n')
|
||||
assert.match(details, /没有已发布的成绩/)
|
||||
// 「本批没有已发布的分」时不该出现「会一并收回」这种后果描述(那句话在别的分支里)。
|
||||
assert.doesNotMatch(details, /会一并收回/)
|
||||
assert.match(keepPublishedNote(), /撤销发布/)
|
||||
})
|
||||
|
||||
test('没勾选确认时不允许提交', () => {
|
||||
assert.equal(canSubmitClear(false), false)
|
||||
assert.equal(canSubmitClear(true), true)
|
||||
assert.equal(CLEAR_ACK_LABEL, '我知道这会清掉已发布的分数')
|
||||
})
|
||||
|
||||
test('录入页接线:两个入口 + 勾选门禁 + 审计(清空走 /results/clear)', () => {
|
||||
// 两个粒度的入口都在工具栏(发布按钮附近),清空本批只在选了批次时出现。
|
||||
assert.match(ENTRY_SOURCE, /v-if="heatScoped"[\s\S]{0,400}openClear\('batch'\)/)
|
||||
assert.match(ENTRY_SOURCE, /\{\{ CLEAR_BATCH_LABEL \}\}/)
|
||||
assert.match(ENTRY_SOURCE, /openClear\('group'\)/)
|
||||
assert.match(ENTRY_SOURCE, /\{\{ CLEAR_GROUP_LABEL \}\}/)
|
||||
// 勾选门禁与确认框接线。
|
||||
assert.match(ENTRY_SOURCE, /:ack-required="true"/)
|
||||
assert.match(ENTRY_SOURCE, /adminApi\.clearResults\(/)
|
||||
})
|
||||
|
||||
test('权限与录成绩同档:访客只读与示例数据模式都置灰,并在 title 里说明原因', () => {
|
||||
// 两颗按钮都跟着 canWrite / isMock 走(与「撤销发布」「存草稿」同一把尺子)。
|
||||
const groupBtn = ENTRY_SOURCE.slice(ENTRY_SOURCE.indexOf('openClear(\'group\')') - 900)
|
||||
assert.match(groupBtn, /:disabled="!canWrite \|\| data\.isMock"/)
|
||||
assert.match(groupBtn, /示例数据模式下不能清空成绩/)
|
||||
assert.match(groupBtn, /需要登录后才能清空成绩/)
|
||||
// 访客只读时的入口:置灰 + 说明,而不是点了才被告知。
|
||||
assert.match(ENTRY_SOURCE, /toast\.error\('当前不可清空成绩', auth\.readOnly \? '请先登录' : '服务端未连接'\)/)
|
||||
})
|
||||
|
||||
test('确认框支持「必须勾选」:勾选框未勾上时确认键不可点', () => {
|
||||
assert.match(DIALOG_SOURCE, /ackRequired/)
|
||||
assert.match(DIALOG_SOURCE, /:disabled="pending \|\| ackMissing"/)
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
LAST_ENTRY_EVENT_KEY,
|
||||
entryMemoryStorage,
|
||||
pickInitialEventId,
|
||||
readLastEntryEventId,
|
||||
rememberEntryEventId,
|
||||
} from '../pages/entryInitialEvent'
|
||||
|
||||
/**
|
||||
* 进入录入页时选哪个小项。
|
||||
*
|
||||
* 现场踩到的坑:在录入页录完成绩、发布、去基础数据改了显示名称,回来时「撤销发布」按钮
|
||||
* 不见了——右侧表里根本不是那个小项。原因是进入页面时**不记得上次录的是哪个小项**,
|
||||
* 兜底挑「第一个待录入(pending)的小项」,而刚发布完的小项状态已经是 published,
|
||||
* 恰好被这条兜底排除掉。
|
||||
*
|
||||
* 所以这里钉死两件事:
|
||||
* 1. 上次录入的小项要按 **id** 记住并优先选回来(改名字不影响,名字根本不参与判断);
|
||||
* 2. 记忆失效(小项被删、存储读不出来)时,行为退回改动前那套兜底,不因为一条坏记忆卡住录入。
|
||||
*
|
||||
* 项目没有组件测试工具,判定逻辑抽在 entryInitialEvent 里直接测,界面接线读源码断言。
|
||||
*/
|
||||
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
|
||||
/** 内存版 localStorage:node 里没有浏览器存储,存取两条路都要能测。 */
|
||||
function memoryStorage(initial: Record<string, string> = {}) {
|
||||
const map = new Map(Object.entries(initial))
|
||||
return {
|
||||
getItem: (key: string) => map.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void map.set(key, value),
|
||||
dump: () => Object.fromEntries(map),
|
||||
}
|
||||
}
|
||||
|
||||
test('上次录入的小项即使已经发布,回到录入页也要选回它', () => {
|
||||
// 4 号是刚发布完的那个(status=published),3 号是另一个待录入的小项。
|
||||
const events = [
|
||||
{ id: 3, status: 'pending' },
|
||||
{ id: 4, status: 'published' },
|
||||
]
|
||||
assert.equal(pickInitialEventId({ fromQuery: null, lastId: 4, events }), 4)
|
||||
})
|
||||
|
||||
test('按 id 认小项:名字改了、同名了都照样选回原来那一个', () => {
|
||||
// 两个同名小项(现场确实会有「预赛 / 决赛」这类重名),只认 id。
|
||||
const events = [
|
||||
{ id: 7, status: 'pending' },
|
||||
{ id: 9, status: 'pending' },
|
||||
]
|
||||
assert.equal(pickInitialEventId({ fromQuery: null, lastId: 9, events }), 9)
|
||||
})
|
||||
|
||||
test('URL 带 eventId 时优先于记忆', () => {
|
||||
const events = [
|
||||
{ id: 3, status: 'pending' },
|
||||
{ id: 4, status: 'published' },
|
||||
]
|
||||
assert.equal(pickInitialEventId({ fromQuery: 3, lastId: 4, events }), 3)
|
||||
})
|
||||
|
||||
test('记忆里的小项已经被删掉时,退回原来的兜底:第一个待录入,其次第一个', () => {
|
||||
const events = [
|
||||
{ id: 3, status: 'finished' },
|
||||
{ id: 4, status: 'pending' },
|
||||
]
|
||||
assert.equal(pickInitialEventId({ fromQuery: null, lastId: 99, events }), 4)
|
||||
assert.equal(
|
||||
pickInitialEventId({ fromQuery: null, lastId: 99, events: [{ id: 3, status: 'finished' }] }),
|
||||
3,
|
||||
)
|
||||
assert.equal(pickInitialEventId({ fromQuery: null, lastId: 99, events: [] }), null)
|
||||
})
|
||||
|
||||
test('没有记忆、URL 也没带时,行为与改动前一致(第一个 pending)', () => {
|
||||
const events = [
|
||||
{ id: 3, status: 'ongoing' },
|
||||
{ id: 4, status: 'pending' },
|
||||
]
|
||||
assert.equal(pickInitialEventId({ fromQuery: null, lastId: null, events }), 4)
|
||||
assert.equal(pickInitialEventId({ fromQuery: null, lastId: null, events: [{ id: 3, status: 'ongoing' }] }), 3)
|
||||
})
|
||||
|
||||
test('记忆的读写:只存数字 id,脏数据一律当成没有记忆', () => {
|
||||
const storage = memoryStorage()
|
||||
assert.equal(readLastEntryEventId(storage), null)
|
||||
|
||||
rememberEntryEventId(storage, 12)
|
||||
assert.equal(storage.dump()[LAST_ENTRY_EVENT_KEY], '12')
|
||||
assert.equal(readLastEntryEventId(storage), 12)
|
||||
|
||||
// 非数字 / 负数 / 空白 → 不认(会被当成「没有记忆」,走兜底)
|
||||
for (const bad of ['abc', '', ' ', '-1', '0', '3.5', 'null']) {
|
||||
assert.equal(readLastEntryEventId(memoryStorage({ [LAST_ENTRY_EVENT_KEY]: bad })), null, bad)
|
||||
}
|
||||
// 清空(null)时把键写空串,读回来同样是「没有记忆」
|
||||
rememberEntryEventId(storage, null)
|
||||
assert.equal(readLastEntryEventId(storage), null)
|
||||
})
|
||||
|
||||
test('存储不可用(隐私模式 / 禁用存储)时不抛错,只是没有记忆', () => {
|
||||
const throwing = {
|
||||
getItem: () => {
|
||||
throw new Error('禁用了存储')
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('禁用了存储')
|
||||
},
|
||||
}
|
||||
assert.equal(readLastEntryEventId(throwing), null)
|
||||
assert.doesNotThrow(() => rememberEntryEventId(throwing, 5))
|
||||
assert.equal(readLastEntryEventId(null), null)
|
||||
assert.doesNotThrow(() => rememberEntryEventId(null, 5))
|
||||
// node 里没有 window.localStorage:取存储这一步本身也不能抛。
|
||||
assert.equal(entryMemoryStorage(), null)
|
||||
})
|
||||
|
||||
test('录入页接线:进入时用记忆,成功加载后写回记忆(按 id)', () => {
|
||||
assert.match(ENTRY_SOURCE, /readLastEntryEventId\(entryMemoryStorage\(\)\)/)
|
||||
assert.match(ENTRY_SOURCE, /rememberEntryEventId\(entryMemoryStorage\(\), id\)/)
|
||||
// 兜底仍是「第一个 pending」,但不再是进入页面时的第一顺位。
|
||||
assert.match(ENTRY_SOURCE, /lastId:/)
|
||||
})
|
||||
@@ -50,12 +50,16 @@ test('两个按钮在示例数据模式下直接禁用,避免点了才被告
|
||||
assert.match(ENTRY_PAGE_SOURCE, /:disabled="!canWrite \|\| data\.isMock"/)
|
||||
})
|
||||
|
||||
test('「继续录入」的注释按实现写:第一个 pending,没有持久化的「上次编辑」', () => {
|
||||
test('「继续录入」优先选上次录入的小项(按 id),兜底才是第一个 pending', () => {
|
||||
const pickInitial = section(ENTRY_PAGE_SOURCE, '进入录入页时默认选中小项', 'onMounted(')
|
||||
|
||||
assert.doesNotMatch(pickInitial, /默认聚焦上次编辑/, '实现里没有「上次编辑」的记忆,注释不能那么写')
|
||||
// 上次录入的小项按 id 记在本地:刚发布完的那个小项状态已是 published,
|
||||
// 只按「第一个 pending」兜底会把它排除掉,回录入页就换成了别的小项(撤销发布按钮跟着消失)。
|
||||
assert.match(pickInitial, /lastId: readLastEntryEventId\(entryMemoryStorage\(\)\)/)
|
||||
assert.match(pickInitial, /events: data\.events/)
|
||||
// 兜底行为没变:没有记忆时仍是「第一个未开始(pending)的项目,都没有就取列表第一个」。
|
||||
assert.match(pickInitial, /第一个未开始(pending)的项目/)
|
||||
assert.match(pickInitial, /event\.status === 'pending'/)
|
||||
assert.match(pickInitial, /pickInitialEventId/)
|
||||
})
|
||||
|
||||
test('整份 EntryPage 里不再残留「默认聚焦上次编辑」这类与实现不符的说法', () => {
|
||||
|
||||
Reference in new issue
Block a user