mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-07 18:30:55 +00:00
refactor: 大规模代码整理与功能优化
1. 重构后端API路由、CRUD与模块结构,整合日志管理,移除废弃demo代码 2. 优化前端组件类型定义、样式与路由配置,修复权限判断逻辑 3. 调整默认排序规则、滚动条样式与工具类函数,更新依赖与配置文件 4. 修复多处类型不匹配与默认值问题,完善表单与菜单验证逻辑
This commit is contained in:
@@ -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("操作取消");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user