mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-09 02:57:35 +00:00
chore: cleanup old frontend code and update project configs
This commit removes the deprecated frontend/web-old directory, updates various project configuration files including tsconfig, vite config, environment variables, and backend data models. It also fixes several API paths, adds tenant awareness to multiple models, and makes minor UI adjustments.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div class="login-page-root flex h-screen w-full flex-col overflow-hidden">
|
||||
<FaLoginCenterBackdrop v-if="panelAlign === 'center'" viewport-fixed />
|
||||
<AuthTopBar v-model:panel-align="panelAlign" />
|
||||
<FaAuthTopBar v-model:panel-align="panelAlign" />
|
||||
|
||||
<div
|
||||
class="login-auth-split relative z-1 flex min-h-0 flex-1 overflow-hidden"
|
||||
@@ -120,11 +120,11 @@
|
||||
rel="noopener noreferrer"
|
||||
class="login-page-footer__link"
|
||||
>
|
||||
{{ configStore.configData?.sys_web_copyright?.config_value || "" }}
|
||||
{{ configStore.configData?.tenant_copyright?.config_value || "" }}
|
||||
</a>
|
||||
<span class="login-page-footer__sep">|</span>
|
||||
<a
|
||||
:href="configStore.configData?.sys_help_doc?.config_value || '#'"
|
||||
:href="configStore.configData?.tenant_help_doc?.config_value || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="login-page-footer__link"
|
||||
@@ -133,7 +133,7 @@
|
||||
</a>
|
||||
<span class="login-page-footer__sep">|</span>
|
||||
<a
|
||||
:href="configStore.configData?.sys_web_privacy?.config_value || '#'"
|
||||
:href="configStore.configData?.tenant_privacy?.config_value || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="login-page-footer__link"
|
||||
@@ -142,7 +142,7 @@
|
||||
</a>
|
||||
<span class="login-page-footer__sep">|</span>
|
||||
<a
|
||||
:href="configStore.configData?.sys_web_clause?.config_value || '#'"
|
||||
:href="configStore.configData?.tenant_clause?.config_value || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="login-page-footer__link"
|
||||
@@ -150,10 +150,10 @@
|
||||
条款
|
||||
</a>
|
||||
<span
|
||||
v-if="configStore.configData?.sys_keep_record?.config_value"
|
||||
v-if="configStore.configData?.tenant_keep_record?.config_value"
|
||||
class="login-page-footer__record"
|
||||
>
|
||||
{{ configStore.configData.sys_keep_record.config_value }}
|
||||
{{ configStore.configData.tenant_keep_record.config_value }}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -180,6 +180,7 @@ import FaLoginForgetPanel from "@/components/views/fa-login/FaLoginForgetPanel.v
|
||||
import FaLoginMobilePanel from "@/components/views/fa-login/FaLoginMobilePanel.vue";
|
||||
import FaLoginQrPanel from "@/components/views/fa-login/FaLoginQrPanel.vue";
|
||||
import FaLoginRegisterPanel from "@/components/views/fa-login/FaLoginRegisterPanel.vue";
|
||||
import FaAuthTopBar from "@/components/views/fa-login/FaAuthTopBar.vue";
|
||||
import { useLoginPanelAlign } from "@/components/views/fa-login/useLoginPanelAlign";
|
||||
|
||||
defineOptions({ name: "Login" });
|
||||
@@ -225,7 +226,7 @@ const panelSubTitle = computed(() => {
|
||||
});
|
||||
|
||||
const userAgreementHref = computed(
|
||||
() => configStore.configData?.sys_web_clause?.config_value || "#"
|
||||
() => configStore.configData?.tenant_clause?.config_value || "#"
|
||||
);
|
||||
|
||||
function setAuthPanel(panel: AuthPanel) {
|
||||
@@ -592,7 +593,19 @@ const handleSubmit = async () => {
|
||||
|
||||
await userStore.login(loginForm);
|
||||
|
||||
await router.replace(resolveRedirectTarget(route.query));
|
||||
// 多租户:读取登录响应中的租户列表
|
||||
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));
|
||||
}
|
||||
|
||||
if (settingStore.showGuide) {
|
||||
appStore.showGuide(true);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<!-- 租户选择页:登录后选择当前操作的租户 -->
|
||||
<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>
|
||||
@@ -40,7 +40,7 @@
|
||||
@delete="handleBatchDelete"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<ElButton @click="toggleExpand">{{ isExpanded ? "收起" : "展开" }}</ElButton>
|
||||
<ElButton @click="toggleExpand" v-ripple>{{ isExpanded ? "收起" : "展开" }}</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
@@ -17,11 +17,7 @@
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElTabs
|
||||
v-model="menuClientTab"
|
||||
class="menu-client-tabs px-1 mb-2"
|
||||
@tab-change="handleMenuClientTabChange"
|
||||
>
|
||||
<ElTabs v-model="menuClientTab" @tab-change="handleMenuClientTabChange">
|
||||
<ElTabPane label="PC 桌面菜单管理" name="pc" />
|
||||
<ElTabPane label="APP 移动端菜单管理" name="app" />
|
||||
</ElTabs>
|
||||
@@ -49,7 +45,7 @@
|
||||
@delete="handleBatchDelete"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<ElButton @click="toggleExpand">{{ isExpanded ? "收起" : "展开" }}</ElButton>
|
||||
<ElButton @click="toggleExpand" v-ripple>{{ isExpanded ? "收起" : "展开" }}</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
@@ -126,6 +122,7 @@
|
||||
v-model="formData.parent_id"
|
||||
placeholder="选择上级菜单"
|
||||
:data="menuOptions"
|
||||
node-key="value"
|
||||
filterable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
<!-- 插件市场: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>
|
||||
@@ -68,7 +68,7 @@
|
||||
<FaDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="720px"
|
||||
width="640px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
:form-mode="dialogVisible.type"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
destroy-on-close
|
||||
@close="handleCancel"
|
||||
>
|
||||
<div class="drawer-perm-content flex flex-col min-h-0 flex-1 overflow-hidden">
|
||||
<div class="drawer-perm-content flex flex-col flex-1 overflow-hidden">
|
||||
<ElContainer class="h-full min-h-0 flex-1">
|
||||
<!-- 数据权限 -->
|
||||
<ElAside>
|
||||
@@ -15,10 +15,10 @@
|
||||
class="border-r border-r-(--el-border-color-lighter) b-r-solid h-full p-[20px] box-border"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div :style="'display: flex; gap: 10px'">
|
||||
<div :style="'width: 10px; background-color: var(--el-color-primary)'"></div>
|
||||
<div class="flex gap-[10px]">
|
||||
<div class="w-[10px] bg-(--el-color-primary)"></div>
|
||||
<div>
|
||||
<span :style="'font-size: 16px'">数据授权</span>
|
||||
<span class="text-[16px]">数据授权</span>
|
||||
<ElTooltip placement="right">
|
||||
<template #content>
|
||||
<span>授权用户可操作的数据范围</span>
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<div
|
||||
v-if="permissionState.data_scope === 5 && deptTreeData.length"
|
||||
class="mt-5 max-h-[60vh] 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 overflow-auto box-border"
|
||||
>
|
||||
<ElInput v-model="deptFilterText" placeholder="部门名称" />
|
||||
<ElTree
|
||||
@@ -71,13 +71,13 @@
|
||||
|
||||
<!-- 菜单权限 -->
|
||||
<ElMain>
|
||||
<div :style="'display: flex; gap: 10px'">
|
||||
<div :style="'width: 10px; background-color: var(--el-color-primary)'"></div>
|
||||
<div class="flex gap-[10px]">
|
||||
<div class="w-[10px] bg-(--el-color-primary)"></div>
|
||||
<div>
|
||||
<span :style="'font-size: 16px'">菜单授权</span>
|
||||
<span class="text-[16px]">菜单授权</span>
|
||||
<ElTooltip placement="right">
|
||||
<template #content>
|
||||
<span>授权用户可操作的菜单权限</span>
|
||||
<span>勾选菜单和对应的功能按钮权限</span>
|
||||
</template>
|
||||
<ElIcon class="ml-1 inline-block cursor-pointer">
|
||||
<QuestionFilled />
|
||||
@@ -85,53 +85,63 @@
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex-x-between">
|
||||
<ElInput v-model="permFilterText" placeholder="菜单名称" />
|
||||
<div class="flex-center ml-5">
|
||||
<ElButton type="primary" size="small" plain @click="togglePermTree">
|
||||
<template #icon>
|
||||
<SwitchIcon />
|
||||
</template>
|
||||
{{ isExpanded ? "收缩" : "展开" }}
|
||||
</ElButton>
|
||||
<ElCheckbox
|
||||
v-model="parentChildLinked"
|
||||
class="ml-5"
|
||||
@change="handleParentChildLinkedChange"
|
||||
>
|
||||
父子联动
|
||||
</ElCheckbox>
|
||||
|
||||
<ElTooltip placement="bottom">
|
||||
<template #content>
|
||||
如果只需勾选菜单权限,不需要勾选子菜单或者按钮权限,请关闭父子联动
|
||||
</template>
|
||||
<ElIcon class="ml-1 color-[--el-color-primary] inline-block cursor-pointer">
|
||||
<QuestionFilled />
|
||||
</ElIcon>
|
||||
</ElTooltip>
|
||||
</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-5 max-h-[69vh] b-1 b-solid b-[var(--el-border-color-lighter)] p-10px overflow-auto box-border"
|
||||
>
|
||||
<ElTree
|
||||
ref="permTreeRef"
|
||||
node-key="value"
|
||||
show-checkbox
|
||||
:data="menuTreeData"
|
||||
:filter-node-method="handleFilter"
|
||||
default-expand-all
|
||||
:highlight-current="true"
|
||||
:check-strictly="!parentChildLinked"
|
||||
:style="'height: calc(100% - 60px); margin-top: 10px; overflow: auto'"
|
||||
@check="menuTreeCheck"
|
||||
<!-- 菜单树表 -->
|
||||
<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"
|
||||
>
|
||||
<template #empty>
|
||||
<ElEmpty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
</ElTree>
|
||||
<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>
|
||||
</ElMain>
|
||||
</ElContainer>
|
||||
@@ -147,25 +157,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from "vue";
|
||||
import { computed, ref, watch, onMounted, nextTick, h } from "vue";
|
||||
import { QuestionFilled, Switch as SwitchIcon } from "@element-plus/icons-vue";
|
||||
import type { TreeInstance } from "element-plus";
|
||||
import type { TreeInstance, TableInstance, CheckboxValueType } from "element-plus";
|
||||
import FaDrawer from "@/components/modal/fa-drawer/index.vue";
|
||||
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
|
||||
import { listToTree, formatTree } from "@utils";
|
||||
import RoleAPI, {
|
||||
permissionDataType,
|
||||
permissionDeptType,
|
||||
permissionMenuType,
|
||||
} from "@/api/module_system/role";
|
||||
import RoleAPI, { permissionDataType, permissionDeptType } from "@/api/module_system/role";
|
||||
import DeptAPI from "@/api/module_system/dept";
|
||||
import MenuAPI from "@/api/module_system/menu";
|
||||
import MenuAPI, { MenuTable } from "@/api/module_system/menu";
|
||||
import { DeviceEnum } from "@/enums/settings/device.enum";
|
||||
import { useAppStore, useUserStore } from "@stores";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
/** formatTree 后的菜单节点带 value(同原 id),与接口里的 id 二选一存在 */
|
||||
type MenuTreeNode = permissionMenuType & { value?: number };
|
||||
|
||||
const props = defineProps<{
|
||||
roleName: string;
|
||||
roleId: number;
|
||||
@@ -178,7 +182,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "800px" : "90%"));
|
||||
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "1600px" : "90%"));
|
||||
|
||||
const drawerVisible = computed({
|
||||
get() {
|
||||
@@ -189,16 +193,277 @@ const drawerVisible = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const permTreeRef = ref<TreeInstance>();
|
||||
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 menuTreeData = ref<MenuTreeNode[]>([]);
|
||||
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 permissionState = ref<permissionDataType>({
|
||||
role_ids: [],
|
||||
menu_ids: [],
|
||||
@@ -214,25 +479,51 @@ const init = async () => {
|
||||
deptTreeData.value = formatTree(listToTree(deptResponse.data.data));
|
||||
|
||||
const menuResponse = await MenuAPI.listMenu();
|
||||
menuTreeData.value = formatTree(listToTree(menuResponse.data.data));
|
||||
const rawTree = menuResponse.data.data || [];
|
||||
rawMenuTree.value = rawTree;
|
||||
tableData.value = filterTableData(rawTree);
|
||||
|
||||
const roleResponse = await RoleAPI.detailRole(props.roleId);
|
||||
const savedMenuIds = roleResponse.data.data.menus?.map((menu) => menu.id) || [];
|
||||
|
||||
permissionState.value = {
|
||||
role_ids: [props.roleId],
|
||||
menu_ids: roleResponse.data.data.menus?.map((menu) => menu.id) || [],
|
||||
menu_ids: savedMenuIds,
|
||||
data_scope: roleResponse.data.data.data_scope || 1,
|
||||
dept_ids: roleResponse.data.data.depts?.map((dept) => dept.id) || [],
|
||||
};
|
||||
|
||||
parentChildLinked.value = checkParentChildLinked(
|
||||
permissionState.value.menu_ids,
|
||||
menuTreeData.value
|
||||
);
|
||||
parentChildLinked.value = checkParentChildLinked(savedMenuIds, rawMenuTree.value);
|
||||
|
||||
if (permTreeRef.value) {
|
||||
await permTreeRef.value.setCheckedKeys(permissionState.value.menu_ids);
|
||||
}
|
||||
// 设置表格选中状态和按钮选中状态
|
||||
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);
|
||||
@@ -257,8 +548,20 @@ async function handleDrawerSave() {
|
||||
}
|
||||
loading.value = true;
|
||||
|
||||
const rawChecked = (permTreeRef.value?.getCheckedKeys() || []).map((key) => Number(key));
|
||||
const menu_ids = expandMenuIdsWithAncestors(rawChecked, menuTreeData.value);
|
||||
// 收集所有选中的菜单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 submitData: permissionDataType = {
|
||||
role_ids: [props.roleId],
|
||||
@@ -285,21 +588,19 @@ const deptTreeCheck = (checkedIds: number[]) => {
|
||||
permissionState.value.dept_ids = checkedIds;
|
||||
};
|
||||
|
||||
const menuTreeCheck = (checkedIds: number[]) => {
|
||||
permissionState.value.menu_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;
|
||||
if (permTreeRef.value) {
|
||||
Object.values(permTreeRef.value.store.nodesMap).forEach((node: any) => {
|
||||
if (isExpanded.value) {
|
||||
node.expand();
|
||||
} else {
|
||||
node.collapse();
|
||||
}
|
||||
});
|
||||
}
|
||||
expandAllRows(isExpanded.value);
|
||||
}
|
||||
|
||||
watch(deptFilterText, (val) => {
|
||||
@@ -307,28 +608,34 @@ watch(deptFilterText, (val) => {
|
||||
});
|
||||
|
||||
watch(permFilterText, (val) => {
|
||||
permTreeRef.value!.filter(val);
|
||||
if (val) expandAllRows(true);
|
||||
});
|
||||
|
||||
function handleFilter(value: string, data: { [key: string]: any }) {
|
||||
if (!value) return true;
|
||||
return data.label.includes(value);
|
||||
return data.label?.includes(value);
|
||||
}
|
||||
|
||||
function menuTreeNodeId(node: MenuTreeNode): number {
|
||||
const v = node.value ?? node.id;
|
||||
return Number(v);
|
||||
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: MenuTreeNode[]): boolean {
|
||||
function checkParentChildLinked(menuIds: number[], menuTreeData: MenuTable[]): boolean {
|
||||
if (!menuIds.length || !menuTreeData.length) return false;
|
||||
|
||||
const menuMap = new Map<number, MenuTreeNode>();
|
||||
const buildMenuMap = (menus: MenuTreeNode[]) => {
|
||||
const menuMap = new Map<number, MenuTable>();
|
||||
const buildMenuMap = (menus: MenuTable[]) => {
|
||||
menus.forEach((menu) => {
|
||||
menuMap.set(menuTreeNodeId(menu), menu);
|
||||
menuMap.set(menu.id!, menu);
|
||||
if (menu.children) {
|
||||
buildMenuMap(menu.children as MenuTreeNode[]);
|
||||
buildMenuMap(menu.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -341,9 +648,7 @@ function checkParentChildLinked(menuIds: number[], menuTreeData: MenuTreeNode[])
|
||||
if (!menu) continue;
|
||||
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const hasUnselectedChildren = menu.children.some(
|
||||
(child) => !menuIds.includes(menuTreeNodeId(child as MenuTreeNode))
|
||||
);
|
||||
const hasUnselectedChildren = menu.children.some((child) => !menuIds.includes(child.id!));
|
||||
if (hasUnselectedChildren) {
|
||||
hasParentChildConflict = true;
|
||||
break;
|
||||
@@ -351,7 +656,7 @@ function checkParentChildLinked(menuIds: number[], menuTreeData: MenuTreeNode[])
|
||||
}
|
||||
|
||||
const parentMenu = findParentMenu(menuId, menuTreeData);
|
||||
if (parentMenu && !menuIds.includes(menuTreeNodeId(parentMenu))) {
|
||||
if (parentMenu && !menuIds.includes(parentMenu.id!)) {
|
||||
hasParentChildConflict = true;
|
||||
break;
|
||||
}
|
||||
@@ -360,29 +665,13 @@ function checkParentChildLinked(menuIds: number[], menuTreeData: MenuTreeNode[])
|
||||
return !hasParentChildConflict;
|
||||
}
|
||||
|
||||
function findParentMenu(menuId: number, menuTreeData: MenuTreeNode[]): MenuTreeNode | null {
|
||||
for (const menu of menuTreeData) {
|
||||
if (menu.children) {
|
||||
for (const child of menu.children) {
|
||||
if (menuTreeNodeId(child as MenuTreeNode) === menuId) {
|
||||
return menu;
|
||||
}
|
||||
const found = findParentMenu(menuId, [child as MenuTreeNode]);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 为已选菜单补齐所有祖先 id,避免仅选子节点时父级未入库导致侧栏/路由缺入口(BUG #1/#14) */
|
||||
function expandMenuIdsWithAncestors(checkedIds: number[], roots: MenuTreeNode[]): number[] {
|
||||
function expandMenuIdsWithAncestors(checkedIds: number[], roots: MenuTable[]): number[] {
|
||||
const parentById = new Map<number, number | undefined>();
|
||||
const walk = (nodes: MenuTreeNode[], parent: number | undefined) => {
|
||||
const walk = (nodes: MenuTable[], parent: number | undefined) => {
|
||||
for (const n of nodes) {
|
||||
const id = menuTreeNodeId(n);
|
||||
const id = n.id!;
|
||||
parentById.set(id, parent);
|
||||
if (n.children?.length) walk(n.children as MenuTreeNode[], id);
|
||||
if (n.children?.length) walk(n.children as MenuTable[], id);
|
||||
}
|
||||
};
|
||||
walk(roots, undefined);
|
||||
@@ -397,13 +686,7 @@ function expandMenuIdsWithAncestors(checkedIds: number[], roots: MenuTreeNode[])
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function handleParentChildLinkedChange(val: any) {
|
||||
parentChildLinked.value = val;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
/* ============================================================
|
||||
测试数据:与组件中 MenuTable 结构一致
|
||||
============================================================ */
|
||||
const DIR_TYPE = 1;
|
||||
const MENU_TYPE = 2;
|
||||
const BTN_TYPE = 3;
|
||||
const LINK_TYPE = 4;
|
||||
const PERM_TYPES = [BTN_TYPE, LINK_TYPE];
|
||||
|
||||
type MenuTable = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
type?: number;
|
||||
icon?: string;
|
||||
children?: MenuTable[];
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const tableNode = findTableNode(node.id!, mockTableData);
|
||||
const parentAncestors = findAncestors(node.id!, mockRawTree);
|
||||
if (tableNode) {
|
||||
return [tableNode, ...parentAncestors];
|
||||
}
|
||||
return parentAncestors;
|
||||
}
|
||||
const ancestors = findAncestors(nodeId, node.children);
|
||||
if (ancestors.length > 0) {
|
||||
return ancestors;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getMenuBtns(row: MenuTable, rawTree: MenuTable[]): MenuTable[] {
|
||||
const fullNode = findRawNode(row.id!, rawTree);
|
||||
if (!fullNode?.children) return [];
|
||||
return (fullNode.children as MenuTable[]).filter((c) => isPermType(c.type));
|
||||
}
|
||||
|
||||
function findParentMenu(menuId: number, menuTreeData: MenuTable[]): MenuTable | null {
|
||||
for (const menu of menuTreeData) {
|
||||
if (menu.children) {
|
||||
for (const child of menu.children) {
|
||||
if (child.id === menuId) return menu;
|
||||
const found = findParentMenu(menuId, [child]);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAnySelectedDescendant(
|
||||
node: MenuTable,
|
||||
checkedBtns: Record<number, number[]>,
|
||||
tableData: MenuTable[],
|
||||
): boolean {
|
||||
if (checkedBtns[node.id!]?.length > 0) return true;
|
||||
const tableNode = findTableNode(node.id!, tableData);
|
||||
if (tableNode?.children) {
|
||||
for (const child of tableNode.children) {
|
||||
if ((child.type === MENU_TYPE || child.type === DIR_TYPE) && hasAnySelectedDescendant(child, checkedBtns, tableData)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function expandMenuIdsWithAncestors(checkedIds: number[], roots: MenuTable[]): number[] {
|
||||
const parentById = new Map<number, number | undefined>();
|
||||
const walk = (nodes: MenuTable[], parent: number | undefined) => {
|
||||
for (const n of nodes) {
|
||||
const id = n.id!;
|
||||
parentById.set(id, parent);
|
||||
if (n.children?.length) walk(n.children as MenuTable[], id);
|
||||
}
|
||||
};
|
||||
walk(roots, undefined);
|
||||
const out = new Set<number>();
|
||||
for (const id of checkedIds) {
|
||||
let cur: number | undefined = id;
|
||||
while (cur !== undefined) {
|
||||
out.add(cur);
|
||||
cur = parentById.get(cur);
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
测试数据
|
||||
============================================================ */
|
||||
const mockRawTree: MenuTable[] = [
|
||||
{
|
||||
id: 1, name: "系统管理", type: DIR_TYPE, icon: "Setting",
|
||||
children: [
|
||||
{ id: 2, name: "用户管理", type: MENU_TYPE,
|
||||
children: [
|
||||
{ id: 21, name: "新增用户", type: BTN_TYPE },
|
||||
{ id: 22, name: "编辑用户", type: BTN_TYPE },
|
||||
{ id: 23, name: "删除用户", type: BTN_TYPE },
|
||||
],
|
||||
},
|
||||
{ id: 3, name: "角色管理", type: MENU_TYPE,
|
||||
children: [
|
||||
{ id: 31, name: "新增角色", type: BTN_TYPE },
|
||||
{ id: 32, name: "编辑角色", type: BTN_TYPE },
|
||||
],
|
||||
},
|
||||
{ id: 4, name: "菜单管理", type: MENU_TYPE, children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5, name: "业务管理", type: DIR_TYPE, icon: "DataAnalysis",
|
||||
children: [
|
||||
{ id: 6, name: "订单管理", type: MENU_TYPE,
|
||||
children: [
|
||||
{ id: 61, name: "查看订单", type: BTN_TYPE },
|
||||
{ id: 62, name: "导出订单", type: LINK_TYPE },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockTableData = filterTableData(mockRawTree);
|
||||
|
||||
/* ============================================================
|
||||
Test Suite: 纯逻辑函数
|
||||
============================================================ */
|
||||
describe("纯逻辑函数", () => {
|
||||
describe("isPermType", () => {
|
||||
it("应识别按钮类型", () => {
|
||||
expect(isPermType(BTN_TYPE)).toBe(true);
|
||||
});
|
||||
it("应识别链接类型", () => {
|
||||
expect(isPermType(LINK_TYPE)).toBe(true);
|
||||
});
|
||||
it("应排除目录类型", () => {
|
||||
expect(isPermType(DIR_TYPE)).toBe(false);
|
||||
});
|
||||
it("应排除菜单类型", () => {
|
||||
expect(isPermType(MENU_TYPE)).toBe(false);
|
||||
});
|
||||
it("应处理 undefined", () => {
|
||||
expect(isPermType(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterTableData", () => {
|
||||
it("应过滤掉所有按钮和链接节点", () => {
|
||||
const result = filterTableData(mockRawTree);
|
||||
const ids = flattenIds(result);
|
||||
expect(ids).not.toContain(21);
|
||||
expect(ids).not.toContain(22);
|
||||
expect(ids).not.toContain(31);
|
||||
expect(ids).not.toContain(61);
|
||||
expect(ids).not.toContain(62);
|
||||
});
|
||||
it("应保留目录和菜单节点", () => {
|
||||
const result = filterTableData(mockRawTree);
|
||||
const ids = flattenIds(result);
|
||||
expect(ids).toContain(1);
|
||||
expect(ids).toContain(2);
|
||||
expect(ids).toContain(3);
|
||||
expect(ids).toContain(4);
|
||||
expect(ids).toContain(5);
|
||||
expect(ids).toContain(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTableNode", () => {
|
||||
it("应找到根节点", () => {
|
||||
const node = findTableNode(1, mockTableData);
|
||||
expect(node).toBeDefined();
|
||||
expect(node!.name).toBe("系统管理");
|
||||
});
|
||||
it("应找到深层节点", () => {
|
||||
const node = findTableNode(3, mockTableData);
|
||||
expect(node).toBeDefined();
|
||||
expect(node!.name).toBe("角色管理");
|
||||
});
|
||||
it("找不到时返回 undefined", () => {
|
||||
expect(findTableNode(999, mockTableData)).toBeUndefined();
|
||||
});
|
||||
it("按钮ID应返回 undefined(已在过滤后的数据中移除)", () => {
|
||||
expect(findTableNode(21, mockTableData)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRawNode", () => {
|
||||
it("应找到原始树中的按钮节点", () => {
|
||||
const node = findRawNode(21, mockRawTree);
|
||||
expect(node).toBeDefined();
|
||||
expect(node!.name).toBe("新增用户");
|
||||
});
|
||||
it("应找到原始树中的根节点", () => {
|
||||
const node = findRawNode(1, mockRawTree);
|
||||
expect(node).toBeDefined();
|
||||
expect(node!.name).toBe("系统管理");
|
||||
});
|
||||
it("找不到时返回 undefined", () => {
|
||||
expect(findRawNode(999, mockRawTree)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAncestors", () => {
|
||||
it("应返回节点 2 的祖先 [节点 1]", () => {
|
||||
const ancestors = findAncestors(2, mockRawTree);
|
||||
expect(ancestors.length).toBe(1);
|
||||
expect(ancestors[0].id).toBe(1);
|
||||
});
|
||||
it("应返回节点 6 的祖先 [节点 5]", () => {
|
||||
const ancestors = findAncestors(6, mockRawTree);
|
||||
expect(ancestors.length).toBe(1);
|
||||
expect(ancestors[0].id).toBe(5);
|
||||
});
|
||||
it("根节点无祖先", () => {
|
||||
const ancestors = findAncestors(1, mockRawTree);
|
||||
expect(ancestors.length).toBe(0);
|
||||
});
|
||||
it("按钮节点 21 的祖先链应包含节点 1", () => {
|
||||
const ancestors = findAncestors(21, mockRawTree);
|
||||
const ancestorIds = ancestors.map((n) => n.id);
|
||||
expect(ancestorIds).toContain(1);
|
||||
expect(ancestorIds).toContain(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMenuBtns", () => {
|
||||
it("应返回菜单 2 下的按钮列表", () => {
|
||||
const row = findTableNode(2, mockTableData)!;
|
||||
const btns = getMenuBtns(row, mockRawTree);
|
||||
expect(btns.length).toBe(3);
|
||||
expect(btns.map((b) => b.id)).toEqual([21, 22, 23]);
|
||||
});
|
||||
it("菜单 4(无子节点)应返回空数组", () => {
|
||||
const row = findTableNode(4, mockTableData)!;
|
||||
const btns = getMenuBtns(row, mockRawTree);
|
||||
expect(btns.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findParentMenu", () => {
|
||||
it("应找到按钮 21 的父菜单为 2", () => {
|
||||
const parent = findParentMenu(21, mockRawTree);
|
||||
expect(parent).not.toBeNull();
|
||||
expect(parent!.id).toBe(2);
|
||||
});
|
||||
it("应找到菜单 2 的父目录为 1", () => {
|
||||
const parent = findParentMenu(2, mockRawTree);
|
||||
expect(parent).not.toBeNull();
|
||||
expect(parent!.id).toBe(1);
|
||||
});
|
||||
it("根节点无父节点", () => {
|
||||
expect(findParentMenu(1, mockRawTree)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAnySelectedDescendant", () => {
|
||||
it("节点自身有选中按钮时应返回 true", () => {
|
||||
const checkedBtns: Record<number, number[]> = { 2: [21] };
|
||||
const node = findTableNode(2, mockTableData)!;
|
||||
expect(hasAnySelectedDescendant(node, checkedBtns, mockTableData)).toBe(true);
|
||||
});
|
||||
it("子节点有选中按钮时应返回 true", () => {
|
||||
const checkedBtns: Record<number, number[]> = { 3: [31] };
|
||||
const node = findTableNode(1, mockTableData)!;
|
||||
expect(hasAnySelectedDescendant(node, checkedBtns, mockTableData)).toBe(true);
|
||||
});
|
||||
it("无选中时应返回 false", () => {
|
||||
const checkedBtns: Record<number, number[]> = {};
|
||||
const node = findTableNode(2, mockTableData)!;
|
||||
expect(hasAnySelectedDescendant(node, checkedBtns, mockTableData)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkParentChildLinked", () => {
|
||||
it("父子节点全部选中时返回 true", () => {
|
||||
const ids = [1, 2, 3, 4, 5, 6, 21, 22, 23, 31, 32, 61, 62];
|
||||
expect(checkParentChildLinked(ids, mockRawTree)).toBe(true);
|
||||
});
|
||||
it("子选中但父未选中时返回 false", () => {
|
||||
const ids = [2];
|
||||
expect(checkParentChildLinked(ids, mockRawTree)).toBe(false);
|
||||
});
|
||||
it("父选中但子未完全选中时返回 false", () => {
|
||||
const ids = [1, 2];
|
||||
// 节点 2 有三个子按钮未选
|
||||
expect(checkParentChildLinked(ids, mockRawTree)).toBe(false);
|
||||
});
|
||||
it("空数组返回 false", () => {
|
||||
expect(checkParentChildLinked([], mockRawTree)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandMenuIdsWithAncestors", () => {
|
||||
it("应补充所有祖先节点到结果中", () => {
|
||||
const ids = expandMenuIdsWithAncestors([21], mockRawTree);
|
||||
expect(ids).toContain(21);
|
||||
expect(ids).toContain(2);
|
||||
expect(ids).toContain(1);
|
||||
});
|
||||
it("根节点本身应保留", () => {
|
||||
const ids = expandMenuIdsWithAncestors([1], mockRawTree);
|
||||
expect(ids).toEqual([1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
Test Suite: handleSelectAll 行为
|
||||
============================================================ */
|
||||
describe("handleSelectAll 行为模拟", () => {
|
||||
let checkedBtns: Record<number, number[]>;
|
||||
let selectedMenuIds: Set<number>;
|
||||
|
||||
function simulateHandleSelectAll(selectionIds: number[], parentChildLinked: boolean) {
|
||||
const selection = selectionIds
|
||||
.map((id) => findTableNode(id, mockTableData))
|
||||
.filter(Boolean) as MenuTable[];
|
||||
|
||||
if (!selection.length) {
|
||||
checkedBtns = {};
|
||||
selectedMenuIds.clear();
|
||||
} else if (parentChildLinked) {
|
||||
for (const row of selection) {
|
||||
if (row.type === DIR_TYPE || row.type === MENU_TYPE) {
|
||||
selectedMenuIds.add(row.id!);
|
||||
const btns = getMenuBtns(row, mockRawTree);
|
||||
if (btns.length > 0) {
|
||||
checkedBtns[row.id!] = btns.map((b) => b.id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const row of selection) {
|
||||
if (row.type === DIR_TYPE || row.type === MENU_TYPE) {
|
||||
selectedMenuIds.add(row.id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
checkedBtns = {};
|
||||
selectedMenuIds = new Set();
|
||||
});
|
||||
|
||||
describe("取消全选", () => {
|
||||
it("选中行为空时应清空所有按钮状态", () => {
|
||||
// 预设一些按钮选中状态
|
||||
checkedBtns = { 2: [21, 22], 6: [61] };
|
||||
selectedMenuIds = new Set([2, 6]);
|
||||
|
||||
simulateHandleSelectAll([], false);
|
||||
expect(Object.keys(checkedBtns).length).toBe(0);
|
||||
expect(selectedMenuIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("即使有父子联动,取消全选也应清空按钮", () => {
|
||||
checkedBtns = { 2: [21, 22, 23], 3: [31, 32] };
|
||||
selectedMenuIds = new Set([1, 2, 3]);
|
||||
|
||||
simulateHandleSelectAll([], true);
|
||||
expect(Object.keys(checkedBtns).length).toBe(0);
|
||||
expect(selectedMenuIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("选中行为空时再次取消全选应保持清空状态", () => {
|
||||
simulateHandleSelectAll([], false);
|
||||
expect(Object.keys(checkedBtns).length).toBe(0);
|
||||
expect(selectedMenuIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("全选(无父子联动)", () => {
|
||||
it("应选中所有菜单行但不清空按钮", () => {
|
||||
simulateHandleSelectAll([1, 2, 3, 4, 5, 6], false);
|
||||
// 所有菜单行应被记录
|
||||
expect(selectedMenuIds.has(1)).toBe(true);
|
||||
expect(selectedMenuIds.has(2)).toBe(true);
|
||||
expect(selectedMenuIds.has(6)).toBe(true);
|
||||
// 无联动时不应自动收集按钮
|
||||
expect(checkedBtns[2]).toBeUndefined();
|
||||
expect(checkedBtns[6]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("全选(有父子联动)", () => {
|
||||
it("应为每个菜单行收集其下的功能按钮", () => {
|
||||
simulateHandleSelectAll([1, 2, 3, 4, 5, 6], true);
|
||||
// 应收集菜单 2 的按钮
|
||||
expect(checkedBtns[2]).toEqual([21, 22, 23]);
|
||||
// 应收集菜单 3 的按钮
|
||||
expect(checkedBtns[3]).toEqual([31, 32]);
|
||||
// 应收集菜单 6 的按钮(含链接类型)
|
||||
expect(checkedBtns[6]).toEqual([61, 62]);
|
||||
// 目录 1 和 5 没有功能按钮,应为空数组或 undefined
|
||||
expect(checkedBtns[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("菜单下无按钮时不应设置 checkedBtns", () => {
|
||||
simulateHandleSelectAll([4], true);
|
||||
expect(checkedBtns[4]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("部分选择", () => {
|
||||
it("只选中部分菜单行时只收集这些行的按钮", () => {
|
||||
simulateHandleSelectAll([2, 6], true);
|
||||
expect(selectedMenuIds.has(2)).toBe(true);
|
||||
expect(selectedMenuIds.has(6)).toBe(true);
|
||||
expect(selectedMenuIds.has(1)).toBe(false);
|
||||
expect(checkedBtns[2]).toEqual([21, 22, 23]);
|
||||
expect(checkedBtns[6]).toEqual([61, 62]);
|
||||
// 未选中的菜单不应有按钮
|
||||
expect(checkedBtns[3]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
Test Suite: handleSelectionChange 行为(安全网)
|
||||
============================================================ */
|
||||
describe("handleSelectionChange 安全网", () => {
|
||||
let checkedBtns: Record<number, number[]>;
|
||||
|
||||
function simulateHandleSelectionChange(selectionIds: number[]) {
|
||||
const newSelected = new Set(selectionIds);
|
||||
if (newSelected.size === 0) {
|
||||
checkedBtns = {};
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
checkedBtns = { 2: [21, 22], 3: [31] };
|
||||
});
|
||||
|
||||
it("选中行为空时应清空按钮状态", () => {
|
||||
simulateHandleSelectionChange([]);
|
||||
expect(Object.keys(checkedBtns).length).toBe(0);
|
||||
});
|
||||
|
||||
it("选中行非空时不应清空按钮", () => {
|
||||
simulateHandleSelectionChange([2]);
|
||||
expect(checkedBtns[2]).toEqual([21, 22]);
|
||||
expect(checkedBtns[3]).toEqual([31]);
|
||||
});
|
||||
|
||||
it("部分选中不应影响按钮状态", () => {
|
||||
simulateHandleSelectionChange([2, 3]);
|
||||
expect(checkedBtns[2]).toEqual([21, 22]);
|
||||
expect(checkedBtns[3]).toEqual([31]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
Test Suite: collectBtnState + cascadeDeselect
|
||||
============================================================ */
|
||||
describe("collectBtnState / cascadeDeselect", () => {
|
||||
it("collectBtnState 应为有按钮的行收集按钮 ID", () => {
|
||||
const row = findTableNode(2, mockTableData)!;
|
||||
const btns = getMenuBtns(row, mockRawTree);
|
||||
expect(btns.length).toBeGreaterThan(0);
|
||||
const checkedBtns: Record<number, number[]> = {};
|
||||
checkedBtns[row.id!] = btns.map((b) => b.id!);
|
||||
expect(checkedBtns[2]).toEqual([21, 22, 23]);
|
||||
});
|
||||
|
||||
it("cascadeDeselect 应清空指定行及其子行的按钮", () => {
|
||||
const checkedBtns: Record<number, number[]> = {
|
||||
2: [21, 22, 23],
|
||||
3: [31, 32],
|
||||
};
|
||||
// 模拟级联取消: 清空节点 1 下所有子菜单的按钮
|
||||
function cascadeDeselect(id: number) {
|
||||
delete checkedBtns[id];
|
||||
const node = findTableNode(id, mockTableData);
|
||||
if (node?.children) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === MENU_TYPE || child.type === DIR_TYPE) {
|
||||
cascadeDeselect(child.id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cascadeDeselect(1);
|
||||
expect(Object.keys(checkedBtns).length).toBe(0);
|
||||
});
|
||||
|
||||
it("级联取消不应影响无关行的按钮", () => {
|
||||
const checkedBtns: Record<number, number[]> = {
|
||||
2: [21, 22],
|
||||
6: [61],
|
||||
};
|
||||
function cascadeDeselect(id: number) {
|
||||
delete checkedBtns[id];
|
||||
const node = findTableNode(id, mockTableData);
|
||||
if (node?.children) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === MENU_TYPE || child.type === DIR_TYPE) {
|
||||
cascadeDeselect(child.id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cascadeDeselect(2);
|
||||
// 节点 2 的按钮被清空
|
||||
expect(checkedBtns[2]).toBeUndefined();
|
||||
// 节点 6 不受影响
|
||||
expect(checkedBtns[6]).toEqual([61]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
辅助函数
|
||||
============================================================ */
|
||||
function flattenIds(nodes: MenuTable[]): number[] {
|
||||
const ids: number[] = [];
|
||||
const walk = (list: MenuTable[]) => {
|
||||
list.forEach((n) => {
|
||||
ids.push(n.id!);
|
||||
if (n.children) walk(n.children);
|
||||
});
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
<FaDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="720px"
|
||||
width="640px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
:form-mode="dialogVisible.type"
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<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>
|
||||
@@ -0,0 +1,84 @@
|
||||
<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>
|
||||
@@ -0,0 +1,99 @@
|
||||
<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>
|
||||
@@ -55,7 +55,7 @@
|
||||
<FaDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="640px"
|
||||
width="720px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
:form-mode="dialogVisible.type"
|
||||
@@ -91,6 +91,10 @@
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -106,6 +110,9 @@ import TenantAPI, {
|
||||
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";
|
||||
@@ -186,6 +193,27 @@ function buildTenantRowActions(
|
||||
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));
|
||||
}
|
||||
@@ -221,36 +249,34 @@ function formatTenantOperationCell(
|
||||
);
|
||||
}
|
||||
|
||||
const dropdown = h(
|
||||
ElDropdown,
|
||||
{ trigger: "click" },
|
||||
{
|
||||
default: () =>
|
||||
h(ElTooltip, { content: "更多", placement: "top" }, () =>
|
||||
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
|
||||
]),
|
||||
dropdown: () =>
|
||||
h(ElDropdownMenu, null, () =>
|
||||
overflow.map((a) =>
|
||||
h(
|
||||
ElDropdownItem,
|
||||
{
|
||||
key: a.key,
|
||||
disabled: a.disabled,
|
||||
onClick: () => a.run(),
|
||||
},
|
||||
() => a.label
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return h(
|
||||
@@ -369,8 +395,8 @@ const {
|
||||
columnsFactory: (): ColumnOption<TenantTable>[] => [
|
||||
{ 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: "name", label: "租户名称", minWidth: 80, showOverflowTooltip: true },
|
||||
{ prop: "code", label: "租户编码", minWidth: 80, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
@@ -380,14 +406,16 @@ const {
|
||||
row.status === "0" ? "正常" : "禁用"
|
||||
),
|
||||
},
|
||||
{ prop: "start_time", label: "开始时间", width: 168, showOverflowTooltip: true },
|
||||
{ prop: "end_time", label: "结束时间", width: 168, showOverflowTooltip: true },
|
||||
{ prop: "description", label: "描述", minWidth: 150, showOverflowTooltip: true },
|
||||
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
|
||||
{ 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: 200,
|
||||
width: 220,
|
||||
fixed: "right",
|
||||
align: "right",
|
||||
formatter: (row: TenantTable) => formatTenantOperationCell(row, opCtx),
|
||||
@@ -410,6 +438,13 @@ const tenantDetailItems: import("@/components/others/fa-descriptions/index.vue")
|
||||
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" },
|
||||
@@ -423,11 +458,37 @@ const formData = ref<TenantForm>({
|
||||
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) => {
|
||||
@@ -462,6 +523,13 @@ const initialFormData: TenantForm = {
|
||||
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);
|
||||
@@ -471,13 +539,13 @@ const tenantFormRenderKey = ref(0);
|
||||
async function handleOpenDialog(type: "create" | "update" | "detail", id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await TenantAPI.detailTenant(id);
|
||||
const detailRes = await TenantAPI.detailTenant(id);
|
||||
if (type === "detail") {
|
||||
dialogVisible.title = "租户详情";
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
Object.assign(detailFormData.value, detailRes.data.data);
|
||||
} else if (type === "update") {
|
||||
dialogVisible.title = "修改租户";
|
||||
Object.assign(formData.value, response.data.data);
|
||||
Object.assign(formData.value, detailRes.data.data);
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = "新增租户";
|
||||
@@ -500,14 +568,14 @@ const tenantDialogFormItems = computed<FormItem[]>(() => [
|
||||
label: "租户名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
span: 24,
|
||||
span: 12,
|
||||
props: { placeholder: "请输入租户名称", maxlength: 100 },
|
||||
},
|
||||
{
|
||||
label: "租户编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
span: 24,
|
||||
span: 12,
|
||||
props: {
|
||||
placeholder: "字母与数字,创建后不可改",
|
||||
maxlength: 100,
|
||||
@@ -518,7 +586,7 @@ const tenantDialogFormItems = computed<FormItem[]>(() => [
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
span: 24,
|
||||
span: 12,
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
style: { width: "100%" },
|
||||
@@ -529,22 +597,45 @@ const tenantDialogFormItems = computed<FormItem[]>(() => [
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "描述",
|
||||
key: "description",
|
||||
label: "排序",
|
||||
key: "sort",
|
||||
type: "number",
|
||||
span: 12,
|
||||
props: { placeholder: "请输入排序值", min: 0, style: { width: "100%" } },
|
||||
},
|
||||
{
|
||||
label: "联系人",
|
||||
key: "contact_name",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: {
|
||||
type: "textarea",
|
||||
rows: 3,
|
||||
maxlength: 255,
|
||||
placeholder: "请输入描述",
|
||||
},
|
||||
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: 24,
|
||||
span: 12,
|
||||
props: {
|
||||
style: { width: "100%" },
|
||||
placeholder: "可选",
|
||||
@@ -556,7 +647,7 @@ const tenantDialogFormItems = computed<FormItem[]>(() => [
|
||||
label: "结束时间",
|
||||
key: "end_time",
|
||||
type: "datetime",
|
||||
span: 24,
|
||||
span: 12,
|
||||
props: {
|
||||
style: { width: "100%" },
|
||||
placeholder: "可选",
|
||||
@@ -564,6 +655,25 @@ const tenantDialogFormItems = computed<FormItem[]>(() => [
|
||||
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) {
|
||||
@@ -597,18 +707,43 @@ async function handleSubmit() {
|
||||
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,
|
||||
};
|
||||
await TenantAPI.createTenant(payload);
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
<!-- 工单管理:Fa 布局 + useTable,与公告通知页一致 -->
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="ticketSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
>
|
||||
<template #created_id>
|
||||
<FaUserTableSelect
|
||||
:model-value="searchForm.created_id == null ? undefined : searchForm.created_id"
|
||||
@update:model-value="(v: number | undefined) => (searchForm.created_id = v)"
|
||||
@confirm-click="afterUserSelectSearch"
|
||||
@clear-click="afterUserSelectSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #assigned_id>
|
||||
<FaUserTableSelect
|
||||
:model-value="searchForm.assigned_id == null ? undefined : searchForm.assigned_id"
|
||||
@update:model-value="(v: number | undefined) => (searchForm.assigned_id = v)"
|
||||
@confirm-click="afterUserSelectSearch"
|
||||
@clear-click="afterUserSelectSearch"
|
||||
/>
|
||||
</template>
|
||||
</FaSearchBar>
|
||||
|
||||
<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:ticket:create']"
|
||||
:perm-export="['module_system:ticket:export']"
|
||||
:perm-delete="['module_system:ticket:delete']"
|
||||
:perm-patch="['module_system:ticket:patch']"
|
||||
:delete-loading="batchDeleting"
|
||||
@add="handleOpenDialog('create')"
|
||||
@export="openExport"
|
||||
@delete="handleBatchDelete"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
</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="960px"
|
||||
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="4"
|
||||
:data="detailFormData"
|
||||
:items="ticketDetailItems"
|
||||
label-width="120px"
|
||||
max-height="75vh"
|
||||
>
|
||||
<template #ticket_type="{ row }">
|
||||
<ElTag :type="typeTag(row?.ticket_type as string)">
|
||||
{{ typeLabel(row?.ticket_type as string) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<ElTag :type="statusTag(row?.status as string)">
|
||||
{{ statusLabel(row?.status as string) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
<template #ticket_content>
|
||||
<div class="ticket-html-preview">
|
||||
<template v-if="detailHasRenderableContent">
|
||||
<div v-html="detailContentHtml" />
|
||||
</template>
|
||||
<p v-else class="ticket-html-empty">暂无内容</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #reply_content>
|
||||
<div v-if="detailFormData.reply" class="ticket-html-preview">
|
||||
<div v-html="detailFormData.reply" />
|
||||
</div>
|
||||
<p v-else class="ticket-html-empty">暂无回复</p>
|
||||
</template>
|
||||
</FaDescriptions>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FaForm
|
||||
:key="ticketFormRenderKey"
|
||||
scrollbar
|
||||
max-height="75vh"
|
||||
ref="dataFormRef"
|
||||
v-model="formData"
|
||||
:items="ticketDialogFormItems"
|
||||
: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 #ticket_type>
|
||||
<ElSelect v-model="formData.ticket_type" placeholder="请选择工单类型">
|
||||
<ElOption label="💡 建议" value="suggestion" />
|
||||
<ElOption label="🐛 缺陷" value="bug" />
|
||||
<ElOption label="⚡ 优化" value="optimize" />
|
||||
<ElOption label="📋 其他" value="other" />
|
||||
</ElSelect>
|
||||
</template>
|
||||
<template #status>
|
||||
<ElRadioGroup v-model="formData.status">
|
||||
<ElRadio value="0">待处理</ElRadio>
|
||||
<ElRadio value="1">处理中</ElRadio>
|
||||
<ElRadio value="2">已完成</ElRadio>
|
||||
<ElRadio value="3">已关闭</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</template>
|
||||
<template #assigned_id>
|
||||
<FaUserTableSelect
|
||||
:model-value="formData.assigned_id == null ? undefined : formData.assigned_id"
|
||||
@update:model-value="
|
||||
(v: number | undefined) => (formData.assigned_id = v ?? undefined)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #ticket_content>
|
||||
<FaWangEditor
|
||||
:model-value="formData.ticket_content ?? ''"
|
||||
height="min(38vh, 320px)"
|
||||
placeholder="请详细描述您的问题、建议或优化想法..."
|
||||
:exclude-keys="[]"
|
||||
@update:model-value="(v: string) => (formData.ticket_content = v)"
|
||||
/>
|
||||
</template>
|
||||
<template #reply_content>
|
||||
<FaWangEditor
|
||||
:model-value="formData.reply_content ?? ''"
|
||||
height="min(38vh, 280px)"
|
||||
placeholder="请输入回复内容..."
|
||||
:exclude-keys="[]"
|
||||
@update:model-value="(v: string) => (formData.reply_content = v)"
|
||||
/>
|
||||
</template>
|
||||
</FaForm>
|
||||
</template>
|
||||
</FaDialog>
|
||||
|
||||
<FaExportDialog
|
||||
v-model="exportVisible"
|
||||
:content-config="ticketExportContentConfig"
|
||||
:query-params="exportQueryParams"
|
||||
:page-data="data"
|
||||
:selection-data="selectedRows"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTable } from "@/hooks/core/useTable";
|
||||
import { useImportExport } from "@/hooks/core/useImportExport";
|
||||
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
|
||||
import { useTableSelection } from "@/hooks/core/useTableSelection";
|
||||
import { useCrudForm } from "@/hooks/core/useCrudForm";
|
||||
import { confirmDelete, confirmBatchDelete, confirmToggleStatus } from "@/hooks/core/useConfirm";
|
||||
import { cleanEmptyArrayParams, stripPaginationParams } from "@/utils/query";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import TicketAPI, {
|
||||
type TicketForm,
|
||||
type TicketPageQuery,
|
||||
type TicketTable,
|
||||
} from "@/api/module_system/ticket";
|
||||
import { useAuth } from "@/hooks/core/useAuth";
|
||||
import { renderTableOperationCell, type TableOperationAction } from "@utils";
|
||||
import type { IObject } from "@/components/modal/types";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import FaUserTableSelect from "@/components/forms/fa-search-bar/FaUserTableSelect.vue";
|
||||
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
import FaWangEditor from "@/components/forms/fa-wang-editor/index.vue";
|
||||
import { ElTag, ElMessage, ElSelect, ElOption, ElRadioGroup, ElRadio } from "element-plus";
|
||||
import { h } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: "Ticket",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
type TicketSearchForm = {
|
||||
title?: string;
|
||||
ticket_type?: string;
|
||||
status?: string;
|
||||
created_time?: string[];
|
||||
created_id?: number;
|
||||
assigned_id?: number;
|
||||
};
|
||||
|
||||
function normalizeTicketQuery(params: Record<string, unknown>): TicketPageQuery {
|
||||
return cleanEmptyArrayParams({ ...params }) as unknown as TicketPageQuery;
|
||||
}
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
suggestion: "建议",
|
||||
bug: "缺陷",
|
||||
optimize: "优化",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
"0": "待处理",
|
||||
"1": "处理中",
|
||||
"2": "已完成",
|
||||
"3": "已关闭",
|
||||
};
|
||||
|
||||
function typeLabel(t: string) {
|
||||
return TYPE_MAP[t] || t;
|
||||
}
|
||||
function statusLabel(s: string) {
|
||||
return STATUS_MAP[s] || s;
|
||||
}
|
||||
function typeTag(t: string): any {
|
||||
return { suggestion: "success", bug: "danger", optimize: "warning", other: "info" }[t] || "info";
|
||||
}
|
||||
function statusTag(s: string): any {
|
||||
return { "0": "warning", "1": "", "2": "success", "3": "info" }[s] || "info";
|
||||
}
|
||||
|
||||
const searchForm = ref<TicketSearchForm>({
|
||||
title: undefined,
|
||||
ticket_type: undefined,
|
||||
status: undefined,
|
||||
created_time: undefined,
|
||||
created_id: undefined,
|
||||
assigned_id: 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" },
|
||||
{ label: "已完成", value: "2" },
|
||||
{ label: "已关闭", value: "3" },
|
||||
]);
|
||||
|
||||
const ticketTypeSearchOptions = ref([
|
||||
{ label: "💡 建议", value: "suggestion" },
|
||||
{ label: "🐛 缺陷", value: "bug" },
|
||||
{ label: "⚡ 优化", value: "optimize" },
|
||||
{ label: "📋 其他", value: "other" },
|
||||
]);
|
||||
|
||||
const ticketSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "工单标题",
|
||||
key: "title",
|
||||
type: "input",
|
||||
placeholder: "请输入工单标题",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "工单类型",
|
||||
key: "ticket_type",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择类型",
|
||||
options: ticketTypeSearchOptions.value,
|
||||
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%" },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "创建人",
|
||||
key: "created_id",
|
||||
type: "input",
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "处理人",
|
||||
key: "assigned_id",
|
||||
type: "input",
|
||||
span: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
|
||||
|
||||
// ─── 表格多选 ───
|
||||
const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
|
||||
useTableSelection<TicketTable>();
|
||||
|
||||
// ─── 对话框状态 ───
|
||||
const { dialogVisible } = useCrudDialog();
|
||||
|
||||
const detailFormData = ref<TicketTable & { reply_content?: string }>(
|
||||
{} as TicketTable & { reply_content?: string }
|
||||
);
|
||||
|
||||
const ticketDetailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] =
|
||||
[
|
||||
{ label: "工单标题", prop: "title", span: 4 },
|
||||
{ label: "工单类型", prop: "ticket_type", slot: "ticket_type" },
|
||||
{
|
||||
label: "状态",
|
||||
prop: "status",
|
||||
slot: "status",
|
||||
},
|
||||
{ label: "处理人", prop: "assigned_by.name" },
|
||||
{ label: "描述", prop: "description", span: 4 },
|
||||
{ label: "详细内容", prop: "ticket_content", slot: "ticket_content", span: 4 },
|
||||
{ label: "回复内容", prop: "reply", slot: "reply_content", span: 4 },
|
||||
{ label: "创建人", prop: "created_by.name" },
|
||||
{ label: "更新人", prop: "updated_by.name" },
|
||||
{ label: "创建时间", prop: "created_time" },
|
||||
{ label: "更新时间", prop: "updated_time" },
|
||||
];
|
||||
|
||||
/** 详情富文本 HTML(用于预览) */
|
||||
const detailContentHtml = computed({
|
||||
get: () => detailFormData.value.ticket_content ?? "",
|
||||
set: (v: string) => {
|
||||
detailFormData.value.ticket_content = v;
|
||||
},
|
||||
});
|
||||
|
||||
/** 详情是否有可视文本 */
|
||||
const detailHasRenderableContent = computed(() => {
|
||||
const raw = detailFormData.value.ticket_content ?? "";
|
||||
if (!raw.trim()) return false;
|
||||
const plain = raw
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return plain.length > 0;
|
||||
});
|
||||
|
||||
const formData = ref<TicketForm & { reply_content?: string }>({
|
||||
id: undefined,
|
||||
title: "",
|
||||
ticket_type: "suggestion",
|
||||
ticket_content: "",
|
||||
status: "0",
|
||||
description: undefined,
|
||||
assigned_id: undefined,
|
||||
reply_content: undefined,
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
title: [{ required: true, message: "请输入工单标题", trigger: "blur" }],
|
||||
ticket_type: [{ required: true, message: "请选择工单类型", trigger: "blur" }],
|
||||
ticket_content: [{ required: true, message: "请输入工单内容", trigger: "blur" }],
|
||||
status: [{ required: true, message: "请选择状态", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const dataFormRef = ref<InstanceType<typeof FaForm> | null>(null);
|
||||
const ticketFormRenderKey = ref(0);
|
||||
|
||||
const initialFormData: TicketForm & { reply_content?: string } = {
|
||||
id: undefined,
|
||||
title: "",
|
||||
ticket_type: "suggestion",
|
||||
ticket_content: "",
|
||||
status: "0",
|
||||
description: undefined,
|
||||
assigned_id: undefined,
|
||||
reply_content: undefined,
|
||||
};
|
||||
|
||||
// ─── CRUD 表单 ───
|
||||
const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = useCrudForm<
|
||||
TicketForm & { reply_content?: string }
|
||||
>({
|
||||
formData,
|
||||
initialFormData,
|
||||
dialogVisible,
|
||||
dataFormRef,
|
||||
formRenderKey: ticketFormRenderKey,
|
||||
detailApi: TicketAPI.detailTicket as any,
|
||||
createApi: TicketAPI.createTicket,
|
||||
updateApi: TicketAPI.updateTicket,
|
||||
titles: { create: "提交工单", update: "处理工单", detail: "工单详情" },
|
||||
detailFormData,
|
||||
onCreateSuccess: async () => {
|
||||
await refreshCreate();
|
||||
},
|
||||
onUpdateSuccess: async () => {
|
||||
await refreshUpdate();
|
||||
},
|
||||
onSubmitSuccess: async () => {
|
||||
await refreshData();
|
||||
},
|
||||
});
|
||||
|
||||
const ticketDialogFormItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
label: "工单标题",
|
||||
key: "title",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "请输入工单标题", maxlength: 200 },
|
||||
},
|
||||
{
|
||||
label: "工单类型",
|
||||
key: "ticket_type",
|
||||
type: "select",
|
||||
span: 12,
|
||||
props: {
|
||||
placeholder: "请选择类型",
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "input",
|
||||
span: 12,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "处理人",
|
||||
key: "assigned_id",
|
||||
type: "input",
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "详细描述",
|
||||
key: "ticket_content",
|
||||
type: "input",
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "回复内容",
|
||||
key: "reply_content",
|
||||
type: "input",
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
]);
|
||||
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
data,
|
||||
loading,
|
||||
pagination,
|
||||
searchParams,
|
||||
getData,
|
||||
replaceSearchParams,
|
||||
resetSearchParams,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData,
|
||||
refreshCreate,
|
||||
refreshUpdate,
|
||||
refreshRemove,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: TicketAPI.listTicket,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<TicketTable>[] => [
|
||||
{ type: "selection", width: 48, fixed: "left" },
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{ prop: "title", label: "工单标题", minWidth: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "ticket_type",
|
||||
label: "工单类型",
|
||||
width: 100,
|
||||
formatter: (row: TicketTable) =>
|
||||
h(ElTag, { type: typeTag(row.ticket_type) }, () => typeLabel(row.ticket_type)),
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 100,
|
||||
formatter: (row: TicketTable) =>
|
||||
h(ElTag, { type: statusTag(row.status) }, () => statusLabel(row.status)),
|
||||
},
|
||||
{
|
||||
prop: "assigned_by",
|
||||
label: "处理人",
|
||||
minWidth: 100,
|
||||
formatter: (row: TicketTable) => row.assigned_by?.name ?? "—",
|
||||
},
|
||||
{ prop: "ticket_content", label: "内容摘要", minWidth: 200, showOverflowTooltip: true },
|
||||
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "created_by",
|
||||
label: "创建人",
|
||||
minWidth: 100,
|
||||
formatter: (row: TicketTable) => row.created_by?.name ?? "—",
|
||||
},
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 280,
|
||||
fixed: "right",
|
||||
align: "right",
|
||||
formatter: (row: TicketTable) => formatTicketOperationCell(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const ticketCrudCols = computed(() =>
|
||||
columns.value.map((c: ColumnOption<TicketTable>) => {
|
||||
const t = (c as { type?: string }).type;
|
||||
return {
|
||||
prop: c.prop,
|
||||
label: c.label,
|
||||
type: t === "selection" ? ("selection" as const) : ("default" as const),
|
||||
show: true,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const exportQueryParams = computed(() => {
|
||||
const sp = stripPaginationParams(searchParams as Record<string, unknown>);
|
||||
return normalizeTicketQuery(sp);
|
||||
});
|
||||
|
||||
const ticketExportContentConfig = computed(() => ({
|
||||
permPrefix: "module_system:ticket",
|
||||
cols: ticketCrudCols.value,
|
||||
exportsBlobAction: async (params: IObject) => {
|
||||
const merged = normalizeTicketQuery({
|
||||
...(exportQueryParams.value as unknown as Record<string, unknown>),
|
||||
...params,
|
||||
} as Record<string, unknown>);
|
||||
const res = await TicketAPI.exportTicket(merged as TicketPageQuery);
|
||||
return res.data as unknown as Blob;
|
||||
},
|
||||
}));
|
||||
|
||||
const { exportVisible, openExport } = useImportExport();
|
||||
|
||||
function buildTicketReplaceParams(p: TicketSearchForm): Record<string, unknown> {
|
||||
return {
|
||||
title: p.title,
|
||||
ticket_type: p.ticket_type,
|
||||
status: p.status,
|
||||
created_id: p.created_id,
|
||||
assigned_id: p.assigned_id,
|
||||
created_time:
|
||||
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSearchBarSearch(params: TicketSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams(buildTicketReplaceParams(params));
|
||||
getData();
|
||||
}
|
||||
|
||||
async function applyTicketSearchFromForm() {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams(buildTicketReplaceParams(searchForm.value));
|
||||
getData();
|
||||
}
|
||||
|
||||
async function afterUserSelectSearch() {
|
||||
await nextTick();
|
||||
await applyTicketSearchFromForm();
|
||||
}
|
||||
|
||||
function onResetSearch() {
|
||||
searchForm.value = {
|
||||
title: undefined,
|
||||
ticket_type: undefined,
|
||||
status: undefined,
|
||||
created_time: undefined,
|
||||
created_id: undefined,
|
||||
assigned_id: undefined,
|
||||
};
|
||||
void resetSearchParams();
|
||||
}
|
||||
|
||||
async function deleteTicketRow(id: number) {
|
||||
try {
|
||||
await confirmDelete();
|
||||
await TicketAPI.deleteTicket([id]);
|
||||
ElMessage.success("删除成功");
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
function buildTicketRowActions(row: TicketTable): TableOperationAction[] {
|
||||
const all: TableOperationAction[] = [
|
||||
{
|
||||
key: "detail",
|
||||
label: "详情",
|
||||
artType: "view",
|
||||
perm: "module_system:ticket:detail",
|
||||
run: () => {
|
||||
if (row.id != null) void handleOpenDialog("detail", row.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "process",
|
||||
label: "处理",
|
||||
artType: "edit",
|
||||
icon: "ri:settings-line",
|
||||
perm: "module_system:ticket:update",
|
||||
run: () => {
|
||||
if (row.id != null) void handleOpenDialog("update", row.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "close",
|
||||
label: "关闭",
|
||||
artType: "delete",
|
||||
icon: "ri:close-circle-line",
|
||||
perm: "module_system:ticket:update",
|
||||
run: () => {
|
||||
if (row.id != null) void closeTicket(row.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
icon: "ri:delete-bin-4-line",
|
||||
perm: "module_system:ticket:delete",
|
||||
run: () => {
|
||||
if (row.id != null) deleteTicketRow(row.id);
|
||||
},
|
||||
},
|
||||
];
|
||||
return all.filter((a) => a.perm != null && hasAuth(a.perm));
|
||||
}
|
||||
|
||||
function formatTicketOperationCell(row: TicketTable) {
|
||||
return renderTableOperationCell(buildTicketRowActions(row), {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 ticket-table-actions",
|
||||
});
|
||||
}
|
||||
|
||||
async function closeTicket(id: number) {
|
||||
try {
|
||||
await TicketAPI.updateTicket(id, { status: "3" });
|
||||
ElMessage.success("工单已关闭");
|
||||
await refreshData();
|
||||
} catch {
|
||||
ElMessage.error("关闭失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedIds.value;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await confirmBatchDelete(ids.length);
|
||||
batchDeleting.value = true;
|
||||
await TicketAPI.deleteTicket(ids);
|
||||
ElMessage.success("删除成功");
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
} finally {
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoreClick(status: string) {
|
||||
const ids = selectedIds.value;
|
||||
if (!ids.length) {
|
||||
ElMessage.warning("请先选择要操作的数据");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await confirmToggleStatus(status);
|
||||
await TicketAPI.batchTicket({ ids, status });
|
||||
await refreshData();
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 富文本预览区域阅读样式(与 FaWangEditor 输出 HTML 展示一致) */
|
||||
.ticket-html-preview {
|
||||
box-sizing: border-box;
|
||||
min-height: 120px;
|
||||
max-height: min(360px, 45vh);
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
}
|
||||
|
||||
.ticket-html-empty {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.ticket-html-preview :deep(h1),
|
||||
.ticket-html-preview :deep(h2),
|
||||
.ticket-html-preview :deep(h3) {
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.ticket-html-preview :deep(p) {
|
||||
margin: 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ticket-html-preview :deep(table) {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.ticket-html-preview :deep(table th),
|
||||
.ticket-html-preview :deep(table td) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.ticket-html-preview :deep(pre) {
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ticket-html-preview :deep(blockquote) {
|
||||
padding-left: 16px;
|
||||
margin: 12px 0;
|
||||
color: var(--el-text-color-regular);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.ticket-html-preview :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user