diff --git a/web/src/api/activation.js b/web/src/api/activation.js index 37b0c71..fb40a5a 100644 --- a/web/src/api/activation.js +++ b/web/src/api/activation.js @@ -1,4 +1,4 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const listChannelActivations = (params) => service({ url: '/sw/promoteActivePool/bindList', method: 'get', params }) -export const getActivationSpend = (id) => service({ url: '/sw/promoteActivePool/bindSpendDetail', method: 'get', params: { id } }) +export const getActivationSpend = (id, options = {}) => service({ ...options, url: '/sw/promoteActivePool/bindSpendDetail', method: 'get', params: { id } }) diff --git a/web/src/api/alert.js b/web/src/api/alert.js index 30a0486..ac4c64d 100644 --- a/web/src/api/alert.js +++ b/web/src/api/alert.js @@ -1,4 +1,4 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const listAlertRules = (params) => service({ url: '/sw/alertRule/list', method: 'get', params }) export const getAlertRuleTypes = () => service({ url: '/sw/alertRule/ruleTypes', method: 'get' }) @@ -7,7 +7,7 @@ export const updateAlertRule = (data) => service({ url: '/sw/alertRule/edit', me export const deleteAlertRule = (id) => service({ url: `/sw/alertRule/${id}`, method: 'delete' }) export const toggleAlertRule = (id, enabled) => service({ url: `/sw/alertRule/toggle/${id}`, method: 'put', params: { enabled } }) -export const listAlertRecords = (params) => service({ url: '/sw/alertRecord/list', method: 'get', params }) -export const getAlertRecord = (id) => service({ url: `/sw/alertRecord/detail/${id}`, method: 'get' }) +export const listAlertRecords = (params, options = {}) => service({ ...options, url: '/sw/alertRecord/list', method: 'get', params }) +export const getAlertRecord = (id, options = {}) => service({ ...options, url: `/sw/alertRecord/detail/${id}`, method: 'get' }) export const processAlertRecord = (action, data) => service({ url: `/sw/alertRecord/${action}`, method: 'post', data }) export const addAlertNote = (data) => service({ url: '/sw/alertRecord/addNote', method: 'post', data }) diff --git a/web/src/api/analytics.js b/web/src/api/analytics.js index 14a7a31..ca49dce 100644 --- a/web/src/api/analytics.js +++ b/web/src/api/analytics.js @@ -1,3 +1,3 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const listAnalytics = (kind, params) => service({ url: `/sw/${kind === 'channel' ? 'channelData' : 'gameData'}/list`, method: 'get', params }) export const exportChannelAnalytics = params => service({ url: '/sw/channelData/list', method: 'get', params: { ...params, export: true }, responseType: 'blob' }) diff --git a/web/src/api/business.js b/web/src/api/business.js index 7619318..82fc74e 100644 --- a/web/src/api/business.js +++ b/web/src/api/business.js @@ -1,4 +1,4 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const getUserPreference = () => service({ url: '/sw/user/preference', method: 'get' }) export const updateUserPreference = (data) => service({ url: '/sw/user/preference', method: 'put', data }) @@ -8,8 +8,8 @@ export const getBusiness = (id) => service({ url: `/sw/business/${id}`, method: export const createBusiness = (data) => service({ url: '/sw/business', method: 'post', data }) export const updateBusiness = (data) => service({ url: '/sw/business', method: 'put', data }) -export const listBusinessBindings = (params) => service({ url: '/sw/business/bindPlatform', method: 'get', params }) -export const listUserBindings = (id) => service({ url: `/sw/business/${id}/bindPlatform`, method: 'get' }) +export const listBusinessBindings = (params, options = {}) => service({ ...options, url: '/sw/business/bindPlatform', method: 'get', params }) +export const listUserBindings = (id, options = {}) => service({ ...options, url: `/sw/business/${id}/bindPlatform`, method: 'get' }) export const bindPlatform = (data) => service({ url: '/sw/business/bindPlatform', method: 'post', data }) export const unbindPlatform = (data) => service({ url: '/sw/business/bindPlatform', method: 'delete', data }) -export const listBusinessChannels = (id, params) => service({ url: `/sw/business/${id}/promotes`, method: 'get', params }) +export const listBusinessChannels = (id, params, options = {}) => service({ ...options, url: `/sw/business/${id}/promotes`, method: 'get', params }) diff --git a/web/src/api/businessPortrait.js b/web/src/api/businessPortrait.js index 87ecd53..5fb64d3 100644 --- a/web/src/api/businessPortrait.js +++ b/web/src/api/businessPortrait.js @@ -1,4 +1,4 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' -export const getBusinessPortrait = (userId, params) => service({ url: `/sw/business/${userId}/portrait`, method: 'get', params }) -export const getBindingPortrait = params => service({ url: '/sw/business/bindPlatform/portrait', method: 'get', params }) +export const getBusinessPortrait = (userId, params, options = {}) => service({ ...options, url: `/sw/business/${userId}/portrait`, method: 'get', params }) +export const getBindingPortrait = (params, options = {}) => service({ ...options, url: '/sw/business/bindPlatform/portrait', method: 'get', params }) diff --git a/web/src/api/channel.js b/web/src/api/channel.js index e7cdb60..7c07021 100644 --- a/web/src/api/channel.js +++ b/web/src/api/channel.js @@ -1,7 +1,7 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const listChannels = params => service({ url: '/sw/promote/list', method: 'get', params }) -export const channelBindOptions = sourceDb => service({ url: '/sw/promote/bindOptions', method: 'get', params: { sourceDb } }) +export const channelBindOptions = (sourceDb, options = {}) => service({ ...options, url: '/sw/promote/bindOptions', method: 'get', params: { sourceDb } }) export const createChannel = data => service({ url: '/sw/promote', method: 'post', data }) -export const channelGames = params => service({ url: '/sw/promote/game-distribution', method: 'get', params }) -export const channelPortrait = params => service({ url: '/sw/promote/portrait', method: 'get', params }) +export const channelGames = (params, options = {}) => service({ ...options, url: '/sw/promote/game-distribution', method: 'get', params }) +export const channelPortrait = (params, options = {}) => service({ ...options, url: '/sw/promote/portrait', method: 'get', params }) diff --git a/web/src/api/fileUploadAndDownload.js b/web/src/api/fileUploadAndDownload.js index 0f260b6..f079b2c 100644 --- a/web/src/api/fileUploadAndDownload.js +++ b/web/src/api/fileUploadAndDownload.js @@ -58,8 +58,9 @@ export const importURL = (data) => { // 上传文件 暂时用于头像上传 -export const uploadFile = (data) => { +export const uploadFile = (data, options = {}) => { return service({ + ...options, url: "/fileUploadAndDownload/upload", method: "post", data, diff --git a/web/src/api/ledger.js b/web/src/api/ledger.js index 8394b9d..b06908d 100644 --- a/web/src/api/ledger.js +++ b/web/src/api/ledger.js @@ -1,4 +1,4 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const listLedger = (kind, params) => service({ url: `/sw/${kind}/list`, method: 'get', params }) export const exportLedger = (kind, data) => { diff --git a/web/src/api/otherSettings.js b/web/src/api/otherSettings.js index c758159..c7bf92c 100644 --- a/web/src/api/otherSettings.js +++ b/web/src/api/otherSettings.js @@ -1,4 +1,4 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const getOtherSettings = () => service({ url: '/sw/settings/effectiveness', method: 'get', donNotShowLoading: true }) export const saveOtherSettings = data => service({ url: '/sw/settings/effectiveness', method: 'put', data, donNotShowLoading: true }) diff --git a/web/src/api/performance.js b/web/src/api/performance.js index 4d96314..9e40af6 100644 --- a/web/src/api/performance.js +++ b/web/src/api/performance.js @@ -1,12 +1,12 @@ -import service from '@/utils/request' -const get = (url, params) => service({ url, params, method: 'get', donNotShowLoading: true }) +import service from '@/utils/businessRequest' +const get = (url, params, options = {}) => service({ ...options, url, params, method: 'get', donNotShowLoading: true }) export const getTeams = () => get('/sw/groups') export const saveTeams = data => service({ url: '/sw/groups', method: 'put', data }) export const updateMember = data => service({ url: '/sw/business/profile', method: 'put', data }) export const getManagement = params => get('/sw/business/management', params) export const getPerformance = params => get('/sw/performance', params) -export const getPerformanceDetails = params => get('/sw/performance/details', params) -export const getGoals = params => get('/sw/goals', params) +export const getPerformanceDetails = (params, options) => get('/sw/performance/details', params, options) +export const getGoals = (params, options) => get('/sw/goals', params, options) export const saveGoal = data => service({ url: '/sw/goals', method: 'put', data }) export const getMyGoal = () => get('/sw/goals/mine') export const getDashboardOverview = params => get('/sw/workbench/overview', params) diff --git a/web/src/api/ranking.js b/web/src/api/ranking.js index c60d1f8..fb674fb 100644 --- a/web/src/api/ranking.js +++ b/web/src/api/ranking.js @@ -1,3 +1,3 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const listRankings = (dimension, params) => service({ url: `/sw/rank/${dimension}`, method: 'get', params, timeout: 30000, donNotShowLoading: true }) diff --git a/web/src/api/serverAnalysis.js b/web/src/api/serverAnalysis.js index bf30b08..c63036e 100644 --- a/web/src/api/serverAnalysis.js +++ b/web/src/api/serverAnalysis.js @@ -1,4 +1,4 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const listServerAnalysis = params => service({ url: '/sw/serverAnalysis/list', method: 'get', params }) export const listServerOptions = params => service({ url: '/sw/serverAnalysis/servers', method: 'get', params, donNotShowLoading: true }) diff --git a/web/src/api/workbench.js b/web/src/api/workbench.js index 1861a17..ae70e0c 100644 --- a/web/src/api/workbench.js +++ b/web/src/api/workbench.js @@ -1,4 +1,4 @@ -import service from '@/utils/request' +import service from '@/utils/businessRequest' export const getWorkbenchSummary = params => service({ url: '/sw/workbench/summary', method: 'get', params, donNotShowLoading: true }) export const getWorkbenchRanks = (endpoint, params) => service({ url: `/sw/workbench/${endpoint}`, method: 'get', params, donNotShowLoading: true }) diff --git a/web/src/utils/businessRequest.js b/web/src/utils/businessRequest.js new file mode 100644 index 0000000..f33ca66 --- /dev/null +++ b/web/src/utils/businessRequest.js @@ -0,0 +1,5 @@ +import service from './request' + +// Business views own their table, drawer and submit-button loading states. +// Keep the framework request default for screens that rely on its page mask. +export default config => service({ donNotShowLoading: true, ...config }) diff --git a/web/src/utils/latestRequest.js b/web/src/utils/latestRequest.js new file mode 100644 index 0000000..691b173 --- /dev/null +++ b/web/src/utils/latestRequest.js @@ -0,0 +1,21 @@ +// A query owns one AbortController. Replacing or closing it invalidates both +// its network request and late callbacks from adapters that ignore cancellation. +export const createLatestRequest = () => { + let current + return { + start() { + current?.abort() + const controller = new AbortController() + current = controller + return { + signal: controller.signal, + isCurrent: () => current === controller && !controller.signal.aborted + } + }, + cancel() { + const previous = current + current = undefined + previous?.abort() + } + } +} diff --git a/web/src/utils/latestRequest.test.js b/web/src/utils/latestRequest.test.js new file mode 100644 index 0000000..9a7143b --- /dev/null +++ b/web/src/utils/latestRequest.test.js @@ -0,0 +1,77 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import http from 'node:http' +import axios from 'axios' +import { createLatestRequest } from './latestRequest.js' + +test('replacing and closing a query aborts it and invalidates late callbacks', () => { + const scope = createLatestRequest() + const first = scope.start() + let aborted = 0 + first.signal.addEventListener('abort', () => aborted++) + const second = scope.start() + assert.equal(aborted, 1) + assert.equal(first.isCurrent(), false) + assert.equal(second.isCurrent(), true) + scope.cancel() + scope.cancel() + assert.equal(second.signal.aborted, true) + assert.equal(second.isCurrent(), false) + const reopened = scope.start() + assert.equal(reopened.signal.aborted, false) + assert.equal(reopened.isCurrent(), true) +}) + +test('a late response or finally callback cannot overwrite the newly opened query', async () => { + const scope = createLatestRequest() + let resolveOld, resolveNew + const oldResponse = new Promise(resolve => { resolveOld = resolve }) + const newResponse = new Promise(resolve => { resolveNew = resolve }) + let data = null, loading = false + const load = async response => { + const request = scope.start() + loading = true + try { + const result = await response + if (request.isCurrent()) data = result + } finally { if (request.isCurrent()) loading = false } + } + const old = load(oldResponse) + scope.cancel() + const current = load(newResponse) + resolveOld('old channel') + await old + assert.equal(data, null) + assert.equal(loading, true) + resolveNew('new channel') + await current + assert.equal(data, 'new channel') + assert.equal(loading, false) +}) + +test('axios cancellation releases the slow HTTP request without cancelling another query', async t => { + const pending = new Map() + let ready + const bothConnected = new Promise(resolve => { ready = resolve }) + const server = http.createServer((req, res) => { + pending.set(req.url, res) + if (pending.size === 2) ready() + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + t.after(() => { server.closeAllConnections(); server.close() }) + const firstScope = createLatestRequest(), secondScope = createLatestRequest() + const first = firstScope.start(), second = secondScope.start() + const base = `http://127.0.0.1:${server.address().port}` + const cancelled = axios.get(`${base}/slow`, { signal: first.signal }) + const cancellation = assert.rejects(cancelled, error => axios.isCancel(error) && !error.response) + const completed = axios.get(`${base}/other`, { signal: second.signal }) + await bothConnected + const disconnected = new Promise(resolve => pending.get('/slow').on('close', resolve)) + firstScope.cancel() + await cancellation + await disconnected + assert.equal(second.isCurrent(), true) + assert.equal(second.signal.aborted, false) + pending.get('/other').end('other query finished') + assert.equal((await completed).data, 'other query finished') +}) diff --git a/web/src/utils/request.js b/web/src/utils/request.js index cc0a625..ece979e 100644 --- a/web/src/utils/request.js +++ b/web/src/utils/request.js @@ -144,12 +144,14 @@ service.interceptors.request.use( closeLoading(error.config?.loadingOption) } + if (axios.isCancel(error)) return Promise.reject(error) + emitter.emit('show-error', { code: 'request', message: error.message || '请求发送失败' }) - return error + return Promise.reject(error) } ) @@ -219,6 +221,10 @@ service.interceptors.response.use( closeLoading(error.config?.loadingOption) } + // Closing a query is intentional. It must neither open a network error + // dialog nor reset loading owned by another request that is still running. + if (axios.isCancel(error)) return Promise.reject(error) + if (!error.response) { resetLoading() emitter.emit('show-error', { diff --git a/web/src/view/sw/AlertRules.vue b/web/src/view/sw/AlertRules.vue index dbb0632..7244a3a 100644 --- a/web/src/view/sw/AlertRules.vue +++ b/web/src/view/sw/AlertRules.vue @@ -17,7 +17,7 @@ - +
@@ -64,6 +64,7 @@ const loading = ref(false) const saving = ref(false) const drawer = ref(false) const busy = ref(0) +const removing = ref(0) const formRef = ref() const form = ref({}) const rules = Object.fromEntries(['ruleName', 'ruleType', 'monitorMetric', 'statPeriodDays', 'momDirection', 'momThreshold', 'unloginDays', 'warningColor'].map((name) => [name, [{ required: true, message: '请填写完整规则信息', trigger: 'change' }]])) @@ -88,9 +89,13 @@ const toggle = async (row, enabled) => { try { const res = await toggleAlertRule(row.id, enabled); if (res.code === 0) row.enabled = enabled } finally { busy.value = 0 } } const remove = async (row) => { + if (removing.value) return if (!await ElMessageBox.confirm(`确认删除规则「${row.ruleName}」?`, '删除确认', { type: 'warning' }).then(() => true).catch(() => false)) return - const res = await deleteAlertRule(row.id) - if (res.code === 0) { ElMessage.success('删除成功'); await load() } + removing.value = row.id + try { + const res = await deleteAlertRule(row.id) + if (res.code === 0) { ElMessage.success('删除成功'); await load() } + } finally { removing.value = 0 } } onMounted(load) diff --git a/web/src/view/sw/ChannelActivationList.vue b/web/src/view/sw/ChannelActivationList.vue index f59d62b..b6f6805 100644 --- a/web/src/view/sw/ChannelActivationList.vue +++ b/web/src/view/sw/ChannelActivationList.vue @@ -23,7 +23,7 @@
- +
{{ selected?.platformTitle || '—' }} @@ -49,7 +49,8 @@ diff --git a/web/src/view/sw/components/AddChannelDrawer.vue b/web/src/view/sw/components/AddChannelDrawer.vue index e540a2c..086fc1f 100644 --- a/web/src/view/sw/components/AddChannelDrawer.vue +++ b/web/src/view/sw/components/AddChannelDrawer.vue @@ -1,5 +1,5 @@ diff --git a/web/src/view/sw/components/AlertHandleDrawer.vue b/web/src/view/sw/components/AlertHandleDrawer.vue index 77d8422..87238e3 100644 --- a/web/src/view/sw/components/AlertHandleDrawer.vue +++ b/web/src/view/sw/components/AlertHandleDrawer.vue @@ -1,5 +1,5 @@