Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
fd8473f2
Unverified
Commit
fd8473f2
authored
Jan 12, 2026
by
程序猿MT
Committed by
GitHub
Jan 12, 2026
Browse files
Merge branch 'Wei-Shaw:main' into main
parents
18b8bd43
cc4910dd
Changes
124
Show whitespace changes
Inline
Side-by-side
frontend/src/views/admin/ops/components/OpsSettingsDialog.vue
0 → 100644
View file @
fd8473f2
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
opsAPI
}
from
'
@/api/admin/ops
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
type
{
OpsAlertRuntimeSettings
,
EmailNotificationConfig
,
AlertSeverity
,
OpsAdvancedSettings
}
from
'
../types
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
props
=
defineProps
<
{
show
:
boolean
}
>
()
const
emit
=
defineEmits
<
{
close
:
[]
saved
:
[]
}
>
()
const
loading
=
ref
(
false
)
const
saving
=
ref
(
false
)
// 运行时设置
const
runtimeSettings
=
ref
<
OpsAlertRuntimeSettings
|
null
>
(
null
)
// 邮件通知配置
const
emailConfig
=
ref
<
EmailNotificationConfig
|
null
>
(
null
)
// 高级设置
const
advancedSettings
=
ref
<
OpsAdvancedSettings
|
null
>
(
null
)
// 加载所有配置
async
function
loadAllSettings
()
{
loading
.
value
=
true
try
{
const
[
runtime
,
email
,
advanced
]
=
await
Promise
.
all
([
opsAPI
.
getAlertRuntimeSettings
(),
opsAPI
.
getEmailNotificationConfig
(),
opsAPI
.
getAdvancedSettings
()
])
runtimeSettings
.
value
=
runtime
emailConfig
.
value
=
email
advancedSettings
.
value
=
advanced
}
catch
(
err
:
any
)
{
console
.
error
(
'
[OpsSettingsDialog] Failed to load settings
'
,
err
)
appStore
.
showError
(
err
?.
response
?.
data
?.
detail
||
t
(
'
admin.ops.settings.loadFailed
'
))
}
finally
{
loading
.
value
=
false
}
}
// 监听弹窗打开
watch
(()
=>
props
.
show
,
(
show
)
=>
{
if
(
show
)
{
loadAllSettings
()
}
})
// 邮件输入
const
alertRecipientInput
=
ref
(
''
)
const
reportRecipientInput
=
ref
(
''
)
// 严重级别选项
const
severityOptions
:
Array
<
{
value
:
AlertSeverity
|
''
;
label
:
string
}
>
=
[
{
value
:
''
,
label
:
t
(
'
admin.ops.email.minSeverityAll
'
)
},
{
value
:
'
critical
'
,
label
:
t
(
'
common.critical
'
)
},
{
value
:
'
warning
'
,
label
:
t
(
'
common.warning
'
)
},
{
value
:
'
info
'
,
label
:
t
(
'
common.info
'
)
}
]
// 验证邮箱
function
isValidEmailAddress
(
email
:
string
):
boolean
{
return
/^
[^\s
@
]
+@
[^\s
@
]
+
\.[^\s
@
]
+$/
.
test
(
email
)
}
// 添加收件人
function
addRecipient
(
target
:
'
alert
'
|
'
report
'
)
{
if
(
!
emailConfig
.
value
)
return
const
raw
=
(
target
===
'
alert
'
?
alertRecipientInput
.
value
:
reportRecipientInput
.
value
).
trim
()
if
(
!
raw
)
return
if
(
!
isValidEmailAddress
(
raw
))
{
appStore
.
showError
(
t
(
'
common.invalidEmail
'
))
return
}
const
normalized
=
raw
.
toLowerCase
()
const
list
=
target
===
'
alert
'
?
emailConfig
.
value
.
alert
.
recipients
:
emailConfig
.
value
.
report
.
recipients
if
(
!
list
.
includes
(
normalized
))
{
list
.
push
(
normalized
)
}
if
(
target
===
'
alert
'
)
alertRecipientInput
.
value
=
''
else
reportRecipientInput
.
value
=
''
}
// 移除收件人
function
removeRecipient
(
target
:
'
alert
'
|
'
report
'
,
email
:
string
)
{
if
(
!
emailConfig
.
value
)
return
const
list
=
target
===
'
alert
'
?
emailConfig
.
value
.
alert
.
recipients
:
emailConfig
.
value
.
report
.
recipients
const
idx
=
list
.
indexOf
(
email
)
if
(
idx
>=
0
)
list
.
splice
(
idx
,
1
)
}
// 验证
const
validation
=
computed
(()
=>
{
const
errors
:
string
[]
=
[]
// 验证运行时设置
if
(
runtimeSettings
.
value
)
{
const
evalSeconds
=
runtimeSettings
.
value
.
evaluation_interval_seconds
if
(
!
Number
.
isFinite
(
evalSeconds
)
||
evalSeconds
<
1
||
evalSeconds
>
86400
)
{
errors
.
push
(
t
(
'
admin.ops.runtime.validation.evalIntervalRange
'
))
}
}
// 验证邮件配置
if
(
emailConfig
.
value
)
{
if
(
emailConfig
.
value
.
alert
.
enabled
&&
emailConfig
.
value
.
alert
.
recipients
.
length
===
0
)
{
errors
.
push
(
t
(
'
admin.ops.email.validation.alertRecipientsRequired
'
))
}
if
(
emailConfig
.
value
.
report
.
enabled
&&
emailConfig
.
value
.
report
.
recipients
.
length
===
0
)
{
errors
.
push
(
t
(
'
admin.ops.email.validation.reportRecipientsRequired
'
))
}
}
// 验证高级设置
if
(
advancedSettings
.
value
)
{
const
{
error_log_retention_days
,
minute_metrics_retention_days
,
hourly_metrics_retention_days
}
=
advancedSettings
.
value
.
data_retention
if
(
error_log_retention_days
<
1
||
error_log_retention_days
>
365
)
{
errors
.
push
(
t
(
'
admin.ops.settings.validation.retentionDaysRange
'
))
}
if
(
minute_metrics_retention_days
<
1
||
minute_metrics_retention_days
>
365
)
{
errors
.
push
(
t
(
'
admin.ops.settings.validation.retentionDaysRange
'
))
}
if
(
hourly_metrics_retention_days
<
1
||
hourly_metrics_retention_days
>
365
)
{
errors
.
push
(
t
(
'
admin.ops.settings.validation.retentionDaysRange
'
))
}
}
return
{
valid
:
errors
.
length
===
0
,
errors
}
})
// 保存所有配置
async
function
saveAllSettings
()
{
if
(
!
validation
.
value
.
valid
)
{
appStore
.
showError
(
validation
.
value
.
errors
[
0
])
return
}
saving
.
value
=
true
try
{
await
Promise
.
all
([
runtimeSettings
.
value
?
opsAPI
.
updateAlertRuntimeSettings
(
runtimeSettings
.
value
)
:
Promise
.
resolve
(),
emailConfig
.
value
?
opsAPI
.
updateEmailNotificationConfig
(
emailConfig
.
value
)
:
Promise
.
resolve
(),
advancedSettings
.
value
?
opsAPI
.
updateAdvancedSettings
(
advancedSettings
.
value
)
:
Promise
.
resolve
()
])
appStore
.
showSuccess
(
t
(
'
admin.ops.settings.saveSuccess
'
))
emit
(
'
saved
'
)
emit
(
'
close
'
)
}
catch
(
err
:
any
)
{
console
.
error
(
'
[OpsSettingsDialog] Failed to save settings
'
,
err
)
appStore
.
showError
(
err
?.
response
?.
data
?.
detail
||
t
(
'
admin.ops.settings.saveFailed
'
))
}
finally
{
saving
.
value
=
false
}
}
</
script
>
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.ops.settings.title')"
width=
"extra-wide"
@
close=
"emit('close')"
>
<div
v-if=
"loading"
class=
"py-10 text-center text-sm text-gray-500"
>
{{
t
(
'
common.loading
'
)
}}
</div>
<div
v-else-if=
"runtimeSettings && emailConfig && advancedSettings"
class=
"space-y-6"
>
<!-- 验证错误 -->
<div
v-if=
"!validation.valid"
class=
"rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200"
>
<div
class=
"font-bold"
>
{{
t
(
'
admin.ops.settings.validation.title
'
)
}}
</div>
<ul
class=
"mt-1 list-disc space-y-1 pl-4"
>
<li
v-for=
"msg in validation.errors"
:key=
"msg"
>
{{
msg
}}
</li>
</ul>
</div>
<!-- 数据采集频率 -->
<div
class=
"rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50"
>
<h4
class=
"mb-3 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.ops.settings.dataCollection
'
)
}}
</h4>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.evaluationInterval
'
)
}}
</label>
<input
v-model.number=
"runtimeSettings.evaluation_interval_seconds"
type=
"number"
min=
"1"
max=
"86400"
class=
"input"
/>
<p
class=
"mt-1 text-xs text-gray-500"
>
{{
t
(
'
admin.ops.settings.evaluationIntervalHint
'
)
}}
</p>
</div>
</div>
<!-- 预警配置 -->
<div
class=
"rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50"
>
<h4
class=
"mb-3 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.ops.settings.alertConfig
'
)
}}
</h4>
<div
class=
"space-y-4"
>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.ops.settings.enableAlert
'
)
}}
</label>
</div>
<Toggle
v-model=
"emailConfig.alert.enabled"
/>
</div>
<div
v-if=
"emailConfig.alert.enabled"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.alertRecipients
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<input
v-model=
"alertRecipientInput"
type=
"email"
class=
"input"
:placeholder=
"t('admin.ops.settings.emailPlaceholder')"
@
keydown.enter.prevent=
"addRecipient('alert')"
/>
<button
class=
"btn btn-secondary whitespace-nowrap"
type=
"button"
@
click=
"addRecipient('alert')"
>
{{
t
(
'
common.add
'
)
}}
</button>
</div>
<div
class=
"mt-2 flex flex-wrap gap-2"
>
<span
v-for=
"email in emailConfig.alert.recipients"
:key=
"email"
class=
"inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{
email
}}
<button
type=
"button"
class=
"text-blue-700/80 hover:text-blue-900"
@
click=
"removeRecipient('alert', email)"
>
×
</button>
</span>
</div>
<p
class=
"mt-2 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.ops.settings.recipientsHint
'
)
}}
</p>
</div>
<div
v-if=
"emailConfig.alert.enabled"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.minSeverity
'
)
}}
</label>
<Select
v-model=
"emailConfig.alert.min_severity"
:options=
"severityOptions"
/>
</div>
</div>
</div>
<!-- 评估报告配置 -->
<div
class=
"rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50"
>
<h4
class=
"mb-3 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.ops.settings.reportConfig
'
)
}}
</h4>
<div
class=
"space-y-4"
>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.ops.settings.enableReport
'
)
}}
</label>
</div>
<Toggle
v-model=
"emailConfig.report.enabled"
/>
</div>
<div
v-if=
"emailConfig.report.enabled"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.reportRecipients
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<input
v-model=
"reportRecipientInput"
type=
"email"
class=
"input"
:placeholder=
"t('admin.ops.settings.emailPlaceholder')"
@
keydown.enter.prevent=
"addRecipient('report')"
/>
<button
class=
"btn btn-secondary whitespace-nowrap"
type=
"button"
@
click=
"addRecipient('report')"
>
{{
t
(
'
common.add
'
)
}}
</button>
</div>
<div
class=
"mt-2 flex flex-wrap gap-2"
>
<span
v-for=
"email in emailConfig.report.recipients"
:key=
"email"
class=
"inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{
email
}}
<button
type=
"button"
class=
"text-blue-700/80 hover:text-blue-900"
@
click=
"removeRecipient('report', email)"
>
×
</button>
</span>
</div>
<p
class=
"mt-2 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.ops.settings.recipientsHint
'
)
}}
</p>
</div>
<div
v-if=
"emailConfig.report.enabled"
class=
"grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div
class=
"flex items-center justify-between"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.dailySummary
'
)
}}
</label>
<Toggle
v-model=
"emailConfig.report.daily_summary_enabled"
/>
</div>
<div
v-if=
"emailConfig.report.daily_summary_enabled"
>
<input
v-model=
"emailConfig.report.daily_summary_schedule"
type=
"text"
class=
"input"
placeholder=
"0 9 * * *"
/>
</div>
<div
class=
"flex items-center justify-between"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.weeklySummary
'
)
}}
</label>
<Toggle
v-model=
"emailConfig.report.weekly_summary_enabled"
/>
</div>
<div
v-if=
"emailConfig.report.weekly_summary_enabled"
>
<input
v-model=
"emailConfig.report.weekly_summary_schedule"
type=
"text"
class=
"input"
placeholder=
"0 9 * * 1"
/>
</div>
</div>
</div>
</div>
<!-- 高级设置 -->
<details
class=
"rounded-2xl bg-gray-50 dark:bg-dark-700/50"
>
<summary
class=
"cursor-pointer p-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.ops.settings.advancedSettings
'
)
}}
</summary>
<div
class=
"space-y-4 px-4 pb-4"
>
<!-- 数据保留策略 -->
<div
class=
"space-y-3"
>
<h5
class=
"text-xs font-semibold text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.dataRetention
'
)
}}
</h5>
<div
class=
"flex items-center justify-between"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.enableCleanup
'
)
}}
</label>
<Toggle
v-model=
"advancedSettings.data_retention.cleanup_enabled"
/>
</div>
<div
v-if=
"advancedSettings.data_retention.cleanup_enabled"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.cleanupSchedule
'
)
}}
</label>
<input
v-model=
"advancedSettings.data_retention.cleanup_schedule"
type=
"text"
class=
"input"
placeholder=
"0 2 * * *"
/>
<p
class=
"mt-1 text-xs text-gray-500"
>
{{
t
(
'
admin.ops.settings.cleanupScheduleHint
'
)
}}
</p>
</div>
<div
class=
"grid grid-cols-1 gap-4 md:grid-cols-3"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.errorLogRetentionDays
'
)
}}
</label>
<input
v-model.number=
"advancedSettings.data_retention.error_log_retention_days"
type=
"number"
min=
"1"
max=
"365"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.minuteMetricsRetentionDays
'
)
}}
</label>
<input
v-model.number=
"advancedSettings.data_retention.minute_metrics_retention_days"
type=
"number"
min=
"1"
max=
"365"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.hourlyMetricsRetentionDays
'
)
}}
</label>
<input
v-model.number=
"advancedSettings.data_retention.hourly_metrics_retention_days"
type=
"number"
min=
"1"
max=
"365"
class=
"input"
/>
</div>
</div>
<p
class=
"text-xs text-gray-500"
>
{{
t
(
'
admin.ops.settings.retentionDaysHint
'
)
}}
</p>
</div>
<!-- 预聚合任务 -->
<div
class=
"space-y-3"
>
<h5
class=
"text-xs font-semibold text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.aggregation
'
)
}}
</h5>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.enableAggregation
'
)
}}
</label>
<p
class=
"mt-1 text-xs text-gray-500"
>
{{
t
(
'
admin.ops.settings.aggregationHint
'
)
}}
</p>
</div>
<Toggle
v-model=
"advancedSettings.aggregation.aggregation_enabled"
/>
</div>
</div>
</div>
</details>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-2"
>
<button
class=
"btn btn-secondary"
@
click=
"emit('close')"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
class=
"btn btn-primary"
:disabled=
"saving || !validation.valid"
@
click=
"saveAllSettings"
>
{{
saving
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
frontend/src/views/admin/ops/components/OpsThroughputTrendChart.vue
0 → 100644
View file @
fd8473f2
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Chart
as
ChartJS
,
CategoryScale
,
Filler
,
Legend
,
LineElement
,
LinearScale
,
PointElement
,
Title
,
Tooltip
}
from
'
chart.js
'
import
{
Line
}
from
'
vue-chartjs
'
import
type
{
ChartComponentRef
}
from
'
vue-chartjs
'
import
type
{
OpsThroughputGroupBreakdownItem
,
OpsThroughputPlatformBreakdownItem
,
OpsThroughputTrendPoint
}
from
'
@/api/admin/ops
'
import
type
{
ChartState
}
from
'
../types
'
import
{
formatHistoryLabel
,
sumNumbers
}
from
'
../utils/opsFormatters
'
import
HelpTooltip
from
'
@/components/common/HelpTooltip.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
{
formatNumber
}
from
'
@/utils/format
'
ChartJS
.
register
(
Title
,
Tooltip
,
Legend
,
LineElement
,
LinearScale
,
PointElement
,
CategoryScale
,
Filler
)
interface
Props
{
points
:
OpsThroughputTrendPoint
[]
loading
:
boolean
timeRange
:
string
byPlatform
?:
OpsThroughputPlatformBreakdownItem
[]
topGroups
?:
OpsThroughputGroupBreakdownItem
[]
}
const
props
=
defineProps
<
Props
>
()
const
{
t
}
=
useI18n
()
const
emit
=
defineEmits
<
{
(
e
:
'
selectPlatform
'
,
platform
:
string
):
void
(
e
:
'
selectGroup
'
,
groupId
:
number
):
void
(
e
:
'
openDetails
'
):
void
}
>
()
const
throughputChartRef
=
ref
<
ChartComponentRef
|
null
>
(
null
)
watch
(
()
=>
props
.
timeRange
,
()
=>
{
setTimeout
(()
=>
{
const
chart
:
any
=
throughputChartRef
.
value
?.
chart
if
(
chart
&&
typeof
chart
.
resetZoom
===
'
function
'
)
{
chart
.
resetZoom
()
}
},
100
)
}
)
const
isDarkMode
=
computed
(()
=>
document
.
documentElement
.
classList
.
contains
(
'
dark
'
))
const
colors
=
computed
(()
=>
({
blue
:
'
#3b82f6
'
,
blueAlpha
:
'
#3b82f620
'
,
green
:
'
#10b981
'
,
greenAlpha
:
'
#10b98120
'
,
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#f3f4f6
'
,
text
:
isDarkMode
.
value
?
'
#9ca3af
'
:
'
#6b7280
'
}))
const
totalRequests
=
computed
(()
=>
sumNumbers
(
props
.
points
.
map
((
p
)
=>
p
.
request_count
)))
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
points
.
length
||
totalRequests
.
value
<=
0
)
return
null
return
{
labels
:
props
.
points
.
map
((
p
)
=>
formatHistoryLabel
(
p
.
bucket_start
,
props
.
timeRange
)),
datasets
:
[
{
label
:
t
(
'
admin.ops.qps
'
),
data
:
props
.
points
.
map
((
p
)
=>
p
.
qps
??
0
),
borderColor
:
colors
.
value
.
blue
,
backgroundColor
:
colors
.
value
.
blueAlpha
,
fill
:
true
,
tension
:
0.4
,
pointRadius
:
0
,
pointHitRadius
:
10
},
{
label
:
t
(
'
admin.ops.tpsK
'
),
data
:
props
.
points
.
map
((
p
)
=>
(
p
.
tps
??
0
)
/
1000
),
borderColor
:
colors
.
value
.
green
,
backgroundColor
:
colors
.
value
.
greenAlpha
,
fill
:
true
,
tension
:
0.4
,
pointRadius
:
0
,
pointHitRadius
:
10
,
yAxisID
:
'
y1
'
}
]
}
})
const
state
=
computed
<
ChartState
>
(()
=>
{
if
(
chartData
.
value
)
return
'
ready
'
if
(
props
.
loading
)
return
'
loading
'
return
'
empty
'
})
const
options
=
computed
(()
=>
{
const
c
=
colors
.
value
return
{
responsive
:
true
,
maintainAspectRatio
:
false
,
interaction
:
{
intersect
:
false
,
mode
:
'
index
'
as
const
},
plugins
:
{
legend
:
{
position
:
'
top
'
as
const
,
align
:
'
end
'
as
const
,
labels
:
{
color
:
c
.
text
,
usePointStyle
:
true
,
boxWidth
:
6
,
font
:
{
size
:
10
}
}
},
tooltip
:
{
backgroundColor
:
isDarkMode
.
value
?
'
#1f2937
'
:
'
#ffffff
'
,
titleColor
:
isDarkMode
.
value
?
'
#f3f4f6
'
:
'
#111827
'
,
bodyColor
:
isDarkMode
.
value
?
'
#d1d5db
'
:
'
#4b5563
'
,
borderColor
:
c
.
grid
,
borderWidth
:
1
,
padding
:
10
,
displayColors
:
true
,
callbacks
:
{
label
:
(
context
:
any
)
=>
{
let
label
=
context
.
dataset
.
label
||
''
if
(
label
)
label
+=
'
:
'
if
(
context
.
raw
!==
null
)
label
+=
context
.
parsed
.
y
.
toFixed
(
1
)
return
label
}
}
},
// Optional: if chartjs-plugin-zoom is installed, these options will enable zoom/pan.
zoom
:
{
pan
:
{
enabled
:
true
,
mode
:
'
x
'
as
const
,
modifierKey
:
'
ctrl
'
as
const
},
zoom
:
{
wheel
:
{
enabled
:
true
},
pinch
:
{
enabled
:
true
},
mode
:
'
x
'
as
const
}
}
},
scales
:
{
x
:
{
type
:
'
category
'
as
const
,
grid
:
{
display
:
false
},
ticks
:
{
color
:
c
.
text
,
font
:
{
size
:
10
},
maxTicksLimit
:
8
,
autoSkip
:
true
,
autoSkipPadding
:
10
}
},
y
:
{
type
:
'
linear
'
as
const
,
display
:
true
,
position
:
'
left
'
as
const
,
grid
:
{
color
:
c
.
grid
,
borderDash
:
[
4
,
4
]
},
ticks
:
{
color
:
c
.
text
,
font
:
{
size
:
10
}
}
},
y1
:
{
type
:
'
linear
'
as
const
,
display
:
true
,
position
:
'
right
'
as
const
,
grid
:
{
display
:
false
},
ticks
:
{
color
:
c
.
green
,
font
:
{
size
:
10
}
}
}
}
}
})
function
resetZoom
()
{
const
chart
:
any
=
throughputChartRef
.
value
?.
chart
if
(
chart
&&
typeof
chart
.
resetZoom
===
'
function
'
)
chart
.
resetZoom
()
}
function
downloadChart
()
{
const
chart
:
any
=
throughputChartRef
.
value
?.
chart
if
(
!
chart
||
typeof
chart
.
toBase64Image
!==
'
function
'
)
return
const
url
=
chart
.
toBase64Image
(
'
image/png
'
,
1
)
const
a
=
document
.
createElement
(
'
a
'
)
a
.
href
=
url
a
.
download
=
`ops-throughput-
${
new
Date
().
toISOString
().
slice
(
0
,
19
).
replace
(
/
[
:T
]
/g
,
'
-
'
)}
.png`
a
.
click
()
}
</
script
>
<
template
>
<div
class=
"flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"
>
<div
class=
"mb-4 flex shrink-0 items-center justify-between"
>
<h3
class=
"flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white"
>
<svg
class=
"h-4 w-4 text-blue-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
{{
t
(
'
admin.ops.throughputTrend
'
)
}}
<HelpTooltip
:content=
"t('admin.ops.tooltips.throughputTrend')"
/>
</h3>
<div
class=
"flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<span
class=
"flex items-center gap-1"
><span
class=
"h-2 w-2 rounded-full bg-blue-500"
></span>
{{
t
(
'
admin.ops.qps
'
)
}}
</span>
<span
class=
"flex items-center gap-1"
><span
class=
"h-2 w-2 rounded-full bg-green-500"
></span>
{{
t
(
'
admin.ops.tpsK
'
)
}}
</span>
<button
type=
"button"
class=
"ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled=
"state !== 'ready'"
:title=
"t('admin.ops.requestDetails.title')"
@
click=
"emit('openDetails')"
>
{{
t
(
'
admin.ops.requestDetails.details
'
)
}}
</button>
<button
type=
"button"
class=
"ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled=
"state !== 'ready'"
:title=
"t('admin.ops.charts.resetZoomHint')"
@
click=
"resetZoom"
>
{{
t
(
'
admin.ops.charts.resetZoom
'
)
}}
</button>
<button
type=
"button"
class=
"inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled=
"state !== 'ready'"
:title=
"t('admin.ops.charts.downloadChartHint')"
@
click=
"downloadChart"
>
{{
t
(
'
admin.ops.charts.downloadChart
'
)
}}
</button>
</div>
</div>
<!-- Drilldown chips (baseline interaction: click to set global filter) -->
<div
v-if=
"(props.topGroups?.length ?? 0) > 0"
class=
"mb-3 flex flex-wrap gap-2"
>
<button
v-for=
"g in props.topGroups"
:key=
"g.group_id"
type=
"button"
class=
"inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800"
@
click=
"emit('selectGroup', g.group_id)"
>
<span
class=
"max-w-[180px] truncate"
>
{{
g
.
group_name
||
`#${g.group_id
}
`
}}
<
/span
>
<
span
class
=
"
text-gray-400 dark:text-gray-500
"
>
{{
formatNumber
(
g
.
request_count
)
}}
<
/span
>
<
/button
>
<
/div
>
<
div
v
-
else
-
if
=
"
(props.byPlatform?.length ?? 0) > 0
"
class
=
"
mb-3 flex flex-wrap gap-2
"
>
<
button
v
-
for
=
"
p in props.byPlatform
"
:
key
=
"
p.platform
"
type
=
"
button
"
class
=
"
inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800
"
@
click
=
"
emit('selectPlatform', p.platform)
"
>
<
span
class
=
"
uppercase
"
>
{{
p
.
platform
}}
<
/span
>
<
span
class
=
"
text-gray-400 dark:text-gray-500
"
>
{{
formatNumber
(
p
.
request_count
)
}}
<
/span
>
<
/button
>
<
/div
>
<
div
class
=
"
min-h-0 flex-1
"
>
<
Line
v
-
if
=
"
state === 'ready' && chartData
"
ref
=
"
throughputChartRef
"
:
data
=
"
chartData
"
:
options
=
"
options
"
/>
<
div
v
-
else
class
=
"
flex h-full items-center justify-center
"
>
<
div
v
-
if
=
"
state === 'loading'
"
class
=
"
animate-pulse text-sm text-gray-400
"
>
{{
t
(
'
common.loading
'
)
}}
<
/div
>
<
EmptyState
v
-
else
:
title
=
"
t('common.noData')
"
:
description
=
"
t('admin.ops.charts.emptyRequest')
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
frontend/src/views/admin/ops/types.ts
0 → 100644
View file @
fd8473f2
// Ops 前端视图层的共享类型(与后端 DTO 解耦)。
export
type
ChartState
=
'
loading
'
|
'
empty
'
|
'
ready
'
// Re-export ops alert/settings types so view components can import from a single place
// while keeping the API contract centralized in `@/api/admin/ops`.
export
type
{
AlertRule
,
AlertEvent
,
AlertSeverity
,
ThresholdMode
,
MetricType
,
Operator
,
EmailNotificationConfig
,
OpsDistributedLockSettings
,
OpsAlertRuntimeSettings
,
OpsAdvancedSettings
,
OpsDataRetentionSettings
,
OpsAggregationSettings
}
from
'
@/api/admin/ops
'
frontend/src/views/admin/ops/utils/opsFormatters.ts
0 → 100644
View file @
fd8473f2
/**
* Ops 页面共享的格式化/样式工具。
*
* 目标:尽量对齐 `docs/sub2api` 备份版本的视觉表现(需求一致部分保持一致),
* 同时避免引入额外 UI 依赖。
*/
import
type
{
OpsSeverity
}
from
'
@/api/admin/ops
'
import
{
formatBytes
}
from
'
@/utils/format
'
export
function
getSeverityClass
(
severity
:
OpsSeverity
):
string
{
const
classes
:
Record
<
string
,
string
>
=
{
P0
:
'
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400
'
,
P1
:
'
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400
'
,
P2
:
'
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400
'
,
P3
:
'
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400
'
}
return
classes
[
String
(
severity
||
''
)]
||
classes
.
P3
}
export
function
truncateMessage
(
msg
:
string
,
maxLength
=
80
):
string
{
if
(
!
msg
)
return
''
return
msg
.
length
>
maxLength
?
msg
.
substring
(
0
,
maxLength
)
+
'
...
'
:
msg
}
/**
* 格式化日期时间(短格式,和旧 Ops 页面一致)。
* 输出: `MM-DD HH:mm:ss`
*/
export
function
formatDateTime
(
dateStr
:
string
):
string
{
const
d
=
new
Date
(
dateStr
)
if
(
Number
.
isNaN
(
d
.
getTime
()))
return
''
return
`
${
String
(
d
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
d
.
getDate
()).
padStart
(
2
,
'
0
'
)}
${
String
(
d
.
getHours
()).
padStart
(
2
,
'
0
'
)}
:
${
String
(
d
.
getMinutes
()).
padStart
(
2
,
'
0
'
)}
:
${
String
(
d
.
getSeconds
()).
padStart
(
2
,
'
0
'
)}
`
}
export
function
sumNumbers
(
values
:
Array
<
number
|
null
|
undefined
>
):
number
{
return
values
.
reduce
<
number
>
((
acc
,
v
)
=>
{
const
n
=
typeof
v
===
'
number
'
&&
Number
.
isFinite
(
v
)
?
v
:
0
return
acc
+
n
},
0
)
}
/**
* 解析 time_range 为分钟数。
* 支持:`5m/30m/1h/6h/24h`
*/
export
function
parseTimeRangeMinutes
(
range
:
string
):
number
{
const
trimmed
=
(
range
||
''
).
trim
()
if
(
!
trimmed
)
return
60
if
(
trimmed
.
endsWith
(
'
m
'
))
{
const
v
=
Number
.
parseInt
(
trimmed
.
slice
(
0
,
-
1
),
10
)
return
Number
.
isFinite
(
v
)
&&
v
>
0
?
v
:
60
}
if
(
trimmed
.
endsWith
(
'
h
'
))
{
const
v
=
Number
.
parseInt
(
trimmed
.
slice
(
0
,
-
1
),
10
)
return
Number
.
isFinite
(
v
)
&&
v
>
0
?
v
*
60
:
60
}
return
60
}
export
function
formatHistoryLabel
(
date
:
string
|
undefined
,
timeRange
:
string
):
string
{
if
(
!
date
)
return
''
const
d
=
new
Date
(
date
)
if
(
Number
.
isNaN
(
d
.
getTime
()))
return
''
const
minutes
=
parseTimeRangeMinutes
(
timeRange
)
if
(
minutes
>=
24
*
60
)
{
return
`
${
String
(
d
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
d
.
getDate
()).
padStart
(
2
,
'
0
'
)}
${
String
(
d
.
getHours
()).
padStart
(
2
,
'
0
'
)}
:
${
String
(
d
.
getMinutes
()).
padStart
(
2
,
'
0
'
)}
`
}
return
`
${
String
(
d
.
getHours
()).
padStart
(
2
,
'
0
'
)}
:
${
String
(
d
.
getMinutes
()).
padStart
(
2
,
'
0
'
)}
`
}
export
function
formatByteRate
(
bytes
:
number
,
windowMinutes
:
number
):
string
{
const
seconds
=
Math
.
max
(
1
,
(
windowMinutes
||
1
)
*
60
)
return
`
${
formatBytes
(
bytes
/
seconds
,
1
)}
/s`
}
Prev
1
…
3
4
5
6
7
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment