fix: 消除业务重复loading并取消已关闭抽屉的查询
This commit is contained in:
@@ -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 } })
|
||||
|
||||
@@ -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,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' })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,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,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 })
|
||||
|
||||
@@ -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,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,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,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 })
|
||||
|
||||
@@ -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 })
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user