chore: 完成多批次代码优化与重构

- 重构工作流模块目录结构,迁移代码文件
- 修复类型断言空值安全问题,添加 ! 操作符
- 优化样式类名,替换 flex-cc 为标准 flex 工具类
- 更新路由标签简化文案,移除冗余注释
- 调整 ruff 配置,放宽行长度限制
- 更新 README 与多语言文案,优化项目描述
- 修复表单、图表组件的类型与样式问题
- 简化搜索表单、数据卡片的布局代码
This commit is contained in:
zhangtao
2026-06-20 05:31:46 +08:00
parent 82b742620b
commit d34d4a4c50
378 changed files with 4149 additions and 6375 deletions
@@ -24,7 +24,7 @@
>
<ElScrollbar>
<div
class="login-page-panel__scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-6 [-webkit-overflow-scrolling:touch]"
class="login-page-panel__scroll pb-6"
:class="panelAlign === 'center' && 'login-page-panel__scroll--centered'"
>
<div
@@ -176,7 +176,6 @@
</template>
<script setup lang="ts">
import { ElScrollbar } from "element-plus";
import type { LocationQuery, RouteLocationRaw } from "vue-router";
import AuthAPI, {
type CaptchaInfo,
@@ -189,13 +188,13 @@ import { useConfigStore, useAppStore, useSettingsStore, useUserStore } from "@st
import { Auth, HttpError, startOAuthLogin } from "@utils";
import { ElMessage, ElNotification, type FormRules } from "element-plus";
import type { Account, AccountKey } from "./types";
import FaLoginAccountForm from "@/components/views/fa-login/FaLoginAccountForm.vue";
import FaLoginForgetPanel from "@/components/views/fa-login/FaLoginForgetPanel.vue";
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";
import FaLoginAccountForm from "@/components/views/fa-login/forms/FaLoginAccountForm.vue";
import FaLoginForgetPanel from "@/components/views/fa-login/panels/FaLoginForgetPanel.vue";
import FaLoginMobilePanel from "@/components/views/fa-login/panels/FaLoginMobilePanel.vue";
import FaLoginQrPanel from "@/components/views/fa-login/panels/FaLoginQrPanel.vue";
import FaLoginRegisterPanel from "@/components/views/fa-login/panels/FaLoginRegisterPanel.vue";
import FaAuthTopBar from "@/components/views/fa-login/widgets/FaAuthTopBar.vue";
import { useLoginPanelAlign } from "@/components/views/fa-login/composables/useLoginPanelAlign";
defineOptions({ name: "Login" });
@@ -681,5 +680,5 @@ async function submitForget() {
</script>
<style scoped lang="scss">
@use "@styles/custom/fa-login";
@use "../../../../components/views/fa-login/fa-login";
</style>
@@ -119,12 +119,17 @@ import DeptAPI, {
} from "@/api/module_system/dept";
import { useAuth } from "@/hooks/core/useAuth";
import { useUserStore } from "@stores";
import { formatTree, renderTableOperationCell, type TableOperationAction } from "@utils";
import {
formatTree,
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
} from "@utils";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag, ElMessage } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import { ElMessage } from "element-plus";
defineOptions({
name: "Dept",
@@ -136,7 +141,7 @@ const userStore = useUserStore();
type DeptSearchForm = {
name?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -381,33 +386,35 @@ const opCtx = {
onDelete: deleteDeptRow,
};
const { columnChecks, columns } = useTableColumns<DeptTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "部门名称", minWidth: 120, showOverflowTooltip: true },
{ prop: "code", label: "部门编码", minWidth: 120, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 88,
formatter: (row: DeptTable) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
},
{ prop: "order", label: "排序", width: 88, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 100, 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: DeptTable) => formatDeptOperationCell(row, opCtx),
},
]);
const { columnChecks, columns } = useTableColumns<DeptTable>(
resolveStatusColumns(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "部门名称", minWidth: 120, showOverflowTooltip: true },
{ prop: "code", label: "部门编码", minWidth: 120, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 88,
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "order", label: "排序", width: 88, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 100, 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: DeptTable) => formatDeptOperationCell(row, opCtx),
},
])
);
const deptDialogFormItems = computed<FormItem[]>(() => [
{
@@ -196,6 +196,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -209,15 +210,16 @@ import DictAPI, {
type DictDataTable,
} from "@/api/module_system/dict";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import { useAppStore, useDictStore } from "@stores";
import { DeviceEnum } from "@/enums/settings/device.enum";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag, ElMessage } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import { ElMessage } from "element-plus";
defineOptions({ name: "DictDataDrawer", inheritAttrs: false });
@@ -281,7 +283,7 @@ function getTagPreviewStyle(value?: string) {
type DictDataSearchForm = {
dict_label?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -392,7 +394,7 @@ const {
dict_type: props.dictType,
dict_type_id: props.dictTypeId,
},
columnsFactory: (): ColumnOption<DictDataTable>[] => [
columnsFactory: resolveStatusColumns<DictDataTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "dict_label", label: "标签", minWidth: 150, showOverflowTooltip: true },
@@ -400,19 +402,17 @@ const {
prop: "status",
label: "状态",
width: 100,
formatter: (row: DictDataTable) => {
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "danger" as const, text: "停用" };
return h(ElTag, { type: cfg.type }, () => cfg.text);
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{
prop: "dict_type",
label: "类型",
minWidth: 180,
formatter: (row: DictDataTable) => h(ElTag, { type: "primary" }, () => row.dict_type ?? ""),
formatter: (row: DictDataTable) =>
h(FaStatusTag, { type: "primary", label: row.dict_type ?? "" }),
},
{ prop: "dict_value", label: "值", minWidth: 100, showOverflowTooltip: true },
{ prop: "css_class", label: "样式属性", minWidth: 100, showOverflowTooltip: true },
@@ -422,10 +422,10 @@ const {
prop: "is_default",
label: "是否默认",
width: 100,
formatter: (row: DictDataTable) =>
h(ElTag, { type: row.is_default ? "success" : "danger" }, () =>
row.is_default ? "是" : "否"
),
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
@@ -438,7 +438,7 @@ const {
align: "right",
formatter: (row: DictDataTable) => formatDictDataOperationCell(row),
},
],
]),
},
});
@@ -75,7 +75,7 @@
max-height="70vh"
>
<template #dict_type="{ row }">
<ElTag type="primary">{{ row?.dict_type }}</ElTag>
<FaStatusTag type="primary" :label="(row as unknown as DictTable)?.dict_type" />
</template>
</FaDescriptions>
</template>
@@ -126,6 +126,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -141,14 +142,15 @@ import DictAPI, {
} from "@/api/module_system/dict";
import { useDictStore } from "@stores";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar 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 type FaForm from "@/components/forms/fa-form/index.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import DataDrawer from "./components/DataDrawer.vue";
import { ElTag, ElMessage } from "element-plus";
import { ElMessage } from "element-plus";
defineOptions({
name: "Dict",
@@ -158,7 +160,7 @@ defineOptions({
type DictTypeSearchForm = {
dict_name?: string;
dict_type?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -364,7 +366,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<DictTable>[] => [
columnsFactory: resolveStatusColumns<DictTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "dict_name", label: "字典名称", minWidth: 140, showOverflowTooltip: true },
@@ -372,18 +374,16 @@ const {
prop: "dict_type",
label: "字典类型",
minWidth: 180,
formatter: (row: DictTable) => h(ElTag, { type: "primary" }, () => row.dict_type ?? ""),
formatter: (row: DictTable) =>
h(FaStatusTag, { type: "primary", label: row.dict_type ?? "" }),
},
{
prop: "status",
label: "状态",
width: 88,
formatter: (row: DictTable) => {
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "danger" as const, text: "停用" };
return h(ElTag, { type: cfg.type }, () => cfg.text);
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "description", label: "描述", minWidth: 140, showOverflowTooltip: true },
@@ -397,7 +397,7 @@ const {
align: "right",
formatter: (row: DictTable) => formatDictOperationCell(row),
},
],
]),
},
});
@@ -90,10 +90,16 @@
}}</ElTag>
</template>
<template #request_payload="{ row }">
<FaJsonPretty :value="row?.request_payload as any" height="80px" />
<FaJsonPretty
:value="(row as unknown as OperationLogTable)?.request_payload"
height="80px"
/>
</template>
<template #response_json="{ row }">
<FaJsonPretty :value="row?.response_json as any" height="140px" />
<FaJsonPretty
:value="(row as unknown as OperationLogTable)?.response_json"
height="140px"
/>
</template>
</FaDescriptions>
</FaDialog>
@@ -187,6 +193,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -201,13 +208,17 @@ import OperationLogAPI, {
LoginLogAPI,
} from "@/api/module_system/log";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import {
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
type StatusType,
} from "@utils";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import FaUserTableSelect from "@/components/forms/fa-search-bar/FaUserTableSelect.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import FaCopyButton from "@/components/others/fa-copy-button/index.vue";
import { ElTag } from "element-plus";
defineOptions({
name: "Log",
@@ -306,16 +317,20 @@ const {
label: "请求方法",
minWidth: 100,
formatter: (row: OperationLogTable) =>
h(ElTag, { type: getMethodType(row.request_method) }, () => row.request_method ?? ""),
h(FaStatusTag, {
type: getMethodType(row.request_method),
label: row.request_method ?? "",
}),
},
{
prop: "response_code",
label: "状态码",
minWidth: 100,
formatter: (row: OperationLogTable) =>
h(ElTag, { type: getStatusCodeType(row.response_code) }, () =>
String(row.response_code ?? "")
),
h(FaStatusTag, {
type: getStatusCodeType(row.response_code),
label: String(row.response_code ?? ""),
}),
},
{ prop: "process_time", label: "处理时间", minWidth: 120 },
{ prop: "description", label: "描述", minWidth: 120, showOverflowTooltip: true },
@@ -351,11 +366,11 @@ const opExportContentConfig = computed(() => ({
permPrefix: "module_system:log",
cols: opCrudCols.value,
exportsBlobAction: async (params: IObject) => {
const res = await (OperationLogAPI as any).exportLog(
const res = await OperationLogAPI.export(
cleanEmptyArrayParams({
...(opExportQueryParams.value as unknown as Record<string, unknown>),
...params,
})
}) as OperationLogPageQuery
);
return res.data as Blob;
},
@@ -535,17 +550,17 @@ const {
core: {
apiFn: LoginLogAPI.list,
apiParams: { page_no: 1, page_size: 10 },
columnsFactory: (): ColumnOption<LoginLogTable>[] => [
columnsFactory: resolveStatusColumns<LoginLogTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{
prop: "status",
label: "登录状态",
width: 88,
formatter: (row: LoginLogTable) =>
h(ElTag, { type: row.status === 1 ? "success" : "danger" }, () =>
row.status === 1 ? "成功" : "失败"
),
status: {
1: { type: "success", text: "成功" },
0: { type: "danger", text: "失败" },
},
},
{ prop: "username", label: "用户名", minWidth: 120, showOverflowTooltip: true },
{
@@ -573,7 +588,7 @@ const {
align: "right",
formatter: (row: LoginLogTable) => formatLoginActionCell(row),
},
],
]),
},
});
@@ -685,14 +700,14 @@ watch(activeTab, (tab) => {
// ==================== 通用 ====================
function getStatusCodeType(code?: number) {
function getStatusCodeType(code?: number): StatusType {
if (code === undefined) return "info";
if (code >= 200 && code < 300) return "success";
if (code >= 300 && code < 400) return "warning";
return "danger";
}
function getMethodType(method?: string) {
function getMethodType(method?: string): StatusType {
if (method === undefined) return "info";
if (method === "GET") return "info";
if (method === "POST") return "success";
@@ -85,17 +85,18 @@
max-height="75vh"
>
<template #notice_type="{ row }">
<ElTag :type="row?.notice_type === '1' ? 'primary' : 'warning'">
{{ noticeTypeLabel(row?.notice_type as string) }}
</ElTag>
<FaStatusTag
:type="row?.notice_type === '1' ? 'primary' : 'warning'"
:label="noticeTypeLabel(row?.notice_type as string)"
/>
</template>
<template #notice_content>
<div class="notice-html-preview">
<ElScrollbar class="notice-html-preview" view-class="p-3">
<template v-if="detailHasRenderableContent">
<div v-html="detailContentHtml" />
</template>
<p v-else class="notice-html-empty">暂无内容</p>
</div>
</ElScrollbar>
</template>
</FaDescriptions>
</template>
@@ -161,15 +162,14 @@ import NoticeAPI, {
type NoticeTable,
} from "@/api/module_system/notice";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import { useDictStore, useNoticeStore } from "@stores";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar 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 { ElTag, ElMessage } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import { ElMessage, ElScrollbar } from "element-plus";
import DOMPurify from "dompurify";
defineOptions({
@@ -184,7 +184,7 @@ const { hasAuth } = useAuth();
type NoticeSearchForm = {
notice_title?: string;
notice_type?: string;
status?: string;
status?: number;
created_time?: string[];
created_id?: number;
};
@@ -457,7 +457,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<NoticeTable>[] => [
columnsFactory: resolveStatusColumns<NoticeTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "notice_title", label: "通知标题", minWidth: 140, showOverflowTooltip: true },
@@ -465,22 +465,19 @@ const {
prop: "status",
label: "状态",
width: 88,
formatter: (row: NoticeTable) => {
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "danger" as const, text: "停用" };
return h(ElTag, { type: cfg.type }, () => cfg.text);
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{
prop: "notice_type",
label: "类型",
minWidth: 100,
formatter: (row: NoticeTable) =>
h(ElTag, { type: row.notice_type === "1" ? "primary" : "warning" }, () =>
noticeTypeLabel(row.notice_type)
),
status: {
"1": { type: "primary", text: "通知" },
"2": { type: "warning", text: "公告" },
},
},
{ prop: "notice_content", label: "内容", minWidth: 200, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 140, showOverflowTooltip: true },
@@ -506,7 +503,7 @@ const {
align: "right",
formatter: (row: NoticeTable) => formatNoticeOperationCell(row),
},
],
]),
},
});
@@ -678,8 +675,6 @@ onMounted(async () => {
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);
@@ -130,10 +130,9 @@ import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { useConfigStore } from "@stores";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
defineOptions({
name: "Params",
@@ -386,10 +385,10 @@ const {
prop: "config_type",
label: "系统内置",
minWidth: 100,
formatter: (row: ConfigTable) =>
h(ElTag, { type: row.config_type ? "success" : "danger" }, () =>
row.config_type ? "是" : "否"
),
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{ prop: "description", label: "描述", minWidth: 120, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
@@ -123,6 +123,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -140,19 +141,12 @@ import { useAuth } from "@/hooks/core/useAuth";
import { useUserStore } from "@stores";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar 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 type 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";
import { resolveStatusColumns } from "@utils";
import { ElMessage, ElTooltip, ElDropdown, ElDropdownMenu, ElDropdownItem } from "element-plus";
defineOptions({
name: "Position",
@@ -165,7 +159,7 @@ const userStore = useUserStore();
type PositionSearchForm = {
name?: string;
status?: string;
status?: number;
created_time?: string[];
created_id?: number;
};
@@ -390,7 +384,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<PositionTable>[] => [
columnsFactory: resolveStatusColumns<PositionTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "岗位名称", minWidth: 100, showOverflowTooltip: true },
@@ -398,10 +392,10 @@ const {
prop: "status",
label: "状态",
width: 88,
formatter: (row: PositionTable) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "order", label: "岗位排序", width: 100, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 120, showOverflowTooltip: true },
@@ -427,7 +421,7 @@ const {
align: "right",
formatter: (row: PositionTable) => formatPositionOperationCell(row, opCtx),
},
],
]),
},
});
@@ -109,10 +109,9 @@
import { computed, ref, watch, onMounted, nextTick } from "vue";
import { QuestionFilled } from "@element-plus/icons-vue";
import type { TreeInstance } from "element-plus";
import FaDrawer from "@/components/modal/fa-drawer/index.vue";
import FaMenuTreeTable from "@/components/others/fa-menu-tree-drawer/index.vue";
import { listToTree, formatTree } from "@utils";
import RoleAPI, { permissionDataType, permissionDeptType } from "@/api/module_system/role";
import { listToTree, formatTree, type CascaderNode } from "@utils";
import FaMenuTreeTable from "@/components/others/fa-menu-tree-table/index.vue";
import RoleAPI, { permissionDataType } from "@/api/module_system/role";
import DeptAPI from "@/api/module_system/dept";
import MenuAPI, { MenuTable } from "@/api/module_platform/menu";
import { DeviceEnum } from "@/enums/settings/device.enum";
@@ -146,7 +145,7 @@ const deptTreeRef = ref<TreeInstance>();
const deptFilterText = ref("");
const dataFormRef = ref();
const loading = ref(false);
const deptTreeData = ref<permissionDeptType[]>([]);
const deptTreeData = ref<CascaderNode[]>([]);
const rawMenuTree = ref<MenuTable[]>([]);
const menuCheckedIds = ref<number[]>([]);
const menuTreeTableRef = ref<InstanceType<typeof FaMenuTreeTable>>();
@@ -1,621 +0,0 @@
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;
}
@@ -75,16 +75,25 @@
max-height="75vh"
>
<template #data_scope="{ row }">
<ElTag v-if="row?.data_scope === 1" type="primary">仅本人数据权限</ElTag>
<ElTag v-else-if="row?.data_scope === 2" type="info">本部门数据权限</ElTag>
<ElTag v-else-if="row?.data_scope === 3" type="warning">本部门及以下数据权限</ElTag>
<ElTag v-else-if="row?.data_scope === 4" type="success">全部数据权限</ElTag>
<ElTag v-else type="danger">自定义数据权限</ElTag>
<FaStatusTag v-if="row?.data_scope === 1" type="primary" label="仅本人数据权限" />
<FaStatusTag v-else-if="row?.data_scope === 2" type="info" label="本部门数据权限" />
<FaStatusTag
v-else-if="row?.data_scope === 3"
type="warning"
label="本部门及以下数据权限"
/>
<FaStatusTag v-else-if="row?.data_scope === 4" type="success" label="全部数据权限" />
<FaStatusTag v-else type="danger" label="自定义数据权限" />
</template>
<template #depts="{ row }">
<template v-if="row?.depts && (row.depts as any[]).length > 0">
<template
v-if="
(row as unknown as RoleTable)?.depts &&
(row as unknown as RoleTable).depts!.length > 0
"
>
<ElTag
v-for="dept in row.depts as any[]"
v-for="dept in (row as unknown as RoleTable).depts!"
:key="dept.id"
type="info"
:style="'margin-right: 4px; margin-bottom: 4px'"
@@ -143,6 +152,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -150,7 +160,7 @@ 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 { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { ColumnOption } from "@/types/component";
import RoleAPI, {
type RoleForm,
@@ -161,10 +171,11 @@ import { useAuth } from "@/hooks/core/useAuth";
import { useUserStore } from "@stores";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag, ElMessage } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import StatusTag from "@/components/others/fa-status-tag/index.vue";
import { ElMessage } from "element-plus";
import FaPermissonDrawer from "./components/FaPermissonDrawer.vue";
defineOptions({
@@ -176,7 +187,7 @@ const { hasAuth } = useAuth();
type RoleSearchForm = {
name?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -194,32 +205,22 @@ function buildRoleReplaceParams(p: RoleSearchForm): Record<string, unknown> {
};
}
function dataScopeTag(row: RoleTable) {
const ds = row.data_scope;
if (ds === 1) return h(ElTag, { type: "primary" }, () => "仅本人数据权限");
if (ds === 2) return h(ElTag, { type: "info" }, () => "本部门数据权限");
if (ds === 3) return h(ElTag, { type: "warning" }, () => "本部门及以下数据权限");
if (ds === 4) return h(ElTag, { type: "success" }, () => "全部数据权限");
return h(ElTag, { type: "danger" }, () => "自定义数据权限");
}
function deptsCell(row: RoleTable) {
const list = row.depts;
if (!list?.length) {
return h("span", { style: { color: "var(--el-text-color-placeholder)" } }, "-");
}
const tags = list
.slice(0, 3)
.map((dept) =>
h(
ElTag,
{ key: dept.id, type: "info", style: { marginRight: "4px", marginBottom: "4px" } },
() => dept.name ?? ""
)
);
const tags = list.slice(0, 3).map((dept) =>
h(StatusTag, {
key: dept.id,
type: "info",
label: dept.name ?? "",
style: { marginRight: "4px", marginBottom: "4px" },
})
);
if (list.length > 3) {
tags.push(
h(ElTag, { type: "info", style: { marginBottom: "4px" } }, () => `+${list.length - 3}`)
h(StatusTag, { type: "info", label: `+${list.length - 3}`, style: { marginBottom: "4px" } })
);
}
return h("span", { class: "inline-flex flex-wrap items-center" }, tags);
@@ -547,7 +548,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<RoleTable>[] => [
columnsFactory: resolveStatusColumns<RoleTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "角色名称", minWidth: 100, showOverflowTooltip: true },
@@ -556,7 +557,13 @@ const {
prop: "data_scope",
label: "数据权限",
minWidth: 200,
formatter: (row: RoleTable) => dataScopeTag(row),
status: {
1: { type: "primary", text: "仅本人数据权限" },
2: { type: "info", text: "本部门数据权限" },
3: { type: "warning", text: "本部门及以下数据权限" },
4: { type: "success", text: "全部数据权限" },
5: { type: "danger", text: "自定义数据权限" },
},
},
{
prop: "depts",
@@ -569,10 +576,10 @@ const {
prop: "status",
label: "状态",
width: 88,
formatter: (row: RoleTable) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
@@ -585,7 +592,7 @@ const {
align: "right",
formatter: (row: RoleTable) => formatRoleOperationCell(row, opCtx),
},
],
]),
},
});
@@ -93,27 +93,29 @@
max-height="75vh"
>
<template #ticket_type="{ row }">
<ElTag :type="typeTag(row?.ticket_type as string)">
{{ typeLabel(row?.ticket_type as string) }}
</ElTag>
<FaStatusTag
:type="typeTag(row?.ticket_type as string)"
:label="typeLabel(row?.ticket_type as string)"
/>
</template>
<template #status="{ row }">
<ElTag :type="statusTag(row?.status as string)">
{{ statusLabel(row?.status as string) }}
</ElTag>
<FaStatusTag
:type="statusTag(row?.status as string)"
:label="statusLabel(row?.status as string)"
/>
</template>
<template #ticket_content>
<div class="ticket-html-preview">
<ElScrollbar class="ticket-html-preview" view-class="p-3">
<template v-if="detailHasRenderableContent">
<div v-html="detailContentHtml" />
</template>
<p v-else class="ticket-html-empty">暂无内容</p>
</div>
</ElScrollbar>
</template>
<template #reply_content>
<div v-if="detailFormData.reply" class="ticket-html-preview">
<ElScrollbar v-if="detailFormData.reply" class="ticket-html-preview" view-class="p-3">
<div v-html="sanitizedReply" />
</div>
</ElScrollbar>
<p v-else class="ticket-html-empty">暂无回复</p>
</template>
</FaDescriptions>
@@ -207,16 +209,13 @@ import TicketAPI, {
type TicketTable,
} from "@/api/module_system/ticket";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar 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";
import type FaForm from "@/components/forms/fa-form/index.vue";
import { ElMessage, ElSelect, ElOption, ElRadioGroup, ElRadio, ElScrollbar } from "element-plus";
import DOMPurify from "dompurify";
defineOptions({
@@ -229,7 +228,7 @@ const { hasAuth } = useAuth();
type TicketSearchForm = {
title?: string;
ticket_type?: string;
status?: string;
status?: number;
created_time?: string[];
created_id?: number;
assigned_id?: number;
@@ -541,7 +540,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<TicketTable>[] => [
columnsFactory: resolveStatusColumns<TicketTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "title", label: "工单标题", minWidth: 180, showOverflowTooltip: true },
@@ -549,15 +548,23 @@ const {
prop: "ticket_type",
label: "工单类型",
width: 100,
formatter: (row: TicketTable) =>
h(ElTag, { type: typeTag(row.ticket_type) }, () => typeLabel(row.ticket_type)),
status: {
suggestion: { type: "success", text: "建议" },
bug: { type: "danger", text: "缺陷" },
optimize: { type: "warning", text: "优化" },
other: { type: "info", text: "其他" },
},
},
{
prop: "status",
label: "状态",
width: 100,
formatter: (row: TicketTable) =>
h(ElTag, { type: statusTag(String(row.status)) }, () => statusLabel(String(row.status))),
status: {
"0": { type: "warning", text: "待处理" },
"1": { type: "info", text: "处理中" },
"2": { type: "success", text: "已完成" },
"3": { type: "info", text: "已关闭" },
},
},
{
prop: "assigned_by",
@@ -581,7 +588,7 @@ const {
align: "right",
formatter: (row: TicketTable) => formatTicketOperationCell(row),
},
],
]),
},
});
@@ -768,8 +775,6 @@ async function handleMoreClick(status: string) {
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);
@@ -114,19 +114,23 @@
</template>
<!-- 性别 → 三种状态 Tag -->
<template #gender="{ row }">
<ElTag v-if="row?.gender === '0'" type="success">男</ElTag>
<ElTag v-else-if="row?.gender === '1'" type="warning">女</ElTag>
<ElTag v-else type="info">未知</ElTag>
<FaStatusTag v-if="row?.gender === '0'" type="success" label="男" />
<FaStatusTag v-else-if="row?.gender === '1'" type="warning" label="女" />
<FaStatusTag v-else type="info" label="未知" />
</template>
<!-- 角色 → 数组 join 渲染 -->
<template #roles="{ row }">
{{ row?.roles ? (row.roles as any[]).map((item: any) => item.name).join("、") : "" }}
{{
(row as unknown as UserInfo)?.roles
? (row as unknown as UserInfo).roles!.map((item) => item.name).join("、")
: ""
}}
</template>
<!-- 岗位 → 数组 join 渲染 -->
<template #positions="{ row }">
{{
row?.positions
? (row.positions as any[]).map((item: any) => item.name).join("、")
(row as unknown as UserInfo)?.positions
? (row as unknown as UserInfo).positions!.map((item) => item.name).join("、")
: ""
}}
</template>
@@ -209,6 +213,7 @@ defineOptions({
inheritAttrs: false,
});
import { h } from "vue";
import { UserFilled } from "@element-plus/icons-vue";
import { ElAvatar } from "element-plus";
import { DeviceEnum } from "@/enums/settings/device.enum";
@@ -224,22 +229,26 @@ import UserAPI, {
type UserInfo,
type UserPageQuery,
} from "@/api/module_system/user";
import { formatTree, renderTableOperationCell, type TableOperationAction } from "@utils";
import {
formatTree,
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
} from "@utils";
import PositionAPI from "@/api/module_system/position";
import DeptAPI from "@/api/module_system/dept";
import RoleAPI from "@/api/module_system/role";
import { useAppStore, useUserStore } from "@stores";
import { useAuth } from "@/hooks/core/useAuth";
import type { ColumnOption } from "@/types/component";
import FaUserTableSelect from "@/components/forms/fa-search-bar/FaUserTableSelect.vue";
import type { DescriptionsItem } from "@/components/others/fa-descriptions/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import type FaForm from "@/components/forms/fa-form/index.vue";
import type { IContentConfig, IObject } from "@/components/modal/types";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaDeptTree from "./components/FaDeptTree.vue";
import { ElTag, ElMessage, ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
const { hasAuth } = useAuth();
const appStore = useAppStore();
@@ -248,7 +257,7 @@ const userStore = useUserStore();
type UserSearchForm = {
username?: string;
name?: string;
status?: string;
status?: number;
created_id?: number;
created_time?: string[];
};
@@ -592,7 +601,7 @@ const {
page_no: 1,
page_size: 20,
},
columnsFactory: (): import("@/types/component").ColumnOption<UserInfo>[] => [
columnsFactory: resolveStatusColumns<UserInfo>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{
@@ -616,10 +625,10 @@ const {
prop: "status",
label: "状态",
width: 88,
formatter: (row: UserInfo) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{
prop: "dept",
@@ -631,10 +640,9 @@ const {
prop: "gender",
label: "性别",
width: 88,
formatter: (row: UserInfo) => {
if (row.gender === "0") return h(ElTag, { type: "success" }, () => "男");
if (row.gender === "1") return h(ElTag, { type: "warning" }, () => "女");
return h(ElTag, { type: "info" }, () => "未知");
status: {
"0": { type: "success", text: "男" },
"1": { type: "warning", text: "女" },
},
},
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
@@ -647,7 +655,7 @@ const {
align: "right",
formatter: (row: UserInfo) => formatUserOperationCell(row, opCtx),
},
],
]),
},
});