fix: 消除业务重复loading并取消已关闭抽屉的查询

This commit is contained in:
Yvan
2026-11-05 12:39:50 +08:00
parent d4233c7139
commit 5f6e893457
28 changed files with 270 additions and 112 deletions
+2 -2
View File
@@ -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 } })
+3 -3
View File
@@ -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 })
+1 -1
View File
@@ -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' })
+4 -4
View File
@@ -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 })
+3 -3
View File
@@ -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 })
+4 -4
View File
@@ -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 })
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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) => {
+1 -1
View File
@@ -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 })
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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 })
+1 -1
View File
@@ -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 })
+1 -1
View File
@@ -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 })
+5
View File
@@ -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 })
+21
View File
@@ -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()
}
}
}
+77
View File
@@ -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')
})
+7 -1
View File
@@ -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', {
+8 -3
View File
@@ -17,7 +17,7 @@
<el-table-column prop="ruleDescription" label="规则描述" min-width="320" show-overflow-tooltip />
<el-table-column prop="creatorName" label="创建人" min-width="120" />
<el-table-column label="作用范围" min-width="150"><template #default>仅对一级渠道生效</template></el-table-column>
<el-table-column v-if="canEdit || canDelete" label="操作" width="130" fixed="right"><template #default="{ row }"><el-button v-if="canEdit" type="primary" link @click="open(row)">编辑</el-button><el-button v-if="canDelete" type="danger" link @click="remove(row)">删除</el-button></template></el-table-column>
<el-table-column v-if="canEdit || canDelete" label="操作" width="130" fixed="right"><template #default="{ row }"><el-button v-if="canEdit" type="primary" link @click="open(row)">编辑</el-button><el-button v-if="canDelete" type="danger" link :loading="removing === row.id" :disabled="!!removing" @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
@@ -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)
</script>
+12 -8
View File
@@ -23,7 +23,7 @@
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
<el-dialog class="sw-dialog" v-model="dialog" title="流水明细" width="min(640px, 95vw)" append-to-body destroy-on-close @closed="clearDetail">
<el-dialog class="sw-dialog" v-model="dialog" title="流水明细" width="min(640px, 95vw)" append-to-body destroy-on-close>
<div v-loading="flowLoading">
<el-descriptions :column="1">
<el-descriptions-item label="所属平台">{{ selected?.platformTitle || '—' }}</el-descriptions-item>
@@ -49,7 +49,8 @@
<script setup>
import PlatformTag from './components/PlatformTag.vue'
import MetricValue from './components/MetricValue.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onDeactivated, onMounted, reactive, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { listChannelActivations, getActivationSpend } from '@/api/activation'
defineOptions({ name: 'SwChannelActivationList' })
@@ -66,7 +67,7 @@ const flowError = ref('')
const selected = ref(null)
const detail = ref(null)
let listVersion = 0
let detailVersion = 0
const detailRequests = createLatestRequest()
const emptyText = computed(() => !query.platformNames.length ? '请选择所属平台' : error.value ? '查询失败,请重试' : !available.value ? 'OA 数据源暂不可用' : '当前筛选条件及账号权限下暂无激活记录')
const date = value => value ? value.slice(0, 10) : '—'
// 金额按字符串分组,保留超出 JavaScript 精确整数范围的金额。
@@ -98,19 +99,22 @@ const load = async () => {
}
const search = () => { query.pageNum = 1; return load() }
const reset = () => { Object.assign(query, { platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '' }); return search() }
const clearDetail = () => { ++detailVersion; detail.value = null; selected.value = null; flowError.value = ''; flowLoading.value = false }
const clearDetail = () => { detailRequests.cancel(); detail.value = null; selected.value = null; flowError.value = ''; flowLoading.value = false }
const openFlow = async row => {
clearDetail()
const version = ++detailVersion
const request = detailRequests.start()
selected.value = row
dialog.value = true
flowLoading.value = true
try {
const res = await getActivationSpend(row.id)
if (version !== detailVersion) return
const res = await getActivationSpend(row.id, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code === 0) detail.value = res.data
else flowError.value = res.msg || '流水明细读取失败'
} catch { if (version === detailVersion) flowError.value = '流水明细读取失败,请重试' } finally { if (version === detailVersion) flowLoading.value = false }
} catch { if (request.isCurrent()) flowError.value = '流水明细读取失败,请重试' } finally { if (request.isCurrent()) flowLoading.value = false }
}
watch(dialog, open => { if (!open) clearDetail() }, { flush: 'sync' })
onBeforeUnmount(clearDetail)
onDeactivated(() => { dialog.value = false; clearDetail() })
onMounted(load)
</script>
@@ -1,5 +1,5 @@
<template>
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close title="添加渠道" size="min(520px, 100vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving" @closed="reset">
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close title="添加渠道" size="min(520px, 100vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
<el-form-item label="所属平台" prop="sourceDb"><el-select v-model="form.sourceDb" placeholder="请选择所属平台" clearable @change="loadOptions"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="游戏商务" prop="platformBusierId"><el-select v-model="form.platformBusierId" placeholder="请选择游戏商务" filterable clearable :disabled="!form.sourceDb" :loading="optionsLoading"><el-option v-for="item in options" :key="item.id" :label="item.account" :value="item.id" /></el-select></el-form-item>
@@ -13,7 +13,8 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { ElMessage } from 'element-plus'
import { channelBindOptions, createChannel } from '@/api/channel'
const props = defineProps({ modelValue: Boolean })
@@ -26,26 +27,26 @@ const formRef = ref()
const options = ref([])
const optionsLoading = ref(false)
const saving = ref(false)
let optionVersion = 0
const optionRequests = createLatestRequest()
const rules = {
sourceDb: [{ required: true, message: '请选择所属平台', trigger: 'change' }],
platformBusierId: [{ required: true, message: '请选择游戏商务', trigger: 'change' }],
account: [{ required: true, message: '请输入渠道账号', trigger: 'blur' }, { validator: (_rule, value, callback) => /^[\u4e00-\u9fa5a-zA-Z0-9]{4,30}$/.test(value?.trim() || '') ? callback() : callback(new Error('支持中文、英文、数字;长度 4-30 位')), trigger: 'blur' }],
password: [{ required: true, message: '请输入渠道密码', trigger: 'blur' }, { min: 6, max: 15, message: '密码长度需在 6 到 15 位', trigger: 'blur' }]
}
const reset = () => { ++optionVersion; form.value = initial(); options.value = []; optionsLoading.value = false }
const reset = () => { optionRequests.cancel(); form.value = initial(); options.value = []; optionsLoading.value = false }
const loadOptions = async () => {
const version = ++optionVersion
const request = optionRequests.start()
form.value.platformBusierId = undefined
options.value = []
if (!form.value.sourceDb) { optionsLoading.value = false; return }
optionsLoading.value = true
try {
const res = await channelBindOptions(form.value.sourceDb)
if (version !== optionVersion || res.code !== 0) return
const res = await channelBindOptions(form.value.sourceDb, { signal: request.signal })
if (!request.isCurrent() || res.code !== 0) return
const unique = new Map(res.data.map(item => [item.platformBusierId, { id: item.platformBusierId, account: item.platformBusierAccount?.trim() || String(item.platformBusierId) }]))
options.value = [...unique.values()]
} finally { if (version === optionVersion) optionsLoading.value = false }
} catch { if (request.isCurrent()) options.value = [] } finally { if (request.isCurrent()) optionsLoading.value = false }
}
const save = async () => {
if (saving.value || !await formRef.value.validate().catch(() => false)) return
@@ -55,4 +56,7 @@ const save = async () => {
if (res.code === 0) { ElMessage.success(res.msg || '添加成功,数据同步到列表可能有延迟'); visible.value = false; emit('success') }
} finally { saving.value = false }
}
watch(visible, open => { if (!open) reset() }, { flush: 'sync' })
onBeforeUnmount(reset)
onDeactivated(() => { visible.value = false; reset() })
</script>
@@ -1,5 +1,5 @@
<template>
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close title="预警处理 / 详情" size="min(560px, 100vw)" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" @closed="reset">
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close title="预警处理 / 详情" size="min(560px, 100vw)" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy">
<div v-loading="loading" tabindex="0" @paste="pasteImages">
<template v-if="current">
<h3 class="mb-3">渠道:{{ current.targetPromoteName.replace(/^渠道[::]\s*/, '') || '—' }} — {{ current.ruleName || '预警' }}</h3>
@@ -56,7 +56,8 @@
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAlertRecord, processAlertRecord, addAlertNote } from '@/api/alert'
import { uploadFile } from '@/api/fileUploadAndDownload'
@@ -78,20 +79,21 @@ const busy = computed(() => savingNote.value || processing.value)
const note = ref('')
const pending = ref([])
const fileInput = ref()
let requestVersion = 0
const requests = createLatestRequest()
// 图片只能作为图片地址展示,不将历史附件内容作为 HTML 或脚本执行。
const fileURL = (url) => /^https?:\/\//i.test(url) || !/^[a-z][a-z\d+.-]*:/i.test(url) ? getUrl(url) : ''
const clearImages = () => { pending.value.forEach(item => URL.revokeObjectURL(item.url)); pending.value = [] }
const reset = () => { ++requestVersion; clearImages(); note.value = ''; current.value = null; logs.value = []; detailCanAudit.value = false; loading.value = false }
const reset = () => { requests.cancel(); clearImages(); note.value = ''; current.value = null; logs.value = []; detailCanAudit.value = false; loading.value = false }
const load = async () => {
const version = ++requestVersion
if (!visible.value || !props.alertId) return
const request = requests.start()
loading.value = true
try {
const res = await getAlertRecord(props.alertId)
if (version !== requestVersion) return
const res = await getAlertRecord(props.alertId, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code === 0) { current.value = res.data.alertRecord; logs.value = res.data.operationLogs; detailCanAudit.value = res.data.canAudit === true }
else { current.value = null; logs.value = [] }
} catch { if (version === requestVersion) { current.value = null; logs.value = [] } } finally { if (version === requestVersion) loading.value = false }
} catch { if (request.isCurrent()) { current.value = null; logs.value = [] } } finally { if (request.isCurrent()) loading.value = false }
}
const addImages = (files) => {
if (busy.value || !canNote.value) return
@@ -115,7 +117,7 @@ const addNote = async () => {
if (item.uploaded) continue
const data = new FormData()
data.append('file', item.file)
const res = await uploadFile(data)
const res = await uploadFile(data, { donNotShowLoading: true })
const file = res.data?.file
if (res.code !== 0 || !file?.url) throw new Error('图片上传失败')
// 保留已经上传的结果,备注失败重试时不重复上传文件。
@@ -143,8 +145,9 @@ const process = async (action) => {
note.value = ''; ElMessage.success('操作成功'); emit('success'); await load()
} finally { processing.value = false }
}
watch(() => [props.modelValue, props.alertId], ([open]) => { if (open) { reset(); void load() } })
watch(() => [props.modelValue, props.alertId], ([open]) => { reset(); if (open) void load() }, { flush: 'sync' })
onBeforeUnmount(reset)
onDeactivated(() => { visible.value = false; reset() })
</script>
<style scoped>
@@ -62,7 +62,8 @@
<script setup>
import PlatformTag from './PlatformTag.vue'
import { computed, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
@@ -86,6 +87,7 @@ const query = reactive({ pageNum: 1, pageSize: 10, sourceDb: [], platformBusierA
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const requests = createLatestRequest()
const dialog = ref(false)
const rejectDrawer = ref(false)
const unbindDrawer = ref(false)
@@ -100,14 +102,16 @@ const form = ref({})
const rules = Object.fromEntries(['sourceDb', 'account', 'password'].map((key) => [key, [{ required: true, message: '请填写完整信息', trigger: 'blur' }]]))
const rowKey = (row) => `${row.userId}:${row.sourceDb}:${row.platformBusierId}`
const load = async () => {
const request = requests.start()
loading.value = true
try {
const res = props.userId ? await listUserBindings(props.userId) : await listBusinessBindings({ ...query, sourceDb: query.sourceDb.join(',') })
const res = props.userId ? await listUserBindings(props.userId, { signal: request.signal }) : await listBusinessBindings({ ...query, sourceDb: query.sourceDb.join(',') }, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code === 0) {
rows.value = props.userId ? res.data : res.data.rows
total.value = props.userId ? rows.value.length : res.data.total
}
} finally { loading.value = false }
} catch { if (request.isCurrent()) { rows.value = []; total.value = 0 } } finally { if (request.isCurrent()) loading.value = false }
}
const search = () => { query.pageNum = 1; return load() }
const reset = () => { Object.assign(query, { sourceDb: [], platformBusierAccount: '', status: '' }); return search() }
@@ -131,4 +135,5 @@ const confirmUnbind = async () => {
} finally { saving.value = false }
}
watch(() => props.userId, load, { immediate: true })
onBeforeUnmount(() => { requests.cancel(); loading.value = false })
</script>
@@ -14,7 +14,8 @@
<script setup>
import PlatformTag from './PlatformTag.vue'
import { ref, watch } from 'vue'
import { onBeforeUnmount, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { listBusinessChannels } from '@/api/business'
defineOptions({ name: 'SwBusinessChannels' })
@@ -24,12 +25,16 @@ const rows = ref([])
const total = ref(0)
const available = ref(true)
const loading = ref(false)
const requests = createLatestRequest()
const load = async () => {
const request = requests.start()
loading.value = true
try {
const res = await listBusinessChannels(props.userId, { pageNum: page.value, pageSize: 10, sourceDb: props.sourceDb || undefined, platformBusierId: props.platformBusierId || undefined })
const res = await listBusinessChannels(props.userId, { pageNum: page.value, pageSize: 10, sourceDb: props.sourceDb || undefined, platformBusierId: props.platformBusierId || undefined }, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total; available.value = res.data.available !== false }
} finally { loading.value = false }
} catch { if (request.isCurrent()) { rows.value = []; total.value = 0 } } finally { if (request.isCurrent()) loading.value = false }
}
watch(() => [props.userId, props.sourceDb, props.platformBusierId], () => { page.value = 1; load() }, { immediate: true })
onBeforeUnmount(() => { requests.cancel(); loading.value = false })
</script>
@@ -1,6 +1,6 @@
<template>
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close :title="title" size="min(960px, 100vw)" @closed="reset">
<div v-loading="loading">
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close :title="title" size="min(960px, 100vw)">
<div v-loading="loading" class="profile-content">
<el-alert v-if="error" type="warning" :closable="false" :title="error" />
<template v-if="profile">
<h3 class="profile-heading">基本信息</h3>
@@ -54,7 +54,8 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { getBusinessPortrait, getBindingPortrait } from '@/api/businessPortrait'
import { listAlertRecords } from '@/api/alert'
import { channelCount, channelMoney } from '../channel-display'
@@ -75,7 +76,7 @@ const alertPage = ref(1)
const alertTotal = ref(0)
const alertsLoading = ref(false)
const alertError = ref('')
let profileVersion = 0, alertVersion = 0
const profileRequests = createLatestRequest(), alertRequests = createLatestRequest()
const basic = computed(() => profile.value?.basic || {})
const sources = computed(() => props.sourceDb || 'lx,hw,yq')
const title = computed(() => `${props.platformBusierId ? '游戏商务画像' : '商务画像'}${props.name || basic.value.userName ? ` — ${props.name || basic.value.userName}` : ''}`)
@@ -93,33 +94,39 @@ const cards = computed(() => [
return { ...card, value: card.format(metric?.value), tone: numeric == null || numeric === 0 ? '' : numeric > 0 ? 'positive' : 'negative', comparison: diff == null ? `${metric?.compareLabel || ''} ${metric?.compareValue == null ? '—' : card.format(metric.compareValue)}` : `${metric?.compareLabel || ''} ${numeric > 0 ? '↑ ' : numeric < 0 ? '↓ ' : ''}${Math.abs(numeric).toFixed(1)}${suffix}` }
}))
const alertType = row => ({ register_anomaly: '注册显著暴跌', recharge_anomaly: '充值额暴涨', login_anomaly: '渠道长期未登录' })[row.ruleType] || row.ruleName || row.ruleType || '—'
const reset = () => { ++profileVersion; ++alertVersion; profile.value = null; error.value = ''; alerts.value = []; alertTotal.value = 0; alertPage.value = 1; alertError.value = ''; loading.value = false; alertsLoading.value = false; channelsOpen.value = false }
const reset = () => { profileRequests.cancel(); alertRequests.cancel(); profile.value = null; error.value = ''; alerts.value = []; alertTotal.value = 0; alertPage.value = 1; alertError.value = ''; loading.value = false; alertsLoading.value = false; channelsOpen.value = false }
const loadProfile = async () => {
const version = ++profileVersion
if (!visible.value || !props.userId) return
const request = profileRequests.start()
loading.value = true; error.value = ''
try {
const res = props.platformBusierId ? await getBindingPortrait({ sourceDb: props.sourceDb, platformBusierId: props.platformBusierId, period: period.value }) : await getBusinessPortrait(props.userId, { sourceDbList: sources.value, period: period.value })
if (version !== profileVersion) return
const res = props.platformBusierId ? await getBindingPortrait({ sourceDb: props.sourceDb, platformBusierId: props.platformBusierId, period: period.value }, { signal: request.signal }) : await getBusinessPortrait(props.userId, { sourceDbList: sources.value, period: period.value }, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code === 0) profile.value = res.data
else { profile.value = null; error.value = res.msg || '画像读取失败' }
} catch { if (version === profileVersion) { profile.value = null; error.value = '画像读取失败,请重试' } } finally { if (version === profileVersion) loading.value = false }
} catch { if (request.isCurrent()) { profile.value = null; error.value = '画像读取失败,请重试' } } finally { if (request.isCurrent()) loading.value = false }
}
const loadAlerts = async () => {
const version = ++alertVersion
if (!visible.value || !props.userId) return
const request = alertRequests.start()
alertsLoading.value = true; alertError.value = ''
try {
const res = await listAlertRecords({ platformCodes: sources.value, businessId: props.userId, pageNum: alertPage.value, pageSize: 10 })
if (version !== alertVersion) return
const res = await listAlertRecords({ platformCodes: sources.value, businessId: props.userId, pageNum: alertPage.value, pageSize: 10 }, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code === 0 && res.data.available !== false) { alerts.value = res.data.pageData.rows; alertTotal.value = res.data.pageData.total }
else { alerts.value = []; alertTotal.value = 0; alertError.value = res.code === 0 ? '渠道数据尚未接入,暂时无法核对关联预警。' : res.msg || '关联预警读取失败' }
} catch { if (version === alertVersion) { alerts.value = []; alertError.value = '关联预警读取失败,请重试' } } finally { if (version === alertVersion) alertsLoading.value = false }
} catch { if (request.isCurrent()) { alerts.value = []; alertError.value = '关联预警读取失败,请重试' } } finally { if (request.isCurrent()) alertsLoading.value = false }
}
watch(() => [props.modelValue, props.userId, props.sourceDb, props.platformBusierId], ([open]) => {
if (open && props.userId) { reset(); period.value = 'today'; void loadProfile(); void loadAlerts() }
})
reset()
if (open && props.userId) { period.value = 'today'; void loadProfile(); void loadAlerts() }
}, { flush: 'sync' })
onBeforeUnmount(reset)
onDeactivated(() => { visible.value = false; reset() })
</script>
<style scoped>
.profile-content { min-height: 160px; }
.profile-heading { margin: 20px 0 14px; font-size: 16px; font-weight: 600; }
.profile-info { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0; }
.profile-info dt, .metric-label, .metric-compare { color: var(--el-text-color-secondary); font-size: 12px; }
@@ -1,5 +1,5 @@
<template>
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close :title="`推广游戏数据 — ${channel?.nickname || channel?.account || ''}`" size="min(560px, 100vw)" @closed="reset">
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close :title="`推广游戏数据 — ${channel?.nickname || channel?.account || ''}`" size="min(560px, 100vw)">
<p class="mb-4">该渠道在各推广游戏的注册与充值分布(累计口径)。</p>
<el-alert v-if="error" type="warning" :closable="false" :title="error" />
<el-table scrollbar-always-on v-else v-loading="loading" :data="games">
@@ -11,7 +11,8 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { channelGames } from '@/api/channel'
import MetricValue from './MetricValue.vue'
const props = defineProps({ modelValue: Boolean, channel: { type: Object, default: null } })
@@ -20,19 +21,21 @@ const visible = computed({ get: () => props.modelValue, set: value => emit('upda
const games = ref([])
const loading = ref(false)
const error = ref('')
let requestVersion = 0
const reset = () => { ++requestVersion; games.value = []; error.value = ''; loading.value = false }
const requests = createLatestRequest()
const reset = () => { requests.cancel(); games.value = []; error.value = ''; loading.value = false }
const load = async () => {
reset()
const version = ++requestVersion
if (!props.channel) return
if (!visible.value || !props.channel) return
const request = requests.start()
loading.value = true
try {
const res = await channelGames({ sourceDb: props.channel.sourceDb, promoteId: props.channel.id, period: 'total' })
if (version !== requestVersion) return
const res = await channelGames({ sourceDb: props.channel.sourceDb, promoteId: props.channel.id, period: 'total' }, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code === 0) games.value = res.data
else error.value = res.msg || '推广游戏数据读取失败'
} catch { if (version === requestVersion) error.value = '推广游戏数据读取失败,请重试' } finally { if (version === requestVersion) loading.value = false }
} catch { if (request.isCurrent()) error.value = '推广游戏数据读取失败,请重试' } finally { if (request.isCurrent()) loading.value = false }
}
watch(() => [props.modelValue, props.channel], ([open]) => { if (open) void load() })
watch(() => [props.modelValue, props.channel], ([open]) => { if (open) void load(); else reset() }, { flush: 'sync' })
onBeforeUnmount(reset)
onDeactivated(() => { visible.value = false; reset() })
</script>
@@ -1,6 +1,6 @@
<template>
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close :title="title" size="min(900px, 100vw)" @closed="reset">
<div v-loading="loading">
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close :title="title" size="min(900px, 100vw)">
<div v-loading="loading" class="profile-content">
<el-alert v-if="error" :title="error" type="warning" :closable="false" />
<template v-if="profile">
<h3 class="profile-heading">基本信息</h3>
@@ -53,7 +53,8 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { channelPortrait } from '@/api/channel'
import { listAlertRecords } from '@/api/alert'
import { channelCount, channelMoney } from '../channel-display'
@@ -66,7 +67,7 @@ const visible = computed({ get: () => props.modelValue, set: value => emit('upda
const periods = [{ label: '今日', value: 'today' }, { label: '昨日', value: 'yesterday' }, { label: '近7天', value: 'last7' }, { label: '近30天', value: 'last30' }, { label: '累计', value: 'total' }]
const profile = ref(null), loading = ref(false), error = ref(''), period = ref('today'), gamesOpen = ref(false)
const alerts = ref([]), alertsLoading = ref(false), alertError = ref(''), alertPage = ref(1), alertTotal = ref(0)
let profileVersion = 0, alertVersion = 0
const profileRequests = createLatestRequest(), alertRequests = createLatestRequest()
const basic = computed(() => profile.value?.basic || {})
const title = computed(() => `渠道画像 — ${props.channel?.nickname || basic.value.nickname || props.channel?.account || ''}`)
const gameChannel = computed(() => props.channel ? { ...props.channel, nickname: basic.value.nickname || props.channel.nickname, account: basic.value.account || props.channel.account } : null)
@@ -89,34 +90,37 @@ const tierMeta = {
}
const tiers = computed(() => (profile.value?.payTiers || []).map(tier => ({ ...tier, ...tierMeta[tier.tier], percent: Math.max(0, Math.min(100, Number(tier.ratio) || 0)) })))
const alertType = row => ({ register_anomaly: '注册显著暴跌', recharge_anomaly: '充值额暴涨', login_anomaly: '渠道长期未登录' })[row.ruleType] || row.ruleName || row.ruleType || '—'
const reset = () => { ++profileVersion; ++alertVersion; profile.value = null; loading.value = false; error.value = ''; alerts.value = []; alertError.value = ''; alertsLoading.value = false; alertPage.value = 1; alertTotal.value = 0; gamesOpen.value = false }
const reset = () => { profileRequests.cancel(); alertRequests.cancel(); profile.value = null; loading.value = false; error.value = ''; alerts.value = []; alertError.value = ''; alertsLoading.value = false; alertPage.value = 1; alertTotal.value = 0; gamesOpen.value = false }
const loadProfile = async () => {
const version = ++profileVersion
if (!props.channel) return
if (!visible.value || !props.channel) return
const request = profileRequests.start()
loading.value = true; error.value = ''
try {
const res = await channelPortrait({ sourceDb: props.channel.sourceDb, promoteId: props.channel.id, period: period.value })
if (version !== profileVersion) return
const res = await channelPortrait({ sourceDb: props.channel.sourceDb, promoteId: props.channel.id, period: period.value }, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code === 0) profile.value = res.data
else { profile.value = null; error.value = res.msg || '渠道画像读取失败' }
} catch { if (version === profileVersion) { profile.value = null; error.value = '渠道画像读取失败,请重试' } } finally { if (version === profileVersion) loading.value = false }
} catch { if (request.isCurrent()) { profile.value = null; error.value = '渠道画像读取失败,请重试' } } finally { if (request.isCurrent()) loading.value = false }
}
const loadAlerts = async () => {
const version = ++alertVersion
if (!props.channel) return
if (!visible.value || !props.channel) return
const request = alertRequests.start()
alertsLoading.value = true; alertError.value = ''
try {
// 渠道关联预警使用渠道 ID;不能把它误传成 SW 商务用户 ID。
const res = await listAlertRecords({ platformCodes: props.channel.sourceDb, targetPromoteId: props.channel.id, pageNum: alertPage.value, pageSize: 10 })
if (version !== alertVersion) return
const res = await listAlertRecords({ platformCodes: props.channel.sourceDb, targetPromoteId: props.channel.id, pageNum: alertPage.value, pageSize: 10 }, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code === 0 && res.data.available !== false) { alerts.value = res.data.pageData.rows; alertTotal.value = res.data.pageData.total }
else { alerts.value = []; alertTotal.value = 0; alertError.value = res.code === 0 ? '渠道数据尚未接入,关联预警暂不可用。' : res.msg || '关联预警读取失败' }
} catch { if (version === alertVersion) { alerts.value = []; alertError.value = '关联预警读取失败,请重试' } } finally { if (version === alertVersion) alertsLoading.value = false }
} catch { if (request.isCurrent()) { alerts.value = []; alertError.value = '关联预警读取失败,请重试' } } finally { if (request.isCurrent()) alertsLoading.value = false }
}
watch(() => [props.modelValue, props.channel], ([open]) => { if (open && props.channel) { reset(); period.value = 'today'; void loadProfile(); void loadAlerts() } })
watch(() => [props.modelValue, props.channel], ([open]) => { reset(); if (open && props.channel) { period.value = 'today'; void loadProfile(); void loadAlerts() } }, { flush: 'sync' })
onBeforeUnmount(reset)
onDeactivated(() => { visible.value = false; reset() })
</script>
<style scoped>
.profile-content { min-height: 160px; }
.profile-heading { margin: 20px 0 14px; font-size: 16px; font-weight: 600; }
.profile-info { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0; }
.profile-info dt, .metric-label, .metric-compare, .tier-range { color: var(--el-text-color-secondary); font-size: 12px; }
+8 -4
View File
@@ -27,7 +27,8 @@
</el-drawer>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onDeactivated, reactive, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getGoals, saveGoal } from '@/api/performance'
import { performanceMetrics as metrics, currentMonth, monthOffset, displayMetric, displayTarget } from '../performance-display'
@@ -35,15 +36,18 @@ const props = defineProps({ modelValue: Boolean, target: Object }), emit = defin
const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const loading = ref(false), saving = ref(false), error = ref(''), data = ref(null), tab = ref('settings'), month = ref(currentMonth())
const form = reactive({ open: '', effective: '', amount: '' })
let version = 0
const requests = createLatestRequest()
const selected = computed(() => data.value?.months.find(m => m.month === month.value)), previous = computed(() => data.value?.months.find(m => m.month === monthOffset(month.value, -1)))
const history = computed(() => (data.value?.months || []).filter(m => m.month <= currentMonth()).slice(0, 6))
const monthChoices = computed(() => [...new Set([month.value, ...[-1, 0, 1].map(n => monthOffset(currentMonth(), n))])].sort())
const statuses = { unset: '未设目标', ongoing: '进行中', future: '未开始', achieved: '达成', unavailable: '未接入' }
const statusType = status => ({ achieved: 'success', missing: 'danger', ongoing: 'primary' }[status] || 'info')
const fill = () => { for (const key of Object.keys(form)) form[key] = selected.value?.goal?.[key] == null ? '' : String(selected.value.goal[key]) }
const load = async () => { const current = ++version; loading.value = true; error.value = ''; try { const res = await getGoals({ kind: props.target.kind, targetId: props.target.id }); if (current !== version) return; if (res.code !== 0) { error.value = res.msg || '目标读取失败'; return } data.value = res.data; fill() } catch { if (current === version) error.value = '目标读取失败,请重试' } finally { if (current === version) loading.value = false } }
watch(visible, v => { if (v) { data.value = null; tab.value = 'settings'; month.value = currentMonth(); load() } else version++ })
const load = async () => { if (!visible.value || !props.target) return; const request = requests.start(); loading.value = true; error.value = ''; try { const res = await getGoals({ kind: props.target.kind, targetId: props.target.id }, { signal: request.signal }); if (!request.isCurrent()) return; if (res.code !== 0) { error.value = res.msg || '目标读取失败'; return } data.value = res.data; fill() } catch { if (request.isCurrent()) error.value = '目标读取失败,请重试' } finally { if (request.isCurrent()) loading.value = false } }
const cancel = () => { requests.cancel(); loading.value = false }
watch(() => [props.modelValue, props.target], ([open]) => { if (open) { data.value = null; tab.value = 'settings'; month.value = currentMonth(); load() } else cancel() }, { flush: 'sync' })
onBeforeUnmount(cancel)
onDeactivated(() => { visible.value = false; cancel() })
const editMonth = value => { month.value = value; fill(); tab.value = 'settings' }
const persist = async values => { saving.value = true; error.value = ''; try { const res = await saveGoal({ kind: props.target.kind, targetId: props.target.id, month: month.value, ...values }); if (res.code !== 0) { error.value = res.msg || '目标保存失败'; return } ElMessage.success('目标已保存'); emit('saved'); await load() } catch { error.value = '目标保存失败,请重试' } finally { saving.value = false } }
const save = async () => { if (saving.value) return; const values = {}; for (const [key, raw] of Object.entries(form)) { if (raw.trim() === '') { values[key] = null; continue } if (!/^\d+$/.test(raw.trim()) || !Number.isSafeInteger(Number(raw))) { ElMessage.warning('目标须为非负整数,或留空'); return } values[key] = Number(raw) } await persist(values) }
@@ -16,14 +16,18 @@
</el-drawer>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { getPerformanceDetails } from '@/api/performance'
import { dateText, displayMetric } from '../performance-display'
const props = defineProps({ modelValue: Boolean, query: Object, title: { type: String, default: '渠道明细' } }), emit = defineEmits(['update:modelValue'])
const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const rows = ref([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), error = ref('')
let version = 0
const load = async () => { if (!visible.value || !props.query) return; const v = ++version; loading.value = true; error.value = ''; try { const res = await getPerformanceDetails({ ...props.query, page: page.value, size: size.value }); if (v !== version) return; if (res.code !== 0) { error.value = res.msg || '明细读取失败'; rows.value = []; total.value = 0; return } rows.value = res.data.rows; total.value = res.data.total } catch { if (v === version) error.value = '明细读取失败,请重试' } finally { if (v === version) loading.value = false } }
const requests = createLatestRequest()
const load = async () => { if (!visible.value || !props.query) return; const request = requests.start(); loading.value = true; error.value = ''; try { const res = await getPerformanceDetails({ ...props.query, page: page.value, size: size.value }, { signal: request.signal }); if (!request.isCurrent()) return; if (res.code !== 0) { error.value = res.msg || '明细读取失败'; rows.value = []; total.value = 0; return } rows.value = res.data.rows; total.value = res.data.total } catch { if (request.isCurrent()) error.value = '明细读取失败,请重试' } finally { if (request.isCurrent()) loading.value = false } }
const search = () => { page.value = 1; return load() }
watch(visible, v => { if (v) { rows.value = []; search() } else version++ })
const cancel = () => { requests.cancel(); loading.value = false }
watch(() => [props.modelValue, props.query], ([open]) => { if (open) { rows.value = []; search() } else cancel() }, { flush: 'sync' })
onBeforeUnmount(cancel)
onDeactivated(() => { visible.value = false; cancel() })
</script>