refactor: 整合仪表盘功能到监控模块,清理冗余代码

- 移除原监控仪表盘独立模块,将相关功能合并到在线监控模块
- 重构租户配置字段名,统一使用logo_url和name替代tenant_logo/tenant_name
- 优化搜索工具函数,移除重复导入
- 调整参数配置模型字段长度限制,移除config_value的max_length约束
- 清理冗余的常量定义和导入语句
- 修复批量状态设置接口的redis依赖注入
- 增强OAuth登录安全性,添加租户默认归属和state一次性消费
- 优化资源目录缓存逻辑,减少重复计算
- 新增API Token模块基础框架
- 完善用户token版本管理,支持主动失效JWT
- 调整AI模型配置缓存过期时间
- 修复菜单类型字段索引,提升查询性能
- 简化前端刷新token调用逻辑
- 新增滑块验证完成接口和忘记密码验证码校验
- 调整系统配置默认值,添加操作日志保留天数和接口白名单配置
- 限制Mock支付回调仅在开发环境可用
- 重构websocket认证方式,支持更安全的subprotocol传参
This commit is contained in:
zhangtao
2026-07-13 01:14:20 +08:00
parent 6a5f8cf0dd
commit cf88ab8897
102 changed files with 4078 additions and 3777 deletions
@@ -237,14 +237,13 @@
<FaDataListCard
class="mb-5"
:maxCount="4"
:list="dataList"
title="最近活动"
subtitle="近期活动列表"
:list="healthList"
title="系统健康"
subtitle="实时 · 30s"
:showMoreButton="true"
@more="handleMore"
/>
<TodoList class="mb-5" />
<HealthStatus />
</ElCol>
</ElRow>
@@ -288,7 +287,7 @@
<script setup lang="ts">
defineOptions({ name: "Home", inheritAttrs: false });
import { ref, computed, onMounted } from "vue";
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ElMessage } from "element-plus";
import { useUserStore } from "@stores";
import TenantAPI from "@/api/module_platform/tenant";
@@ -305,6 +304,54 @@ const workspace = ref<WorkspaceData | null>(null);
const dashboardStats = ref<DashboardStats | null>(null);
const userStore = useUserStore();
const healthList = ref<{ icon: string; class: string; title: string; status: string; time: string }[]>([]);
let healthEventSource: EventSource | null = null;
function connectHealth() {
const baseURL = import.meta.env.VITE_APP_BASE_API || "";
const es = new EventSource(`${baseURL}/common/health/stream`);
es.addEventListener("health", (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
const deps = data.dependencies || {};
const dbOk = deps.database?.status;
const redisOk = deps.redis?.status;
const diskOk = (data.disk_usage ?? 100) < 90;
healthList.value = [
{
icon: "ri:database-2-line",
class: dbOk ? "bg-success/12 text-success" : "bg-error/12 text-error",
title: "数据库",
status: dbOk ? "正常" : "异常",
time: dbOk ? `${deps.database.latency_ms || 0}ms` : "离线",
},
{
icon: "ri:server-line",
class: redisOk ? "bg-success/12 text-success" : "bg-error/12 text-error",
title: "Redis",
status: redisOk ? "正常" : "异常",
time: redisOk ? `${deps.redis.latency_ms || 0}ms` : "离线",
},
{
icon: "ri:hard-drive-2-line",
class: diskOk ? "bg-success/12 text-success" : "bg-error/12 text-error",
title: "磁盘",
status: diskOk ? "正常" : "异常",
time: `${data.disk_usage ?? "-"}%`,
},
];
} catch {
/* 静默忽略 */
}
});
es.onerror = () => {
es.close();
};
healthEventSource = es;
}
const isTenantMode = computed(() => userStore.workspaceMode === "tenant");
const tenantStatusStyle = computed(() => {
@@ -390,6 +437,11 @@ onMounted(() => {
loading.value = false;
loadWorkspace();
loadDashboardStats();
connectHealth();
});
onUnmounted(() => {
healthEventSource?.close();
});
import FaCardBanner from "@/components/banners/fa-card-banner/index.vue";
import FaImageCard from "@/components/cards/fa-image-card/index.vue";
@@ -406,7 +458,6 @@ import TodoList from "./modules/todo-list.vue";
import CardList from "./modules/card-list.vue";
import AboutProject from "./modules/about-project.vue";
import QuickLinks from "./modules/quick-links.vue";
import HealthStatus from "./modules/health-status.vue";
function handleBannerDemoConfirm() {
// TODO: 接入真实操作
@@ -1,98 +0,0 @@
<template>
<ElCard shadow="hover" class="health-card">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<FaSvgIcon icon="ri:heart-pulse-line" class="text-base" />
<span class="font-medium text-sm">系统健康</span>
</div>
<span class="text-xs text-gray-400">实时 · 30s</span>
</div>
</template>
<div class="flex flex-col gap-2">
<div v-for="row in items" :key="row.label" class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="inline-block w-2 h-2 rounded-full"
:class="row.status === 1 ? 'bg-green-500' : 'bg-red-500'"
/>
<span class="text-sm">{{ row.label }}</span>
</div>
<span class="text-xs" :class="row.status === 1 ? 'text-gray-400' : 'text-red-500'">{{
row.value
}}</span>
</div>
</div>
</ElCard>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from "vue";
defineOptions({ name: "HealthStatus" });
interface Item {
label: string;
value: string;
status: number;
}
const items = ref<Item[]>([
{ label: "数据库", value: "检查中…", status: 0 },
{ label: "Redis", value: "检查中…", status: 0 },
{ label: "磁盘", value: "检查中…", status: 0 },
]);
let eventSource: EventSource | null = null;
function connect() {
const baseURL = import.meta.env.VITE_APP_BASE_API || "";
const es = new EventSource(`${baseURL}/common/health/stream`);
es.addEventListener("health", (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
const deps = data.dependencies || {};
items.value = [
{
label: "数据库",
value: deps.database?.status ? `${deps.database.latency_ms || 0}ms` : "异常",
status: deps.database?.status ? 1 : 0,
},
{
label: "Redis",
value: deps.redis?.status ? `${deps.redis.latency_ms || 0}ms` : "异常",
status: deps.redis?.status ? 1 : 0,
},
{
label: "磁盘",
value: `${data.disk_usage ?? "-"}%`,
status: (data.disk_usage ?? 100) < 90 ? 1 : 0,
},
];
} catch {
/* 静默忽略 */
}
});
es.onerror = () => {
es.close();
};
eventSource = es;
}
onMounted(() => {
connect();
});
onUnmounted(() => {
eventSource?.close();
});
</script>
<style scoped>
.health-card {
--el-card-border-radius: calc(var(--custom-radius) + 2px);
border: 1px solid var(--fa-card-border);
}
</style>
@@ -0,0 +1,635 @@
<!-- API 令牌管理 CRUD -->
<template>
<div class="fa-full-height">
<FaSearchBar
v-show="showSearchBar"
v-model="searchForm"
:items="searchItems"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:default-expanded="false"
@search="handleSearch"
@reset="onResetSearch"
/>
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
<FaTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<ElButton v-hasPerm="['module_system:token:create']" type="primary" @click="handleAdd">
<ElIcon><Plus /></ElIcon>
新增 Token
</ElButton>
</template>
</FaTableHeader>
<FaTable
ref="faTableRef"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
<!-- 新增/编辑/详情弹窗 -->
<FaDialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
width="680px"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
:form-mode="dialogVisible.type"
:confirm-loading="submitLoading"
:show-footer="dialogVisible.type !== 'detail'"
@cancel="handleCloseDialog"
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
>
<!-- 详情模式 -->
<template v-if="dialogVisible.type === 'detail'">
<FaDescriptions
:column="2"
:data="detailFormData"
:items="detailItems"
label-width="120px"
max-height="70vh"
>
<template #status="{ row }">
<ElTag :type="statusTagType(String((row as any)?.status ?? 0))" effect="plain">
{{ statusLabel(String((row as any)?.status ?? 0)) }}
</ElTag>
</template>
<template #scopes="{ row }">
<div class="flex flex-wrap gap-1">
<ElTag v-for="s in (row as any)?.scopes || []" :key="s" size="small" type="info">
{{ s }}
</ElTag>
<span v-if="!(row as any)?.scopes?.length" class="text-g-400">无限制</span>
</div>
</template>
<template #token_prefix="{ row }">
<span class="font-mono text-sm">{{ (row as any)?.token_prefix || '—' }}</span>
</template>
</FaDescriptions>
</template>
<!-- 表单模式 -->
<template v-else>
<FaForm
:key="formRenderKey"
ref="dataFormRef"
v-model="formData"
:items="formItems"
:rules="rules"
label-suffix=":"
:label-width="100"
label-position="right"
:span="24"
:gutter="16"
:show-reset="false"
:show-submit="false"
class="crud-dialog-art-form"
scrollbar
max-height="70vh"
>
<template #status>
<ElSelect v-model="formData.status" placeholder="请选择状态">
<ElOption :value="0" label="启用" />
<ElOption :value="1" label="停用" />
</ElSelect>
</template>
</FaForm>
</template>
</FaDialog>
<!-- 查看明文弹窗(二次验证) -->
<FaDialog
v-model="revealDialog.visible"
title="查看 Token 明文"
width="520px"
:form-mode="'detail'"
:confirm-loading="revealLoading"
@cancel="revealDialog.visible = false"
@confirm="handleReveal"
>
<ElAlert type="warning" :closable="false" class="mb-4" show-icon>
查看明文需要输入当前登录用户的密码以二次验证身份
</ElAlert>
<FaForm
ref="revealFormRef"
v-model="revealForm"
:items="revealFormItems"
:rules="revealRules"
label-suffix=":"
:label-width="80"
label-position="right"
:span="24"
:show-reset="false"
:show-submit="false"
>
<template #password>
<ElInput
v-model="revealForm.password"
type="password"
placeholder="请输入当前用户密码"
show-password
autocomplete="off"
/>
</template>
</FaForm>
<template v-if="revealResult" #extra>
<ElDivider />
<div class="space-y-3">
<div>
<div class="text-sm text-g-500 mb-1">Token 明文</div>
<div class="flex items-center gap-2">
<ElInput
:model-value="revealResult.token_plain"
readonly
class="font-mono"
/>
<ElButton type="primary" @click="copyTokenPlain">复制</ElButton>
</div>
</div>
<div v-if="revealResult.expires_at" class="text-xs text-g-400">
过期时间:{{ revealResult.expires_at }}
</div>
</div>
</template>
</FaDialog>
<!-- 创建成功弹窗 -->
<FaDialog
v-model="createdDialog.visible"
title="Token 创建成功"
width="520px"
:form-mode="'detail'"
:show-footer="false"
@cancel="createdDialog.visible = false"
>
<ElAlert type="success" :closable="false" class="mb-4" show-icon>
Token 已创建成功,请立即复制保存。关闭后不再显示完整 Token。
</ElAlert>
<div>
<div class="text-sm text-g-500 mb-1">Token</div>
<div class="flex items-center gap-2">
<ElInput
:model-value="createdDialog.tokenPlain"
readonly
class="font-mono"
/>
<ElButton type="primary" @click="copyCreatedToken">复制</ElButton>
</div>
</div>
</FaDialog>
</div>
</template>
<script setup lang="ts">
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTable } from "@/hooks/core/useTable";
import { confirmDelete } from "@/hooks/core/useConfirm";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import ApiTokenAPI, {
type ApiTokenTable,
type ApiTokenCreateForm,
type ApiTokenRevealSchema,
} from "@/api/module_system/api-token";
import { Plus } from "@element-plus/icons-vue";
import type { ColumnOption } from "@/types/component";
import type { AuditSearchFormParams } from "@/components/forms/fa-search-bar/auditSearchFormItems";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import { ElMessage } from "element-plus";
import { useClipboard } from "@vueuse/core";
defineOptions({
name: "ApiToken",
inheritAttrs: false,
});
// ─── 常量 ───
const TOKEN_STATUS_OPTIONS = [
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
{ label: "已过期", value: 2 },
] as const;
const TOKEN_STATUS_MAP: Record<string, string> = {
"0": "启用",
"1": "停用",
"2": "已过期",
};
function statusLabel(s: string) {
return TOKEN_STATUS_MAP[s] || s;
}
function statusTagType(s: string): "success" | "info" | "danger" | undefined {
return { "0": "success" as const, "1": "info" as const, "2": "danger" as const }[s];
}
const createInitialFormData = (): ApiTokenCreateForm => ({
id: undefined,
name: "",
scopes: [],
expires_at: undefined,
rate_limit: undefined,
description: "",
});
// ─── 搜索 ───
type TokenSearchFormParams = { name?: string; status?: number } & AuditSearchFormParams;
const searchForm = ref<TokenSearchFormParams>({
name: undefined,
status: undefined,
});
const showSearchBar = ref(true);
const searchItems = computed(() => [
{
label: "名称",
key: "name",
type: "input",
props: { placeholder: "请输入名称", clearable: true },
span: 6,
},
{
label: "状态",
key: "status",
type: "select",
props: {
placeholder: "请选择状态",
options: TOKEN_STATUS_OPTIONS,
clearable: true,
},
span: 6,
},
]);
// ─── 表格 ───
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
refreshCreate,
refreshUpdate,
refreshRemove,
} = useTable({
core: {
apiFn: ApiTokenAPI.listToken,
apiParams: { page_no: 1, page_size: 10, name: undefined, status: undefined },
columnsFactory: (): ColumnOption<ApiTokenTable>[] => [
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "名称", minWidth: 140, showOverflowTooltip: true },
{
prop: "token_prefix",
label: "前缀",
minWidth: 120,
showOverflowTooltip: true,
formatter: (row: ApiTokenTable) =>
row.token_prefix ? (
h("span", { class: "font-mono text-sm" }, row.token_prefix + "***")
) : (
h("span", { class: "text-g-400" }, "—")
),
},
{
prop: "status",
label: "状态",
width: 90,
status: {
0: { type: "success", text: "启用" },
1: { type: "info", text: "停用" },
2: { type: "danger", text: "已过期" },
},
},
{ prop: "used_count", label: "已用次数", width: 100, align: "center" },
{ prop: "expires_at", label: "过期时间", width: 170, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 320,
fixed: "right",
align: "center",
formatter: (row: ApiTokenTable) => renderTokenOperationCell(row),
},
],
},
});
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
function renderTokenOperationCell(row: ApiTokenTable) {
return renderTableOperationCell(buildTokenRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
});
}
function buildTokenRowActions(row: ApiTokenTable): TableOperationAction[] {
return [
{
key: "detail",
label: "详情",
artType: "view",
run: () => void openDetailDialog(row),
},
{
key: "edit",
label: "编辑",
artType: "edit",
run: () => void openEditDialog(row),
},
{
key: "reveal",
label: "查看明文",
artType: "more",
run: () => void openRevealDialog(row),
},
{
key: "status",
label: "变更状态",
artType: "more",
run: () => {},
},
{
key: "delete",
label: "删除",
artType: "delete",
run: () => deleteTokenRow(row),
},
];
}
// ─── 主弹窗 ───
const { dialogVisible } = useCrudDialog();
const detailFormData = ref<ApiTokenTable>({});
const detailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] = [
{ label: "名称", prop: "name" },
{ label: "前缀", prop: "token_prefix", slot: "token_prefix" },
{ label: "状态", prop: "status", slot: "status" },
{ label: "权限范围", prop: "scopes", slot: "scopes" },
{ label: "过期时间", prop: "expires_at" },
{ label: "每小时限额", prop: "rate_limit" },
{ label: "已用次数", prop: "used_count" },
{ label: "最近使用", prop: "last_used_at" },
{ label: "描述", prop: "description", span: 2 },
{ label: "创建时间", prop: "created_time" },
{ label: "更新时间", prop: "updated_time" },
];
const formItems: FormItem[] = [
{
key: "name",
label: "名称",
type: "input",
span: 12,
props: { placeholder: "请输入 Token 名称", maxlength: 100 },
},
{
key: "expires_at",
label: "过期时间",
type: "date",
span: 12,
props: { placeholder: "留空=永不过期", valueFormat: "YYYY-MM-DD HH:mm:ss" },
},
{
key: "rate_limit",
label: "每小时限额",
type: "number",
span: 12,
props: { placeholder: "留空=不限", controlsPosition: "right", min: 0, max: 100000 },
},
{
key: "status",
label: "状态",
type: "select",
span: 12,
props: { placeholder: "请选择状态" },
},
{
key: "description",
label: "描述",
type: "input",
span: 24,
props: { type: "textarea", rows: 3, placeholder: "请输入描述" },
},
];
const formData = ref<ApiTokenCreateForm>(createInitialFormData());
const rules = reactive({
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
});
const dataFormRef = ref<{
resetFields: () => void;
clearValidate: () => void;
validate: (cb: (valid: boolean) => void) => void;
} | null>(null);
const submitLoading = ref(false);
const formRenderKey = ref(0);
// ─── 查看明文弹窗 ───
const revealDialog = reactive({ visible: false, row: null as ApiTokenTable | null });
const revealLoading = ref(false);
const revealForm = reactive({ password: "" });
const revealResult = ref<ApiTokenRevealSchema | null>(null);
const revealFormRef = ref<{
resetFields: () => void;
clearValidate: () => void;
validate: (cb: (valid: boolean) => void) => void;
} | null>(null);
const revealFormItems: FormItem[] = [
{
key: "password",
label: "登录密码",
type: "input",
span: 24,
},
];
const revealRules = reactive({
password: [{ required: true, message: "请输入当前用户密码", trigger: "blur" }],
});
// ─── 创建成功弹窗 ───
const createdDialog = reactive({ visible: false, tokenPlain: "" });
// ─── 搜索事件 ───
const handleSearch = async (params: TokenSearchFormParams) => {
replaceSearchParams({
name: params.name ?? undefined,
status: params.status ?? undefined,
} as Record<string, unknown>);
getData();
};
const onResetSearch = async () => {
searchForm.value = { name: undefined, status: undefined };
await resetSearchParams();
};
// ─── 主弹窗事件 ───
async function openDetailDialog(row: ApiTokenTable) {
if (!row.id) return;
const response = await ApiTokenAPI.detailToken(row.id);
dialogVisible.type = "detail";
dialogVisible.title = "Token 详情";
detailFormData.value = response.data.data ?? { ...row };
dialogVisible.visible = true;
}
async function handleAdd() {
await openEditDialog();
}
async function openEditDialog(row?: ApiTokenTable) {
dialogVisible.type = row ? "update" : "create";
dialogVisible.title = row ? "编辑 Token" : "新增 Token";
formRenderKey.value += 1;
if (row) {
const response = await ApiTokenAPI.detailToken(row.id!);
const data = response.data.data ?? {};
Object.assign(formData.value, data);
} else {
Object.assign(formData.value, createInitialFormData());
}
dialogVisible.visible = true;
}
async function resetForm() {
if (dataFormRef.value) {
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
}
Object.assign(formData.value, createInitialFormData());
}
async function handleCloseDialog() {
dialogVisible.visible = false;
await resetForm();
}
async function handleSubmit() {
dataFormRef.value?.validate(async (valid: boolean) => {
if (!valid) return;
const id = formData.value.id;
try {
if (id) {
// 编辑模式:调用 reset API 更新基本信息
const res = await ApiTokenAPI.resetToken(id, {
name: formData.value.name,
description: formData.value.description,
});
dialogVisible.visible = false;
await resetForm();
await refreshUpdate();
// 如果 reset 也返回了新 token,展示创建成功弹窗
if (res.data.data?.token_plain) {
createdDialog.tokenPlain = res.data.data.token_plain;
createdDialog.visible = true;
}
} else {
// 新增模式
const res = await ApiTokenAPI.createToken(formData.value);
dialogVisible.visible = false;
await resetForm();
await refreshCreate();
const created = res.data.data;
if (created?.token_plain) {
createdDialog.tokenPlain = created.token_plain;
createdDialog.visible = true;
}
}
} catch (error: unknown) {
console.error(error);
}
});
}
// ─── 删除事件 ───
const deleteTokenRow = async (row: ApiTokenTable) => {
if (!row.id) return;
try {
await confirmDelete(`确定删除 Token「${row.name ?? row.id}」吗?此操作不可恢复!`);
await ApiTokenAPI.deleteToken(row.id!);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
}
};
// ─── 查看明文事件 ───
async function openRevealDialog(row: ApiTokenTable) {
revealDialog.row = row;
revealForm.password = "";
revealResult.value = null;
revealDialog.visible = true;
}
async function handleReveal() {
revealFormRef.value?.validate(async (valid: boolean) => {
if (!valid || !revealDialog.row?.id) return;
revealLoading.value = true;
try {
const res = await ApiTokenAPI.revealToken(revealDialog.row.id, {
password: revealForm.password,
});
revealResult.value = res.data.data ?? null;
} catch {
// 错误由全局拦截处理
} finally {
revealLoading.value = false;
}
});
}
const { copy } = useClipboard();
function copyTokenPlain() {
if (revealResult.value?.token_plain) {
copy(revealResult.value.token_plain);
ElMessage.success("已复制到剪贴板");
}
}
function copyCreatedToken() {
if (createdDialog.tokenPlain) {
copy(createdDialog.tokenPlain);
ElMessage.success("已复制到剪贴板");
}
}
</script>
<style scoped lang="scss">
.fa-full-height {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
</style>
@@ -2,7 +2,7 @@
<template>
<div class="login-page-root flex h-screen w-full flex-col overflow-hidden" :style="loginBgStyle">
<FaLoginCenterBackdrop v-if="panelAlign === 'center'" viewport-fixed />
<FaAuthTopBar v-model:panel-align="panelAlign" />
<FaAuthTopBar v-model:panel-align="panelAlign" @tenant-change="handleTopBarTenantChange" />
<div
class="login-auth-split relative z-1 flex min-h-0 flex-1 overflow-hidden"
@@ -42,92 +42,6 @@
<p class="sub-title">{{ panelSubTitle }}</p>
</div>
<!-- 租户品牌卡片(位置在 form-intro 上方) -->
<div
v-if="isTenantResolved"
class="tenant-brand-card mb-5 p-4 rounded-lg border border-(--el-border-color-light) bg-(--el-fill-color-blank)"
>
<div class="flex items-center gap-3">
<img
v-if="tenantLogo"
:src="tenantLogo"
class="size-10 rounded-lg object-contain shrink-0"
alt=""
/>
<div class="flex-1 min-w-0">
<div
class="text-sm font-semibold text-(--el-text-color-primary) truncate"
>
{{ tenantDisplayName }}
</div>
<div class="text-xs text-(--el-text-color-secondary) mt-0.5">
当前租户
</div>
</div>
<ElButton text size="small" type="primary" @click="switchTenant">
切换
</ElButton>
</div>
<!-- 切换面板 -->
<div
v-if="showTenantInput"
class="mt-3 pt-3 border-t border-(--el-border-color-light)"
>
<div class="flex items-center gap-2">
<ElInput
v-model="tenantCode"
size="default"
placeholder="输入租户编码"
clearable
@keyup.enter="handleLookupTenant"
/>
<ElButton
type="primary"
:loading="tenantLookupLoading"
@click="handleLookupTenant"
>
确定
</ElButton>
</div>
<p v-if="tenantLookupError" class="mt-1 text-xs text-danger">
{{ tenantLookupError }}
</p>
</div>
</div>
<!-- 未识别租户时显示的小型选择器 -->
<div v-else class="tenant-picker mb-4">
<ElButton
text
size="small"
type="primary"
@click="showTenantInput = !showTenantInput"
>
<template #icon><FaSvgIcon icon="ri:building-line" /></template>
{{ showTenantInput ? "取消" : "选择租户" }}
</ElButton>
<div v-if="showTenantInput" class="flex items-center gap-2 mt-2">
<ElInput
v-model="tenantCode"
size="default"
placeholder="输入租户编码"
clearable
@keyup.enter="handleLookupTenant"
/>
<ElButton
type="primary"
:loading="tenantLookupLoading"
@click="handleLookupTenant"
>
确定
</ElButton>
</div>
<p v-if="tenantLookupError" class="mt-1 text-xs text-danger">
{{ tenantLookupError }}
</p>
</div>
<template v-if="authPanel === 'login'">
<template v-if="loginFlowMode === 'account'">
<FaLoginAccountForm
@@ -350,7 +264,6 @@ function backToAccountLogin() {
loginFlowMode.value = "account";
nextTick(() => {
getCaptcha();
loginForm.captcha = "";
accountFormRef.value?.resetDragVerify?.();
isPassing.value = false;
isClickPass.value = false;
@@ -416,7 +329,6 @@ watch(authPanel, (panel) => {
if (panel !== "login") return;
if (loginFlowMode.value !== "account") return;
getCaptcha();
loginForm.captcha = "";
accountFormRef.value?.resetDragVerify?.();
isPassing.value = false;
isClickPass.value = false;
@@ -557,23 +469,14 @@ const forgetRules = computed<FormRules<ForgetPasswordForm>>(() => ({
const loginForm = reactive<LoginFormData>({
username: "",
password: "",
captcha: "",
captcha_key: "",
remember: true,
login_type: "PC端",
});
// ── 租户品牌 ──
const tenantCode = ref("");
const showTenantInput = ref(false);
const tenantLookupLoading = ref(false);
const tenantLookupError = ref("");
const tenantLogo = computed(() => configStore.configData?.tenant_logo?.config_value?.trim() || "");
const tenantDisplayName = computed(
() => configStore.configData?.tenant_name?.config_value?.trim() || "系统平台"
);
const loginBgStyle = computed(() => {
const bg = configStore.configData?.tenant_login_bg?.config_value?.trim();
const bg = configStore.configData?.login_bg?.config_value?.trim();
return bg
? { backgroundImage: `url(${bg})`, backgroundSize: "cover", backgroundPosition: "center" }
: {};
@@ -646,8 +549,6 @@ async function autoDetectTenant() {
/** 根据编码查询租户并加载配置 */
async function loadTenantByCode(code: string, markResolved = true) {
tenantLookupLoading.value = true;
tenantLookupError.value = "";
try {
const { data: res } = await AuthAPI.lookupTenant(code);
const info = res?.data as Record<string, any> | undefined;
@@ -655,38 +556,14 @@ async function loadTenantByCode(code: string, markResolved = true) {
currentTenantId.value = Number(info.id);
await configStore.getConfig(true, currentTenantId.value);
if (markResolved) isTenantResolved.value = true;
showTenantInput.value = false;
tenantCode.value = "";
return;
}
} catch {
// 静默失败
} finally {
tenantLookupLoading.value = false;
}
if (markResolved) isTenantResolved.value = false;
}
/** 手动查询租户(点击确定按钮) */
async function handleLookupTenant() {
const code = tenantCode.value.trim();
if (!code) {
tenantLookupError.value = "请输入租户编码";
return;
}
await loadTenantByCode(code);
if (!isTenantResolved.value) {
tenantLookupError.value = "未找到该租户";
}
}
/** 切换租户 */
function switchTenant() {
showTenantInput.value = !showTenantInput.value;
tenantCode.value = "";
tenantLookupError.value = "";
}
const captchaState = reactive<CaptchaInfo>({
enable: false,
key: "",
@@ -715,15 +592,6 @@ const rules = computed<FormRules>(() => {
},
],
};
if (captchaState.enable) {
base.captcha = [
{
required: true,
trigger: "blur",
message: t("login.message.captchaCode.required"),
},
];
}
return base;
});
@@ -742,15 +610,40 @@ async function getCaptcha() {
loginForm.captcha_key = data.key;
captchaState.img_base = data.img_base;
captchaState.enable = data.enable;
// 重置滑块状态
isPassing.value = false;
isClickPass.value = false;
} catch {
captchaState.enable = false;
loginForm.captcha = "";
loginForm.captcha_key = "";
} finally {
codeLoading.value = false;
}
}
/** 滑块验证完成后通知后端标记 */
async function handleSliderPass(passed: boolean) {
if (!passed || !loginForm.captcha_key) return;
try {
await AuthAPI.sliderComplete(loginForm.captcha_key);
} catch {
isPassing.value = false;
await getCaptcha();
}
}
/** 监听滑块通过状态 */
watch(isPassing, (val) => {
handleSliderPass(val);
});
/** 顶部栏租户切换 */
async function handleTopBarTenantChange(tenantId: number) {
currentTenantId.value = tenantId;
isTenantResolved.value = true;
await configStore.getConfig(true, tenantId);
}
function resolveRedirectTarget(query: LocationQuery): RouteLocationRaw {
const defaultPath = "/";
const rawRedirect = (query.redirect as string) || defaultPath;
@@ -801,7 +694,6 @@ onMounted(async () => {
onActivated(() => {
if (authPanel.value !== "login" || loginFlowMode.value !== "account") return;
getCaptcha();
loginForm.captcha = "";
});
onBeforeUnmount(() => {
@@ -815,7 +707,6 @@ watch(
() => {
if (authPanel.value !== "login" || loginFlowMode.value !== "account") return;
getCaptcha();
loginForm.captcha = "";
}
);
@@ -840,6 +731,8 @@ const handleSubmit = async () => {
appStore.showGuide(true);
}
} catch (error) {
// 自增 formKey 强制重新挂载表单(滑块自动重置为初始状态)
formKey.value++;
await getCaptcha();
if (!(error instanceof HttpError)) {
console.error("[Login] Unexpected error:", error);
@@ -851,7 +744,6 @@ const handleSubmit = async () => {
}
} finally {
loading.value = false;
accountFormRef.value?.resetDragVerify?.();
}
};