前端代码
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen bg-gray-100">
|
||||
<el-card class="w-96">
|
||||
<template #header>
|
||||
<span class="text-base font-medium">密码已过期,请修改密码</span>
|
||||
</template>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="原密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input v-model="form.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirm">
|
||||
<el-input v-model="form.confirm" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="onSubmit">提交并重新登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { changePassword } from '@/api/user'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
|
||||
defineOptions({ name: 'ForceChangePassword' })
|
||||
|
||||
const userStore = useUserStore()
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const form = ref({ password: '', newPassword: '', confirm: '' })
|
||||
|
||||
const rules = {
|
||||
password: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||
newPassword: [{ required: true, message: '请输入新密码', trigger: 'blur' }],
|
||||
confirm: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, cb) =>
|
||||
value === form.value.newPassword ? cb() : cb(new Error('两次密码不一致')),
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await changePassword({
|
||||
password: form.value.password,
|
||||
newPassword: form.value.newPassword
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('修改成功,请重新登录')
|
||||
await userStore.LoginOut()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '修改失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="kra-table-box">
|
||||
<div class="flex items-center mb-4">
|
||||
<span class="text-base font-medium">安全配置</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="验证码" name="captcha" class="mt-4">
|
||||
<el-form :model="form" label-width="160px" class="max-w-2xl">
|
||||
<el-form-item label="错误N次后出验证码">
|
||||
<el-input-number v-model="form.captchaOpen" :min="0" />
|
||||
<span class="ml-2 text-gray-400 text-sm">0 = 每次都需要验证码</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="计数缓存超时(秒)">
|
||||
<el-input-number v-model="form.captchaTimeout" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码长度">
|
||||
<el-input-number v-model="form.keyLong" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码宽度">
|
||||
<el-input-number v-model="form.imgWidth" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码高度">
|
||||
<el-input-number v-model="form.imgHeight" :min="1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="密码复杂度" name="password" class="mt-4">
|
||||
<el-form :model="form" label-width="160px" class="max-w-2xl">
|
||||
<el-form-item label="最小长度">
|
||||
<el-input-number v-model="form.pwdMinLength" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="需大写字母">
|
||||
<el-switch v-model="form.pwdRequireUpper" />
|
||||
</el-form-item>
|
||||
<el-form-item label="需小写字母">
|
||||
<el-switch v-model="form.pwdRequireLower" />
|
||||
</el-form-item>
|
||||
<el-form-item label="需数字">
|
||||
<el-switch v-model="form.pwdRequireDigit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="需特殊字符">
|
||||
<el-switch v-model="form.pwdRequireSpecial" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="限流" name="limit" class="mt-4">
|
||||
<el-form :model="form" label-width="160px" class="max-w-2xl">
|
||||
<el-form-item label="开启限流">
|
||||
<el-switch v-model="form.limitEnable" />
|
||||
</el-form-item>
|
||||
<el-form-item label="窗口(秒)">
|
||||
<el-input-number v-model="form.limitWindow" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="窗口内最大次数">
|
||||
<el-input-number v-model="form.limitCount" :min="1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="失败锁定" name="lock" class="mt-4">
|
||||
<el-form :model="form" label-width="160px" class="max-w-2xl">
|
||||
<el-form-item label="开启失败锁定">
|
||||
<el-switch v-model="form.lockEnable" />
|
||||
</el-form-item>
|
||||
<el-form-item label="失败次数阈值">
|
||||
<el-input-number v-model="form.lockThreshold" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="锁定时长(分钟)">
|
||||
<el-input-number v-model="form.lockDuration" :min="1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="密码过期" name="expire" class="mt-4">
|
||||
<el-form :model="form" label-width="200px" class="max-w-2xl">
|
||||
<el-form-item label="开启密码过期">
|
||||
<el-switch v-model="form.pwdExpireEnable" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新用户首次登录强制改密">
|
||||
<el-switch v-model="form.forceNewUserChangePassword" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效天数">
|
||||
<el-input-number v-model="form.pwdExpireDays" :min="1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSecurityConfig, setSecurityConfig } from '@/api/securityConfig'
|
||||
|
||||
defineOptions({ name: 'SecurityConfig' })
|
||||
|
||||
const activeTab = ref('captcha')
|
||||
const saving = ref(false)
|
||||
const form = ref({
|
||||
captchaOpen: 0,
|
||||
captchaTimeout: 3600,
|
||||
keyLong: 6,
|
||||
imgWidth: 240,
|
||||
imgHeight: 80,
|
||||
pwdMinLength: 8,
|
||||
pwdRequireUpper: false,
|
||||
pwdRequireLower: false,
|
||||
pwdRequireDigit: false,
|
||||
pwdRequireSpecial: false,
|
||||
limitEnable: false,
|
||||
limitWindow: 60,
|
||||
limitCount: 30,
|
||||
lockEnable: false,
|
||||
lockThreshold: 5,
|
||||
lockDuration: 30,
|
||||
pwdExpireEnable: false,
|
||||
pwdExpireDays: 90,
|
||||
forceNewUserChangePassword: false
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
const res = await getSecurityConfig()
|
||||
if (res.code === 0) {
|
||||
form.value = { ...form.value, ...res.data }
|
||||
} else {
|
||||
ElMessage.error(res.msg || '加载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await setSecurityConfig(form.value)
|
||||
if (res.code === 0) {
|
||||
form.value = { ...form.value, ...res.data }
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div class="space-y-2 pt-2">
|
||||
<!-- 首载骨架 -->
|
||||
<div v-if="!loaded" class="kra-card bg-container p-4">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- KPI 行 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<div v-for="tile in kpiTiles" :key="tile.key" class="kra-card bg-container px-4 py-3">
|
||||
<div class="text-xs text-muted-foreground tracking-wide">{{ tile.label }}</div>
|
||||
<div class="mt-0.5 text-2xl font-semibold leading-snug" :class="tile.valueClass">
|
||||
{{ tile.value }}<span v-if="tile.suffix" class="text-xs font-normal text-muted-foreground ml-1">{{
|
||||
tile.suffix }}</span>
|
||||
</div>
|
||||
<!-- 趋势微线: 本地累积快照, 不足 2 个时留白占位(高度不塌) -->
|
||||
<svg v-if="tile.points" class="block mt-1.5 w-full" :class="tile.lineClass" height="22" viewBox="0 0 120 22"
|
||||
preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline :points="tile.points" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
<div v-else class="mt-1.5 h-[22px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双栏面板 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-3">
|
||||
<!-- CPU 每核负载 -->
|
||||
<div class="lg:col-span-3 kra-card bg-container px-4 py-3">
|
||||
<div class="flex items-baseline justify-between mb-2.5">
|
||||
<span class="text-sm font-semibold tracking-tight text-base-text">CPU 每核负载</span>
|
||||
<span class="text-xs text-muted-foreground">{{ coreSummary }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<div v-for="core in cores" :key="core.index" class="flex items-center gap-2 mb-1.5">
|
||||
<span class="text-xs text-muted-foreground w-11 shrink-0">core {{ core.index }}</span>
|
||||
<div class="flex-1 h-2 rounded bg-control-track overflow-hidden">
|
||||
<div class="h-full rounded" :class="core.barClass" :style="{ width: core.pct + '%' }"></div>
|
||||
</div>
|
||||
<span class="text-xs text-base-text w-9 text-right tabular-nums">{{ core.pct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 磁盘 -->
|
||||
<div class="lg:col-span-2 kra-card bg-container px-4 py-3">
|
||||
<div class="flex items-baseline justify-between mb-2.5">
|
||||
<span class="text-sm font-semibold tracking-tight text-base-text">磁盘</span>
|
||||
<span class="text-xs text-muted-foreground">{{ disks.length }} 个挂载点</span>
|
||||
</div>
|
||||
<template v-if="disks.length">
|
||||
<div v-for="d in disks" :key="d.mountPoint" class="mb-3 last:mb-0">
|
||||
<div class="flex justify-between text-xs mb-1">
|
||||
<span class="font-semibold text-base-text">{{ d.mountPoint }}</span>
|
||||
<span class="text-muted-foreground tabular-nums">{{ d.usedGb }} / {{ d.totalGb }} GB · {{ d.usedPercent
|
||||
}}%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded bg-control-track overflow-hidden">
|
||||
<div class="h-full rounded" :class="d.barClass" :style="{ width: d.usedPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-xs text-muted-foreground py-4 text-center">
|
||||
未配置挂载点(见 config.yaml 的 disk-list)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 运行时信息 chips -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="chip in runtimeChips" :key="chip.label"
|
||||
class="inline-flex items-center gap-1.5 bg-muted border border-border rounded-md px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{{ chip.label }} <b class="font-semibold text-base-text">{{ chip.value }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<span class="text-xs text-muted-foreground">10 秒自动刷新 · 最近更新 {{ lastUpdated || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getSystemState } from '@/api/system'
|
||||
import { computed, onActivated, onDeactivated, onUnmounted, ref } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'State'
|
||||
})
|
||||
|
||||
// 10s 轮询 × 30 个快照 ≈ 5 分钟趋势窗口; 仅前端内存态, 刷新即清空(设计文档已确认)
|
||||
const HISTORY_MAX = 30
|
||||
|
||||
const state = ref(null)
|
||||
const loaded = ref(false)
|
||||
const lastUpdated = ref('')
|
||||
const history = ref({ cpu: [], ram: [], disk: [], goroutine: [] })
|
||||
|
||||
// 阈值状态: <70 主题色 / ≥70 偏高 / ≥90 告警。
|
||||
// UnoCSS 只识别字面量类名, 必须用完整映射, 禁止 `bg-${status}` 动态拼接。
|
||||
const STATUS_TEXT = { primary: 'text-primary', warning: 'text-warning', error: 'text-error' }
|
||||
const STATUS_BG = { primary: 'bg-primary', warning: 'bg-warning', error: 'bg-error' }
|
||||
const statusOf = (pct) => (pct >= 90 ? 'error' : pct >= 70 ? 'warning' : 'primary')
|
||||
|
||||
const pushHistory = (list, value) => {
|
||||
list.push(value)
|
||||
if (list.length > HISTORY_MAX) list.shift()
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
const res = await getSystemState()
|
||||
const server = res?.data?.server
|
||||
if (!server) return // 失败静默保留上次数据, 请求层已有统一错误提示
|
||||
state.value = server
|
||||
|
||||
const h = history.value
|
||||
pushHistory(h.cpu, cpuAvg.value)
|
||||
pushHistory(h.ram, server.ram?.usedPercent ?? 0)
|
||||
pushHistory(h.disk, topDisk.value ? topDisk.value.usedPercent : 0)
|
||||
pushHistory(h.goroutine, server.os?.numGoroutine ?? 0)
|
||||
|
||||
lastUpdated.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
loaded.value = true
|
||||
} catch {
|
||||
// 请求层已统一提示, 静默保留上次数据
|
||||
}
|
||||
}
|
||||
|
||||
// 序列 -> polyline points。百分比序列用固定 0-100 标尺; 计数序列(goroutine)min-max 自适应
|
||||
const toPoints = (series, { fixedScale = true } = {}) => {
|
||||
if (series.length < 2) return ''
|
||||
const W = 120
|
||||
const H = 22
|
||||
const PAD = 2
|
||||
let min = 0
|
||||
let max = 100
|
||||
if (!fixedScale) {
|
||||
min = Math.min(...series)
|
||||
max = Math.max(...series)
|
||||
if (min === max) {
|
||||
min -= 1
|
||||
max += 1 // 平线时置中显示
|
||||
}
|
||||
}
|
||||
const span = max - min
|
||||
return series
|
||||
.map((v, i) => {
|
||||
const x = (i / (series.length - 1)) * W
|
||||
const y = H - PAD - ((v - min) / span) * (H - PAD * 2)
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const gb = (mb) => (mb / 1024).toFixed(1)
|
||||
|
||||
const cpuAvg = computed(() => {
|
||||
const cpus = state.value?.cpu?.cpus || []
|
||||
return cpus.length ? Math.round(cpus.reduce((sum, v) => sum + v, 0) / cpus.length) : 0
|
||||
})
|
||||
|
||||
const topDisk = computed(() => {
|
||||
const list = state.value?.disk || []
|
||||
return list.reduce((max, d) => (max && max.usedPercent >= d.usedPercent ? max : d), null)
|
||||
})
|
||||
|
||||
const kpiTiles = computed(() => {
|
||||
const s = state.value
|
||||
const h = history.value
|
||||
const ramPct = s?.ram?.usedPercent ?? 0
|
||||
const diskStatus = topDisk.value ? statusOf(topDisk.value.usedPercent) : 'primary'
|
||||
return [
|
||||
{
|
||||
key: 'cpu',
|
||||
label: 'CPU 平均',
|
||||
value: cpuAvg.value,
|
||||
suffix: '%',
|
||||
valueClass: 'text-base-text',
|
||||
lineClass: STATUS_TEXT[statusOf(cpuAvg.value)],
|
||||
points: toPoints(h.cpu)
|
||||
},
|
||||
{
|
||||
key: 'ram',
|
||||
label: '内存',
|
||||
value: ramPct,
|
||||
suffix: `% · ${gb(s?.ram?.usedMb ?? 0)} / ${gb(s?.ram?.totalMb ?? 0)} GB`,
|
||||
valueClass: 'text-base-text',
|
||||
lineClass: STATUS_TEXT[statusOf(ramPct)],
|
||||
points: toPoints(h.ram)
|
||||
},
|
||||
{
|
||||
key: 'disk',
|
||||
label: topDisk.value ? `磁盘 · ${topDisk.value.mountPoint} 最高` : '磁盘',
|
||||
value: topDisk.value ? topDisk.value.usedPercent : '—',
|
||||
suffix: topDisk.value ? '%' : '',
|
||||
// 设计稿确认: 磁盘 KPI 大数字是唯一取状态色的大数字, 其余保持正文色
|
||||
valueClass: topDisk.value ? STATUS_TEXT[diskStatus] : 'text-base-text',
|
||||
lineClass: STATUS_TEXT[diskStatus],
|
||||
points: topDisk.value ? toPoints(h.disk) : ''
|
||||
},
|
||||
{
|
||||
key: 'goroutine',
|
||||
label: 'Goroutines',
|
||||
value: s?.os?.numGoroutine ?? 0,
|
||||
suffix: '',
|
||||
valueClass: 'text-base-text',
|
||||
lineClass: 'text-primary',
|
||||
points: toPoints(h.goroutine, { fixedScale: false })
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const cores = computed(() =>
|
||||
(state.value?.cpu?.cpus || []).map((v, index) => {
|
||||
const pct = Math.round(v)
|
||||
return { index, pct, barClass: STATUS_BG[statusOf(pct)] }
|
||||
})
|
||||
)
|
||||
|
||||
const coreSummary = computed(() => {
|
||||
const logical = state.value?.cpu?.cpus?.length ?? 0
|
||||
const physical = state.value?.cpu?.cores ?? 0
|
||||
return `${logical} 逻辑核 · ${physical} 物理核`
|
||||
})
|
||||
|
||||
const disks = computed(() =>
|
||||
(state.value?.disk || []).map((d) => ({ ...d, barClass: STATUS_BG[statusOf(d.usedPercent)] }))
|
||||
)
|
||||
|
||||
const runtimeChips = computed(() => {
|
||||
const os = state.value?.os || {}
|
||||
return [
|
||||
{ label: 'OS', value: os.goos || '—' },
|
||||
{ label: 'Go', value: os.goVersion || '—' },
|
||||
{ label: '编译器', value: os.compiler || '—' },
|
||||
{ label: '逻辑核', value: os.numCpu ?? '—' }
|
||||
]
|
||||
})
|
||||
|
||||
// keep-alive 缓存页切走时走 deactivated 而非 unmounted, 需停止轮询避免后台空转
|
||||
let timer = null
|
||||
const stopPolling = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
timer = setInterval(reload, 10 * 1000)
|
||||
}
|
||||
|
||||
reload()
|
||||
startPolling()
|
||||
onActivated(() => {
|
||||
reload()
|
||||
startPolling()
|
||||
})
|
||||
onDeactivated(stopPolling)
|
||||
onUnmounted(stopPolling)
|
||||
</script>
|
||||
Reference in New Issue
Block a user