前端代码

This commit is contained in:
2026-08-13 22:35:13 +08:00
parent 9044bafdff
commit f1a0b17bb0
412 changed files with 36769 additions and 6617 deletions
@@ -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>
+156
View File
@@ -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>
+264
View File
@@ -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>