fix: complete nested portrait views and binding alert scope
This commit is contained in:
@@ -49,6 +49,7 @@ type AlertRecordScope struct {
|
|||||||
type AlertRecordFilter struct {
|
type AlertRecordFilter struct {
|
||||||
PlatformCodes []string
|
PlatformCodes []string
|
||||||
TargetPromoteID *uint64
|
TargetPromoteID *uint64
|
||||||
|
PlatformBusierID *uint64
|
||||||
BusinessID *uint
|
BusinessID *uint
|
||||||
TargetPromoteName, RuleName, RuleType, BusinessName string
|
TargetPromoteName, RuleName, RuleType, BusinessName string
|
||||||
MonitorMetric, Status *int
|
MonitorMetric, Status *int
|
||||||
|
|||||||
@@ -87,6 +87,16 @@ func (r *alertRecordRepo) List(ctx context.Context, scope business.AlertRecordSc
|
|||||||
if empty || !available {
|
if empty || !available {
|
||||||
return page, nil
|
return page, nil
|
||||||
}
|
}
|
||||||
|
if filter.PlatformBusierID != nil {
|
||||||
|
available, err := businessTableAvailable(db, "tab_promote")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
page.Available = false
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
base := func() *gorm.DB {
|
base := func() *gorm.DB {
|
||||||
q := alertScopedQuery(db, scope)
|
q := alertScopedQuery(db, scope)
|
||||||
if len(filter.PlatformCodes) > 0 {
|
if len(filter.PlatformCodes) > 0 {
|
||||||
@@ -95,6 +105,9 @@ func (r *alertRecordRepo) List(ctx context.Context, scope business.AlertRecordSc
|
|||||||
if filter.TargetPromoteID != nil {
|
if filter.TargetPromoteID != nil {
|
||||||
q = q.Where("r.target_promote_id = ?", *filter.TargetPromoteID)
|
q = q.Where("r.target_promote_id = ?", *filter.TargetPromoteID)
|
||||||
}
|
}
|
||||||
|
if filter.PlatformBusierID != nil {
|
||||||
|
q = q.Where("EXISTS (?)", db.Table("tab_promote bp").Select("1").Where("bp.id=r.target_promote_id AND bp.source_db=r.platform_code AND bp.busier_id=?", *filter.PlatformBusierID))
|
||||||
|
}
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
q := base()
|
q := base()
|
||||||
|
|||||||
@@ -111,6 +111,41 @@ func TestAlertRecordsMissingChannelsFailClosed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBindingPortraitAlertsStayWithinSelectedSDKAccount(t *testing.T) {
|
||||||
|
db, repo := prepareAlertRecords(t)
|
||||||
|
if err := db.Exec("INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',16,'0')").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Exec("INSERT INTO tab_promote VALUES(103,'lx',16,1,1)").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&alertRecordPO{ID: 8, PlatformCode: "lx", TargetPromoteID: 103, BusinessID: 7, Status: 1, CreatedAt: time.Now()}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, sample := range []struct {
|
||||||
|
busier uint64
|
||||||
|
want int64
|
||||||
|
}{{14, 2}, {16, 1}, {999, 0}} {
|
||||||
|
page, err := repo.List(ctx, alertTestScope(9), business.AlertRecordFilter{PlatformCodes: []string{"lx"}, PlatformBusierID: &sample.busier})
|
||||||
|
if err != nil || page.Total != sample.want {
|
||||||
|
t.Fatalf("binding %d: %+v %v", sample.busier, page, err)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, count := range page.StatusCount {
|
||||||
|
total += count
|
||||||
|
}
|
||||||
|
if total != sample.want {
|
||||||
|
t.Fatal("binding filter must also constrain status counts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
busier := uint64(16)
|
||||||
|
page, err := repo.List(ctx, alertTestScope(10), business.AlertRecordFilter{PlatformCodes: []string{"lx"}, PlatformBusierID: &busier})
|
||||||
|
if err != nil || page.Total != 0 {
|
||||||
|
t.Fatal("binding filter must not expand the viewer's permissions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlertRecordTransactionRollsBackWhenLogFails(t *testing.T) {
|
func TestAlertRecordTransactionRollsBackWhenLogFails(t *testing.T) {
|
||||||
db, repo := prepareAlertRecords(t)
|
db, repo := prepareAlertRecords(t)
|
||||||
if err := db.Exec("CREATE TRIGGER reject_note BEFORE INSERT ON sys_alert_operation_log WHEN NEW.log_type = 2 BEGIN SELECT RAISE(ABORT, 'test failure'); END").Error; err != nil {
|
if err := db.Exec("CREATE TRIGGER reject_note BEFORE INSERT ON sys_alert_operation_log WHEN NEW.log_type = 2 BEGIN SELECT RAISE(ABORT, 'test failure'); END").Error; err != nil {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ func (s *AlertRecordService) List(ctx context.Context, req *dto.AlertRecordQuery
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
return nil, business.ErrAlertRecordInput
|
return nil, business.ErrAlertRecordInput
|
||||||
}
|
}
|
||||||
|
if req.PlatformBusierID != nil && *req.PlatformBusierID == 0 {
|
||||||
|
return nil, business.ErrAlertRecordInput
|
||||||
|
}
|
||||||
if req.MonitorMetric != nil && (*req.MonitorMetric < 1 || *req.MonitorMetric > 3) {
|
if req.MonitorMetric != nil && (*req.MonitorMetric < 1 || *req.MonitorMetric > 3) {
|
||||||
return nil, business.ErrAlertRecordInput
|
return nil, business.ErrAlertRecordInput
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,7 @@ func (s *AlertRecordService) List(ctx context.Context, req *dto.AlertRecordQuery
|
|||||||
}
|
}
|
||||||
filter := business.AlertRecordFilter{TargetPromoteID: req.TargetPromoteID, BusinessID: req.BusinessID, TargetPromoteName: strings.TrimSpace(req.TargetPromoteName), RuleName: strings.TrimSpace(req.RuleName), RuleType: strings.TrimSpace(req.RuleType), BusinessName: strings.TrimSpace(req.BusinessName), MonitorMetric: req.MonitorMetric, Status: req.Status}
|
filter := business.AlertRecordFilter{TargetPromoteID: req.TargetPromoteID, BusinessID: req.BusinessID, TargetPromoteName: strings.TrimSpace(req.TargetPromoteName), RuleName: strings.TrimSpace(req.RuleName), RuleType: strings.TrimSpace(req.RuleType), BusinessName: strings.TrimSpace(req.BusinessName), MonitorMetric: req.MonitorMetric, Status: req.Status}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
filter.PlatformBusierID = req.PlatformBusierID
|
||||||
for _, code := range strings.Split(req.PlatformCodes, ",") {
|
for _, code := range strings.Split(req.PlatformCodes, ",") {
|
||||||
code = strings.TrimSpace(code)
|
code = strings.TrimSpace(code)
|
||||||
if code != "" && !seen[code] {
|
if code != "" && !seen[code] {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type AlertRecordQuery struct {
|
|||||||
PageSize int `form:"pageSize"`
|
PageSize int `form:"pageSize"`
|
||||||
PlatformCodes string `form:"platformCodes"`
|
PlatformCodes string `form:"platformCodes"`
|
||||||
TargetPromoteID *uint64 `form:"targetPromoteId"`
|
TargetPromoteID *uint64 `form:"targetPromoteId"`
|
||||||
|
PlatformBusierID *uint64 `form:"platformBusierId"`
|
||||||
BusinessID *uint `form:"businessId"`
|
BusinessID *uint `form:"businessId"`
|
||||||
TargetPromoteName string `form:"targetPromoteName"`
|
TargetPromoteName string `form:"targetPromoteName"`
|
||||||
RuleName string `form:"ruleName"`
|
RuleName string `form:"ruleName"`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="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="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>
|
<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>
|
||||||
<el-form-item label="推广类型"><el-select v-model="form.registerType"><el-option label="个人" :value="0" /><el-option label="企业" :value="1" /></el-select></el-form-item>
|
<el-form-item label="渠道类型"><el-select v-model="form.registerType"><el-option label="个人" :value="0" /><el-option label="公会" :value="1" /></el-select></el-form-item>
|
||||||
<el-form-item label="渠道账号" prop="account"><el-input v-model="form.account" placeholder="中文/英文/数字,4-30位" maxlength="30" clearable /></el-form-item>
|
<el-form-item label="渠道账号" prop="account"><el-input v-model="form.account" placeholder="中文/英文/数字,4-30位" maxlength="30" clearable /></el-form-item>
|
||||||
<el-form-item label="渠道密码" prop="password"><el-input v-model="form.password" type="password" show-password placeholder="6-15位" maxlength="15" autocomplete="new-password" /></el-form-item>
|
<el-form-item label="渠道密码" prop="password"><el-input v-model="form.password" type="password" show-password placeholder="6-15位" maxlength="15" autocomplete="new-password" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="profile-heading">数据趋势</h3>
|
<h3 class="profile-heading">数据趋势</h3>
|
||||||
<div class="profile-two-columns">
|
<div class="profile-two-columns">
|
||||||
<el-card shadow="never"><template #header>{{ rangeLabel }}注册量{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.registerTrend" title="注册量趋势" /></el-card>
|
<el-card shadow="never"><template #header>{{ rangeLabel }}注册量{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.registerTrend" title="注册量趋势" :hide-date-labels="!hourly" /></el-card>
|
||||||
<el-card shadow="never"><template #header>{{ rangeLabel }}充值金额{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.payTrend" title="充值金额趋势" money color="var(--el-color-success)" /></el-card>
|
<el-card shadow="never"><template #header>{{ rangeLabel }}充值金额{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.payTrend" title="充值金额趋势" :hide-date-labels="!hourly" money color="var(--el-color-success)" /></el-card>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="profile-heading">旗下渠道排行</h3>
|
<h3 class="profile-heading">旗下渠道排行</h3>
|
||||||
<div class="profile-two-columns">
|
<div class="profile-two-columns">
|
||||||
@@ -111,7 +111,7 @@ const loadAlerts = async () => {
|
|||||||
const request = alertRequests.start()
|
const request = alertRequests.start()
|
||||||
alertsLoading.value = true; alertError.value = ''
|
alertsLoading.value = true; alertError.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await listAlertRecords({ platformCodes: sources.value, businessId: props.userId, pageNum: alertPage.value, pageSize: 10 }, { signal: request.signal })
|
const res = await listAlertRecords({ platformCodes: sources.value, businessId: props.userId, platformBusierId: props.platformBusierId || undefined, pageNum: alertPage.value, pageSize: 10 }, { signal: request.signal })
|
||||||
if (!request.isCurrent()) return
|
if (!request.isCurrent()) return
|
||||||
if (res.code === 0 && res.data.available !== false) { alerts.value = res.data.pageData.rows; alertTotal.value = res.data.pageData.total }
|
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 || '关联预警读取失败' }
|
else { alerts.value = []; alertTotal.value = 0; alertError.value = res.code === 0 ? '渠道数据尚未接入,暂时无法核对关联预警。' : res.msg || '关联预警读取失败' }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</g>
|
</g>
|
||||||
<polygon v-if="points.length" :points="area" :fill="color" opacity="0.08" />
|
<polygon v-if="points.length" :points="area" :fill="color" opacity="0.08" />
|
||||||
<polyline :points="line" fill="none" :stroke="color" stroke-width="2.5" stroke-linejoin="round" />
|
<polyline :points="line" fill="none" :stroke="color" stroke-width="2.5" stroke-linejoin="round" />
|
||||||
<text v-for="index in labelIndexes" :key="index" :x="pointX(index)" y="244" text-anchor="middle" class="axis-text">{{ series[index]?.label }}</text>
|
<text v-for="index in labelIndexes" :key="index" :x="pointX(index)" y="244" text-anchor="middle" class="axis-text">{{ hourly ? `${index}:00` : series[index]?.label }}</text>
|
||||||
<template v-if="selected">
|
<template v-if="selected">
|
||||||
<line :x1="selected.x" y1="18" :x2="selected.x" y2="220" class="cursor-line" />
|
<line :x1="selected.x" y1="18" :x2="selected.x" y2="220" class="cursor-line" />
|
||||||
<circle :cx="selected.x" :cy="selected.y" r="4" :fill="color" />
|
<circle :cx="selected.x" :cy="selected.y" r="4" :fill="color" />
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { channelCount, channelMoney } from '../channel-display'
|
import { channelCount, channelMoney } from '../channel-display'
|
||||||
const props = defineProps({ series: { type: Array, default: () => [] }, title: { type: String, default: '' }, money: Boolean, color: { type: String, default: 'var(--el-color-primary)' } })
|
const props = defineProps({ series: { type: Array, default: () => [] }, title: { type: String, default: '' }, money: Boolean, hideDateLabels: Boolean, color: { type: String, default: 'var(--el-color-primary)' } })
|
||||||
const active = ref(null)
|
const active = ref(null)
|
||||||
const values = computed(() => props.series.map(point => Number.isFinite(Number(point.value)) ? Number(point.value) : 0))
|
const values = computed(() => props.series.map(point => Number.isFinite(Number(point.value)) ? Number(point.value) : 0))
|
||||||
const maximum = computed(() => Math.max(1, ...values.value))
|
const maximum = computed(() => Math.max(1, ...values.value))
|
||||||
@@ -33,7 +33,8 @@ const points = computed(() => props.series.map((point, index) => ({ ...point, x:
|
|||||||
const line = computed(() => points.value.map(point => `${point.x},${point.y}`).join(' '))
|
const line = computed(() => points.value.map(point => `${point.x},${point.y}`).join(' '))
|
||||||
const area = computed(() => `52,${pointY(0)} ${line.value} ${points.value.at(-1)?.x || 52},${pointY(0)}`)
|
const area = computed(() => `52,${pointY(0)} ${line.value} ${points.value.at(-1)?.x || 52},${pointY(0)}`)
|
||||||
const ticks = computed(() => [0, 1, 2, 3, 4].map(index => ({ y: 220 - index * 50.5, value: minimum.value + (maximum.value - minimum.value) * index / 4 })))
|
const ticks = computed(() => [0, 1, 2, 3, 4].map(index => ({ y: 220 - index * 50.5, value: minimum.value + (maximum.value - minimum.value) * index / 4 })))
|
||||||
const labelIndexes = computed(() => [...new Set([0, 1, 2, 3, 4].map(index => Math.round(Math.max(0, props.series.length - 1) * index / 4)))])
|
const hourly = computed(() => props.series.length === 24 && props.series.every(point => /^\d{1,2}:00$/.test(point.label)))
|
||||||
|
const labelIndexes = computed(() => props.hideDateLabels ? [] : hourly.value ? [0, 4, 8, 12, 16, 20] : [...new Set([0, 1, 2, 3, 4].map(index => Math.round(Math.max(0, props.series.length - 1) * index / 4)))])
|
||||||
const selected = computed(() => active.value == null ? null : points.value[active.value])
|
const selected = computed(() => active.value == null ? null : points.value[active.value])
|
||||||
const compact = value => new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
|
const compact = value => new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
|
||||||
const formatted = value => props.money ? channelMoney(value) : channelCount(String(value).replace(/\.0+$/, ''))
|
const formatted = value => props.money ? channelMoney(value) : channelCount(String(value).replace(/\.0+$/, ''))
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const cards = computed(() => [
|
|||||||
const metric = profile.value?.metrics?.[card.key]
|
const metric = profile.value?.metrics?.[card.key]
|
||||||
const change = metric?.changeRate ?? metric?.changeDiff
|
const change = metric?.changeRate ?? metric?.changeDiff
|
||||||
const n = change == null ? null : Number(change)
|
const n = change == null ? null : Number(change)
|
||||||
const comparison = period.value === 'total' ? '' : change == null ? `${metric?.compareLabel || ''} ${metric?.compareValue == null ? '—' : card.format(metric.compareValue)}` : `${metric?.compareLabel || ''} ${n > 0 ? '↑ ' : n < 0 ? '↓ ' : ''}${Math.abs(n).toFixed(1)}${metric?.changeRate != null ? '%' : ' 个百分点'}`
|
const comparison = period.value === 'total' ? '—' : change == null ? `${metric?.compareLabel || ''} ${metric?.compareValue == null ? '—' : card.format(metric.compareValue)}` : `${metric?.compareLabel || ''} ${n > 0 ? '↑ ' : n < 0 ? '↓ ' : ''}${Math.abs(n).toFixed(1)}${metric?.changeRate != null ? '%' : ' 个百分点'}`
|
||||||
return { ...card, value: card.format(metric?.value), comparison, tone: n == null || n === 0 ? '' : n > 0 ? 'positive' : 'negative' }
|
return { ...card, value: card.format(metric?.value), comparison, tone: n == null || n === 0 ? '' : n > 0 ? 'positive' : 'negative' }
|
||||||
}))
|
}))
|
||||||
const tierMeta = {
|
const tierMeta = {
|
||||||
|
|||||||
Reference in New Issue
Block a user