refactor: 大规模代码整理与功能优化

1. 重构后端API路由、CRUD与模块结构,整合日志管理,移除废弃demo代码
2. 优化前端组件类型定义、样式与路由配置,修复权限判断逻辑
3. 调整默认排序规则、滚动条样式与工具类函数,更新依赖与配置文件
4. 修复多处类型不匹配与默认值问题,完善表单与菜单验证逻辑
This commit is contained in:
zhangtao
2026-06-17 01:56:31 +08:00
parent 17b3cd0a4c
commit 73f2823692
500 changed files with 40763 additions and 26616 deletions
@@ -22,109 +22,116 @@
<div
class="login-page-panel__main relative z-1 flex min-h-0 flex-1 flex-col overflow-hidden px-5 pb-2 pt-14 md:px-10 md:pt-18"
>
<div
class="login-page-panel__scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-6 [-webkit-overflow-scrolling:touch]"
:class="panelAlign === 'center' && 'login-page-panel__scroll--centered'"
>
<ElScrollbar>
<div
class="login-panel-align-row flex w-full items-center justify-center max-sm:min-h-0"
:class="
panelAlign === 'center'
? 'min-h-0 flex-1 py-4'
: 'min-h-[min(720px,calc(100vh-13rem))]'
"
class="login-page-panel__scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-6 [-webkit-overflow-scrolling:touch]"
:class="panelAlign === 'center' && 'login-page-panel__scroll--centered'"
>
<div class="auth-right-wrap">
<div class="form">
<div class="form-intro">
<h3 class="title">{{ panelTitle }}</h3>
<p class="sub-title">{{ panelSubTitle }}</p>
</div>
<div
class="login-panel-align-row flex w-full items-center justify-center max-sm:min-h-0"
:class="
panelAlign === 'center'
? 'min-h-0 flex-1 py-4'
: 'min-h-[min(720px,calc(100vh-13rem))]'
"
>
<div class="auth-right-wrap">
<div class="form">
<div class="form-intro">
<h3 class="title">{{ panelTitle }}</h3>
<p class="sub-title">{{ panelSubTitle }}</p>
</div>
<template v-if="authPanel === 'login'">
<template v-if="loginFlowMode === 'account'">
<FaLoginAccountForm
ref="accountFormRef"
v-model:is-passing="isPassing"
v-model:is-click-pass="isClickPass"
v-model:login-form="loginForm"
:rules="rules"
:captcha-state="captchaState"
:code-loading="codeLoading"
:demo-account-key="demoAccountKey"
:accounts="accounts"
:form-key="formKey"
:is-dark="isDark"
:drag-verify-text-color="dragVerifyTextColor"
:loading="loading"
@submit="handleSubmit"
@setup-account="setupAccount"
@get-captcha="getCaptcha"
@open-mobile="openMobileLogin"
@open-qr="openQrLogin"
@forget="setAuthPanel('forget')"
<template v-if="authPanel === 'login'">
<template v-if="loginFlowMode === 'account'">
<FaLoginAccountForm
ref="accountFormRef"
v-model:is-passing="isPassing"
v-model:is-click-pass="isClickPass"
v-model:login-form="loginForm"
:rules="rules"
:captcha-state="captchaState"
:code-loading="codeLoading"
:demo-account-key="demoAccountKey"
:accounts="accounts"
:form-key="formKey"
:is-dark="isDark"
:drag-verify-text-color="dragVerifyTextColor"
:loading="loading"
@submit="handleSubmit"
@setup-account="setupAccount"
@get-captcha="getCaptcha"
@open-mobile="openMobileLogin"
@open-qr="openQrLogin"
@forget="setAuthPanel('forget')"
@register="setAuthPanel('register')"
@oauth="handleOAuthLogin"
/>
</template>
<FaLoginMobilePanel
v-else-if="loginFlowMode === 'mobile'"
@back="backToAccountLogin"
@register="setAuthPanel('register')"
/>
<FaLoginQrPanel
v-else-if="loginFlowMode === 'qr'"
@back="backToAccountLogin"
@register="setAuthPanel('register')"
@oauth="handleOAuthLogin"
/>
</template>
<FaLoginMobilePanel
v-else-if="loginFlowMode === 'mobile'"
@back="backToAccountLogin"
@register="setAuthPanel('register')"
<FaLoginRegisterPanel
v-else-if="authPanel === 'register'"
ref="registerPanelRef"
v-model:register-agreement-read="registerAgreementRead"
v-model:register-form="registerForm"
:register-rules="registerRules"
:form-key="formKey"
:register-loading="registerLoading"
:show-email="true"
:user-agreement-href="userAgreementHref"
@submit="submitRegister"
@to-login="setAuthPanel('login')"
/>
<FaLoginQrPanel
v-else-if="loginFlowMode === 'qr'"
@back="backToAccountLogin"
@register="setAuthPanel('register')"
<FaLoginForgetPanel
v-else
ref="forgetPanelRef"
v-model:forget-form="forgetForm"
:forget-rules="forgetRules"
:form-key="formKey"
:forget-loading="forgetLoading"
@submit="submitForget"
@to-login="setAuthPanel('login')"
/>
</template>
<FaLoginRegisterPanel
v-else-if="authPanel === 'register'"
ref="registerPanelRef"
v-model:register-agreement-read="registerAgreementRead"
v-model:register-form="registerForm"
:register-rules="registerRules"
:form-key="formKey"
:register-loading="registerLoading"
:user-agreement-href="userAgreementHref"
@submit="submitRegister"
@to-login="setAuthPanel('login')"
/>
<FaLoginForgetPanel
v-else
ref="forgetPanelRef"
v-model:forget-form="forgetForm"
:forget-rules="forgetRules"
:form-key="formKey"
:forget-loading="forgetLoading"
@submit="submitForget"
@to-login="setAuthPanel('login')"
/>
</div>
</div>
</div>
</div>
</div>
</ElScrollbar>
</div>
<footer
class="login-page-footer login-page-footer--pinned shrink-0 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
:class="panelAlign === 'center' && 'login-page-footer--floating-layout'"
>
<div class="login-footer-text text-sm">
<footer
class="login-page-footer login-page-footer--pinned shrink-0 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
:class="panelAlign === 'center' && 'login-page-footer--floating-layout'"
>
<div class="login-footer-text text-sm">
<div class="login-footer-row">
<a
:href="configStore.configData?.sys_git_code?.config_value || '#'"
:href="configStore.configData?.git_code?.config_value || '#'"
target="_blank"
rel="noopener noreferrer"
class="login-page-footer__link"
>
{{ configStore.configData?.tenant_copyright?.config_value || "" }}
{{ configStore.configData?.copyright?.config_value || "" }}
</a>
<span class="login-page-footer__sep">|</span>
</div>
<span class="login-page-footer__sep login-footer-sep-center">|</span>
<div class="login-footer-row">
<a
:href="configStore.configData?.tenant_help_doc?.config_value || '#'"
:href="configStore.configData?.help_doc?.config_value || '#'"
target="_blank"
rel="noopener noreferrer"
class="login-page-footer__link"
@@ -133,7 +140,7 @@
</a>
<span class="login-page-footer__sep">|</span>
<a
:href="configStore.configData?.tenant_privacy?.config_value || '#'"
:href="configStore.configData?.privacy?.config_value || '#'"
target="_blank"
rel="noopener noreferrer"
class="login-page-footer__link"
@@ -142,7 +149,7 @@
</a>
<span class="login-page-footer__sep">|</span>
<a
:href="configStore.configData?.tenant_clause?.config_value || '#'"
:href="configStore.configData?.clause?.config_value || '#'"
target="_blank"
rel="noopener noreferrer"
class="login-page-footer__link"
@@ -150,26 +157,33 @@
条款
</a>
<span
v-if="configStore.configData?.tenant_keep_record?.config_value"
v-if="configStore.configData?.keep_record?.config_value"
class="login-page-footer__sep"
>|</span
>
<span
v-if="configStore.configData?.keep_record?.config_value"
class="login-page-footer__record"
>
{{ configStore.configData.tenant_keep_record.config_value }}
{{ configStore.configData.keep_record.config_value }}
</span>
</div>
</footer>
</div>
</div>
</footer>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ElScrollbar } from "element-plus";
import type { LocationQuery, RouteLocationRaw } from "vue-router";
import AuthAPI, {
type CaptchaInfo,
type LoginFormData,
type OAuthProvider,
} from "@/api/module_system/auth";
import type { TenantRegisterForm } from "@/api/module_system/auth";
import UserAPI, { type ForgetPasswordForm, type RegisterForm } from "@/api/module_system/user";
import { useConfigStore, useAppStore, useSettingsStore, useUserStore } from "@stores";
import { Auth, HttpError, startOAuthLogin } from "@utils";
@@ -225,9 +239,7 @@ const panelSubTitle = computed(() => {
return t("login.subTitle");
});
const userAgreementHref = computed(
() => configStore.configData?.tenant_clause?.config_value || "#"
);
const userAgreementHref = computed(() => configStore.configData?.clause?.config_value || "#");
function setAuthPanel(panel: AuthPanel) {
authPanel.value = panel;
@@ -367,10 +379,11 @@ const codeLoading = ref(false);
const registerAgreementRead = ref(false);
const registerForm = reactive<RegisterForm>({
const registerForm = reactive<RegisterForm & { email: string }>({
username: "",
password: "",
confirmPassword: "",
email: "",
});
const forgetForm = reactive<ForgetPasswordForm>({
@@ -533,10 +546,13 @@ const showVoteNotification = () => {
notificationInstance = ElNotification({
title: "⭐ FastapiAdmin 完全开源 · 期待您的 Star 支持 🙏",
message: `项目持续迭代中,若对您有所帮助,欢迎点亮 Star 支持!
<br/><a href="https://github.com/fastapiadmin/FastapiAdmin" target="_blank" :style="'color: var(--el-color-primary); text-decoration: none; font-weight: 500;'">Github仓库 →</a>
<br/><a href="https://gitee.com/fastapiadmin/FastapiAdmin" target="_blank" :style="'color: var(--el-color-warning); text-decoration: none; font-weight: 500;'">Gitee仓库 →</a>`,
<br/><a href="https://github.com/fastapiadmin/FastapiAdmin" target="_blank" style="color: var(--el-color-primary); text-decoration: none; font-weight: 500;">Github仓库 →</a>
<br/><a href="https://gitee.com/fastapiadmin/FastapiAdmin" target="_blank" style="color: var(--el-color-warning); text-decoration: none; font-weight: 500;">Gitee仓库 →</a>`,
type: "success",
position: "bottom-left",
position:
panelAlign.value === "right" || panelAlign.value === "center"
? "bottom-left"
: "bottom-right",
duration: 0,
dangerouslyUseHTMLString: true,
});
@@ -546,7 +562,7 @@ let voteTimer: ReturnType<typeof setTimeout> | null = null;
onMounted(async () => {
setupAccount("super");
await configStore.getConfig();
await configStore.getConfig(true);
await tryConsumeOAuthCallback();
if (userStore.isLogin) {
await router.replace(resolveRedirectTarget(route.query));
@@ -592,20 +608,7 @@ const handleSubmit = async () => {
loading.value = true;
await userStore.login(loginForm);
// 多租户:读取登录响应中的租户列表
const tenants = userStore.tenantList;
if (tenants.length > 1) {
await router.replace({
name: "TenantSelect",
query: { redirect: route.query.redirect as string },
});
} else if (tenants.length === 1) {
await userStore.setCurrentTenant(tenants[0]);
await router.replace(resolveRedirectTarget(route.query));
} else {
await router.replace(resolveRedirectTarget(route.query));
}
await router.replace(resolveRedirectTarget(route.query));
if (settingStore.showGuide) {
appStore.showGuide(true);
@@ -635,12 +638,19 @@ async function submitRegister() {
try {
await registerPanelRef.value.validate?.();
registerLoading.value = true;
await UserAPI.registerUser(registerForm);
// 租户自助注册(PRD §4.5)
const regData: TenantRegisterForm = {
username: registerForm.username,
password: registerForm.password,
email: registerForm.email || `${registerForm.username}@temp.com`,
};
await AuthAPI.tenantRegister(regData);
loginForm.username = registerForm.username;
loginForm.password = registerForm.password;
registerForm.username = "";
registerForm.password = "";
registerForm.confirmPassword = "";
registerForm.email = "";
registerAgreementRead.value = false;
setAuthPanel("login");
} catch (error) {
@@ -1,101 +0,0 @@
<!-- 租户选择页:登录后选择当前操作的租户 -->
<template>
<div
class="tenant-select-root flex min-h-screen flex-col items-center justify-center bg-(--el-bg-color-page) px-4"
>
<div class="w-full max-w-md">
<div class="mb-8 text-center">
<h1 class="text-2xl font-semibold text-(--el-text-color-primary)">选择租户</h1>
<p class="mt-2 text-sm text-(--el-text-color-secondary)">请选择您要进入的租户空间</p>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ElIcon class="is-loading text-3xl text-(--el-color-primary)"><Loading /></ElIcon>
</div>
<!-- Empty -->
<ElAlert
v-else-if="tenants.length === 0"
type="warning"
title="无可选租户"
description="您未被关联到任何租户,请联系管理员。"
show-icon
:closable="false"
/>
<!-- Tenant Cards -->
<div v-else class="flex flex-col gap-3">
<div
v-for="tenant in tenants"
:key="tenant.id"
class="tenant-card flex cursor-pointer items-center gap-4 rounded-lg border border-(--el-border-color) bg-(--el-bg-color) p-4 transition-all hover:border-(--el-color-primary) hover:shadow-md"
@click="handleSelect(tenant)"
>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-(--el-color-primary-light-9) text-lg font-bold text-(--el-color-primary)"
>
{{ tenant.name.charAt(0).toUpperCase() }}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-(--el-text-color-primary)">
{{ tenant.name }}
</div>
<div class="truncate text-xs text-(--el-text-color-secondary)">
{{ tenant.code }}
</div>
</div>
<ElIcon class="text-(--el-text-color-placeholder)"><ArrowRight /></ElIcon>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter, useRoute } from "vue-router";
import { useUserStore } from "@stores";
import { ElMessage } from "element-plus";
import type { TenantOption } from "@/api/module_system/auth";
defineOptions({ name: "TenantSelect" });
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const { tenantList: tenants } = storeToRefs(userStore);
const loading = ref(false);
onMounted(async () => {
if (tenants.value.length === 0) {
loading.value = true;
try {
await userStore.fetchTenants();
} catch {
// handled
} finally {
loading.value = false;
}
}
// 单租户自动选择
if (tenants.value.length === 1) {
await handleSelect(tenants.value[0]);
}
});
async function handleSelect(tenant: TenantOption) {
loading.value = true;
try {
await userStore.selectTenant(tenant.id);
ElMessage.success(`已进入「${tenant.name}」`);
const redirect = (route.query.redirect as string) || "/home";
await router.replace(redirect);
} catch {
ElMessage.error("租户切换失败,请重试");
} finally {
loading.value = false;
}
}
</script>
@@ -96,8 +96,8 @@
>
<template #status>
<ElRadioGroup v-model="formData.status">
<ElRadio value="0">启用</ElRadio>
<ElRadio value="1">停用</ElRadio>
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</template>
</FaForm>
@@ -281,7 +281,6 @@ async function deleteDeptRow(id: number) {
await confirmDelete();
await DeptAPI.deleteDept([id]);
await userStore.getUserInfo();
ElMessage.success("删除成功");
selectedRows.value = [];
await loadDeptData();
} catch {
@@ -318,7 +317,7 @@ const formData = ref<DeptForm>({
code: "",
order: 1,
parent_id: undefined,
status: "0",
status: 0,
description: undefined,
});
@@ -344,7 +343,7 @@ const initialFormData: DeptForm = {
code: "",
order: 1,
parent_id: undefined,
status: "0",
status: 0,
description: undefined,
};
@@ -392,8 +391,8 @@ const { columnChecks, columns } = useTableColumns<DeptTable>(() => [
label: "状态",
width: 88,
formatter: (row: DeptTable) =>
h(ElTag, { type: row.status === "0" ? "success" : "danger" }, () =>
row.status === "0" ? "启用" : "停用"
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
},
{ prop: "order", label: "排序", width: 88, showOverflowTooltip: true },
@@ -498,7 +497,6 @@ async function handleBatchDelete() {
batchDeleting.value = true;
await DeptAPI.deleteDept(ids);
await userStore.getUserInfo();
ElMessage.success("删除成功");
selectedRows.value = [];
await loadDeptData();
} catch {
@@ -300,9 +300,7 @@ async function fetchDictDataListMerged(params: Record<string, unknown>) {
dict_type: props.dictType,
dict_type_id: props.dictTypeId,
status:
params.status !== undefined && params.status !== null && params.status !== ""
? String(params.status)
: undefined,
params.status !== undefined && params.status !== null ? Number(params.status) : undefined,
created_time: Array.isArray(params.created_time)
? (params.created_time as string[])
: undefined,
@@ -403,7 +401,7 @@ const {
label: "状态",
width: 100,
formatter: (row: DictDataTable) => {
const ok = row.status === "0";
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "danger" as const, text: "停用" };
@@ -520,7 +518,7 @@ const formData = ref<DictDataForm>({
css_class: "",
list_class: undefined,
is_default: false,
status: "0",
status: 0,
description: "",
dict_type_id: undefined,
});
@@ -627,7 +625,7 @@ const initialFormData: DictDataForm = {
css_class: "",
list_class: undefined,
is_default: false,
status: "0",
status: 0,
description: "",
dict_type_id: props.dictTypeId,
};
@@ -661,7 +659,7 @@ function onResetSearch() {
async function resetForm() {
dataFormRef.value?.resetFields();
dataFormRef.value?.clearValidate();
Object.assign(formData, {
Object.assign(formData.value, {
...initialFormData,
dict_type_id: props.dictTypeId,
dict_type: props.dictType,
@@ -682,14 +680,14 @@ async function handleOpenDialog(type: "create" | "update" | "detail", id?: numbe
detailFormData.value = response.data.data ?? {};
} else if (type === "update") {
dialogVisible.title = "修改字典数据";
Object.assign(formData, response.data.data);
Object.assign(formData.value, response.data.data);
}
} else {
dialogVisible.title = "新增字典数据";
Object.assign(formData.value, initialFormData);
formData.value.dict_type = props.dictType;
formData.value.dict_type_id = props.dictTypeId;
formData.value.status = "0";
formData.value.status = 0;
formData.value.id = undefined;
}
dictDataFormRenderKey.value += 1;
@@ -763,11 +761,10 @@ async function deleteDictDataRow(id: number) {
await DictAPI.deleteDictData([id]);
dictStore.clearDictData();
if (props.dictType) await dictStore.getDict([props.dictType]);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
ElMessage.error("删除失败");
}
}
@@ -780,7 +777,6 @@ async function handleBatchDelete() {
await DictAPI.deleteDictData(ids);
dictStore.clearDictData();
if (props.dictType) await dictStore.getDict([props.dictType]);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -99,8 +99,8 @@
>
<template #status>
<ElRadioGroup v-model="formData.status">
<ElRadio value="0">启用</ElRadio>
<ElRadio value="1">停用</ElRadio>
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</template>
</FaForm>
@@ -257,7 +257,7 @@ const formData = ref<DictForm>({
id: undefined,
dict_name: "",
dict_type: "",
status: "0",
status: 0,
description: undefined,
});
@@ -274,7 +274,7 @@ const initialFormData: DictForm = {
id: undefined,
dict_name: "",
dict_type: "",
status: "0",
status: 0,
description: undefined,
};
@@ -379,7 +379,7 @@ const {
label: "状态",
width: 88,
formatter: (row: DictTable) => {
const ok = row.status === "0";
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "danger" as const, text: "停用" };
@@ -522,7 +522,6 @@ async function deleteDictTypeRow(id: number) {
dictStore.clearDictData();
const dictTypes = Object.keys(dictStore.dictData);
if (dictTypes.length > 0) await dictStore.getDict(dictTypes);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -540,7 +539,6 @@ async function handleBatchDelete() {
dictStore.clearDictData();
const dictTypes = Object.keys(dictStore.dictData);
if (dictTypes.length > 0) await dictStore.getDict(dictTypes);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -119,8 +119,8 @@
>
<template #status>
<ElRadioGroup v-model="formData.status">
<ElRadio value="0">启用</ElRadio>
<ElRadio value="1">停用</ElRadio>
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</template>
<template #notice_content>
@@ -335,7 +335,7 @@ const formData = ref<NoticeForm>({
notice_title: "",
notice_type: "",
notice_content: "",
status: "0",
status: 0,
description: undefined,
});
@@ -354,7 +354,7 @@ const initialFormData: NoticeForm = {
notice_title: "",
notice_type: "",
notice_content: "",
status: "0",
status: 0,
description: undefined,
};
@@ -466,7 +466,7 @@ const {
label: "状态",
width: 88,
formatter: (row: NoticeTable) => {
const ok = row.status === "0";
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "danger" as const, text: "停用" };
@@ -586,11 +586,10 @@ async function deleteNoticeRow(id: number) {
await confirmDelete();
await NoticeAPI.deleteNotice([id]);
await noticeStore.getNotice();
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
ElMessage.info("删除取消");
}
}
@@ -643,11 +642,10 @@ async function handleBatchDelete() {
batchDeleting.value = true;
await NoticeAPI.deleteNotice(ids);
await noticeStore.getNotice();
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
ElMessage.info("删除取消");
} finally {
batchDeleting.value = false;
}
@@ -665,7 +663,7 @@ async function handleMoreClick(status: string) {
await refreshData();
await noticeStore.getNotice();
} catch {
// 用户取消
ElMessage.info("操作取消");
}
}
@@ -133,7 +133,7 @@ import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue"
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag, ElMessage } from "element-plus";
import { ElTag } from "element-plus";
defineOptions({
name: "Params",
@@ -460,7 +460,6 @@ async function deleteParamRow(id: number) {
await ParamsAPI.deleteParams([id]);
configStore.isConfigLoaded = false;
await configStore.getConfig();
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -518,7 +517,6 @@ async function handleBatchDelete() {
await ParamsAPI.deleteParams(ids);
configStore.isConfigLoaded = false;
await configStore.getConfig();
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -1,684 +0,0 @@
<!-- 插件市场:FA + useTable;支持超管 CRUD 与租户安装/卸载 -->
<template>
<div class="fa-full-height">
<FaSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="pluginSearchItems"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
<ElCard
shadow="hover"
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>
<FaTableHeaderLeft
:remove-ids="selectedIds"
:perm-create="['module_system:plugin:create']"
:perm-delete="['module_system:plugin:delete']"
:delete-loading="batchDeleting"
@add="handleOpenDialog('create')"
@delete="handleBatchDelete"
/>
</template>
</FaTableHeader>
<FaTable
ref="faTableRef"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="onTableSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
<FaDialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
width="720px"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
:form-mode="dialogVisible.type"
:confirm-loading="submitLoading"
@cancel="handleCloseDialog"
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
>
<template v-if="dialogVisible.type === 'detail'">
<FaDescriptions
:column="2"
:data="detailFormData"
:items="pluginDetailItems"
max-height="75vh"
>
<template #status>
<ElTag :type="detailFormData.status === '0' ? 'success' : 'danger'">
{{ detailFormData.status === "0" ? "启用" : "停用" }}
</ElTag>
</template>
<template #price>
{{
detailFormData.price === 0
? "免费"
: "¥" + ((detailFormData.price ?? 0) / 100).toFixed(2)
}}
</template>
</FaDescriptions>
</template>
<template v-else>
<FaForm
scrollbar
max-height="75vh"
:key="pluginFormRenderKey"
ref="dataFormRef"
v-model="formData"
:items="pluginDialogFormItems"
: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"
>
<template #status>
<ElRadioGroup v-model="formData.status">
<ElRadio value="0">启用</ElRadio>
<ElRadio value="1">停用</ElRadio>
</ElRadioGroup>
</template>
</FaForm>
</template>
</FaDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { useCrudForm } from "@/hooks/core/useCrudForm";
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
import { cleanEmptyArrayParams } from "@/utils/query";
import type { ColumnOption } from "@/types/component";
import PluginAPI, {
type PluginForm,
type PluginQueryParam,
type PluginTable,
} from "@/api/module_system/plugin";
import { useAuth } from "@/hooks/core/useAuth";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaButtonTable from "@/components/forms/fa-button-table/index.vue";
import {
ElTag,
ElMessage,
ElTooltip,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
} from "element-plus";
defineOptions({
name: "PluginMarketplace",
inheritAttrs: false,
});
const MAX_INLINE_ROW_ACTIONS = 3;
const { hasAuth } = useAuth();
type PluginSearchForm = {
name?: string;
category?: string;
status?: string;
};
function normalizePluginQuery(params: Record<string, unknown>): PluginQueryParam {
return cleanEmptyArrayParams({ ...params }) as unknown as PluginQueryParam;
}
type RowAction = {
key: string;
label: string;
artType: "add" | "edit" | "delete" | "view" | "more";
icon?: string;
perm: string;
disabled?: boolean;
run: () => void;
};
function buildPluginRowActions(
row: PluginTable,
ctx: {
onDetail: (id: number) => void;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onInstall: (row: PluginTable) => void;
onUninstall: (row: PluginTable) => void;
onToggle: (row: PluginTable) => void;
}
): RowAction[] {
const all: RowAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:plugin:query",
run: () => ctx.onDetail(row.id!),
},
];
if (hasAuth("module_system:plugin:update")) {
all.push({
key: "edit",
label: "编辑",
artType: "edit",
perm: "module_system:plugin:update",
run: () => ctx.onEdit(row.id!),
});
}
if (hasAuth("module_system:plugin:delete")) {
all.push({
key: "delete",
label: "删除",
artType: "delete",
perm: "module_system:plugin:delete",
run: () => ctx.onDelete(row.id!),
});
}
if (hasAuth("module_system:plugin:install")) {
if (row.installed) {
all.push({
key: "uninstall",
label: "卸载",
artType: "more",
perm: "module_system:plugin:uninstall",
run: () => ctx.onUninstall(row),
});
all.push({
key: "toggle",
label: row.status === "0" ? "禁用" : "启用",
artType: "more",
perm: "module_system:plugin:toggle",
run: () => ctx.onToggle(row),
});
} else {
all.push({
key: "install",
label: "安装",
artType: "add",
perm: "module_system:plugin:install",
run: () => ctx.onInstall(row),
});
}
}
return all.filter((a) => hasAuth(a.perm));
}
function formatPluginOperationCell(
row: PluginTable,
ctx: Parameters<typeof buildPluginRowActions>[1]
) {
const actions = buildPluginRowActions(row, ctx);
if (actions.length === 0) {
return h("span", { class: "text-g-400" }, "—");
}
const inline = actions.slice(0, MAX_INLINE_ROW_ACTIONS);
const overflow = actions.slice(MAX_INLINE_ROW_ACTIONS);
const inlineNodes = inline.map((a) =>
h(ElTooltip, { content: a.label, placement: "top" }, () =>
h("span", { class: "inline-flex" }, [
h(FaButtonTable, {
type: a.artType,
icon: a.icon,
onClick: a.run,
}),
])
)
);
if (overflow.length === 0) {
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 plugin-table-actions" },
inlineNodes
);
}
const dropdown = h(
ElDropdown,
{ trigger: "click" },
{
default: () =>
h(ElTooltip, { content: "更多", placement: "top" }, () =>
h("span", { class: "inline-flex align-middle" }, [
h(FaButtonTable, {
type: "more",
onClick: () => {},
}),
])
),
dropdown: () =>
h(
ElDropdownMenu,
null,
overflow.map((a) =>
h(
ElDropdownItem,
{
key: a.key,
disabled: a.disabled,
onClick: () => a.run(),
},
() => a.label
)
)
),
}
);
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 plugin-table-actions" },
[...inlineNodes, dropdown]
);
}
const searchForm = ref<PluginSearchForm>({
name: undefined,
category: undefined,
status: undefined,
});
const showSearchBar = ref(true);
const categoryOptions = ref([
{ label: "工具", value: "tool" },
{ label: "AI", value: "ai" },
{ label: "监控", value: "monitor" },
{ label: "业务", value: "business" },
]);
const statusOptions = ref([
{ label: "启用", value: "0" },
{ label: "停用", value: "1" },
]);
const pluginSearchItems = computed<SearchFormItem[]>(() => [
{
label: "插件名称",
key: "name",
type: "input",
placeholder: "请输入插件名称",
clearable: true,
span: 6,
},
{
label: "分类",
key: "category",
type: "select",
props: {
placeholder: "请选择分类",
options: categoryOptions.value,
clearable: true,
},
span: 6,
},
{
label: "状态",
key: "status",
type: "select",
props: {
placeholder: "请选择状态",
options: statusOptions.value,
clearable: true,
},
span: 6,
},
]);
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
const { selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection<PluginTable>();
async function deletePluginRow(id: number) {
try {
await confirmDelete("确认删除该插件?");
await PluginAPI.delete([id]);
ElMessage.success("删除成功");
await refreshRemove();
} catch {
ElMessage.error("删除失败");
}
}
async function handleBatchDelete() {
if (selectedIds.value.length === 0) {
ElMessage.warning("请先选择要删除的插件");
return;
}
try {
await confirmBatchDelete(selectedIds.value.length);
await PluginAPI.delete(selectedIds.value as number[]);
ElMessage.success("批量删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshData();
} catch {
ElMessage.error("批量删除失败");
}
}
async function doInstall(row: PluginTable) {
if (!row.id) return;
try {
await PluginAPI.install(row.id);
ElMessage.success("安装成功");
row.installed = true;
await refreshData();
} catch {
ElMessage.error("安装失败");
}
}
async function doUninstall(row: PluginTable) {
if (!row.id) return;
try {
await PluginAPI.uninstall(row.id);
ElMessage.success("卸载成功");
row.installed = false;
await refreshData();
} catch {
ElMessage.error("卸载失败");
}
}
async function doToggle(row: PluginTable) {
if (!row.id) return;
try {
await PluginAPI.toggle(row.id);
ElMessage.success(row.status === "0" ? "已禁用" : "已启用");
row.status = row.status === "0" ? "1" : "0";
await refreshData();
} catch {
ElMessage.error("操作失败");
}
}
const opCtx = {
onDetail: (id: number) => void handleOpenDialog("detail", id),
onEdit: (id: number) => void handleOpenDialog("update", id),
onDelete: deletePluginRow,
onInstall: doInstall,
onUninstall: doUninstall,
onToggle: doToggle,
};
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
refreshCreate,
refreshUpdate,
refreshRemove,
} = useTable({
core: {
apiFn: PluginAPI.list,
apiParams: {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<PluginTable>[] => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "插件名称", minWidth: 140, showOverflowTooltip: true },
{ prop: "code", label: "插件编码", minWidth: 120, showOverflowTooltip: true },
{ prop: "version", label: "版本", width: 88 },
{
prop: "category",
label: "分类",
width: 88,
formatter: (row: PluginTable) => {
const map: Record<string, string> = {
tool: "🛠️ 工具",
ai: "🤖 AI",
monitor: "📊 监控",
business: "💼 业务",
};
return (row.category && map[row.category]) || row.category || "—";
},
},
{
prop: "price",
label: "价格",
width: 88,
formatter: (row: PluginTable) =>
row.price === 0 ? "免费" : "¥" + ((row.price ?? 0) / 100).toFixed(2),
},
{
prop: "status",
label: "状态",
width: 88,
formatter: (row: PluginTable) =>
h(ElTag, { type: row.status === "0" ? "success" : "danger" }, () =>
row.status === "0" ? "启用" : "停用"
),
},
{
prop: "installed",
label: "安装状态",
width: 96,
formatter: (row: PluginTable) =>
h(ElTag, { type: row.installed ? "success" : "info" }, () =>
row.installed ? "已安装" : "未安装"
),
},
{ prop: "author", label: "作者", width: 100, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 160, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 220,
fixed: "right",
align: "right",
formatter: (row: PluginTable) => formatPluginOperationCell(row, opCtx),
},
],
},
});
async function handleSearchBarSearch(params: PluginSearchForm) {
replaceSearchParams(normalizePluginQuery(params as unknown as Record<string, unknown>) as any);
await getData();
}
function onResetSearch() {
resetSearchParams();
getData();
}
// ─── 对话框状态 ───
const { dialogVisible } = useCrudDialog();
const detailFormData = ref<PluginTable>({} as PluginTable);
const pluginDetailItems = [
{ label: "插件名称", prop: "name" },
{ label: "插件编码", prop: "code" },
{ label: "版本", prop: "version" },
{ label: "分类", prop: "category" },
{ label: "作者", prop: "author" },
{ label: "状态", prop: "status", slot: "status" },
{ label: "价格", prop: "price", slot: "price" },
{ label: "排序", prop: "sort" },
{ label: "菜单路径", prop: "menu_path" },
{ label: "权限前缀", prop: "permission_prefix" },
{ label: "依赖插件", prop: "dependencies" },
{ label: "描述", prop: "description", span: 4 },
];
const formData = ref<PluginForm>({
name: undefined,
code: undefined,
category: "tool",
version: "1.0.0",
status: "0",
price: 0,
sort: 0,
description: undefined,
author: undefined,
icon: undefined,
menu_path: undefined,
permission_prefix: undefined,
dependencies: undefined,
});
const initialFormData: PluginForm = { ...formData.value };
const pluginFormRenderKey = ref(0);
const dataFormRef = ref<any>(null);
const rules = {
name: [{ required: true, message: "请输入插件名称", trigger: "blur" }],
code: [{ required: true, message: "请输入插件编码", trigger: "blur" }],
category: [{ required: true, message: "请选择分类", trigger: "change" }],
};
const pluginDialogFormItems: FormItem[] = [
{
label: "插件名称",
key: "name",
type: "input",
span: 12,
props: { placeholder: "请输入插件名称", clearable: true },
},
{
label: "插件编码",
key: "code",
type: "input",
span: 12,
props: { placeholder: "请输入插件编码", clearable: true },
},
{
label: "分类",
key: "category",
type: "select",
span: 12,
props: { options: categoryOptions.value, placeholder: "请选择分类", clearable: true },
},
{
label: "版本",
key: "version",
type: "input",
span: 12,
props: { placeholder: "版本号", clearable: true },
},
{
label: "作者",
key: "author",
type: "input",
span: 12,
props: { placeholder: "请输入作者", clearable: true },
},
{
label: "图标URL",
key: "icon",
type: "input",
span: 12,
props: { placeholder: "图标URL", clearable: true },
},
{
label: "价格(分)",
key: "price",
type: "input-number",
span: 12,
props: { min: 0, placeholder: "0=免费" },
},
{ label: "排序", key: "sort", type: "input-number", span: 12, props: { min: 0 } },
{
label: "菜单路径",
key: "menu_path",
type: "input",
span: 12,
props: { placeholder: "安装后的菜单路径", clearable: true },
},
{
label: "权限前缀",
key: "permission_prefix",
type: "input",
span: 12,
props: { placeholder: "权限前缀", clearable: true },
},
{
label: "依赖插件",
key: "dependencies",
type: "textarea",
span: 24,
props: { placeholder: "依赖插件编码(JSON数组)", rows: 2 },
},
{
label: "描述",
key: "description",
type: "textarea",
span: 24,
props: { placeholder: "请输入描述", rows: 3 },
},
];
const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } =
useCrudForm<PluginForm>({
formData,
initialFormData,
dialogVisible,
dataFormRef,
formRenderKey: pluginFormRenderKey,
detailApi: PluginAPI.detail as any,
createApi: PluginAPI.create,
updateApi: PluginAPI.update,
titles: { create: "新增插件", update: "编辑插件", detail: "插件详情" },
detailFormData,
onCreateSuccess: async () => {
await refreshCreate();
},
onUpdateSuccess: async () => {
await refreshUpdate();
},
onSubmitSuccess: async () => {
await refreshData();
},
});
</script>
@@ -104,8 +104,8 @@
>
<template #status>
<ElRadioGroup v-model="formData.status">
<ElRadio value="0">启用</ElRadio>
<ElRadio value="1">停用</ElRadio>
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</template>
</FaForm>
@@ -399,8 +399,8 @@ const {
label: "状态",
width: 88,
formatter: (row: PositionTable) =>
h(ElTag, { type: row.status === "0" ? "success" : "danger" }, () =>
row.status === "0" ? "启用" : "停用"
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
},
{ prop: "order", label: "岗位排序", width: 100, showOverflowTooltip: true },
@@ -487,7 +487,7 @@ const formData = ref<PositionForm>({
id: undefined,
name: undefined,
order: 1,
status: "0",
status: 0,
description: undefined,
});
@@ -503,7 +503,7 @@ const initialFormData: PositionForm = {
id: undefined,
name: undefined,
order: 1,
status: "0",
status: 0,
description: undefined,
};
@@ -604,11 +604,10 @@ async function deletePositionRow(id: number) {
await confirmDelete();
await PositionAPI.deletePosition([id]);
await userStore.getUserInfo();
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
ElMessage.info("删除取消");
}
}
@@ -620,7 +619,6 @@ async function handleBatchDelete() {
batchDeleting.value = true;
await PositionAPI.deletePosition(ids);
await userStore.getUserInfo();
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -43,9 +43,9 @@
</ElFormItem>
</ElForm>
<div
<ElScrollbar
v-if="permissionState.data_scope === 5 && deptTreeData.length"
class="mt-5 max-h-[72vh] b-1 b-solid b-[var(--el-border-color-lighter)] p-10px overflow-auto box-border"
class="mt-5 max-h-[72vh] b-1 b-solid b-[var(--el-border-color-lighter)] p-10px box-border"
>
<ElInput v-model="deptFilterText" placeholder="部门名称" />
<ElTree
@@ -56,15 +56,14 @@
:filter-node-method="handleFilter"
default-expand-all
:highlight-current="true"
:check-strictly="!parentChildLinked"
:style="'height: calc(100% - 60px); margin-top: 10px; overflow-y: auto'"
:style="'height: calc(100% - 60px); margin-top: 10px'"
@check="deptTreeCheck"
>
<template #empty>
<ElEmpty :image-size="80" description="暂无数据" />
</template>
</ElTree>
</div>
</ElScrollbar>
</div>
</div>
</ElAside>
@@ -85,61 +84,13 @@
</ElTooltip>
</div>
</div>
<div class="mt-3 flex items-center gap-3">
<ElInput v-model="permFilterText" placeholder="菜单名称" class="flex-1" />
<ElButton type="primary" size="small" plain @click="togglePermTree">
<template #icon>
<SwitchIcon />
</template>
{{ isExpanded ? "收缩" : "展开" }}
</ElButton>
<ElCheckbox v-model="parentChildLinked"> 父子联动 </ElCheckbox>
<ElTooltip placement="bottom">
<template #content> 勾选父级菜单时自动勾选所有子菜单 </template>
<ElIcon class="color-[--el-color-primary] inline-block cursor-pointer">
<QuestionFilled />
</ElIcon>
</ElTooltip>
</div>
<!-- 菜单树表 -->
<div class="mt-3 b-1 b-solid b-[var(--el-border-color-lighter)] rounded-lg">
<ElTable
ref="permTableRef"
:data="tableData"
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="true"
max-height="72vh"
@selection-change="handleSelectionChange"
@select="handleSelect"
@select-all="handleSelectAll"
>
<ElTableColumn type="selection" width="48" />
<ElTableColumn
prop="name"
label="菜单名称"
width="200"
show-overflow-tooltip
:formatter="formatMenuName"
/>
<ElTableColumn label="功能权限" min-width="400">
<template #default="{ row }">
<ElCheckboxGroup
v-model="checkedBtns[row.id]"
class="flex flex-wrap gap-x-3"
@change="(val) => handleBtnChange(row, val)"
>
<ElCheckbox
v-for="btn in getMenuBtns(row)"
:key="btn.id"
:value="btn.id"
:label="btn.name"
/>
</ElCheckboxGroup>
</template>
</ElTableColumn>
</ElTable>
<div class="mt-3 flex-1 min-h-0">
<FaMenuTreeTable
ref="menuTreeTableRef"
:menu-tree="rawMenuTree"
:checked-ids="menuCheckedIds"
:loading="loading"
/>
</div>
</ElMain>
</ElContainer>
@@ -155,15 +106,15 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, nextTick, h } from "vue";
import { QuestionFilled, Switch as SwitchIcon } from "@element-plus/icons-vue";
import type { TreeInstance, TableInstance, CheckboxValueType } from "element-plus";
import { computed, ref, watch, onMounted, nextTick } from "vue";
import { QuestionFilled } from "@element-plus/icons-vue";
import type { TreeInstance } from "element-plus";
import FaDrawer from "@/components/modal/fa-drawer/index.vue";
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
import FaMenuTreeTable from "@/components/others/fa-menu-tree-drawer/index.vue";
import { listToTree, formatTree } from "@utils";
import RoleAPI, { permissionDataType, permissionDeptType } from "@/api/module_system/role";
import DeptAPI from "@/api/module_system/dept";
import MenuAPI, { MenuTable } from "@/api/module_system/menu";
import MenuAPI, { MenuTable } from "@/api/module_platform/menu";
import { DeviceEnum } from "@/enums/settings/device.enum";
import { useAppStore, useUserStore } from "@stores";
import { ElMessage } from "element-plus";
@@ -174,300 +125,31 @@ const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
interface Emits {
"update:modelValue": [v: boolean];
saved: [];
}>();
}
const emit = defineEmits<Emits>();
const appStore = useAppStore();
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "1600px" : "90%"));
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "1200px" : "90%"));
const drawerVisible = computed({
get() {
return props.modelValue;
},
set(value: boolean) {
emit("update:modelValue", value);
get: () => props.modelValue,
set(v) {
emit("update:modelValue", v);
},
});
const deptTreeRef = ref<TreeInstance>();
const deptFilterText = ref("");
const permFilterText = ref("");
const dataFormRef = ref();
const isExpanded = ref(true);
const parentChildLinked = ref(false);
/** 防止 toggleRowSelection 触发 @select 递归级联 */
const isHandlingCascade = ref(false);
const loading = ref(false);
const deptTreeData = ref<permissionDeptType[]>([]);
const rawMenuTree = ref<MenuTable[]>([]);
const tableData = ref<MenuTable[]>([]);
const permTableRef = ref<TableInstance>();
const checkedBtns = ref<Record<number, number[]>>({});
/** 跟踪选中的菜单 ID */
const selectedMenuIds = ref<Set<number>>(new Set());
/** 菜单类型枚举: 1=目录, 2=菜单, 3=按钮/权限, 4=链接 */
const DIR_TYPE = 1;
const MENU_TYPE = 2;
const BTN_TYPE = 3;
const LINK_TYPE = 4;
// 功能权限类型(按钮+链接)
const PERM_TYPES = [BTN_TYPE, LINK_TYPE];
/** 判断是否是功能权限类型(按钮或链接) */
function isPermType(type: number | undefined): boolean {
return type != null && PERM_TYPES.includes(type);
}
/** 过滤按钮,只保留目录+菜单作为树表行数据 */
function filterTableData(nodes: MenuTable[]): MenuTable[] {
return nodes
.filter((n) => n.type === DIR_TYPE || n.type === MENU_TYPE)
.map((n) => ({
...n,
children: n.children ? filterTableData(n.children) : undefined,
}));
}
function formatMenuName(row: MenuTable): string | ReturnType<typeof h> {
const name = row.name || "";
if (row.icon) {
return h("span", { class: "inline-flex items-center gap-1.5" }, [
h(FaMenuRouteIcon, { icon: row.icon, style: { verticalAlign: "-0.15em" } }),
name,
]);
}
return name;
}
/** 获取某菜单下的功能按钮 */
function getMenuBtns(row: MenuTable): MenuTable[] {
const fullNode = findRawNode(row.id!, rawMenuTree.value);
if (!fullNode?.children) return [];
return (fullNode.children as MenuTable[]).filter((c: MenuTable) => isPermType(c.type as number));
}
/** 处理按钮勾选变化 */
function handleBtnChange(row: MenuTable, val: CheckboxValueType[]) {
checkedBtns.value[row.id!] = val.map((v) => Number(v));
if (!parentChildLinked.value) return;
withCascadeGuard(() => {
if (val.length > 0) {
permTableRef.value?.toggleRowSelection(row, true);
selectAncestors(row);
} else if (!hasAnySelectedDescendant(row)) {
permTableRef.value?.toggleRowSelection(row, false);
deselectAncestorsIfNeeded(row);
}
});
}
/** 检查某菜单是否有任何选中的子孙(包括子孙菜单和按钮) */
function hasAnySelectedDescendant(node: MenuTable): boolean {
// 检查当前节点是否有选中的按钮
if (checkedBtns.value[node.id!]?.length > 0) {
return true;
}
// 检查子菜单
const tableNode = findTableNode(node.id!, tableData.value);
if (tableNode?.children) {
for (const child of tableNode.children) {
if (
(child.type === MENU_TYPE || child.type === DIR_TYPE) &&
hasAnySelectedDescendant(child)
) {
return true;
}
}
}
return false;
}
/** 递归获取所有祖先节点(必须是菜单或目录类型) */
function findAncestors(nodeId: number, nodes: any[]): any[] {
for (const node of nodes) {
if ((node.type === DIR_TYPE || node.type === MENU_TYPE) && node.children) {
const childIndex = node.children.findIndex((c: any) => c.id === nodeId);
if (childIndex !== -1) {
// 从 tableData 中获取正确的节点引用
const tableNode = findTableNode(node.id!, tableData.value);
// 继续向上查找当前节点的祖先
const parentAncestors = findAncestors(node.id!, rawMenuTree.value);
if (tableNode) {
return [tableNode, ...parentAncestors];
}
return parentAncestors;
}
const ancestors = findAncestors(nodeId, node.children);
if (ancestors.length > 0) {
return ancestors;
}
}
}
return [];
}
/** 处理表格选中行变化 */
function handleSelectionChange() {
const selection = (permTableRef.value as any)?.store?.states?.selection?.value || [];
const newSelected = new Set<number>();
selection.forEach((row: MenuTable) => {
if (row.type === DIR_TYPE || row.type === MENU_TYPE) {
newSelected.add(row.id!);
}
});
selectedMenuIds.value = newSelected;
// 当没有任何菜单行被选中时,清空所有功能按钮状态
// 作为安全网:覆盖 @select-all 事件可能无法触发取消全选逻辑的场景
if (newSelected.size === 0) {
checkedBtns.value = {};
}
}
/** 处理全选/取消全选 */
function handleSelectAll() {
const selection = (permTableRef.value as any)?.store?.states?.selection?.value || [];
withCascadeGuard(() => {
if (!selection.length) {
checkedBtns.value = {};
selectedMenuIds.value.clear();
return;
}
for (const row of selection) {
if (row.type === DIR_TYPE || row.type === MENU_TYPE) {
selectedMenuIds.value.add(row.id!);
if (parentChildLinked.value) collectBtnState(row);
}
}
});
}
function handleSelect(selection: MenuTable[], row: MenuTable) {
if (isHandlingCascade.value || !parentChildLinked.value) return;
const tableRow = findTableNode(row.id!, tableData.value);
if (!tableRow) return;
const isSelected = selection.some((r) => r.id === tableRow.id);
withCascadeGuard(() => {
if (isSelected) {
selectAncestors(tableRow);
cascadeSelect(tableRow);
} else {
cascadeDeselect(tableRow);
deselectAncestorsIfNeeded(tableRow);
}
});
}
/** 向上检查祖先是否需要取消选中 */
function deselectAncestorsIfNeeded(node: MenuTable) {
const ancestors = findAncestors(node.id!, rawMenuTree.value);
ancestors.forEach((ancestor) => {
const hasSelectedDescendant = hasAnySelectedDescendant(ancestor);
if (!hasSelectedDescendant) {
permTableRef.value?.toggleRowSelection(ancestor, false);
}
});
}
/** 选中所有祖先菜单行(向上级联) */
function selectAncestors(node: MenuTable) {
const ancestors = findAncestors(node.id!, rawMenuTree.value);
ancestors.forEach((ancestor) => {
permTableRef.value?.toggleRowSelection(ancestor, true);
});
}
/** 级联选中:选中当前节点及所有子孙菜单,并为每个菜单收集其按钮 */
function cascadeSelect(node: MenuTable) {
// 先选中当前节点(此时 handleSelect 因 isHandlingCascade 被跳过)
permTableRef.value?.toggleRowSelection(node, true);
// 收集当前节点的功能按钮
collectBtnState(node);
// 递归处理子节点
const tableNode = findTableNode(node.id!, tableData.value);
if (tableNode?.children) {
for (const child of tableNode.children) {
if (child.type === MENU_TYPE || child.type === DIR_TYPE) {
cascadeSelect(child);
}
}
}
}
/** 级联取消:取消当前节点及所有子孙菜单,并清空所有按钮 */
function cascadeDeselect(node: MenuTable) {
permTableRef.value?.toggleRowSelection(node, false);
checkedBtns.value[node.id!] = [];
// 递归处理子节点
const tableNode = findTableNode(node.id!, tableData.value);
if (tableNode?.children) {
for (const child of tableNode.children) {
if (child.type === MENU_TYPE || child.type === DIR_TYPE) {
cascadeDeselect(child);
}
}
}
}
/** 收集某个菜单节点的功能按钮到 checkedBtns */
function collectBtnState(node: MenuTable) {
const ids = getMenuBtns(node).map((b) => b.id!);
if (ids.length) checkedBtns.value[node.id!] = ids;
}
/** 在 isHandlingCascade 保护下执行操作,防止复选事件递归 */
function withCascadeGuard(fn: () => void) {
isHandlingCascade.value = true;
try {
fn();
} finally {
isHandlingCascade.value = false;
}
}
/** 递归遍历树节点并对每个节点执行回调 */
function walkTree(nodes: MenuTable[], fn: (node: MenuTable) => void) {
for (const node of nodes) {
fn(node);
if (node.children) walkTree(node.children, fn);
}
}
/** 从 tableData 中递归查找节点 */
function findTableNode(id: number, nodes: MenuTable[]): MenuTable | undefined {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children) {
const found = findTableNode(id, node.children);
if (found) return found;
}
}
return undefined;
}
/** 从原始树中递归查找节点 */
function findRawNode(id: number, nodes: any[]): any | undefined {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children) {
const found = findRawNode(id, node.children);
if (found) return found;
}
}
return undefined;
}
const menuCheckedIds = ref<number[]>([]);
const menuTreeTableRef = ref<InstanceType<typeof FaMenuTreeTable>>();
const permissionState = ref<permissionDataType>({
role_ids: [],
@@ -478,57 +160,26 @@ const permissionState = ref<permissionDataType>({
const init = async () => {
loading.value = true;
try {
const deptResponse = await DeptAPI.listDept();
deptTreeData.value = formatTree(listToTree(deptResponse.data.data));
const menuResponse = await MenuAPI.listMenu();
const rawTree = menuResponse.data.data || [];
rawMenuTree.value = rawTree;
tableData.value = filterTableData(rawTree);
rawMenuTree.value = menuResponse.data.data || [];
const roleResponse = await RoleAPI.detailRole(props.roleId);
const savedMenuIds = roleResponse.data.data.menus?.map((menu) => menu.id) || [];
const savedMenuIds = roleResponse.data.data.menus?.map((menu: any) => menu.id) || [];
permissionState.value = {
role_ids: [props.roleId],
menu_ids: savedMenuIds,
data_scope: roleResponse.data.data.data_scope || 1,
dept_ids: roleResponse.data.data.depts?.map((dept) => dept.id) || [],
dept_ids: roleResponse.data.data.depts?.map((dept: any) => dept.id) || [],
};
parentChildLinked.value = checkParentChildLinked(savedMenuIds, rawMenuTree.value);
menuCheckedIds.value = savedMenuIds;
// 设置表格选中状态和按钮选中状态
await nextTick();
const btnsState: Record<number, number[]> = {};
const rowsToSelect: MenuTable[] = [];
// 从 tableData 中收集需要选中的菜单行(与表格同数据源,确保对象引用一致)
walkTree(tableData.value, (node) => {
if ((node.type === DIR_TYPE || node.type === MENU_TYPE) && savedMenuIds.includes(node.id!)) {
rowsToSelect.push(node);
}
});
// 从原始树中收集功能按钮状态
walkTree(rawMenuTree.value, (node) => {
if (node.type !== BTN_TYPE || !savedMenuIds.includes(node.id!)) return;
const parent = findParentMenu(node.id!, rawMenuTree.value);
if (parent) {
(btnsState[parent.id!] ??= []).push(node.id!);
}
});
checkedBtns.value = btnsState;
withCascadeGuard(() => {
if (!permTableRef.value) return;
const store = (permTableRef.value as any)?.store;
if (!store) return;
store.clearSelection();
rowsToSelect.forEach((row) => store.toggleRowSelection(row, true, false));
});
if (permissionState.value.data_scope === 5 && deptTreeRef.value) {
await deptTreeRef.value.setCheckedKeys(permissionState.value.dept_ids);
@@ -553,20 +204,8 @@ async function handleDrawerSave() {
}
loading.value = true;
// 收集所有选中的菜单ID和按钮ID
const allIds = new Set<number>();
// 使用 selectedMenuIds 获取选中的菜单
selectedMenuIds.value.forEach((id) => {
allIds.add(id);
});
// 添加选中的按钮
Object.values(checkedBtns.value).forEach((btnIds) => {
btnIds.forEach((id) => allIds.add(id));
});
const menu_ids = expandMenuIdsWithAncestors([...allIds], rawMenuTree.value);
const checkedIds = menuTreeTableRef.value?.getCheckedIds() ?? [];
const menu_ids = expandMenuIdsWithAncestors(checkedIds, rawMenuTree.value);
const submitData: permissionDataType = {
role_ids: [props.roleId],
@@ -593,83 +232,12 @@ const deptTreeCheck = (checkedIds: number[]) => {
permissionState.value.dept_ids = checkedIds;
};
function expandAllRows(expanded: boolean) {
nextTick(() => {
const el = permTableRef.value;
if (!el || !tableData.value.length) return;
walkTree(tableData.value, (row) => {
if (row.children?.length) el.toggleRowExpansion(row, expanded);
});
});
}
function togglePermTree() {
isExpanded.value = !isExpanded.value;
expandAllRows(isExpanded.value);
}
watch(deptFilterText, (val) => {
deptTreeRef.value!.filter(val);
});
watch(permFilterText, (val) => {
if (val) expandAllRows(true);
});
function handleFilter(value: string, data: { [key: string]: any }) {
if (!value) return true;
return data.label?.includes(value);
}
function findParentMenu(menuId: number, menuTreeData: MenuTable[]): MenuTable | null {
for (const menu of menuTreeData) {
if (menu.children?.some((child) => child.id === menuId)) return menu;
if (menu.children) {
const found = findParentMenu(menuId, menu.children);
if (found) return found;
}
}
return null;
}
function checkParentChildLinked(menuIds: number[], menuTreeData: MenuTable[]): boolean {
if (!menuIds.length || !menuTreeData.length) return false;
const menuMap = new Map<number, MenuTable>();
const buildMenuMap = (menus: MenuTable[]) => {
menus.forEach((menu) => {
menuMap.set(menu.id!, menu);
if (menu.children) {
buildMenuMap(menu.children);
}
});
};
buildMenuMap(menuTreeData);
let hasParentChildConflict = false;
for (const menuId of menuIds) {
const menu = menuMap.get(menuId);
if (!menu) continue;
if (menu.children && menu.children.length > 0) {
const hasUnselectedChildren = menu.children.some((child) => !menuIds.includes(child.id!));
if (hasUnselectedChildren) {
hasParentChildConflict = true;
break;
}
}
const parentMenu = findParentMenu(menuId, menuTreeData);
if (parentMenu && !menuIds.includes(parentMenu.id!)) {
hasParentChildConflict = true;
break;
}
}
return !hasParentChildConflict;
}
/** 将选中的菜单/按钮 ID 展开为包含所有祖先菜单的完整集合 */
function expandMenuIdsWithAncestors(checkedIds: number[], roots: MenuTable[]): number[] {
const parentById = new Map<number, number | undefined>();
const walk = (nodes: MenuTable[], parent: number | undefined) => {
@@ -691,6 +259,10 @@ function expandMenuIdsWithAncestors(checkedIds: number[], roots: MenuTable[]): n
return [...out];
}
watch(deptFilterText, (val) => {
deptTreeRef.value!.filter(val);
});
onMounted(async () => {
await init();
});
@@ -116,8 +116,8 @@
>
<template #status>
<ElRadioGroup v-model="formData.status">
<ElRadio value="0">启用</ElRadio>
<ElRadio value="1">停用</ElRadio>
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</template>
</FaForm>
@@ -373,7 +373,6 @@ async function deleteRoleRow(id: number) {
await RoleAPI.deleteRole([id]);
const userStore = useUserStore();
await userStore.getUserInfo();
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -410,7 +409,7 @@ const formData = ref<RoleForm>({
name: undefined,
order: 1,
code: "",
status: "0",
status: 0,
description: undefined,
});
@@ -438,7 +437,7 @@ const initialFormData: RoleForm = {
name: undefined,
order: 1,
code: "",
status: "0",
status: 0,
description: undefined,
};
@@ -571,8 +570,8 @@ const {
label: "状态",
width: 88,
formatter: (row: RoleTable) =>
h(ElTag, { type: row.status === "0" ? "success" : "danger" }, () =>
row.status === "0" ? "启用" : "停用"
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
},
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
@@ -644,7 +643,6 @@ async function handleBatchDelete() {
await RoleAPI.deleteRole(ids);
const userStore = useUserStore();
await userStore.getUserInfo();
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -1,330 +0,0 @@
<template>
<FaDialog
v-model="visible"
title="租户个性化配置"
width="700px"
form-mode="update"
:confirm-loading="loading"
@confirm="handleSubmit"
@cancel="visible = false"
>
<ElTabs v-model="activeTab" type="border-card">
<!-- 网站配置 -->
<ElTabPane label="网站信息" name="website">
<FaForm
v-model="formData.website"
:items="websiteItems"
:label-width="120"
label-position="right"
:span="24"
:show-reset="false"
:show-submit="false"
/>
</ElTabPane>
<!-- 品牌标识 -->
<ElTabPane label="品牌标识" name="brand">
<ElForm :model="formData.brand" label-position="top" class="brand-form">
<ElRow :gutter="24">
<ElCol :span="8">
<ElFormItem label="网站 Logo">
<FaUpload
v-model="formData.brand.tenant_logo"
:data="{ type: 'tenant_logo' }"
name="file"
:max-file-size="5"
:show-tip="true"
:enable-preview="true"
:enable-crop="true"
v-bind="brandCropBind('tenant_logo')"
@success="
(fileInfo: UploadFilePath) => handleUploadSuccess(fileInfo, 'tenant_logo')
"
@error="handleUploadError"
/>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem label="Favicon">
<FaUpload
v-model="formData.brand.tenant_favicon"
:data="{ type: 'tenant_favicon' }"
name="file"
:max-file-size="5"
:show-tip="true"
:enable-preview="true"
:enable-crop="true"
v-bind="brandCropBind('tenant_favicon')"
@success="
(fileInfo: UploadFilePath) => handleUploadSuccess(fileInfo, 'tenant_favicon')
"
@error="handleUploadError"
/>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem label="登录背景图">
<FaUpload
v-model="formData.brand.tenant_login_bg"
:data="{ type: 'tenant_login_bg' }"
name="file"
:max-file-size="10"
:show-tip="true"
:enable-preview="true"
:enable-crop="true"
v-bind="brandCropBind('tenant_login_bg')"
@success="
(fileInfo: UploadFilePath) => handleUploadSuccess(fileInfo, 'tenant_login_bg')
"
@error="handleUploadError"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :span="8">
<ElFormItem label="主题色">
<ElColorPicker
v-model="formData.brand.theme_color"
show-alpha
:predefine="predefineColors"
/>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem label="默认语言">
<ElInput v-model="formData.brand.default_language" placeholder="zh-CN" />
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ElTabPane>
<!-- 安全隐私 -->
<ElTabPane label="安全隐私" name="security">
<FaForm
v-model="formData.security"
:items="securityItems"
:label-width="120"
label-position="right"
:span="24"
:show-reset="false"
:show-submit="false"
/>
</ElTabPane>
<!-- 用户协议 -->
<ElTabPane label="用户协议" name="agreement">
<FaForm
v-model="formData.agreement"
:items="agreementItems"
:label-width="120"
label-position="right"
:span="24"
:show-reset="false"
:show-submit="false"
/>
</ElTabPane>
</ElTabs>
</FaDialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import { ElMessage, ElColorPicker, ElCol, ElForm, ElFormItem, ElInput, ElRow } from "element-plus";
import TenantAPI, { type TenantConfigItem } from "@/api/module_system/tenant";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaUpload from "@/components/others/fa-upload/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
defineOptions({ name: "TenantConfigDialog" });
const props = defineProps<{ tenantId: number | null }>();
const emit = defineEmits<{ (e: "saved"): void }>();
const visible = defineModel<boolean>({ required: true });
const loading = ref(false);
const activeTab = ref("website");
const formData = reactive({
website: { tenant_name: "", tenant_version: "", tenant_description: "" },
brand: {
tenant_logo: "",
tenant_favicon: "",
tenant_login_bg: "",
theme_color: "#409EFF",
default_language: "zh-CN",
},
security: {
tenant_help_doc: "",
tenant_git_code: "",
tenant_keep_record: "",
tenant_copyright: "",
tenant_privacy: "",
},
agreement: { tenant_clause: "" },
});
const websiteItems: FormItem[] = [
{ key: "tenant_name", label: "网站名称", type: "input", placeholder: "企业/租户显示名称" },
{ key: "tenant_version", label: "版本号", type: "input", placeholder: "如 v3.0.0" },
{
key: "tenant_description",
label: "网站描述",
type: "textarea",
placeholder: "SEO 描述或企业简介",
},
];
const securityItems: FormItem[] = [
{ key: "tenant_help_doc", label: "帮助文档", type: "input", placeholder: "帮助文档链接" },
{ key: "tenant_git_code", label: "源码地址", type: "input", placeholder: "Git 仓库地址" },
{ key: "tenant_keep_record", label: "备案号", type: "input", placeholder: "ICP 备案号" },
{ key: "tenant_copyright", label: "版权信息", type: "input", placeholder: "© 2024 Company Name" },
{ key: "tenant_privacy", label: "隐私政策", type: "input", placeholder: "隐私政策链接或内容" },
];
const agreementItems: FormItem[] = [
{ key: "tenant_clause", label: "用户协议", type: "textarea", placeholder: "注册/使用协议内容" },
];
const predefineColors = [
"#409EFF",
"#337ecc",
"#67C23A",
"#529b2e",
"#E6A23C",
"#b88230",
"#F56C6C",
"#c45656",
"#909399",
"#73767a",
"#B37FEB",
"#8f5cc7",
];
function brandCropBind(key: string) {
switch (key) {
case "tenant_favicon":
return {
cropCutWidth: 64,
cropCutHeight: 64,
cropBoxWidth: 380,
cropBoxHeight: 320,
cropDialogTitle: "裁剪网站图标",
cropInnerTitle: "调整图标",
cropPreviewTitle: "预览",
};
case "tenant_logo":
return {
cropCutWidth: 320,
cropCutHeight: 96,
cropBoxWidth: 520,
cropBoxHeight: 360,
cropDialogTitle: "裁剪站点 Logo",
cropInnerTitle: "调整 Logo",
cropPreviewTitle: "预览",
};
case "tenant_login_bg":
return {
cropCutWidth: 960,
cropCutHeight: 540,
cropBoxWidth: 560,
cropBoxHeight: 380,
cropDialogTitle: "裁剪登录背景",
cropInnerTitle: "调整背景图",
cropPreviewTitle: "预览",
cropFileType: "jpeg" as const,
};
default:
return {};
}
}
function handleUploadSuccess(fileInfo: UploadFilePath, key: string) {
const fileUrl = fileInfo.file_url;
if (key in formData.brand) {
(formData.brand as Record<string, string>)[key] = fileUrl;
}
}
function handleUploadError(error: any) {
console.error("上传失败:", error.message || "未知错误");
ElMessage.error(`上传失败:${error.message || "请稍后重试"}`);
}
/** 从后端加载租户已有配置 */
watch(
() => [props.tenantId, visible.value],
async ([id, show]) => {
if (id && show) {
try {
const res = await TenantAPI.getTenantConfig(id as number);
const items = res.data?.data ?? res.data ?? [];
const map: Record<string, string> = {};
for (const item of items) {
map[item.config_key] = item.config_value;
}
formData.website = {
tenant_name: map.tenant_name || "",
tenant_version: map.tenant_version || "",
tenant_description: map.tenant_description || "",
};
formData.brand = {
tenant_logo: map.tenant_logo || "",
tenant_favicon: map.tenant_favicon || "",
tenant_login_bg: map.tenant_login_bg || "",
theme_color: map.theme_color || "#409EFF",
default_language: map.default_language || "zh-CN",
};
formData.security = {
tenant_help_doc: map.tenant_help_doc || "",
tenant_git_code: map.tenant_git_code || "",
tenant_keep_record: map.tenant_keep_record || "",
tenant_copyright: map.tenant_copyright || "",
tenant_privacy: map.tenant_privacy || "",
};
formData.agreement = { tenant_clause: map.tenant_clause || "" };
} catch {
ElMessage.error("获取配置信息失败");
}
}
}
);
/** 收集所有分组中有值的配置项,提交到后端 */
async function handleSubmit() {
if (!props.tenantId) return;
loading.value = true;
try {
const items: TenantConfigItem[] = [];
const groups: Record<string, Record<string, string>> = {
website: formData.website,
brand: formData.brand,
security: formData.security,
agreement: formData.agreement,
};
for (const [, fields] of Object.entries(groups)) {
for (const [key, val] of Object.entries(fields)) {
if (val !== "") {
items.push({ config_key: key, config_value: val as string, config_type: "string" });
}
}
}
await TenantAPI.updateTenantConfig(props.tenantId, items);
ElMessage.success("配置更新成功");
emit("saved");
visible.value = false;
} catch {
ElMessage.error("配置更新失败");
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.brand-form {
max-width: 100%;
}
</style>
@@ -1,84 +0,0 @@
<template>
<FaDialog
v-model="visible"
title="租户菜单权限"
width="560px"
form-mode="update"
:confirm-loading="loading"
@confirm="handleSubmit"
@cancel="visible = false"
>
<ElTree
ref="treeRef"
:data="menuTree"
show-checkbox
node-key="id"
:default-checked-keys="checkedMenuIds"
:props="{ children: 'children', label: 'name' }"
default-expand-all
style="max-height: 50vh; overflow-y: auto"
/>
</FaDialog>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { ElMessage, ElTree } from "element-plus";
import TenantAPI from "@/api/module_system/tenant";
import MenuAPI from "@/api/module_system/menu";
defineOptions({ name: "TenantMenuDialog" });
const props = defineProps<{ tenantId: number | null }>();
const emit = defineEmits<{ (e: "saved"): void }>();
const visible = defineModel<boolean>({ required: true });
const loading = ref(false);
const treeRef = ref<InstanceType<typeof ElTree>>();
const menuTree = ref<any[]>([]);
const checkedMenuIds = ref<number[]>([]);
watch(
() => [props.tenantId, visible.value],
async ([id, show]) => {
if (id && show) {
try {
const [menuRes, checkedRes] = await Promise.all([
MenuAPI.listMenu(),
TenantAPI.getTenantMenus(id as number),
]);
const menuData = (menuRes.data?.data ?? menuRes.data) as any;
if (Array.isArray(menuData)) {
menuTree.value = menuData;
} else if (Array.isArray(menuData?.records)) {
menuTree.value = menuData.records;
}
const checkedData = (checkedRes.data?.data ?? checkedRes.data) as any;
if (Array.isArray(checkedData)) {
checkedMenuIds.value = checkedData;
}
} catch {
ElMessage.error("获取菜单数据失败");
}
}
}
);
async function handleSubmit() {
if (!props.tenantId || !treeRef.value) return;
loading.value = true;
try {
const keys = treeRef.value.getCheckedKeys() as number[];
const halfKeys = treeRef.value.getHalfCheckedKeys() as number[];
await TenantAPI.setTenantMenus(props.tenantId, [...keys, ...halfKeys]);
ElMessage.success("菜单权限设置成功");
emit("saved");
visible.value = false;
} catch {
ElMessage.error("菜单权限设置失败");
} finally {
loading.value = false;
}
}
</script>
@@ -1,99 +0,0 @@
<template>
<FaDialog
v-model="visible"
title="租户配额设置"
width="520px"
form-mode="update"
:confirm-loading="loading"
@confirm="handleSubmit"
@cancel="visible = false"
>
<FaForm
ref="formRef"
v-model="formData"
:items="quotaFormItems"
:rules="rules"
:label-width="120"
label-position="right"
:span="24"
:show-reset="false"
:show-submit="false"
/>
</FaDialog>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import TenantAPI from "@/api/module_system/tenant";
import FaForm from "@/components/forms/fa-form/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
defineOptions({ name: "TenantQuotaDialog" });
const props = defineProps<{ tenantId: number | null }>();
const emit = defineEmits<{ (e: "saved"): void }>();
const visible = defineModel<boolean>({ required: true });
const loading = ref(false);
const formRef = ref<{ validate: () => Promise<boolean> }>();
const formData = ref({ max_users: 50, max_roles: 20, max_storage_mb: 500, max_depts: 50 });
const quotaFormItems: FormItem[] = [
{ key: "max_users", label: "最大用户数", type: "input-number", props: { min: 0, max: 9999 } },
{ key: "max_roles", label: "最大角色数", type: "input-number", props: { min: 0, max: 9999 } },
{
key: "max_storage_mb",
label: "最大存储(MB)",
type: "input-number",
props: { min: 0, max: 999999 },
},
{ key: "max_depts", label: "最大部门数", type: "input-number", props: { min: 0, max: 9999 } },
];
const rules = {
max_users: [{ required: true, message: "请输入最大用户数" }],
max_roles: [{ required: true, message: "请输入最大角色数" }],
max_storage_mb: [{ required: true, message: "请输入最大存储" }],
max_depts: [{ required: true, message: "请输入最大部门数" }],
};
watch(
() => [props.tenantId, visible.value],
async ([id, show]) => {
if (id && show) {
try {
const res = await TenantAPI.getTenantQuota(id as number);
const quota = res.data?.data ?? res.data;
if (quota) {
formData.value = {
max_users: quota.max_users,
max_roles: quota.max_roles,
max_storage_mb: quota.max_storage_mb,
max_depts: quota.max_depts,
};
}
} catch {
ElMessage.error("获取配额信息失败");
}
}
}
);
async function handleSubmit() {
if (!props.tenantId) return;
if (formRef.value && !(await formRef.value.validate())) return;
loading.value = true;
try {
await TenantAPI.updateTenantQuota(props.tenantId, formData.value);
ElMessage.success("配额更新成功");
emit("saved");
visible.value = false;
} catch {
ElMessage.error("配额更新失败");
} finally {
loading.value = false;
}
}
</script>
@@ -1,781 +0,0 @@
<!-- 租户管理:Art + useTable;操作列前 3 个为 FaButtonTable,其余收入「更多」下拉 -->
<template>
<div class="fa-full-height">
<FaSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="tenantSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
<ElCard
shadow="hover"
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>
<FaTableHeaderLeft
:remove-ids="selectedIds"
:perm-create="['module_system:tenant:create']"
:perm-delete="['module_system:tenant:delete']"
:delete-loading="batchDeleting"
@add="handleOpenDialog('create')"
@delete="handleBatchDelete"
/>
</template>
</FaTableHeader>
<FaTable
ref="faTableRef"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="onTableSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
<FaDialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
width="720px"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
:form-mode="dialogVisible.type"
:confirm-loading="submitLoading"
@cancel="handleCloseDialog"
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
>
<template v-if="dialogVisible.type === 'detail'">
<FaDescriptions
:column="2"
:data="detailFormData"
:items="tenantDetailItems"
max-height="75vh"
/>
</template>
<template v-else>
<FaForm
:key="tenantFormRenderKey"
scrollbar
max-height="75vh"
ref="dataFormRef"
v-model="formData"
:items="tenantDialogFormItems"
: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"
/>
</template>
</FaDialog>
<TenantQuotaDialog v-model="quotaVisible" :tenant-id="currentTenantId" @saved="refreshData" />
<TenantConfigDialog v-model="configVisible" :tenant-id="currentTenantId" @saved="refreshData" />
<TenantMenuDialog v-model="menuVisible" :tenant-id="currentTenantId" @saved="refreshData" />
</div>
</template>
<script setup lang="ts">
import { useTable } from "@/hooks/core/useTable";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
import type { ColumnOption } from "@/types/component";
import TenantAPI, {
type TenantCreateForm,
type TenantForm,
type TenantTable,
type TenantUpdateForm,
} from "@/api/module_system/tenant";
import TenantQuotaDialog from "./components/TenantQuotaDialog.vue";
import TenantConfigDialog from "./components/TenantConfigDialog.vue";
import TenantMenuDialog from "./components/TenantMenuDialog.vue";
import { useAuth } from "@/hooks/core/useAuth";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaButtonTable from "@/components/forms/fa-button-table/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import {
ElTag,
ElMessage,
ElTooltip,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
} from "element-plus";
defineOptions({
name: "Tenant",
inheritAttrs: false,
});
const MAX_INLINE_ROW_ACTIONS = 3;
const { hasAuth } = useAuth();
type TenantSearchForm = {
name?: string;
code?: string;
status?: string;
created_time?: string[];
};
function buildTenantReplaceParams(p: TenantSearchForm): Record<string, unknown> {
return {
name: p.name,
code: p.code,
status: p.status,
created_time:
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
};
}
type RowAction = {
key: string;
label: string;
artType: "add" | "edit" | "delete" | "view" | "more";
icon?: string;
perm: string;
disabled?: boolean;
run: () => void;
};
function buildTenantRowActions(
row: TenantTable,
ctx: {
onDetail: (id: number) => void;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
}
): RowAction[] {
const all: RowAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:tenant:query",
run: () => ctx.onDetail(row.id!),
},
{
key: "edit",
label: "编辑",
artType: "edit",
perm: "module_system:tenant:update",
run: () => ctx.onEdit(row.id!),
},
{
key: "delete",
label: "删除",
artType: "delete",
perm: "module_system:tenant:delete",
run: () => ctx.onDelete(row.id!),
},
{
key: "quota",
label: "配额",
artType: "edit",
perm: "module_system:tenant:update",
run: () => openQuotaDialog(row.id!),
},
{
key: "config",
label: "配置",
artType: "edit",
perm: "module_system:tenant:update",
run: () => openConfigDialog(row.id!),
},
{
key: "menu",
label: "菜单",
artType: "edit",
perm: "module_system:tenant:update",
run: () => openMenuDialog(row.id!),
},
];
return all.filter((a) => hasAuth(a.perm));
}
function formatTenantOperationCell(
row: TenantTable,
ctx: Parameters<typeof buildTenantRowActions>[1]
) {
const actions = buildTenantRowActions(row, ctx);
if (actions.length === 0) {
return h("span", { class: "text-g-400" }, "—");
}
const inline = actions.slice(0, MAX_INLINE_ROW_ACTIONS);
const overflow = actions.slice(MAX_INLINE_ROW_ACTIONS);
const inlineNodes = inline.map((a) =>
h(ElTooltip, { content: a.label, placement: "top" }, () =>
h("span", { class: "inline-flex" }, [
h(FaButtonTable, {
type: a.artType,
icon: a.icon,
onClick: a.run,
}),
])
)
);
if (overflow.length === 0) {
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 tenant-table-actions" },
inlineNodes
);
}
const dropdown = h(ElTooltip, { content: "更多", placement: "top" }, () =>
h(
ElDropdown,
{ trigger: "click" },
{
default: () =>
h("span", { class: "inline-flex align-middle" }, [
h(FaButtonTable, {
type: "more",
onClick: () => {},
}),
]),
dropdown: () =>
h(ElDropdownMenu, null, () =>
overflow.map((a) =>
h(
ElDropdownItem,
{
key: a.key,
disabled: a.disabled,
onClick: () => a.run(),
},
() => a.label
)
)
),
}
)
);
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 tenant-table-actions" },
[...inlineNodes, dropdown]
);
}
const searchForm = ref<TenantSearchForm>({
name: undefined,
code: undefined,
status: undefined,
created_time: undefined,
});
const showSearchBar = ref(true);
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
const searchBarRules: Record<string, unknown> = {};
const statusOptions = ref([
{ label: "正常", value: "0" },
{ label: "禁用", value: "1" },
]);
const tenantSearchItems = computed<SearchFormItem[]>(() => [
{
label: "租户名称",
key: "name",
type: "input",
placeholder: "请输入租户名称",
clearable: true,
span: 6,
},
{
label: "租户编码",
key: "code",
type: "input",
placeholder: "请输入租户编码",
clearable: true,
span: 6,
},
{
label: "状态",
key: "status",
type: "select",
props: {
placeholder: "请选择状态",
options: statusOptions.value,
clearable: true,
},
span: 6,
},
{
label: "创建时间",
key: "created_time",
type: "datetimerange",
span: 6,
props: {
type: "datetimerange",
rangeSeparator: "至",
startPlaceholder: "开始日期",
endPlaceholder: "结束日期",
format: "YYYY-MM-DD HH:mm:ss",
valueFormat: "YYYY-MM-DD HH:mm:ss",
style: { width: "100%" },
},
},
]);
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
// ─── 表格多选 ───
const { selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection<TenantTable>();
async function deleteTenantRow(id: number) {
try {
await confirmDelete();
await TenantAPI.deleteTenant([id]);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
}
}
const opCtx = {
onDetail: (id: number) => void handleOpenDialog("detail", id),
onEdit: (id: number) => void handleOpenDialog("update", id),
onDelete: deleteTenantRow,
};
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
refreshCreate,
refreshUpdate,
refreshRemove,
} = useTable({
core: {
apiFn: TenantAPI.listTenant,
apiParams: {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<TenantTable>[] => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "租户名称", minWidth: 80, showOverflowTooltip: true },
{ prop: "code", label: "租户编码", minWidth: 80, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 88,
formatter: (row: TenantTable) =>
h(ElTag, { type: row.status === "0" ? "success" : "danger" }, () =>
row.status === "0" ? "正常" : "禁用"
),
},
{ prop: "contact_name", label: "联系人", minWidth: 100, showOverflowTooltip: true },
{ prop: "contact_phone", label: "联系电话", width: 100, showOverflowTooltip: true },
{ prop: "start_time", label: "开始时间", width: 100, showOverflowTooltip: true },
{ prop: "end_time", label: "结束时间", width: 100, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 130, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 138, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 220,
fixed: "right",
align: "right",
formatter: (row: TenantTable) => formatTenantOperationCell(row, opCtx),
},
],
},
});
const detailFormData = ref<TenantTable>({ code: "", name: "", status: "0" });
const tenantDetailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] =
[
{ label: "租户名称", prop: "name" },
{ label: "租户编码", prop: "code" },
{
label: "状态",
prop: "status",
span: 1,
tag: {
map: { "0": { type: "success", text: "正常" }, "1": { type: "danger", text: "禁用" } },
},
},
{ label: "联系人", prop: "contact_name", span: 1 },
{ label: "联系电话", prop: "contact_phone", span: 1 },
{ label: "联系邮箱", prop: "contact_email", span: 1 },
{ label: "地址", prop: "address", span: 2 },
{ label: "域名", prop: "domain", span: 1 },
{ label: "Logo", prop: "logo_url", span: 1 },
{ label: "排序", prop: "sort", span: 1 },
{ label: "开始时间", prop: "start_time", span: 1 },
{ label: "结束时间", prop: "end_time", span: 1 },
{ label: "描述", prop: "description" },
{ label: "创建时间", prop: "created_time" },
];
const formData = ref<TenantForm>({
name: "",
code: "",
status: "0",
description: "",
start_time: undefined,
end_time: undefined,
contact_name: "",
contact_phone: "",
contact_email: "",
address: "",
domain: "",
logo_url: "",
sort: 0,
});
// ─── 对话框状态 ───
const { dialogVisible } = useCrudDialog();
// P1 功能状态
const currentTenantId = ref<number | null>(null);
const quotaVisible = ref(false);
const configVisible = ref(false);
const menuVisible = ref(false);
function openQuotaDialog(id: number) {
currentTenantId.value = id;
quotaVisible.value = true;
}
function openConfigDialog(id: number) {
currentTenantId.value = id;
configVisible.value = true;
}
function openMenuDialog(id: number) {
currentTenantId.value = id;
menuVisible.value = true;
}
const CODE_PATTERN = /^[A-Za-z0-9]+$/;
const validateTimeRange = (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
if (
formData.value.start_time &&
formData.value.end_time &&
formData.value.start_time > formData.value.end_time
) {
callback(new Error("结束时间不能早于开始时间"));
} else {
callback();
}
};
const rules = reactive({
name: [{ required: true, message: "请输入租户名称", trigger: "blur" }],
code: [
{ required: true, message: "请输入租户编码", trigger: "blur" },
{
pattern: CODE_PATTERN,
message: "编码仅允许字母与数字",
trigger: "blur",
},
],
end_time: [{ validator: validateTimeRange, trigger: "change" }],
});
const initialFormData: TenantForm = {
name: "",
code: "",
status: "0",
description: "",
start_time: undefined,
end_time: undefined,
contact_name: "",
contact_phone: "",
contact_email: "",
address: "",
domain: "",
logo_url: "",
sort: 0,
};
const dataFormRef = ref<InstanceType<typeof FaForm> | null>(null);
const submitLoading = ref(false);
const tenantFormRenderKey = ref(0);
async function handleOpenDialog(type: "create" | "update" | "detail", id?: number) {
dialogVisible.type = type;
if (id) {
const detailRes = await TenantAPI.detailTenant(id);
if (type === "detail") {
dialogVisible.title = "租户详情";
Object.assign(detailFormData.value, detailRes.data.data);
} else if (type === "update") {
dialogVisible.title = "修改租户";
Object.assign(formData.value, detailRes.data.data);
}
} else {
dialogVisible.title = "新增租户";
Object.assign(formData.value, initialFormData);
formData.value.id = undefined;
}
tenantFormRenderKey.value += 1;
dialogVisible.visible = true;
}
async function handleCloseDialog() {
dialogVisible.visible = false;
dataFormRef.value?.resetFields();
dataFormRef.value?.clearValidate();
Object.assign(formData.value, initialFormData);
}
const tenantDialogFormItems = computed<FormItem[]>(() => [
{
label: "租户名称",
key: "name",
type: "input",
span: 12,
props: { placeholder: "请输入租户名称", maxlength: 100 },
},
{
label: "租户编码",
key: "code",
type: "input",
span: 12,
props: {
placeholder: "字母与数字,创建后不可改",
maxlength: 100,
disabled: dialogVisible.type === "update",
},
},
{
label: "状态",
key: "status",
type: "select",
span: 12,
props: {
placeholder: "请选择状态",
style: { width: "100%" },
options: [
{ label: "正常", value: "0" },
{ label: "禁用", value: "1" },
],
},
},
{
label: "排序",
key: "sort",
type: "number",
span: 12,
props: { placeholder: "请输入排序值", min: 0, style: { width: "100%" } },
},
{
label: "联系人",
key: "contact_name",
type: "input",
span: 12,
props: { placeholder: "请输入联系人姓名", maxlength: 64 },
},
{
label: "联系电话",
key: "contact_phone",
type: "input",
span: 12,
props: { placeholder: "请输入联系电话", maxlength: 20 },
},
{
label: "联系邮箱",
key: "contact_email",
type: "input",
span: 12,
props: { placeholder: "请输入联系邮箱", maxlength: 128 },
},
{
label: "域名",
key: "domain",
type: "input",
span: 12,
props: { placeholder: "请输入域名", maxlength: 255 },
},
{
label: "开始时间",
key: "start_time",
type: "datetime",
span: 12,
props: {
style: { width: "100%" },
placeholder: "可选",
type: "datetime",
valueFormat: "YYYY-MM-DD HH:mm:ss",
},
},
{
label: "结束时间",
key: "end_time",
type: "datetime",
span: 12,
props: {
style: { width: "100%" },
placeholder: "可选",
type: "datetime",
valueFormat: "YYYY-MM-DD HH:mm:ss",
},
},
{
label: "地址",
key: "address",
type: "input",
span: 24,
props: { placeholder: "请输入地址", maxlength: 255 },
},
{
label: "描述",
key: "description",
type: "input",
span: 24,
props: {
type: "textarea",
rows: 3,
maxlength: 255,
placeholder: "请输入描述",
},
},
]);
async function handleSearchBarSearch(params: TenantSearchForm) {
await searchBarRef.value?.validate?.();
replaceSearchParams(buildTenantReplaceParams(params));
getData();
}
function onResetSearch() {
searchForm.value = {
name: undefined,
code: undefined,
status: undefined,
created_time: undefined,
};
void resetSearchParams();
}
async function handleSubmit() {
const formRef = dataFormRef.value;
if (!formRef) return;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const valid = await formRef.validate().catch(() => false);
if (!valid) return;
submitLoading.value = true;
const id = formData.value.id as number | undefined;
try {
if (id) {
const payload: TenantUpdateForm = {
name: formData.value.name,
start_time: formData.value.start_time,
end_time: formData.value.end_time,
contact_name: formData.value.contact_name,
contact_phone: formData.value.contact_phone,
contact_email: formData.value.contact_email,
address: formData.value.address,
domain: formData.value.domain,
logo_url: formData.value.logo_url,
sort: formData.value.sort,
};
await TenantAPI.updateTenant(id, payload);
await refreshUpdate();
ElMessage.success("修改成功");
} else {
const payload: TenantCreateForm = {
name: formData.value.name as string,
code: formData.value.code as string,
status: formData.value.status,
description: formData.value.description,
start_time: formData.value.start_time,
end_time: formData.value.end_time,
contact_name: formData.value.contact_name,
contact_phone: formData.value.contact_phone,
contact_email: formData.value.contact_email,
address: formData.value.address,
domain: formData.value.domain,
logo_url: formData.value.logo_url,
sort: formData.value.sort,
};
const res: any = await TenantAPI.createTenant(payload);
const newId = res?.data?.data?.id;
await refreshCreate();
ElMessage.success("创建成功,请为该租户分配菜单权限");
dialogVisible.visible = false;
if (newId) {
currentTenantId.value = newId;
menuVisible.value = true;
}
return;
}
dialogVisible.visible = false;
dataFormRef.value?.resetFields();
dataFormRef.value?.clearValidate();
Object.assign(formData.value, initialFormData);
} catch (error: unknown) {
console.error(error);
} finally {
submitLoading.value = false;
}
}
async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
batchDeleting.value = true;
await TenantAPI.deleteTenant(ids);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
} finally {
batchDeleting.value = false;
}
}
</script>
<style scoped lang="scss">
::deep(.tenant-table-actions .inline-flex) {
vertical-align: middle;
}
</style>
@@ -146,10 +146,10 @@
</template>
<template #status>
<ElRadioGroup v-model="formData.status">
<ElRadio value="0">待处理</ElRadio>
<ElRadio value="1">处理中</ElRadio>
<ElRadio value="2">已完成</ElRadio>
<ElRadio value="3">已关闭</ElRadio>
<ElRadio :value="0">待处理</ElRadio>
<ElRadio :value="1">处理中</ElRadio>
<ElRadio :value="2">已完成</ElRadio>
<ElRadio :value="3">已关闭</ElRadio>
</ElRadioGroup>
</template>
<template #assigned_id>
@@ -418,7 +418,7 @@ const formData = ref<TicketForm & { reply_content?: string }>({
title: "",
ticket_type: "suggestion",
ticket_content: "",
status: "0",
status: 0,
description: undefined,
assigned_id: undefined,
reply_content: undefined,
@@ -439,7 +439,7 @@ const initialFormData: TicketForm & { reply_content?: string } = {
title: "",
ticket_type: "suggestion",
ticket_content: "",
status: "0",
status: 0,
description: undefined,
assigned_id: undefined,
reply_content: undefined,
@@ -454,7 +454,7 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = use
dialogVisible,
dataFormRef,
formRenderKey: ticketFormRenderKey,
detailApi: TicketAPI.detailTicket as any,
detailApi: TicketAPI.detailTicket,
createApi: TicketAPI.createTicket,
updateApi: TicketAPI.updateTicket,
titles: { create: "提交工单", update: "处理工单", detail: "工单详情" },
@@ -557,7 +557,7 @@ const {
label: "状态",
width: 100,
formatter: (row: TicketTable) =>
h(ElTag, { type: statusTag(row.status) }, () => statusLabel(row.status)),
h(ElTag, { type: statusTag(String(row.status)) }, () => statusLabel(String(row.status))),
},
{
prop: "assigned_by",
@@ -662,7 +662,6 @@ async function deleteTicketRow(id: number) {
try {
await confirmDelete();
await TicketAPI.deleteTicket([id]);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -723,7 +722,7 @@ function formatTicketOperationCell(row: TicketTable) {
async function closeTicket(id: number) {
try {
await TicketAPI.updateTicket(id, { status: "3" });
await TicketAPI.updateTicket(id, { status: 3 });
ElMessage.success("工单已关闭");
await refreshData();
} catch {
@@ -738,7 +737,6 @@ async function handleBatchDelete() {
await confirmBatchDelete(ids.length);
batchDeleting.value = true;
await TicketAPI.deleteTicket(ids);
ElMessage.success("删除成功");
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -56,11 +56,12 @@ import DeptAPI, { DeptPageQuery } from "@/api/module_system/dept";
import { formatTree } from "@utils";
import type { FilterNodeMethodFunction, TreeInstance } from "element-plus";
const props = defineProps({
modelValue: {
type: [String, Number],
default: undefined,
},
interface Props {
modelValue?: string | number;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: undefined,
});
const deptOptions = ref<OptionType[]>([]); // 部门列表
@@ -540,7 +540,6 @@ async function handleResetPassword(row: UserInfo) {
return;
}
await UserAPI.resetUserPassword({ id: row.id!, password: value });
ElMessage.success("密码已重置");
} catch {
// 用户取消
}
@@ -618,8 +617,8 @@ const {
label: "状态",
width: 88,
formatter: (row: UserInfo) =>
h(ElTag, { type: row.status === "0" ? "success" : "danger" }, () =>
row.status === "0" ? "启用" : "停用"
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
},
{
@@ -724,7 +723,7 @@ const formData = ref<UserForm>({
email: undefined,
mobile: undefined,
is_superuser: false,
status: "0",
status: 0,
description: undefined,
});
@@ -768,7 +767,7 @@ const initialFormData: UserForm = {
email: undefined,
mobile: undefined,
is_superuser: false,
status: "0",
status: 0,
description: undefined,
};
@@ -874,7 +873,7 @@ async function handleOpenDialog(type: "create" | "update" | "detail", id?: numbe
.map((item) => ({
value: item.id as number,
label: item.name as string,
disabled: item.status === "1",
disabled: item.status === 1,
}))
.filter((opt) => !opt.disabled);
@@ -885,7 +884,7 @@ async function handleOpenDialog(type: "create" | "update" | "detail", id?: numbe
.map((item) => ({
value: item.id as number,
label: item.name as string,
disabled: item.status === "1",
disabled: item.status === 1,
}))
.filter((opt) => !opt.disabled);
}