feat(admin-ui): 时间并发冲突自动回读 + 轮播排序脏标记 + 权限热校准
- api/admin.js:setTimes(data, expected);⚠️ 只在基线**非空**时才带 expectedTimes (空对象会让后端误判"所有组都要求校验"而恒冲突)。 - Admin.vue(时间设置):timesBaseline 记录 fetchTimes 载入时的服务端旧值, ⛔ 不用表单当前值(那样永远等于提交值 ⇒ 校验恒真 = 假保护)。冲突时自动 fetchTimes 重载(表单已过期,拿它重试只会再冲突);其它失败保留用户输入。 - Admin.vue(轮播):carouselOrderDirty —— 拖拽只改前端顺序、不落库, 此前卡片"排序: N"用的是渲染下标,拖完立刻变新序号,看起来已生效、刷新却回退。 现加"排序未保存"标签 + 按钮文案变化,未保存状态可见。 - Admin.vue(权限):切菜单时 8s 节流重拉 /api/admin/me,权限变化即提示。 原先前端只在 onMounted 拉一次 ⇒ 被改权限后必须手动刷新页面, 否则停在旧菜单上每个请求都撞 403 且无任何提示。⛔ 不用 location.reload() (会丢未提交的表单输入)。
This commit is contained in:
@@ -68,9 +68,11 @@ export function getTimes() {
|
|||||||
/**
|
/**
|
||||||
* 设置比赛时间
|
* 设置比赛时间
|
||||||
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
|
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
|
||||||
|
* @param {Object} [expected] - B② 乐观锁:打开表单时读到的旧值
|
||||||
|
* { zhongzhi: { start, end, deadline }, ... };不传 ⇒ 后端不校验(旧行为)
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
export function setTimes(data) {
|
export function setTimes(data, expected) {
|
||||||
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
|
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
|
||||||
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
|
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
|
||||||
const times = Object.entries(data).map(([group, item]) => ({
|
const times = Object.entries(data).map(([group, item]) => ({
|
||||||
@@ -81,7 +83,10 @@ export function setTimes(data) {
|
|||||||
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
|
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
|
||||||
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20,
|
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20,
|
||||||
}))
|
}))
|
||||||
return request.put('/admin/times', { times })
|
const payload = { times }
|
||||||
|
// ⛔ 只在真的有基线时才带:空对象会让后端误以为「所有组都要求校验」而恒冲突
|
||||||
|
if (expected && Object.keys(expected).length > 0) payload.expectedTimes = expected
|
||||||
|
return request.put('/admin/times', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1950,9 +1950,15 @@
|
|||||||
<h2 class="section-heading">轮播管理</h2>
|
<h2 class="section-heading">轮播管理</h2>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCarouselDialog()">添加轮播</el-button>
|
<el-button type="primary" @click="openCarouselDialog()">添加轮播</el-button>
|
||||||
<el-button type="primary" plain v-guard="() => saveCarouselOrder()" :disabled="adminCarousels.length < 2">保存排序</el-button>
|
<el-button
|
||||||
|
:type="carouselOrderDirty ? 'warning' : 'primary'"
|
||||||
|
plain
|
||||||
|
v-guard="() => saveCarouselOrder()"
|
||||||
|
:disabled="adminCarousels.length < 2"
|
||||||
|
>{{ carouselOrderDirty ? '保存排序(有未保存改动)' : '保存排序' }}</el-button>
|
||||||
|
<el-tag v-if="carouselOrderDirty" type="warning" effect="light" size="small">排序未保存</el-tag>
|
||||||
<span class="drag-hint" v-if="adminCarousels.length > 1">
|
<span class="drag-hint" v-if="adminCarousels.length > 1">
|
||||||
<el-icon><Rank /></el-icon> 拖拽卡片可调整排序
|
<el-icon><Rank /></el-icon> 拖拽卡片可调整排序;拖完须点「保存排序」才写入服务器
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-loading="carouselLoading" class="carousel-card-list" ref="carouselCardListRef">
|
<div v-loading="carouselLoading" class="carousel-card-list" ref="carouselCardListRef">
|
||||||
@@ -2434,7 +2440,31 @@ function firstAllowedMenu() {
|
|||||||
return MENU_ORDER.find(m => can(MENU_PERM[m])) || null
|
return MENU_ORDER.find(m => can(MENU_PERM[m])) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMenuSelect(index) {
|
// ---- 「改权限需刷新」的收敛点(R189)----
|
||||||
|
// 原状:权限只在 onMounted 拉一次。最高管理员改了你的权限后,你必须**手动刷新页面**;
|
||||||
|
// 若停在旧页面上,菜单还是旧的,每个请求都撞 403 且没有任何提示。
|
||||||
|
// 现法:切菜单时静默重拉 /api/admin/me(8s 节流,避免连点菜单刷请求)。
|
||||||
|
// ⛔ 不用 location.reload() —— 那会丢掉未提交的表单输入。
|
||||||
|
let lastPermCalibAt = 0
|
||||||
|
const PERM_CALIB_MIN_INTERVAL = 8000
|
||||||
|
|
||||||
|
async function calibratePermissions() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastPermCalibAt < PERM_CALIB_MIN_INTERVAL) return
|
||||||
|
lastPermCalibAt = now
|
||||||
|
const prev = authStore.currentUser || {}
|
||||||
|
const prevKey = `${prev.isSuper === true}|${[...(prev.permissions || [])].sort().join(',')}`
|
||||||
|
await loadPermissionCatalog()
|
||||||
|
const cur = authStore.currentUser || {}
|
||||||
|
const curKey = `${cur.isSuper === true}|${[...(cur.permissions || [])].sort().join(',')}`
|
||||||
|
if (prevKey === curKey) return
|
||||||
|
ElMessage.warning('你的权限已被更新,菜单与可用操作已同步')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMenuSelect(index) {
|
||||||
|
// R189:先静默校准权限(节流 8s)。否则这里拦的仍是登录时的旧快照 ——
|
||||||
|
// 「改权限后需刷新」的根因就在这一步缺失,而不是拦少了。
|
||||||
|
await calibratePermissions()
|
||||||
// R178:菜单本身已按权限隐藏,这里再拦一层 ——
|
// R178:菜单本身已按权限隐藏,这里再拦一层 ——
|
||||||
// 权限被最高管理员收回后页面不会自动刷新,若仍停在旧菜单上,每个请求都会撞 403。
|
// 权限被最高管理员收回后页面不会自动刷新,若仍停在旧菜单上,每个请求都会撞 403。
|
||||||
const need = MENU_PERM[index]
|
const need = MENU_PERM[index]
|
||||||
@@ -4259,6 +4289,10 @@ const timesForm = reactive({
|
|||||||
})
|
})
|
||||||
const timesSaving = ref(false)
|
const timesSaving = ref(false)
|
||||||
const timesLoading = ref(false)
|
const timesLoading = ref(false)
|
||||||
|
// B② 乐观锁基线:`fetchTimes` 载入时的「服务端旧值」。
|
||||||
|
// 保存时原样回传,后端比对不一致即判并发冲突 ⇒ 后保存者不会静默覆盖先保存者。
|
||||||
|
// ⛔ 不要在这里放表单当前值:那样永远等于提交值,校验恒真(假保护)。
|
||||||
|
const timesBaseline = ref({})
|
||||||
|
|
||||||
const timeGroups = [
|
const timeGroups = [
|
||||||
{ key: 'zhongzhi', name: '中职组', isExam: true },
|
{ key: 'zhongzhi', name: '中职组', isExam: true },
|
||||||
@@ -4275,6 +4309,8 @@ async function fetchTimes() {
|
|||||||
const list = (res && res.times) || []
|
const list = (res && res.times) || []
|
||||||
const map = {}
|
const map = {}
|
||||||
for (const t of list) map[t.group] = t
|
for (const t of list) map[t.group] = t
|
||||||
|
// 重新载入即视为「新的服务端基线」:冲突后刷新表单走的就是这里
|
||||||
|
const baseline = {}
|
||||||
for (const g of timeGroups) {
|
for (const g of timeGroups) {
|
||||||
const t = map[g.key]
|
const t = map[g.key]
|
||||||
if (!t) continue
|
if (!t) continue
|
||||||
@@ -4285,7 +4321,14 @@ async function fetchTimes() {
|
|||||||
timesForm[g.key + 'JudgeCount'] = t.judgeCount != null ? t.judgeCount : 20
|
timesForm[g.key + 'JudgeCount'] = t.judgeCount != null ? t.judgeCount : 20
|
||||||
timesForm[g.key + 'AvailChoice'] = t.availableChoice || 0
|
timesForm[g.key + 'AvailChoice'] = t.availableChoice || 0
|
||||||
timesForm[g.key + 'AvailJudge'] = t.availableJudge || 0
|
timesForm[g.key + 'AvailJudge'] = t.availableJudge || 0
|
||||||
|
// 只记「会被乐观锁比对」的三项时间字段(choiceCount/judgeCount 不参与)
|
||||||
|
baseline[g.key] = {
|
||||||
|
start: t.startTime ? t.startTime : '',
|
||||||
|
end: t.endTime ? t.endTime : '',
|
||||||
|
deadline: t.deadline ? t.deadline : '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
timesBaseline.value = baseline
|
||||||
} catch (e) { console.warn('获取时间配置失败', e) } finally { timesLoading.value = false }
|
} catch (e) { console.warn('获取时间配置失败', e) } finally { timesLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4297,11 +4340,18 @@ async function saveTimes() {
|
|||||||
gaozhi: { start: timesForm.gaozhiStart, end: timesForm.gaozhiEnd, deadline: timesForm.gaozhiDeadline, choiceCount: timesForm.gaozhiChoiceCount, judgeCount: timesForm.gaozhiJudgeCount },
|
gaozhi: { start: timesForm.gaozhiStart, end: timesForm.gaozhiEnd, deadline: timesForm.gaozhiDeadline, choiceCount: timesForm.gaozhiChoiceCount, judgeCount: timesForm.gaozhiJudgeCount },
|
||||||
benke: { start: timesForm.benkeStart, end: timesForm.benkeEnd, deadline: timesForm.benkeDeadline, choiceCount: timesForm.benkeChoiceCount, judgeCount: timesForm.benkeJudgeCount },
|
benke: { start: timesForm.benkeStart, end: timesForm.benkeEnd, deadline: timesForm.benkeDeadline, choiceCount: timesForm.benkeChoiceCount, judgeCount: timesForm.benkeJudgeCount },
|
||||||
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
|
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
|
||||||
})
|
}, timesBaseline.value)
|
||||||
ElMessage.success('时间设置保存成功')
|
ElMessage.success('时间设置保存成功')
|
||||||
// 保存即重排三节点计划(生产环境)⇒ 立刻回读,让「新计划是否真的排上」当场可见
|
// 保存即重排三节点计划(生产环境)⇒ 立刻回读,让「新计划是否真的排上」当场可见
|
||||||
fetchNodeBackupStatus()
|
fetchNodeBackupStatus()
|
||||||
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { timesSaving.value = false }
|
} catch (e) {
|
||||||
|
// 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹)。
|
||||||
|
// 🔴 仅「并发冲突」时自动回读:此刻表单里的值已过期,拿它重试只会再冲突;
|
||||||
|
// 其它失败(网络 / 参数校验)保留用户输入,不擅自清空。
|
||||||
|
if (e && typeof e.message === 'string' && e.message.includes('已被他人修改')) {
|
||||||
|
await fetchTimes()
|
||||||
|
}
|
||||||
|
} finally { timesSaving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 节点备份状态(P1「生产备份静默失败防护」的可见性)----
|
// ---- 节点备份状态(P1「生产备份静默失败防护」的可见性)----
|
||||||
@@ -4955,6 +5005,10 @@ const carouselForm = reactive({
|
|||||||
})
|
})
|
||||||
const carouselCardListRef = ref(null)
|
const carouselCardListRef = ref(null)
|
||||||
let carouselSortable = null
|
let carouselSortable = null
|
||||||
|
// ㊳ 排序脏标记:拖拽只改前端顺序、**不落库**(需点「保存排序」)。
|
||||||
|
// 此前卡片上「排序: N」用的是渲染下标 idx+1 ⇒ 拖完立刻变成新序号,看起来已生效,
|
||||||
|
// 刷新却回退到旧顺序。加脏标记后,未保存状态在界面上是可见的。
|
||||||
|
const carouselOrderDirty = ref(false)
|
||||||
|
|
||||||
const uploadUrl = '/api/admin/upload/image'
|
const uploadUrl = '/api/admin/upload/image'
|
||||||
const uploadHeaders = computed(() => {
|
const uploadHeaders = computed(() => {
|
||||||
@@ -5005,6 +5059,8 @@ async function fetchCarousels() {
|
|||||||
sort_order: item.sort_order ?? 0,
|
sort_order: item.sort_order ?? 0,
|
||||||
enabled: item.enabled !== false,
|
enabled: item.enabled !== false,
|
||||||
}))
|
}))
|
||||||
|
// 重新载入 = 服务端顺序,脏状态归零
|
||||||
|
carouselOrderDirty.value = false
|
||||||
initCarouselSortable()
|
initCarouselSortable()
|
||||||
} catch (e) { console.warn('获取轮播失败', e) } finally { carouselLoading.value = false }
|
} catch (e) { console.warn('获取轮播失败', e) } finally { carouselLoading.value = false }
|
||||||
}
|
}
|
||||||
@@ -5084,6 +5140,7 @@ async function saveCarouselOrder() {
|
|||||||
}))
|
}))
|
||||||
try {
|
try {
|
||||||
await adminApi.updateCarouselOrder(orders)
|
await adminApi.updateCarouselOrder(orders)
|
||||||
|
carouselOrderDirty.value = false
|
||||||
ElMessage.success('排序保存成功')
|
ElMessage.success('排序保存成功')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('轮播排序保存失败:', e)
|
console.error('轮播排序保存失败:', e)
|
||||||
@@ -5121,6 +5178,8 @@ function initCarouselSortable() {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||||
adminCarousels.value = reordered
|
adminCarousels.value = reordered
|
||||||
|
// 拖拽只改前端顺序,**未落库** ⇒ 必须点亮脏标记,否则「排序: N」看着已生效
|
||||||
|
carouselOrderDirty.value = true
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -5274,11 +5333,10 @@ async function downloadReviewWork(row) {
|
|||||||
|
|
||||||
// ==================== 初始化 ====================
|
// ==================== 初始化 ====================
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// R178:先向服务端校准权限(多管理员下,权限可能在登录后被改动)。
|
// R178/R189:权限校准统一由 onMenuSelect → calibratePermissions 负责(带节流),
|
||||||
|
// 此处不再单独请求 /api/admin/me,避免首屏连发两次。
|
||||||
// 失败不阻断 —— can() 会退回登录时落盘的那份快照。
|
// 失败不阻断 —— can() 会退回登录时落盘的那份快照。
|
||||||
await loadPermissionCatalog()
|
await onMenuSelect(activeMenu.value)
|
||||||
// 首次加载时根据当前活动菜单获取对应数据
|
|
||||||
onMenuSelect(activeMenu.value)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user