chore: 清理冗余代码与配置,优化项目结构

1.  删除无用文件与废弃代码:移除locale枚举、element-plus插件、sse路由、api token模块等
2.  简化类型导入与依赖:移除大量未使用的类型导入,统一echarts导入方式
3.  优化配置与样式:调整gitignore、样式引入顺序,新增列表动画样式
4.  修复接口与模型:修正接口返回类型、查询参数配置,更新部门模型字段
5.  优化性能与体验:添加图片懒加载,优化加载逻辑与表格渲染
6.  调整环境配置:新增并更新开发/生产环境配置文件
This commit is contained in:
zhangtao
2026-07-23 20:49:52 +08:00
parent 096e2216d6
commit 0ce31936aa
360 changed files with 8072 additions and 22122 deletions
@@ -1,261 +1,256 @@
<template>
<div>
<FaDashboardSkeleton v-if="loading" />
<template v-else>
<ElRow :gutter="20">
<ElCol :xl="14" :lg="15" :xs="24"><TodaySales /></ElCol>
<ElCol :xl="10" :lg="9" :xs="24"><VisitorInsights /></ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :xl="14" :lg="15" :xs="24"><TodaySales /></ElCol>
<ElCol :xl="10" :lg="9" :xs="24"><VisitorInsights /></ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :xl="12" :lg="12" :xs="24" class="flex flex-col gap-5">
<CustomerSatisfaction class="flex-1" />
<TotalRevenue class="flex-1" />
</ElCol>
<ElCol :xl="12" :lg="12" :xs="24" class="flex flex-col gap-5">
<TargetVsReality class="flex-1" />
<TopProducts class="flex-1" />
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :xl="12" :lg="12" :xs="24" class="flex flex-col gap-5">
<CustomerSatisfaction class="flex-1" />
<TotalRevenue class="flex-1" />
</ElCol>
<ElCol :xl="12" :lg="12" :xs="24" class="flex flex-col gap-5">
<TargetVsReality class="flex-1" />
<TopProducts class="flex-1" />
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :xl="8" :lg="8" :xs="24"><SalesMappingByCountry /></ElCol>
<ElCol :xl="8" :lg="8" :xs="24"><VolumeServiceLevel /></ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(单数据)</span></div>
<FaBarChart
:data="singleBarData"
:xAxisData="xAxisData"
:showLegend="true"
legendPosition="right"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(多组数据)</span></div>
<FaBarChart
:data="multiBarData"
:xAxisData="xAxisData"
:showLegend="true"
barWidth="26%"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(堆叠)</span></div>
<FaBarChart
:data="stackBarData"
:xAxisData="stackXAxisData"
:showLegend="true"
:stack="true"
barWidth="26%"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">折线图</span></div>
<FaLineChart
:data="[58, 15, 82, 35, 120, 62, 45]"
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
symbol="none"
:symbolSize="7"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">折线图(渐变)</span></div>
<FaLineChart
:data="[28, 45, 82, 35, 100, 32, 55]"
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
:showAreaColor="true"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">折线图(多组)</span></div>
<FaLineChart
:data="multiLineData"
:xAxisData="['1月', '2月', '3月', '4月', '5月', '6月']"
:showLegend="true"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(水平)</span></div>
<FaHBarChart
:data="[50, 80, 120, 90, 60]"
:xAxisData="['产品A', '产品B', '产品C', '产品D', '产品E']"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(水平多组)</span></div>
<FaHBarChart
:data="[
{ name: '系列1', data: [10, 20, 30] },
{ name: '系列2', data: [15, 25, 35] },
]"
:xAxisData="['类目1', '类目2', '类目3']"
:showLegend="true"
barWidth="30%"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(水平堆叠)</span></div>
<FaHBarChart
:data="[
{ name: '系列1', data: [10, 20, 30] },
{ name: '系列2', data: [15, 25, 35] },
]"
:xAxisData="['类目1', '类目2', '类目3']"
:showLegend="true"
:stack="true"
barWidth="30%"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">环形图</span></div>
<FaRingChart
:data="[
{ value: 35, name: '分类A' },
{ value: 28, name: '分类B' },
{ value: 42, name: '分类C' },
]"
:radius="['54%', '70%']"
legendPosition="bottom"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">环形图(中心文字)</span></div>
<FaRingChart
:data="[
{ value: 35, name: '分类A' },
{ value: 28, name: '分类B' },
{ value: 42, name: '分类C' },
{ value: 32, name: '分类D' },
{ value: 26, name: '分类E' },
{ value: 37, name: '分类F' },
]"
:radius="['54%', '70%']"
:showLegend="true"
legendPosition="bottom"
centerText="¥300,458"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">饼图</span></div>
<FaRingChart
:data="[
{ value: 30, name: '分类A' },
{ value: 25, name: '分类B' },
{ value: 45, name: '分类C' },
]"
:radius="['0%', '70%']"
:showLegend="true"
legendPosition="right"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">散点图</span></div>
<FaScatterChart
:data="[
{ value: [1, 3] },
{ value: [2, 4] },
{ value: [3, 5] },
{ value: [4, 6] },
{ value: [5, 7] },
{ value: [6, 8] },
{ value: [7, 7] },
{ value: [8, 9] },
{ value: [9, 8] },
{ value: [10, 6] },
{ value: [11, 7] },
{ value: [12, 8] },
]"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">雷达图</span></div>
<FaRadarChart
:indicator="[
{ name: '销售', max: 100 },
{ name: '管理', max: 100 },
{ name: '技术', max: 100 },
{ name: '客服', max: 100 },
{ name: '开发', max: 100 },
]"
:data="[
{ name: '预算分配', value: [80, 70, 90, 85, 75] },
{ name: '实际开销', value: [70, 75, 85, 80, 70] },
]"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">K线图</span></div>
<FaKLineChart
:data="[
{ time: '2024-01-01', open: 20, close: 23, high: 25, low: 18 },
{ time: '2024-01-02', open: 23, close: 21, high: 24, low: 20 },
{ time: '2024-01-03', open: 21, close: 25, high: 26, low: 21 },
]"
:showDataZoom="false"
:dataZoomStart="0"
:dataZoomEnd="100"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">双向堆叠柱状图</span></div>
<FaDualBarCompareChart
:positiveData="[50, 28, 80, 65, 68, 70, 60, 55]"
:negativeData="[50, 28, 40, 45, 38, 50, 42, 48]"
:xAxisData="[
'0-4岁',
'5-14岁',
'15-24岁',
'25-34岁',
'35-44岁',
'45-54岁',
'55-64岁',
'65岁以上',
]"
positiveName="男性年龄分布"
negativeName="女性年龄分布"
/>
</div>
</ElCol>
</ElRow>
</template>
<ElRow :gutter="20">
<ElCol :xl="8" :lg="8" :xs="24"><SalesMappingByCountry /></ElCol>
<ElCol :xl="8" :lg="8" :xs="24"><VolumeServiceLevel /></ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(单数据)</span></div>
<FaBarChart
:data="singleBarData"
:xAxisData="xAxisData"
:showLegend="true"
legendPosition="right"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(多组数据)</span></div>
<FaBarChart
:data="multiBarData"
:xAxisData="xAxisData"
:showLegend="true"
barWidth="26%"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(堆叠)</span></div>
<FaBarChart
:data="stackBarData"
:xAxisData="stackXAxisData"
:showLegend="true"
:stack="true"
barWidth="26%"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">折线图</span></div>
<FaLineChart
:data="[58, 15, 82, 35, 120, 62, 45]"
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
symbol="none"
:symbolSize="7"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">折线图(渐变)</span></div>
<FaLineChart
:data="[28, 45, 82, 35, 100, 32, 55]"
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
:showAreaColor="true"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">折线图(多组)</span></div>
<FaLineChart
:data="multiLineData"
:xAxisData="['1月', '2月', '3月', '4月', '5月', '6月']"
:showLegend="true"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(水平)</span></div>
<FaHBarChart
:data="[50, 80, 120, 90, 60]"
:xAxisData="['产品A', '产品B', '产品C', '产品D', '产品E']"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(水平多组)</span></div>
<FaHBarChart
:data="[
{ name: '系列1', data: [10, 20, 30] },
{ name: '系列2', data: [15, 25, 35] },
]"
:xAxisData="['类目1', '类目2', '类目3']"
:showLegend="true"
barWidth="30%"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">柱状图(水平堆叠)</span></div>
<FaHBarChart
:data="[
{ name: '系列1', data: [10, 20, 30] },
{ name: '系列2', data: [15, 25, 35] },
]"
:xAxisData="['类目1', '类目2', '类目3']"
:showLegend="true"
:stack="true"
barWidth="30%"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">环形图</span></div>
<FaRingChart
:data="[
{ value: 35, name: '分类A' },
{ value: 28, name: '分类B' },
{ value: 42, name: '分类C' },
]"
:radius="['54%', '70%']"
legendPosition="bottom"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">环形图(中心文字)</span></div>
<FaRingChart
:data="[
{ value: 35, name: '分类A' },
{ value: 28, name: '分类B' },
{ value: 42, name: '分类C' },
{ value: 32, name: '分类D' },
{ value: 26, name: '分类E' },
{ value: 37, name: '分类F' },
]"
:radius="['54%', '70%']"
:showLegend="true"
legendPosition="bottom"
centerText="¥300,458"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">饼图</span></div>
<FaRingChart
:data="[
{ value: 30, name: '分类A' },
{ value: 25, name: '分类B' },
{ value: 45, name: '分类C' },
]"
:radius="['0%', '70%']"
:showLegend="true"
legendPosition="right"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">散点图</span></div>
<FaScatterChart
:data="[
{ value: [1, 3] },
{ value: [2, 4] },
{ value: [3, 5] },
{ value: [4, 6] },
{ value: [5, 7] },
{ value: [6, 8] },
{ value: [7, 7] },
{ value: [8, 9] },
{ value: [9, 8] },
{ value: [10, 6] },
{ value: [11, 7] },
{ value: [12, 8] },
]"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">雷达图</span></div>
<FaRadarChart
:indicator="[
{ name: '销售', max: 100 },
{ name: '管理', max: 100 },
{ name: '技术', max: 100 },
{ name: '客服', max: 100 },
{ name: '开发', max: 100 },
]"
:data="[
{ name: '预算分配', value: [80, 70, 90, 85, 75] },
{ name: '实际开销', value: [70, 75, 85, 80, 70] },
]"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">K线图</span></div>
<FaKLineChart
:data="[
{ time: '2024-01-01', open: 20, close: 23, high: 25, low: 18 },
{ time: '2024-01-02', open: 23, close: 21, high: 24, low: 20 },
{ time: '2024-01-03', open: 21, close: 25, high: 26, low: 21 },
]"
:showDataZoom="false"
:dataZoomStart="0"
:dataZoomEnd="100"
/>
</div>
</ElCol>
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
<div class="fa-card">
<div class="pb-3.5"><span class="text-base font-medium">双向堆叠柱状图</span></div>
<FaDualBarCompareChart
:positiveData="[50, 28, 80, 65, 68, 70, 60, 55]"
:negativeData="[50, 28, 40, 45, 38, 50, 42, 48]"
:xAxisData="[
'0-4岁',
'5-14岁',
'15-24岁',
'25-34岁',
'35-44岁',
'45-54岁',
'55-64岁',
'65岁以上',
]"
positiveName="男性年龄分布"
negativeName="女性年龄分布"
/>
</div>
</ElCol>
</ElRow>
</div>
</template>
<script setup lang="ts">
import type { LineDataItem } from "@/types/component/chart";
import { ref, onMounted } from "vue";
import FaDashboardSkeleton from "@/components/skeleton/fa-dashboard-skeleton.vue";
import { ref } from "vue";
import TodaySales from "./modules/today-sales.vue";
import VisitorInsights from "./modules/visitor-insights.vue";
@@ -268,11 +263,6 @@ import VolumeServiceLevel from "./modules/volume-service-level.vue";
defineOptions({ name: "DashboardAnalysis" });
const loading = ref(true);
onMounted(() => {
loading.value = false;
});
// === 图表演示数据 ===
const multiLineData: LineDataItem[] = [
{
@@ -18,8 +18,6 @@
</template>
<script setup lang="ts">
import type { LineDataItem } from "@/types/component/chart";
const AREA_STYLE_CONFIG = {
startOpacity: 0.08,
endOpacity: 0,
@@ -15,7 +15,7 @@
<div class="mt-2">
<ElRow :gutter="20">
<ElCol :span="6" :xs="24" v-for="(item, index) in salesData" :key="index">
<ElCol :span="6" :xs="24" v-for="item in salesData" :key="item.label">
<div
class="flex px-5 flex-col justify-center h-55 border border-g-300/85 rounded-xl max-lg:mb-4 max-sm:flex-row max-sm:justify-between max-sm:items-center max-sm:h-40"
>
@@ -18,8 +18,6 @@
</template>
<script setup lang="ts">
import type { LineDataItem } from "@/types/component/chart";
/**
* X 轴数据配置
* 表示一年的月份(1-12月)
+30 -18
View File
@@ -181,10 +181,9 @@
<script setup lang="ts">
defineOptions({ name: "Home", inheritAttrs: false });
import { ref, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { ref, onMounted, defineAsyncComponent } from "vue";
import { ElMessage } from "@/utils/message";
import { getDashboardMock } from "@/mock/dashboard";
import FaDashboardSkeleton from "@/components/skeleton/fa-dashboard-skeleton.vue";
import bannerIcon4 from "@imgs/3d/icon4.webp";
import cover2 from "@imgs/cover/img2.webp";
@@ -199,21 +198,34 @@ onMounted(() => {
// const { data } = await DashboardAPI.getStats();
// 并删除 getDashboardMock() 调用
});
import FaCardBanner from "@/components/banners/fa-card-banner/index.vue";
import FaImageCard from "@/components/cards/fa-image-card/index.vue";
import FaDataListCard from "@/components/cards/fa-data-list-card/index.vue";
import FaTimelineListCard from "@/components/cards/fa-timeline-list-card/index.vue";
import FaStatsCard from "@/components/cards/fa-stats-card/index.vue";
import FaLineChartCard from "@/components/cards/fa-line-chart-card/index.vue";
import FaBarChartCard from "@/components/cards/fa-bar-chart-card/index.vue";
import FaDonutChartCard from "@/components/cards/fa-donut-chart-card/index.vue";
import FaProgressCard from "@/components/cards/fa-progress-card/index.vue";
import Banner from "./modules/banner.vue";
import NewUser from "./modules/new-user.vue";
import TodoList from "./modules/todo-list.vue";
import CardList from "./modules/card-list.vue";
import AboutProject from "./modules/about-project.vue";
import QuickLinks from "./modules/quick-links.vue";
// 图表组件异步导入,减少首屏 echarts 加载
const FaLineChartCard = defineAsyncComponent(
() => import("@/components/cards/fa-line-chart-card/index.vue")
);
const FaBarChartCard = defineAsyncComponent(
() => import("@/components/cards/fa-bar-chart-card/index.vue")
);
const FaDonutChartCard = defineAsyncComponent(
() => import("@/components/cards/fa-donut-chart-card/index.vue")
);
// 非关键组件异步导入(延迟加载,提升首屏速度)
const FaCardBanner = defineAsyncComponent(
() => import("@/components/banners/fa-card-banner/index.vue")
);
const FaImageCard = defineAsyncComponent(
() => import("@/components/cards/fa-image-card/index.vue")
);
const FaTimelineListCard = defineAsyncComponent(
() => import("@/components/cards/fa-timeline-list-card/index.vue")
);
const Banner = defineAsyncComponent(() => import("./modules/banner.vue"));
const NewUser = defineAsyncComponent(() => import("./modules/new-user.vue"));
const TodoList = defineAsyncComponent(() => import("./modules/todo-list.vue"));
const CardList = defineAsyncComponent(() => import("./modules/card-list.vue"));
const AboutProject = defineAsyncComponent(() => import("./modules/about-project.vue"));
const QuickLinks = defineAsyncComponent(() => import("./modules/quick-links.vue"));
function handleBannerDemoConfirm() {
// TODO: 接入真实操作
@@ -46,7 +46,6 @@
<script setup lang="ts">
import { computed } from "vue";
import bannerCover from "@imgs/login/lf_icon2.webp";
import FaBasicBanner from "@/components/banners/fa-basic-banner/index.vue";
import { useUserStore } from "@stores";
import { greetings } from "@utils";
import { UserFilled } from "@element-plus/icons-vue";
@@ -1,6 +1,6 @@
<template>
<ElRow :gutter="16">
<ElCol v-for="(item, index) in dataList" :key="index" :sm="12" :md="8" :lg="8" class="mb-5">
<ElCol v-for="item in dataList" :key="item.des" :sm="12" :md="8" :lg="8" class="mb-5">
<div class="fa-card relative flex flex-col justify-center h-30 px-5">
<div class="flex items-center justify-between">
<span class="text-sm text-g-600">{{ item.des }}</span>
@@ -113,14 +113,14 @@ const dataList = ref<CardDataItem[]>([
totalValue: 0,
},
{
des: "总订单",
des: "今日登录",
icon: "ri:eye-line",
iconBg: "bg-primary/10",
iconColor: "text-primary",
num: 0,
rich: true,
animatedCount: 0,
totalLabel: "已支付",
totalLabel: "唯一用户",
totalValue: 0,
},
]);
@@ -143,10 +143,10 @@ async function loadStats() {
dataList.value[1]!.totalValue = `本周 +${stats.week_user_created}`;
dataList.value[1]!.animatedCount = stats.total_users;
// 总订单(第3个卡片)
dataList.value[2]!.num = stats.total_orders;
dataList.value[2]!.totalValue = stats.paid_orders;
dataList.value[2]!.animatedCount = stats.total_orders;
// 今日登录(第3个卡片)
dataList.value[2]!.num = stats.today_login_count;
dataList.value[2]!.totalValue = stats.today_unique_users;
dataList.value[2]!.animatedCount = stats.today_login_count;
} catch {
// 接口错误不影响页面渲染
}
@@ -154,8 +154,6 @@ async function loadStats() {
onMounted(() => {
loadStats();
// 每 30 秒刷新一次
setInterval(loadStats, 30000);
});
</script>
@@ -1,5 +1,5 @@
<template>
<div class="fa-card p-5 pb-3 h-54 max-sm:h-54 flex flex-col">
<div class="fa-card p-5 pb-3 h-54 max-sm:h-54 flex flex-col relative overflow-hidden">
<div class="fa-card-header">
<div class="title">
<h4>
@@ -10,8 +10,12 @@
</h4>
</div>
</div>
<ElScrollbar class="flex-1 min-h-0">
<div v-if="quickLinks.length" class="grid grid-cols-4 gap-2">
<ElScrollbar
v-if="quickLinks.length"
:native="false"
class="flex-1 min-h-0 overflow-clip-margin-none"
>
<div class="grid grid-cols-4 gap-2">
<div
v-for="(item, i) in quickLinks"
:key="item.id || item.href"
@@ -37,8 +41,13 @@
</button>
</div>
</div>
<ElEmpty v-else description="暂无链接" :image-size="50" />
</ElScrollbar>
<ElEmpty
v-else
class="absolute inset-0 flex items-center justify-center"
description="暂无链接"
:image-size="60"
/>
</div>
</template>
@@ -46,7 +55,7 @@
import { onScopeDispose, ref } from "vue";
import { useRouter } from "vue-router";
import { Close, QuestionFilled } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
import { quickStartManager, type QuickLink } from "@utils";
const router = useRouter();
@@ -13,8 +13,8 @@
<ElScrollbar class="h-[calc(100%-40px)]">
<div
class="flex items-center justify-between h-17.5 border-b border-g-300 text-sm last:border-b-0"
v-for="(item, index) in list"
:key="index"
v-for="item in list"
:key="item.target"
>
<div>
<p class="text-sm">
@@ -105,6 +105,12 @@ const containerRef = ref<HTMLDivElement>();
const canvasRef = ref<HTMLCanvasElement>();
const isFullscreen = ref(false);
const updateTime = ref("");
/** 窗口 resize 处理器,保存引用以便在 onUnmounted 中移除 */
function onResize() {
resizeCanvas();
spawnParticles();
}
const tickerItems = ref([
"交易引擎: 23%",
"消息队列: 58%",
@@ -115,6 +121,7 @@ const tickerItems = ref([
]);
const animFrame = shallowRef(0);
let statsTimer = 0;
let timeTickInterval = 0;
const stats = reactive([
{ label: "总交易额", value: "¥128.6万", change: "12.5%", up: true, color: "cyan" as const },
@@ -287,19 +294,18 @@ onMounted(() => {
updateTime.value = new Date().toLocaleTimeString("zh-CN", { hour12: false });
};
timeTick();
window.setInterval(timeTick, 1000);
timeTickInterval = window.setInterval(timeTick, 1000);
initParticles();
statsTimer = window.setInterval(updateStats, 3000);
updateStats();
window.addEventListener("resize", () => {
resizeCanvas();
spawnParticles();
});
window.addEventListener("resize", onResize);
});
onUnmounted(() => {
cancelAnimationFrame(animFrame.value);
clearInterval(statsTimer);
clearInterval(timeTickInterval);
window.removeEventListener("resize", onResize);
document.removeEventListener("fullscreenchange", onFullscreenChange);
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
@@ -7,7 +7,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "ChannelDonut" });
@@ -8,7 +8,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniOrders" });
@@ -8,7 +8,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniRating" });
@@ -8,7 +8,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniRefund" });
@@ -8,7 +8,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniRevenue" });
@@ -8,7 +8,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniTraffic" });
@@ -9,7 +9,7 @@
import { ref, onMounted, onUnmounted } from "vue";
const chartRef = ref<HTMLDivElement>();
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "OrderStatusChart" });
@@ -7,7 +7,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "ProductBar" });
@@ -7,7 +7,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "RealtimeSales" });
@@ -7,7 +7,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "RegionDistributionChart" });
@@ -9,7 +9,7 @@
import { ref, onMounted, onUnmounted } from "vue";
const chartRef = ref<HTMLDivElement>();
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "SalesTrendChart" });
@@ -7,7 +7,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "ServiceLevel" });
@@ -12,13 +12,14 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "SystemGauges" });
const gaugeRefs: Record<string, HTMLDivElement> = {};
const charts: echarts.ECharts[] = [];
let timer = 0;
let isMounted = true;
const gauges = [
{ label: "CPU", value: 42 },
@@ -32,11 +33,16 @@ function setGaugeRef(label: string, el: unknown) {
function waitForSize(el: HTMLDivElement): Promise<void> {
return new Promise((resolve) => {
if (!isMounted) {
resolve();
return;
}
if (el.clientWidth > 0 && el.clientHeight > 0) {
resolve();
return;
}
const check = () => {
if (!isMounted) return;
if (el.clientWidth > 0 && el.clientHeight > 0) {
resolve();
} else {
@@ -123,6 +129,7 @@ onMounted(() => {
});
onUnmounted(() => {
isMounted = false;
clearInterval(timer);
window.removeEventListener("resize", handleResize);
charts.forEach((c) => c.dispose());
@@ -7,7 +7,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "TargetReality" });
@@ -9,7 +9,7 @@
import { ref, onMounted, onUnmounted } from "vue";
const chartRef = ref<HTMLDivElement>();
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "UserDistributionChart" });
@@ -7,7 +7,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "UserProfile" });
@@ -9,7 +9,7 @@
import { ref, onMounted, onUnmounted } from "vue";
const chartRef = ref<HTMLDivElement>();
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "VisitTrendChart" });
@@ -1,36 +1,31 @@
<template>
<div class="flex flex-col relative last:mb-0">
<FaDashboardSkeleton v-if="loading" />
<template v-else>
<ElRow :gutter="24">
<ElCol :sm="12" :md="12" :lg="6" class="mb-5"><TotalOrderVolume /></ElCol>
<ElCol :sm="12" :md="12" :lg="6" class="mb-5"><TotalProducts /></ElCol>
<ElCol :sm="24" :md="12" :lg="6" class="mb-5"><ProductSales /></ElCol>
<ElCol :sm="24" :md="12" :lg="6" class="mb-5"><SalesGrowth /></ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :sm="12" :md="12" :lg="6" class="mb-5"><TotalOrderVolume /></ElCol>
<ElCol :sm="12" :md="12" :lg="6" class="mb-5"><TotalProducts /></ElCol>
<ElCol :sm="24" :md="12" :lg="6" class="mb-5"><ProductSales /></ElCol>
<ElCol :sm="24" :md="12" :lg="6" class="mb-5"><SalesGrowth /></ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :span="24" class="mb-5"><CartConversionRate /></ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :span="24" class="mb-5"><CartConversionRate /></ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :sm="12" :md="12" :lg="6" class="mb-5"><SalesTrend /></ElCol>
<ElCol :sm="12" :md="12" :lg="6" class="mb-5"><SalesClassification /></ElCol>
<ElCol :sm="24" :md="12" :lg="6" class="mb-5"><HotCommodity /></ElCol>
<ElCol :sm="24" :md="12" :lg="6" class="mb-5"><AnnualSales /></ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :sm="12" :md="12" :lg="6" class="mb-5"><SalesTrend /></ElCol>
<ElCol :sm="12" :md="12" :lg="6" class="mb-5"><SalesClassification /></ElCol>
<ElCol :sm="24" :md="12" :lg="6" class="mb-5"><HotCommodity /></ElCol>
<ElCol :sm="24" :md="12" :lg="6" class="mb-5"><AnnualSales /></ElCol>
</ElRow>
<ElRow :gutter="24">
<ElCol :sm="24" :md="12" :lg="12" class="mb-5"><ActiveUser /></ElCol>
<ElCol :sm="24" :md="12" :lg="12" class="mb-5"><SalesOverview /></ElCol>
</ElRow>
</template>
<ElRow :gutter="24">
<ElCol :sm="24" :md="12" :lg="12" class="mb-5"><ActiveUser /></ElCol>
<ElCol :sm="24" :md="12" :lg="12" class="mb-5"><SalesOverview /></ElCol>
</ElRow>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import FaDashboardSkeleton from "@/components/skeleton/fa-dashboard-skeleton.vue";
import ActiveUser from "./modules/active-user.vue";
import SalesOverview from "./modules/sales-overview.vue";
import TotalOrderVolume from "./modules/total-order-volume.vue";
@@ -44,9 +39,4 @@ import SalesGrowth from "./modules/sales-growth.vue";
import CartConversionRate from "./modules/cart-conversion-rate.vue";
defineOptions({ name: "DashboardWorkplace" });
const loading = ref(true);
onMounted(() => {
loading.value = false;
});
</script>
@@ -17,7 +17,7 @@
<p class="mt-1 text-sm">我们为您创建了多个选项,可将它们组合在一起并定制为像素完美的页面</p>
</div>
<div class="flex justify-between mt-2">
<div class="flex-1" v-for="(item, index) in list" :key="index">
<div class="flex-1" v-for="item in list" :key="item.name">
<p class="text-2xl text-g-900">{{ item.num }}</p>
<p class="text-xs text-g-500">{{ item.name }}</p>
</div>
@@ -238,12 +238,43 @@
</ElButton>
</div>
</ElForm>
</div>
<div class="px-5 pb-4">
<ElButton text size="small" @click="router.push('/system/log')">
<FaSvgIcon icon="ri:history-line" class="mr-1" />查看登录日志
</ElButton>
<div class="fa-card-sm my-5">
<h1 class="p-4 text-xl font-normal border-b border-g-300">登录会话</h1>
<div v-if="loadingSessions" class="flex justify-center py-6">
<ElIcon class="is-loading" :size="20">
<Loading />
</ElIcon>
</div>
<div v-else-if="currentSessions.length === 0" class="p-5 text-sm text-g-400 text-center">
暂无活跃会话
</div>
<ElTable v-else :data="currentSessions" stripe class="p-4" size="small">
<ElTableColumn type="index" label="#" width="50" />
<ElTableColumn label="浏览器" min-width="110">
<template #default="{ row }">
<FaSvgIcon :icon="getBrowserIcon(row.browser)" class="mr-1" />{{
row.browser || "—"
}}
</template>
</ElTableColumn>
<ElTableColumn label="操作系统" min-width="110">
<template #default="{ row }">
<FaSvgIcon :icon="getOsIcon(row.os)" class="mr-1" />{{ row.os || "—" }}
</template>
</ElTableColumn>
<ElTableColumn prop="ipaddr" label="IP 地址" min-width="130" />
<ElTableColumn prop="login_location" label="登录位置" min-width="120" />
<ElTableColumn label="登录时间" min-width="160">
<template #default="{ row }">
{{ row.login_time ? formatDate(row.login_time) : "—" }}
</template>
</ElTableColumn>
</ElTable>
</div>
<div class="fa-card-sm my-5">
@@ -306,17 +337,17 @@
import type { FormInstance, UploadRequestOptions, UploadFile } from "element-plus";
import type { ElUpload } from "element-plus";
import UserAPI, { type InfoFormState, type PasswordFormState } from "@/api/module_system/user";
import OnlineAPI from "@/api/module_monitor/online";
import type { OnlineUserTable } from "@/api/module_monitor/online";
import { useUserStore, useDictStore } from "@stores";
import { Camera } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { Camera, Loading } from "@element-plus/icons-vue";
import { ElMessage } from "@/utils/message";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { redirectToLogin, dataURLToFile } from "@utils";
defineOptions({ name: "UserProfile" });
const { t } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const dictStore = useDictStore();
const infoFormRef = ref<FormInstance>();
@@ -338,6 +369,45 @@ const roleTagList = computed(() =>
.filter((n): n is string => !!n && n.trim().length > 0)
);
const currentSessions = ref<OnlineUserTable[]>([]);
const loadingSessions = ref(false);
function getBrowserIcon(browser?: string): string {
if (!browser) return "ri:question-line";
const lower = browser.toLowerCase();
if (lower.includes("chrome")) return "ri:chrome-fill";
if (lower.includes("firefox") || lower.includes("mozilla")) return "ri:firefox-fill";
if (lower.includes("safari")) return "ri:safari-fill";
if (lower.includes("edge")) return "ri:edge-fill";
if (lower.includes("opera")) return "ri:opera-fill";
return "ri:earth-line";
}
function getOsIcon(os?: string): string {
if (!os) return "ri:question-line";
const lower = os.toLowerCase();
if (lower.includes("windows")) return "ri:windows-fill";
if (lower.includes("mac") || lower.includes("darwin")) return "ri:apple-fill";
if (lower.includes("linux")) return "ri:linux-fill";
if (lower.includes("android")) return "ri:android-fill";
if (lower.includes("ios")) return "ri:apple-fill";
return "ri:computer-line";
}
async function fetchCurrentSessions() {
loadingSessions.value = true;
try {
const response = await OnlineAPI.listCurrentOnline();
if (response.data.code === 0) {
currentSessions.value = response.data.data ?? [];
}
} catch {
// ignore
} finally {
loadingSessions.value = false;
}
}
const hasThirdPartyBindings = computed(
() =>
!!(
@@ -680,6 +750,7 @@ onMounted(async () => {
refreshGreeting();
await getOptions();
initInfoForm();
await fetchCurrentSessions();
});
</script>
@@ -157,7 +157,7 @@
<script setup lang="ts">
import { Picture, Paperclip, ArrowDown } from "@element-plus/icons-vue";
import { ElScrollbar } from "element-plus";
import { mittBus } from "@utils";
import meAvatar from "@imgs/avatar/avatar5.webp";
import aiAvatar from "@imgs/avatar/avatar10.webp";
@@ -122,7 +122,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
import { Check } from "@element-plus/icons-vue";
defineOptions({ name: "DashboardPricing" });
@@ -871,7 +871,10 @@
<h3>完整编辑器内容</h3>
<ElTabs v-model="fullActiveTab">
<ElTabPane label="渲染效果" name="preview">
<div class="content-preview" v-html="fullEditorHtml"></div>
<div
class="content-preview"
v-html="DOMPurify.sanitize(fullEditorHtml)"
></div>
</ElTabPane>
<ElTabPane label="HTML源码" name="html">
<ElInput
@@ -889,7 +892,10 @@
<h3>简化编辑器内容</h3>
<ElTabs v-model="simpleActiveTab">
<ElTabPane label="渲染效果" name="preview">
<div class="content-preview" v-html="simpleEditorHtml"></div>
<div
class="content-preview"
v-html="DOMPurify.sanitize(simpleEditorHtml)"
></div>
</ElTabPane>
<ElTabPane label="HTML源码" name="html">
<ElInput
@@ -1036,6 +1042,7 @@ import { computed, ref } from "vue";
import lockImg from "@imgs/lock/bg_dark.webp";
import { MANUAL_MODULES_AFTER_SYSTEM, MANUAL_SYSTEM_TAIL_PAGES } from "./manualSections";
import { manualModuleMatchesQuery, manualPageMatchesQuery } from "./manualTocSearch";
import DOMPurify from "dompurify";
defineOptions({ name: "DashboardTutorial" });
@@ -66,7 +66,7 @@ export const MANUAL_SYSTEM_TAIL_PAGES: CompactManualPage[] = [
"检索:字典类型 FaSearchBar + 分页表。",
"工具栏:新增类型、导出类型、批量删除、批量更多(patch)、刷新、列配置。",
"类型弹层:详情 / 新增编辑 Dialog。",
"子层:`DataDrawer` 维护某字典类型下的字典数据项(二级)。",
"子层:`DictDataPanel` 维护某字典类型下的字典数据项(二级)。",
"权限:`module_system:dict_type:*` 与字典数据接口权限(见行内按钮)。",
],
},
@@ -1,248 +1,257 @@
<template>
<div class="ai-model-config">
<div v-if="loading" class="loading-tip">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
<template v-else>
<!-- 顶部状态栏 -->
<div class="status-bar">
<div class="status-info">
<span class="status-title">当前使用</span>
<ElTag :type="activeId ? 'primary' : 'info'" effect="dark" size="small">
<ElIcon class="tag-icon"><CircleCheck v-if="activeId" /><Cpu v-else /></ElIcon>
<span>{{ activeModelName }}</span>
</ElTag>
</div>
<ElButton
v-if="items.length > 0"
:disabled="!activeId"
size="small"
plain
@click="handleUseDefault"
>
<ElIcon><RefreshLeft /></ElIcon>
<span>恢复系统默认</span>
</ElButton>
<FaDrawer v-model="drawerVisible" title="配置中心" size="60%" destroy-on-close>
<div class="ai-model-config">
<div v-if="loading" class="loading-tip">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
<!-- 配置列表 -->
<div class="config-section">
<div class="section-header">
<div class="header-left">
<span class="section-title">已配置的模型</span>
<ElTag v-if="items.length > 0" size="small" effect="plain" type="info">
{{ items.length }} 个
<template v-else>
<!-- 顶部状态栏 -->
<div class="status-bar">
<div class="status-info">
<span class="status-title">当前使用</span>
<ElTag :type="activeId ? 'primary' : 'info'" effect="dark" size="small">
<ElIcon class="tag-icon"><CircleCheck v-if="activeId" /><Cpu v-else /></ElIcon>
<span>{{ activeModelName }}</span>
</ElTag>
</div>
</div>
<!-- 空状态 - 直接显示添加按钮作为唯一行动 -->
<div v-if="items.length === 0" class="empty-state">
<div class="empty-illust">
<ElIcon class="empty-icon" :size="56"><Cpu /></ElIcon>
<ElIcon class="empty-icon-bg" :size="100"><ChatLineSquare /></ElIcon>
</div>
<div class="empty-title">添加你的第一个 AI 模型</div>
<div class="empty-desc">
支持 OpenAI、DeepSeek、Ollama 等任何 OpenAI 兼容服务<br />
配置后即可在 AI 助手页一键切换
</div>
<ElButton type="primary" size="large" :icon="Plus" @click="openCreate">
立即添加
<ElButton
v-if="items.length > 0"
:disabled="!activeId"
size="small"
plain
@click="handleUseDefault"
>
<ElIcon><RefreshLeft /></ElIcon>
<span>恢复系统默认</span>
</ElButton>
</div>
<!-- 列表 -->
<div v-else class="config-list">
<TransitionGroup name="list" tag="div" class="list-inner">
<div
v-for="item in items"
:key="item.id"
class="config-item"
:class="{
active: item.id === activeId,
expanded: expandedId === item.id,
flash: flashId === item.id,
}"
@click="handleItemClick(item)"
>
<div class="config-item-main">
<div class="item-icon-wrap">
<ElIcon class="item-icon" :size="18">
<CircleCheck v-if="item.id === activeId" />
<ChatLineSquare v-else />
</ElIcon>
</div>
<div class="item-content">
<div class="item-row1">
<span class="item-name">{{ item.name }}</span>
<ElTag v-if="item.id === activeId" type="success" size="small" effect="light">
使用中
</ElTag>
</div>
<div class="item-model">{{ item.model_id }}</div>
</div>
<div class="item-actions" @click.stop>
<ElTooltip content="展开详情" placement="top" :show-after="200">
<ElButton text circle size="small" @click="toggleExpand(item.id)">
<ElIcon :class="{ rotated: expandedId === item.id }">
<ArrowDown />
</ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip content="编辑" placement="top" :show-after="200">
<ElButton text circle size="small" :icon="Edit" @click="openEdit(item)" />
</ElTooltip>
<ElTooltip content="删除" placement="top" :show-after="200">
<ElButton text circle size="small" :icon="Delete" @click="handleDelete(item)" />
</ElTooltip>
</div>
</div>
<!-- 展开详情 -->
<div v-show="expandedId === item.id" class="item-detail">
<div class="detail-row">
<span class="detail-label">Base URL</span>
<span class="detail-value">{{ item.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">API Key</span>
<div class="api-key-wrap">
<span class="detail-value api-key">
{{ showKeyId === item.id ? item.api_key : `****${maskKey(item.api_key)}` }}
</span>
<ElButton
text
size="small"
@click="showKeyId = showKeyId === item.id ? null : item.id"
>
<ElIcon><View v-if="showKeyId !== item.id" /><Hide v-else /></ElIcon>
</ElButton>
<ElButton
text
size="small"
:disabled="!item.api_key"
@click="copyKey(item.api_key)"
>
<ElIcon><CopyDocument /></ElIcon>
</ElButton>
</div>
</div>
<div class="detail-row">
<span class="detail-label">Temperature</span>
<span class="detail-value">{{ item.temperature.toFixed(1) }}</span>
</div>
<div v-if="item.created_time" class="detail-row">
<span class="detail-label">添加于</span>
<span class="detail-value">{{ item.created_time }}</span>
</div>
</div>
<!-- 配置列表 -->
<div class="config-section">
<div class="section-header">
<div class="header-left">
<span class="section-title">已配置的模型</span>
<ElTag v-if="items.length > 0" size="small" effect="plain" type="info">
{{ items.length }} 个
</ElTag>
</div>
</TransitionGroup>
</div>
<!-- 空状态 - 直接显示添加按钮作为唯一行动 -->
<div v-if="items.length === 0" class="empty-state">
<div class="empty-illust">
<ElIcon class="empty-icon" :size="56"><Cpu /></ElIcon>
<ElIcon class="empty-icon-bg" :size="100"><ChatLineSquare /></ElIcon>
</div>
<div class="empty-title">添加你的第一个 AI 模型</div>
<div class="empty-desc">
支持 OpenAI、DeepSeek、Ollama 等任何 OpenAI 兼容服务<br />
配置后即可在 AI 助手页一键切换
</div>
<ElButton type="primary" size="large" :icon="Plus" @click="openCreate">
立即添加
</ElButton>
</div>
<!-- 列表 -->
<div v-else class="config-list">
<TransitionGroup name="list" tag="div" class="list-inner">
<div
v-for="item in items"
:key="item.id"
class="config-item"
:class="{
active: item.id === activeId,
expanded: expandedId === item.id,
flash: flashId === item.id,
}"
@click="handleItemClick(item)"
>
<div class="config-item-main">
<div class="item-icon-wrap">
<ElIcon class="item-icon" :size="18">
<CircleCheck v-if="item.id === activeId" />
<ChatLineSquare v-else />
</ElIcon>
</div>
<div class="item-content">
<div class="item-row1">
<span class="item-name">{{ item.name }}</span>
<ElTag v-if="item.id === activeId" type="success" size="small" effect="light">
使用中
</ElTag>
</div>
<div class="item-model">{{ item.model_id }}</div>
</div>
<div class="item-actions" @click.stop>
<ElTooltip content="展开详情" placement="top" :show-after="200">
<ElButton text circle size="small" @click="toggleExpand(item.id)">
<ElIcon :class="{ rotated: expandedId === item.id }">
<ArrowDown />
</ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip content="编辑" placement="top" :show-after="200">
<ElButton text circle size="small" :icon="Edit" @click="openEdit(item)" />
</ElTooltip>
<ElTooltip content="删除" placement="top" :show-after="200">
<ElButton
text
circle
size="small"
:icon="Delete"
@click="handleDelete(item)"
/>
</ElTooltip>
</div>
</div>
<!-- 展开详情 -->
<div v-show="expandedId === item.id" class="item-detail">
<div class="detail-row">
<span class="detail-label">Base URL</span>
<span class="detail-value">{{ item.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">API Key</span>
<div class="api-key-wrap">
<span class="detail-value api-key">
{{ showKeyId === item.id ? item.api_key : `****${maskKey(item.api_key)}` }}
</span>
<ElButton
text
size="small"
@click="showKeyId = showKeyId === item.id ? null : item.id"
>
<ElIcon><View v-if="showKeyId !== item.id" /><Hide v-else /></ElIcon>
</ElButton>
<ElButton
text
size="small"
:disabled="!item.api_key"
@click="copyKey(item.api_key)"
>
<ElIcon><CopyDocument /></ElIcon>
</ElButton>
</div>
</div>
<div class="detail-row">
<span class="detail-label">Temperature</span>
<span class="detail-value">{{ item.temperature.toFixed(1) }}</span>
</div>
<div v-if="item.created_time" class="detail-row">
<span class="detail-label">添加于</span>
<span class="detail-value">{{ item.created_time }}</span>
</div>
</div>
</div>
</TransitionGroup>
</div>
</div>
</div>
<!-- 底部固定添加按钮 - 始终可见 -->
<div v-if="items.length > 0" class="footer-add">
<ElButton type="primary" plain :icon="Plus" class="add-btn" @click="openCreate">
添加新模型
</ElButton>
</div>
</template>
<!-- 新增/编辑弹窗 -->
<FaDialog
v-model="dialogVisible"
:title="form.id ? '编辑模型' : '新增模型'"
width="520px"
:close-on-click-modal="false"
>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
label-position="right"
@submit.prevent="handleSave"
>
<ElFormItem label="配置名称" prop="name">
<ElInput
v-model="form.name"
placeholder="如:日常对话 / 代码助手"
maxlength="50"
show-word-limit
clearable
autofocus
/>
</ElFormItem>
<ElFormItem label="Base URL" prop="base_url">
<ElInput v-model="form.base_url" placeholder="https://api.openai.com/v1" clearable>
<template #append>
<ElDropdown trigger="click" @command="(v: string) => (form.base_url = v)">
<ElButton text size="small">
预设
<ElIcon><ArrowDown /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="preset in baseUrlPresets"
:key="preset.label"
:command="preset.url"
>
<div class="preset-item">
<span class="preset-label">{{ preset.label }}</span>
<span class="preset-url">{{ preset.url }}</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="API Key" prop="api_key">
<ElInput
v-model="form.api_key"
type="password"
placeholder="sk-..."
show-password
clearable
/>
</ElFormItem>
<ElFormItem label="模型 ID" prop="model_id">
<ElInput
v-model="form.model_id"
placeholder="如:gpt-4o-mini / deepseek-chat"
clearable
/>
</ElFormItem>
<ElFormItem label="Temperature" prop="temperature">
<ElSlider
v-model="form.temperature"
:min="0"
:max="2"
:step="0.1"
show-input
:show-input-controls="false"
/>
<div class="form-tip">越高越有创造性,0 更确定</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="handleSave">
<ElIcon><Check /></ElIcon>
<span>{{ form.id ? "保存" : "新增并使用" }}</span>
</ElButton>
<!-- 底部固定添加按钮 - 始终可见 -->
<div v-if="items.length > 0" class="footer-add">
<ElButton type="primary" plain :icon="Plus" class="add-btn" @click="openCreate">
添加新模型
</ElButton>
</div>
</template>
</FaDialog>
</div>
<!-- 新增/编辑弹窗 -->
<FaDialog
v-model="dialogVisible"
:title="form.id ? '编辑模型' : '新增模型'"
width="520px"
:close-on-click-modal="false"
>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
label-position="right"
@submit.prevent="handleSave"
>
<ElFormItem label="配置名称" prop="name">
<ElInput
v-model="form.name"
placeholder="如:日常对话 / 代码助手"
maxlength="50"
show-word-limit
clearable
autofocus
/>
</ElFormItem>
<ElFormItem label="Base URL" prop="base_url">
<ElInput v-model="form.base_url" placeholder="https://api.openai.com/v1" clearable>
<template #append>
<ElDropdown trigger="click" @command="(v: string) => (form.base_url = v)">
<ElButton text size="small">
预设
<ElIcon><ArrowDown /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="preset in baseUrlPresets"
:key="preset.label"
:command="preset.url"
>
<div class="preset-item">
<span class="preset-label">{{ preset.label }}</span>
<span class="preset-url">{{ preset.url }}</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="API Key" prop="api_key">
<ElInput
v-model="form.api_key"
type="password"
placeholder="sk-..."
show-password
clearable
/>
</ElFormItem>
<ElFormItem label="模型 ID" prop="model_id">
<ElInput
v-model="form.model_id"
placeholder="如:gpt-4o-mini / deepseek-chat"
clearable
/>
</ElFormItem>
<ElFormItem label="Temperature" prop="temperature">
<ElSlider
v-model="form.temperature"
:min="0"
:max="2"
:step="0.1"
show-input
:show-input-controls="false"
/>
<div class="form-tip">越高越有创造性,0 更确定</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="handleSave">
<ElIcon><Check /></ElIcon>
<span>{{ form.id ? "保存" : "新增并使用" }}</span>
</ElButton>
</template>
</FaDialog>
</div>
</FaDrawer>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { ElMessage, ElMessageBox } from "@/utils/message";
import type { FormInstance, FormRules } from "element-plus";
import {
Plus,
Edit,
@@ -264,7 +273,20 @@ import AiChatAPI, {
type AiModelConfigList,
} from "@/api/module_ai/chat";
const emit = defineEmits<{ changed: [] }>();
const emit = defineEmits<{
(e: "changed"): void;
(e: "update:modelValue", value: boolean): void;
}>();
interface Props {
modelValue?: boolean;
}
const props = withDefaults(defineProps<Props>(), { modelValue: false });
const drawerVisible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit("update:modelValue", val),
});
const loading = ref(false);
const saving = ref(false);
@@ -116,7 +116,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
import {
Promotion,
Paperclip,
@@ -17,7 +17,7 @@
<script setup lang="ts">
import { ref, nextTick, watch } from "vue";
import { ElScrollbar } from "element-plus";
import WelcomeScreen from "./FaWelcomeScreen.vue";
import FaMessageItem from "./FaMessageItem.vue";
import type { ChatMessage } from "../types";
@@ -63,7 +63,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
import {
User,
ChatDotRound,
@@ -10,7 +10,12 @@
<ElScrollbar class="sidebar-content" view-class="p-4">
<template v-if="!isCollapsed">
<div class="new-session-section">
<ElButton type="primary" class="new-session-btn" @click="handleNewSession">
<ElButton
v-hasPerm="['module_ai:chat:create']"
type="primary"
class="new-session-btn"
@click="handleNewSession"
>
<ElIcon class="btn-icon"><Plus /></ElIcon>
<span>开启新对话</span>
</ElButton>
@@ -56,8 +61,15 @@
<ElIcon class="more-icon" @click.stop><MoreFilled /></ElIcon>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="rename">重命名</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除</ElDropdownItem>
<ElDropdownItem v-hasPerm="['module_ai:chat:update']" command="rename"
>重命名</ElDropdownItem
>
<ElDropdownItem
v-hasPerm="['module_ai:chat:delete']"
command="delete"
divided
>删除</ElDropdownItem
>
</ElDropdownMenu>
</template>
</ElDropdown>
@@ -113,7 +125,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, ElScrollbar } from "element-plus";
import { ElMessage, ElMessageBox } from "@/utils/message";
import {
ChatLineRound,
User,
@@ -254,11 +266,7 @@ const handleSessionCommand = async (command: string, session: ChatSession) => {
}
} else if (command === "delete") {
try {
await ElMessageBox.confirm("确定要删除此会话吗?", "确认删除", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`确定删除「${session.title}」吗?`);
await AiChatAPI.deleteSession([session.id]);
const index = sessions.value.findIndex((s: ChatSession) => s.id === session.id);
if (index > -1) {
@@ -44,7 +44,7 @@
</ElContainer>
</ElContainer>
<FaConfigInfoDrawer v-model="configDrawerVisible" />
<FaAiModelConfigPanel v-model="configDrawerVisible" />
</div>
</template>
@@ -55,7 +55,7 @@ defineOptions({
});
import { ref, onMounted, onUnmounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "@/utils/message";
import AiChatAPI, { ChatSession } from "@/api/module_ai/chat";
import { Auth } from "@utils/auth";
import type { ChatMessage, UploadedFile } from "./types";
@@ -63,7 +63,7 @@ import FaSidebar from "./components/FaSidebar.vue";
import FaChatNavbar from "./components/FaChatNavbar.vue";
import FaChatMessages from "./components/FaChatMessages.vue";
import FaChatInput from "./components/FaChatInput.vue";
import FaConfigInfoDrawer from "@/components/layouts/fa-header-bar/widgets/FaConfigInfoDrawer.vue";
import FaAiModelConfigPanel from "./components/FaAiModelConfigPanel.vue";
// 状态
const messages = ref<ChatMessage[]>([]);
@@ -94,8 +94,8 @@
<ElDivider content-position="left">消息记录</ElDivider>
<ElTimeline v-if="detailFormData.messages && detailFormData.messages.length > 0">
<ElTimelineItem
v-for="(msg, index) in detailFormData.messages"
:key="index"
v-for="msg in detailFormData.messages"
:key="msg.id"
:type="msg.role === 'user' ? 'primary' : 'success'"
:icon="msg.role === 'user' ? 'User' : 'ChatDotRound'"
>
@@ -144,19 +144,13 @@ defineOptions({
import { ref, reactive, computed, nextTick } from "vue";
import { Edit } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
import AiChatAPI, { type ChatSession, type ChatSessionDetail } from "@/api/module_ai/chat";
import { useTable } from "@/hooks/core/useTable";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
import type { 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 { ColumnOption } from "@/types/component";
import { useAuth } from "@/hooks/core/useAuth";
import { formatToDateTime, renderTableOperationCell, type TableOperationAction } from "@utils";
import { formatToDateTime, type TableOperationAction } from "@utils";
type MemorySearchForm = {
title?: string;
@@ -186,7 +180,7 @@ const { hasAuth } = useAuth();
const memorySearchItems = computed<SearchFormItem[]>(() => [
{
label: "标题",
label: "会话标题",
key: "title",
type: "input",
placeholder: "请输入标题",
@@ -213,13 +207,14 @@ const editingRowId = ref<string | null>(null);
const editingTitle = ref("");
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
const { selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection<ChatSession>();
const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
useTableSelection<ChatSession>();
const createLoading = ref(false);
async function deleteSessionRow(id: string) {
async function deleteSessionRow(id: string, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await AiChatAPI.deleteSession([id]);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
@@ -232,7 +227,10 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
selectedRows.value.map((r) => (r as any)?.name ?? r?.title ?? r?.id)
);
batchDeleting.value = true;
await AiChatAPI.deleteSession(ids as unknown as string[]);
faTableRef.value?.elTableRef?.clearSelection();
@@ -451,7 +449,7 @@ function buildMemoryRowActions(row: ChatSession): TableOperationAction[] {
icon: "ri:delete-bin-4-line",
perm: "module_ai:chat:delete",
run: () => {
deleteSessionRow(row.id);
deleteSessionRow(row.id, (row as any)?.name ?? row?.title ?? row?.id);
},
},
];
@@ -101,7 +101,7 @@
<div class="flex flex-col gap-2">
<div
v-for="(item, index) in metadataList"
:key="index"
:key="item.key"
class="flex items-center gap-2"
>
<ElInput v-model="item.key" placeholder="键" />
@@ -151,26 +151,15 @@
</template>
<script setup lang="ts">
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
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 { confirmDelete, confirmBatchDelete, confirmAction } from "@/hooks/core/useConfirm";
import { stripPaginationParams } from "@/utils/query";
import type { TableOperationAction } from "@/utils/table";
import type { IContentConfig, IObject } from "@/components/modal/types";
import type { AuditSearchFormParams } from "@/components/forms/fa-search-bar/auditSearchFormItems";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaJsonPretty from "@/components/others/fa-json-pretty/index.vue";
import type { ColumnOption } from "@/types/component";
import DemoAPI, {
type DemoForm,
type DemoPageQuery,
type DemoTable,
} from "@/api/module_example/demo";
import { ResultEnum } from "@/enums/api/result.enum";
import { ElMessage } from "element-plus";
defineOptions({
name: "Demo",
@@ -331,7 +320,7 @@ const {
/** 供 CrudImportModal / CrudExportModal 的列配置(与 CrudContent.cols 结构一致) */
const demoCrudCols = computed(() =>
columns.value.map((c: ColumnOption<DemoTable>) => {
(columns?.value ?? []).map((c: ColumnOption<DemoTable>) => {
const t = (c as { type?: string }).type;
return {
prop: c.prop,
@@ -642,7 +631,7 @@ async function handleSubmit() {
dialogVisible.visible = false;
await resetForm();
} catch (error: unknown) {
console.error(error);
if (import.meta.env.DEV) console.error(error);
}
});
}
@@ -704,8 +693,8 @@ async function handleCrudImportUpload(formData: FormData) {
await refreshData();
}
// 非 SUCCESS 分支提示由 axios 拦截器统一处理
} catch (error) {
console.error("[Import]", error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("[Import]", error);
/* 接口错误已由拦截器提示 */
}
}
@@ -68,7 +68,7 @@ import { ref, watch } from "vue";
import Codemirror from "codemirror-editor-vue3";
import type { EditorConfiguration } from "codemirror";
import type { CmComponentRef } from "codemirror-editor-vue3";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
import { ArrowDown, CopyDocument } from "@element-plus/icons-vue";
import { useClipboard } from "@vueuse/core";
import { useSettingsStore } from "@stores";
@@ -91,7 +91,6 @@ import { computed, inject, onUnmounted, ref, watch } from "vue";
import Codemirror from "codemirror-editor-vue3";
import type { EditorConfiguration } from "codemirror";
import type { CmComponentRef } from "codemirror-editor-vue3";
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
import { resolveLocalIconUrl, resolveIconForFaSvgIcon } from "@utils";
import { CopyDocument } from "@element-plus/icons-vue";
import { GENCODE_CM_KEY } from "../gencodeInjectionKeys";
@@ -41,9 +41,10 @@
<ElTable
ref="tableRef"
:data="data"
:height="tableHeight"
height="calc(100vh - 460px)"
@row-click="onRowClick"
@selection-change="onSelectionChange"
class="mb-5"
>
<template #empty>
<ElEmpty :image-size="80" description="暂无数据" />
@@ -90,7 +91,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref } from "vue";
import type { FormInstance, TableInstance } from "element-plus";
import type { DBTableSchema, GenTablePageQuery } from "@/api/module_generator/gencode";
@@ -117,12 +118,6 @@ const emit = defineEmits<Emits>();
const importQueryRef = ref<FormInstance>();
const tableRef = ref<TableInstance>();
const isFullscreen = ref(false);
// 根据全屏状态计算表格高度
const tableHeight = computed(() => {
return isFullscreen.value ? "calc(100vh - 320px)" : "100%";
});
function onRowClick(row: DBTableSchema) {
tableRef.value?.toggleRowSelection(row);
@@ -143,7 +143,8 @@ import { useClipboard } from "@vueuse/core";
import { useRoute } from "vue-router";
import type { EditorConfiguration } from "codemirror";
import type { CmComponentRef } from "codemirror-editor-vue3";
import { ElMessage, ElMessageBox, type FormInstance } from "element-plus";
import { ElMessage, ElMessageBox } from "@/utils/message";
import type { FormInstance } from "element-plus";
import { Plus, Upload, Delete, Download } from "@element-plus/icons-vue";
import GencodeAPI, {
type GenTableSchema,
@@ -154,16 +155,13 @@ import MenuAPI, { MenuTable } from "@/api/module_system/menu";
import DictAPI, { DictTable } from "@/api/module_system/dict";
import { MenuTypeEnum } from "@/enums";
import { useSettingsStore } from "@stores";
import { useTable } from "@/hooks/core/useTable";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaGenCodeDrawer from "./components/FaGenCodeDrawer.vue";
import FaImportDbTableDialog from "./components/FaImportDbTableDialog.vue";
import FaCreateTableDialog from "./components/FaCreateTableDialog.vue";
import { GENCODE_BASIC_FORM_KEY, GENCODE_CM_KEY } from "./gencodeInjectionKeys";
import type { ColumnOption } from "@/types/component";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import type { TableOperationAction } from "@utils";
import type { TreeNode } from "./types";
// 文件数据接口
@@ -469,8 +467,8 @@ async function handlePreview(row: GenTableSchema): Promise<void> {
preview.open = true;
preview.active_name = "model.py";
} catch (error) {
console.error("预览代码失败:", error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("预览代码失败:", error);
} finally {
previewLoading.value = false;
}
@@ -523,8 +521,8 @@ async function handleGenTable(targetGenType: string, row?: GenTableSchema): Prom
URL.revokeObjectURL(url);
ElMessage.success("已开始下载 code.zip");
}
} catch (error) {
console.error("生成代码失败:", error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("生成代码失败:", error);
} finally {
loading.value = false;
}
@@ -790,21 +788,7 @@ const gencodeSearchItems = computed<SearchFormItem[]>(() => [
},
]);
const {
columns,
columnChecks,
data: tableListData,
loading: tableLoading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
refreshCreate,
refreshRemove,
} = useTable({
const useTableResult = useTable({
core: {
apiFn: GencodeAPI.listTable,
apiParams: {
@@ -856,6 +840,23 @@ const {
},
});
// 列配置类型收窄:运行时结构一致,显式断言兼容 FaTableHeader/FaTable 的 ColumnOption[] 期望
const columns = useTableResult.columns as ComputedRef<ColumnOption[]>;
const columnChecks = useTableResult.columnChecks as Ref<ColumnOption[]>;
const {
data: tableListData,
loading: tableLoading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
refreshCreate,
refreshRemove,
} = useTableResult;
listRefresh.refreshData = refreshData;
listRefresh.refreshCreate = refreshCreate;
listRefresh.refreshRemove = refreshRemove;
+45 -32
View File
@@ -126,8 +126,8 @@
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" show-overflow-tooltip />
<ElTableColumn label="操作" width="60" align="center">
<ElTableColumn prop="remark" label="备注" width="200" show-overflow-tooltip />
<ElTableColumn label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<ElPopconfirm
:title="`确认删除缓存 ${row.cache_name} 吗?`"
@@ -172,7 +172,7 @@
<div class="cache-table-wrap">
<ElTable
:loading="subLoading"
:data="cacheKeys.map((key) => ({ cacheKey: key }))"
:data="filteredCacheKeys.map((key) => ({ cacheKey: key }))"
row-key="cacheKey"
>
<template #empty>
@@ -190,7 +190,7 @@
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="60" align="center">
<ElTableColumn label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<ElPopconfirm
:title="`确认删除键 ${row.cacheKey} 吗?`"
@@ -262,14 +262,14 @@
</template>
<script lang="ts" setup>
import { ElMessageBox } from "element-plus";
import { ElMessageBox } from "@/utils/message";
import CacheAPI, {
type CacheInfo,
type CacheForm,
type CacheMonitor,
type RedisInfo,
} from "@/api/module_monitor/cache";
import * as echarts from "echarts";
import { echarts } from "@/plugins/echarts";
import { useWindowSize } from "@vueuse/core";
defineOptions({ name: "CacheMonitor" });
@@ -277,13 +277,19 @@ defineOptions({ name: "CacheMonitor" });
const { width: winWidth } = useWindowSize();
const descColumns = computed(() => (winWidth.value < 768 ? 2 : 6));
let refreshTimer: ReturnType<typeof setInterval> | undefined;
const cacheNames = ref<CacheInfo[]>([]);
const cacheKeys = ref<string[]>([]);
const loading = ref(true);
const subLoading = ref(false);
const nowCacheName = ref("");
const cacheKeySearch = ref("");
/** 根据搜索关键词过滤缓存键名列表 */
const filteredCacheKeys = computed(() => {
const query = cacheKeySearch.value.trim().toLowerCase();
if (!query) return cacheKeys.value;
return cacheKeys.value.filter((key) => key.toLowerCase().includes(query));
});
const commandstats = ref<HTMLElement | null>(null);
const usedmemory = ref<HTMLElement | null>(null);
const cache = ref<CacheMonitor>({
@@ -311,8 +317,8 @@ const getCacheNameList = async () => {
const response = await CacheAPI.getCacheNames();
cacheNames.value = response.data.data;
resetCacheForm();
} catch (error) {
console.error("获取缓存列表出错:", error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("获取缓存列表出错:", error);
} finally {
loading.value = false;
}
@@ -322,16 +328,16 @@ const refreshCacheNames = () => {
getCacheNameList();
};
const handleClearCacheName = async (row: CacheInfo) => {
const handleClearCacheName = async (row: any) => {
try {
await CacheAPI.deleteCacheName(row.cache_name);
refreshCacheNames();
} catch (error) {
console.error("清理缓存名称出错:", error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("清理缓存名称出错:", error);
}
};
const getCacheKeyList = async (row?: CacheInfo) => {
const getCacheKeyList = async (row?: any) => {
try {
const cacheName = row?.cache_name || nowCacheName.value;
if (!cacheName) return;
@@ -340,8 +346,8 @@ const getCacheKeyList = async (row?: CacheInfo) => {
cacheKeys.value = response.data.data;
nowCacheName.value = cacheName;
cacheForm.value = { cache_name: cacheName, cache_key: "", cache_value: "" };
} catch (error) {
console.error("获取缓存键名列表出错:", error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("获取缓存键名列表出错:", error);
} finally {
subLoading.value = false;
}
@@ -355,8 +361,10 @@ async function handleClearCacheKey(cacheKey: string) {
try {
await CacheAPI.deleteCacheKey(cacheKey);
getCacheKeyList();
} catch (error) {
console.error("清理缓存键名出错:", error);
ElMessage.success("缓存键已清理");
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("清理缓存键名出错:", error);
ElMessage.error("清理缓存键失败,请稍后重试");
}
}
@@ -365,8 +373,9 @@ async function handleCacheValue(cacheKey: string) {
loading.value = true;
const response = await CacheAPI.getCacheValue(nowCacheName.value, cacheKey);
cacheForm.value = response.data.data;
} catch (error) {
console.error("获取缓存内容失败:", error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("获取缓存内容失败:", error);
ElMessage.error("获取缓存内容失败,请稍后重试");
} finally {
loading.value = false;
}
@@ -381,9 +390,11 @@ const handleClearCacheAll = async () => {
});
await CacheAPI.deleteCacheAll();
getCacheNameList();
ElMessage.success("全部缓存已清理");
} catch (error: unknown) {
if (error !== "cancel") {
console.error("清理全部缓存失败:", error);
if (import.meta.env.DEV) console.error("清理全部缓存失败:", error);
ElMessage.error("清理全部缓存失败,请稍后重试");
}
}
};
@@ -392,10 +403,11 @@ const getInfo = async () => {
try {
loading.value = true;
const response = await CacheAPI.getCacheInfo();
cache.value = response.data.data || { info: {}, command_stats: [], dbSize: 0 };
cache.value = response.data.data || { info: {}, command_stats: [], db_size: 0 };
initCharts();
} catch (error) {
console.error("获取缓存监控数据失败:", error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("获取缓存监控数据失败:", error);
ElMessage.error("获取缓存监控数据失败");
} finally {
loading.value = false;
}
@@ -451,18 +463,21 @@ const initCharts = () => {
});
};
const handleChartResize = () => {
commandstatsInstance?.resize();
usedmemoryInstance?.resize();
};
onMounted(() => {
getCacheNameList();
getInfo();
// Redis 监控信息自动刷新(30 秒轮询)
refreshTimer = setInterval(() => {
getInfo();
getCacheNameList();
}, 30000);
// Redis 监控信息加载一次,不自动轮询
// 窗口 resize 时自适应图表
window.addEventListener("resize", handleChartResize);
});
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer);
window.removeEventListener("resize", handleChartResize);
commandstatsInstance?.dispose();
usedmemoryInstance?.dispose();
});
@@ -506,7 +521,6 @@ onUnmounted(() => {
gap: 16px;
height: 100%;
min-height: 0;
overflow-y: auto;
}
.chart-card {
@@ -534,7 +548,6 @@ onUnmounted(() => {
flex-direction: column;
height: 100%;
min-height: 0;
overflow-y: auto;
}
.cache-mgmt-col {
@@ -57,14 +57,11 @@ defineOptions({
});
import { h, ref, computed } from "vue";
import { useTable } from "@/hooks/core/useTable";
import OnlineAPI, { type OnlineUserTable } from "@/api/module_monitor/online";
import type { ColumnOption } from "@/types/component";
import { ElMessageBox } from "element-plus";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import { ElMessageBox } from "@/utils/message";
import type { TableOperationAction } from "@/utils/table";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import FaCopyButton from "@/components/others/fa-copy-button/index.vue";
const { hasAuth } = useAuth();
@@ -281,13 +278,9 @@ function handleClearAll() {
})();
}
// 在线用户列表自动刷新(30 秒轮询)
let refreshTimer: ReturnType<typeof setInterval> | undefined;
// 列表数据在页面挂载时加载一次,不自动轮询
onMounted(() => {
refreshTimer = setInterval(() => refreshData(), 30000);
});
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer);
refreshData();
});
</script>
@@ -32,7 +32,7 @@
<ElBreadcrumb separator="/">
<ElBreadcrumbItem
v-for="(item, index) in breadcrumbList"
:key="index"
:key="item.name"
:class="{ 'is-link': index < breadcrumbList.length - 1 }"
@click="handleBreadcrumbClick(item)"
>
@@ -217,13 +217,10 @@ import {
QuestionFilled,
UploadFilled,
} from "@element-plus/icons-vue";
import { useTable } from "@/hooks/core/useTable";
import { ResourceAPI, type ResourceItem } from "@/api/module_monitor/resource";
import type { ColumnOption } from "@/types/component";
import { useAuth } from "@/hooks/core/useAuth";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import type { TableOperationAction } from "@/utils/table";
const { hasAuth } = useAuth();
@@ -241,6 +238,7 @@ function fetchResourceTableList(params: Record<string, unknown>) {
return ResourceAPI.listResource({
page_no: 1,
page_size: 10,
include_hidden: showHiddenFiles.value,
...params,
});
}
@@ -281,7 +279,6 @@ const renameDialogVisible = ref(false);
const uploading = ref(false);
const batchDeleting = ref(false);
const uploadRef = ref();
const uploadFileList = ref<UploadUserFile[]>([]);
const createDirForm = reactive({
@@ -391,7 +388,7 @@ const {
{
prop: "operation",
label: "操作",
width: 136,
width: 180,
fixed: "right",
align: "center",
formatter: (row: ResourceItem) => formatResourceOperationCell(row),
@@ -501,7 +498,8 @@ async function handleUploadConfirm() {
uploadDialogVisible.value = false;
await refreshData();
} catch (error) {
console.error("Upload error:", error);
if (import.meta.env.DEV) console.error("Upload error:", error);
ElMessage.error("上传文件失败,请稍后重试");
} finally {
uploading.value = false;
}
@@ -532,7 +530,8 @@ async function handleCreateDirConfirm() {
createDirDialogVisible.value = false;
await refreshData();
} catch (error) {
console.error("Create directory error:", error);
if (import.meta.env.DEV) console.error("Create directory error:", error);
ElMessage.error("创建文件夹失败,请稍后重试");
}
}
@@ -556,7 +555,8 @@ async function handleRenameConfirm() {
renameDialogVisible.value = false;
await refreshData();
} catch (error) {
console.error("Rename error:", error);
if (import.meta.env.DEV) console.error("Rename error:", error);
ElMessage.error("重命名失败,请稍后重试");
}
}
@@ -573,7 +573,8 @@ async function handleDownload(item: ResourceItem) {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Download error:", error);
if (import.meta.env.DEV) console.error("Download error:", error);
ElMessage.error("下载文件失败,请稍后重试");
}
}
@@ -590,7 +591,8 @@ async function handleDelete(item: ResourceItem) {
await refreshData();
} catch (error) {
if (error !== "cancel") {
console.error("Delete error:", error);
if (import.meta.env.DEV) console.error("Delete error:", error);
ElMessage.error("删除文件失败,请稍后重试");
}
}
}
@@ -619,7 +621,8 @@ async function handleBatchDelete() {
await refreshData();
} catch (error) {
if (error !== "cancel") {
console.error("Batch delete error:", error);
if (import.meta.env.DEV) console.error("Batch delete error:", error);
ElMessage.error("批量删除失败,请稍后重试");
}
} finally {
batchDeleting.value = false;
@@ -186,8 +186,8 @@
</template>
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import ServerAPI, { type ServerInfo } from "@/api/module_monitor/server";
import { Auth } from "@utils";
defineOptions({ name: "ServerMonitor" });
@@ -209,39 +209,18 @@ const server = ref<ServerInfo>({
disks: [],
});
let eventSource: EventSource | null = null;
function connectSSE() {
const token = Auth.getAccessToken();
const baseURL = import.meta.env.VITE_APP_BASE_API || "";
const url = `${baseURL}/monitor/server/stream?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
es.addEventListener("server_status", (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
server.value = data;
} catch {
/* 静默忽略解析错误 */
async function fetchServerInfo() {
try {
const res = await ServerAPI.getServer();
if (res.data?.data) {
server.value = res.data.data;
}
});
es.onerror = () => {
es.close();
};
eventSource = es;
} catch {
// 静默忽略错误
}
}
onMounted(() => {
// 先获取一次静态数据,再开启 SSE
ServerAPI.getServer().then((res) => {
server.value = res.data.data;
});
connectSSE();
});
onUnmounted(() => {
eventSource?.close();
});
onMounted(fetchServerInfo);
</script>
<style scoped lang="scss">
@@ -1,48 +0,0 @@
<!-- 接口文档 -->
<template>
<div class="box-border w-full h-full" v-loading="isLoading">
<iframe
ref="iframeRef"
:src="iframeUrl"
frameborder="0"
class="w-full h-full min-h-[calc(100vh-120px)] border-none"
@load="handleIframeLoad"
></iframe>
</div>
</template>
<script setup lang="ts">
import { IframeRouteManager } from "@/router";
defineOptions({ name: "Redoc", inheritAttrs: false });
const url = import.meta.env.VITE_APP_BASE_API + "/redoc";
const route = useRoute();
const isLoading = ref(true);
const iframeUrl = ref(url);
const iframeRef = ref<HTMLIFrameElement | null>(null);
onBeforeUnmount(() => {
window.onresize = null;
});
/**
* 初始化 iframe URL
* 从路由配置中获取对应的外部链接地址
*/
onMounted(() => {
const iframeRoute = IframeRouteManager.getInstance().findByPath(route.path);
if (iframeRoute?.meta?.link) {
iframeUrl.value = iframeRoute.meta.link;
}
});
/**
* 处理 iframe 加载完成事件
* 隐藏加载状态
*/
const handleIframeLoad = (): void => {
isLoading.value = false;
};
</script>
@@ -1,625 +0,0 @@
<!-- API 令牌管理 CRUD -->
<template>
<div class="fa-full-height">
<FaSearchBar
v-show="showSearchBar"
v-model="searchForm"
:items="searchItems"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:default-expanded="false"
@search="handleSearch"
@reset="onResetSearch"
/>
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
<FaTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<ElButton v-hasPerm="['module_system:token:create']" type="primary" @click="handleAdd">
<ElIcon><Plus /></ElIcon>
新增 Token
</ElButton>
</template>
</FaTableHeader>
<FaTable
ref="faTableRef"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
<!-- 新增/编辑/详情弹窗 -->
<FaDialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
width="760px"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
:form-mode="dialogVisible.type"
:confirm-loading="submitLoading"
:show-footer="dialogVisible.type !== 'detail'"
@cancel="handleCloseDialog"
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
>
<!-- 详情模式 -->
<template v-if="dialogVisible.type === 'detail'">
<FaDescriptions
:column="2"
:data="detailFormData"
:items="detailItems"
label-width="120px"
max-height="70vh"
>
<template #status="{ row }">
<ElTag :type="statusTagType(String((row as any)?.status ?? 0))" effect="plain">
{{ statusLabel(String((row as any)?.status ?? 0)) }}
</ElTag>
</template>
<template #scopes="{ row }">
<div class="flex flex-wrap gap-1">
<ElTag v-for="s in (row as any)?.scopes || []" :key="s" size="small" type="info">
{{ s }}
</ElTag>
<span v-if="!(row as any)?.scopes?.length" class="text-g-400">无限制</span>
</div>
</template>
<template #token_prefix="{ row }">
<span class="font-mono text-sm">{{ (row as any)?.token_prefix || "—" }}</span>
</template>
</FaDescriptions>
</template>
<!-- 表单模式 -->
<template v-else>
<FaForm
:key="formRenderKey"
ref="dataFormRef"
v-model="formData"
:items="formItems"
:rules="rules"
label-suffix=":"
:label-width="100"
label-position="right"
:span="24"
:gutter="16"
:show-reset="false"
:show-submit="false"
class="crud-dialog-art-form"
scrollbar
max-height="70vh"
>
<template #status>
<ElSelect v-model="formData.status" placeholder="请选择状态">
<ElOption :value="0" label="启用" />
<ElOption :value="1" label="停用" />
</ElSelect>
</template>
</FaForm>
</template>
</FaDialog>
<!-- 查看明文弹窗(二次验证) -->
<FaDialog
v-model="revealDialog.visible"
title="查看 Token 明文"
width="520px"
:form-mode="'detail'"
:confirm-loading="revealLoading"
@cancel="revealDialog.visible = false"
@confirm="handleReveal"
>
<ElAlert type="warning" :closable="false" class="mb-4" show-icon>
查看明文需要输入当前登录用户的密码以二次验证身份
</ElAlert>
<FaForm
ref="revealFormRef"
v-model="revealForm"
:items="revealFormItems"
:rules="revealRules"
label-suffix=":"
:label-width="80"
label-position="right"
:span="24"
:show-reset="false"
:show-submit="false"
>
<template #password>
<ElInput
v-model="revealForm.password"
type="password"
placeholder="请输入当前用户密码"
show-password
autocomplete="off"
/>
</template>
</FaForm>
<template v-if="revealResult" #extra>
<ElDivider />
<div class="space-y-3">
<div>
<div class="text-sm text-g-500 mb-1">Token 明文</div>
<div class="flex items-center gap-2">
<ElInput :model-value="revealResult.token_plain" readonly class="font-mono" />
<ElButton type="primary" @click="copyTokenPlain">复制</ElButton>
</div>
</div>
<div v-if="revealResult.expires_at" class="text-xs text-g-400">
过期时间:{{ revealResult.expires_at }}
</div>
</div>
</template>
</FaDialog>
<!-- 创建成功弹窗 -->
<FaDialog
v-model="createdDialog.visible"
title="Token 创建成功"
width="520px"
:form-mode="'detail'"
:show-footer="false"
@cancel="createdDialog.visible = false"
>
<ElAlert type="success" :closable="false" class="mb-4" show-icon>
Token 已创建成功,请立即复制保存。关闭后不再显示完整 Token。
</ElAlert>
<div>
<div class="text-sm text-g-500 mb-1">Token</div>
<div class="flex items-center gap-2">
<ElInput :model-value="createdDialog.tokenPlain" readonly class="font-mono" />
<ElButton type="primary" @click="copyCreatedToken">复制</ElButton>
</div>
</div>
</FaDialog>
</div>
</template>
<script setup lang="ts">
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTable } from "@/hooks/core/useTable";
import { confirmDelete } from "@/hooks/core/useConfirm";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import ApiTokenAPI, {
type ApiTokenTable,
type ApiTokenCreateForm,
type ApiTokenRevealSchema,
} from "@/api/module_system/api_token";
import { Plus } from "@element-plus/icons-vue";
import type { ColumnOption } from "@/types/component";
import type { AuditSearchFormParams } from "@/components/forms/fa-search-bar/auditSearchFormItems";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import { ElMessage } from "element-plus";
import { useClipboard } from "@vueuse/core";
defineOptions({
name: "ApiToken",
inheritAttrs: false,
});
// ─── 常量 ───
const TOKEN_STATUS_OPTIONS = [
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
{ label: "已过期", value: 2 },
] as const;
const TOKEN_STATUS_MAP: Record<string, string> = {
"0": "启用",
"1": "停用",
"2": "已过期",
};
function statusLabel(s: string) {
return TOKEN_STATUS_MAP[s] || s;
}
function statusTagType(s: string): "success" | "info" | "danger" | undefined {
return { "0": "success" as const, "1": "info" as const, "2": "danger" as const }[s];
}
const createInitialFormData = (): ApiTokenCreateForm => ({
id: undefined,
name: "",
scopes: [],
expires_at: undefined,
rate_limit: undefined,
description: "",
});
// ─── 搜索 ───
type TokenSearchFormParams = { name?: string; status?: number } & AuditSearchFormParams;
const searchForm = ref<TokenSearchFormParams>({
name: undefined,
status: undefined,
});
const showSearchBar = ref(true);
const searchItems = computed(() => [
{
label: "名称",
key: "name",
type: "input",
props: { placeholder: "请输入名称", clearable: true },
span: 6,
},
{
label: "状态",
key: "status",
type: "select",
props: {
placeholder: "请选择状态",
options: TOKEN_STATUS_OPTIONS,
clearable: true,
},
span: 6,
},
]);
// ─── 表格 ───
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
refreshCreate,
refreshUpdate,
refreshRemove,
} = useTable({
core: {
apiFn: ApiTokenAPI.listToken,
apiParams: { page_no: 1, page_size: 10, name: undefined, status: undefined },
columnsFactory: (): ColumnOption<ApiTokenTable>[] => [
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "名称", minWidth: 140, showOverflowTooltip: true },
{
prop: "token_prefix",
label: "前缀",
minWidth: 120,
showOverflowTooltip: true,
formatter: (row: ApiTokenTable) =>
row.token_prefix
? h("span", { class: "font-mono text-sm" }, row.token_prefix + "***")
: h("span", { class: "text-g-400" }, "—"),
},
{
prop: "status",
label: "状态",
width: 90,
status: {
0: { type: "success", text: "启用" },
1: { type: "info", text: "停用" },
2: { type: "danger", text: "已过期" },
},
},
{ prop: "used_count", label: "已用次数", width: 100, align: "center" },
{ prop: "expires_at", label: "过期时间", width: 170, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 320,
fixed: "right",
align: "center",
formatter: (row: ApiTokenTable) => renderTokenOperationCell(row),
},
],
},
});
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
function renderTokenOperationCell(row: ApiTokenTable) {
return renderTableOperationCell(buildTokenRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
});
}
function buildTokenRowActions(row: ApiTokenTable): TableOperationAction[] {
return [
{
key: "detail",
label: "详情",
artType: "view",
run: () => void openDetailDialog(row),
},
{
key: "edit",
label: "编辑",
artType: "edit",
run: () => void openEditDialog(row),
},
{
key: "reveal",
label: "查看明文",
artType: "more",
run: () => void openRevealDialog(row),
},
{
key: "status",
label: "变更状态",
artType: "more",
run: () => {},
},
{
key: "delete",
label: "删除",
artType: "delete",
run: () => deleteTokenRow(row),
},
];
}
// ─── 主弹窗 ───
const { dialogVisible } = useCrudDialog();
const detailFormData = ref<ApiTokenTable>({});
const detailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] = [
{ label: "名称", prop: "name" },
{ label: "前缀", prop: "token_prefix", slot: "token_prefix" },
{ label: "状态", prop: "status", slot: "status" },
{ label: "权限范围", prop: "scopes", slot: "scopes" },
{ label: "过期时间", prop: "expires_at" },
{ label: "每小时限额", prop: "rate_limit" },
{ label: "已用次数", prop: "used_count" },
{ label: "最近使用", prop: "last_used_at" },
{ label: "描述", prop: "description", span: 2 },
{ label: "创建时间", prop: "created_time" },
{ label: "更新时间", prop: "updated_time" },
];
const formItems: FormItem[] = [
{
key: "name",
label: "名称",
type: "input",
span: 12,
props: { placeholder: "请输入 Token 名称", maxlength: 100 },
},
{
key: "expires_at",
label: "过期时间",
type: "date",
span: 12,
props: { placeholder: "留空=永不过期", valueFormat: "YYYY-MM-DD HH:mm:ss" },
},
{
key: "rate_limit",
label: "每小时限额",
type: "number",
span: 12,
props: { placeholder: "留空=不限", controlsPosition: "right", min: 0, max: 100000 },
},
{
key: "status",
label: "状态",
type: "select",
span: 12,
props: { placeholder: "请选择状态" },
},
{
key: "description",
label: "描述",
type: "input",
span: 24,
props: { type: "textarea", rows: 3, placeholder: "请输入描述" },
},
];
const formData = ref<ApiTokenCreateForm>(createInitialFormData());
const rules = reactive({
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
});
const dataFormRef = ref<{
resetFields: () => void;
clearValidate: () => void;
validate: (cb: (valid: boolean) => void) => void;
} | null>(null);
const submitLoading = ref(false);
const formRenderKey = ref(0);
// ─── 查看明文弹窗 ───
const revealDialog = reactive({ visible: false, row: null as ApiTokenTable | null });
const revealLoading = ref(false);
const revealForm = reactive({ password: "" });
const revealResult = ref<ApiTokenRevealSchema | null>(null);
const revealFormRef = ref<{
resetFields: () => void;
clearValidate: () => void;
validate: (cb: (valid: boolean) => void) => void;
} | null>(null);
const revealFormItems: FormItem[] = [
{
key: "password",
label: "登录密码",
type: "input",
span: 24,
},
];
const revealRules = reactive({
password: [{ required: true, message: "请输入当前用户密码", trigger: "blur" }],
});
// ─── 创建成功弹窗 ───
const createdDialog = reactive({ visible: false, tokenPlain: "" });
// ─── 搜索事件 ───
const handleSearch = async (params: TokenSearchFormParams) => {
replaceSearchParams({
name: params.name ?? undefined,
status: params.status ?? undefined,
} as Record<string, unknown>);
getData();
};
const onResetSearch = async () => {
searchForm.value = { name: undefined, status: undefined };
await resetSearchParams();
};
// ─── 主弹窗事件 ───
async function openDetailDialog(row: ApiTokenTable) {
if (!row.id) return;
const response = await ApiTokenAPI.detailToken(row.id);
dialogVisible.type = "detail";
dialogVisible.title = "Token 详情";
detailFormData.value = response.data.data ?? { ...row };
dialogVisible.visible = true;
}
async function handleAdd() {
await openEditDialog();
}
async function openEditDialog(row?: ApiTokenTable) {
dialogVisible.type = row ? "update" : "create";
dialogVisible.title = row ? "编辑 Token" : "新增 Token";
formRenderKey.value += 1;
if (row) {
const response = await ApiTokenAPI.detailToken(row.id!);
const data = response.data.data ?? {};
Object.assign(formData.value, data);
} else {
Object.assign(formData.value, createInitialFormData());
}
dialogVisible.visible = true;
}
async function resetForm() {
if (dataFormRef.value) {
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
}
Object.assign(formData.value, createInitialFormData());
}
async function handleCloseDialog() {
dialogVisible.visible = false;
await resetForm();
}
async function handleSubmit() {
dataFormRef.value?.validate(async (valid: boolean) => {
if (!valid) return;
const id = formData.value.id;
try {
if (id) {
// 编辑模式:调用 reset API 更新基本信息
const res = await ApiTokenAPI.resetToken(id, {
name: formData.value.name,
description: formData.value.description,
});
dialogVisible.visible = false;
await resetForm();
await refreshUpdate();
// 如果 reset 也返回了新 token,展示创建成功弹窗
if (res.data.data?.token_plain) {
createdDialog.tokenPlain = res.data.data.token_plain;
createdDialog.visible = true;
}
} else {
// 新增模式
const res = await ApiTokenAPI.createToken(formData.value);
dialogVisible.visible = false;
await resetForm();
await refreshCreate();
const created = res.data.data;
if (created?.token_plain) {
createdDialog.tokenPlain = created.token_plain;
createdDialog.visible = true;
}
}
} catch (error: unknown) {
console.error(error);
}
});
}
// ─── 删除事件 ───
const deleteTokenRow = async (row: ApiTokenTable) => {
if (!row.id) return;
try {
await confirmDelete(`确定删除 Token「${row.name ?? row.id}」吗?此操作不可恢复!`);
await ApiTokenAPI.deleteToken(row.id!);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
}
};
// ─── 查看明文事件 ───
async function openRevealDialog(row: ApiTokenTable) {
revealDialog.row = row;
revealForm.password = "";
revealResult.value = null;
revealDialog.visible = true;
}
async function handleReveal() {
revealFormRef.value?.validate(async (valid: boolean) => {
if (!valid || !revealDialog.row?.id) return;
revealLoading.value = true;
try {
const res = await ApiTokenAPI.revealToken(revealDialog.row.id, {
password: revealForm.password,
});
revealResult.value = res.data.data ?? null;
} catch {
// 错误由全局拦截处理
} finally {
revealLoading.value = false;
}
});
}
const { copy } = useClipboard();
function copyTokenPlain() {
if (revealResult.value?.token_plain) {
copy(revealResult.value.token_plain);
ElMessage.success("已复制到剪贴板");
}
}
function copyCreatedToken() {
if (createdDialog.tokenPlain) {
copy(createdDialog.tokenPlain);
ElMessage.success("已复制到剪贴板");
}
}
</script>
<style scoped lang="scss">
.fa-full-height {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
</style>
@@ -186,15 +186,13 @@ import AuthAPI, {
import UserAPI, { type ForgetPasswordForm, type RegisterForm } from "@/api/module_system/user";
import { useConfigStore, useAppStore, useSettingsStore, useUserStore } from "@stores";
import { Auth, HttpError, startOAuthLogin } from "@utils";
import { ElMessage, ElNotification, type FormRules } from "element-plus";
import { ElMessage, ElNotification } from "@/utils/message";
import type { FormRules } from "element-plus";
import type { Account, AccountKey } from "./types";
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";
import type FaLoginAccountForm from "@/components/views/fa-login/forms/FaLoginAccountForm.vue";
import type FaLoginRegisterPanel from "@/components/views/fa-login/panels/FaLoginRegisterPanel.vue";
import type FaLoginForgetPanel from "@/components/views/fa-login/panels/FaLoginForgetPanel.vue";
defineOptions({ name: "Login" });
@@ -107,28 +107,20 @@
<script setup lang="ts">
import { useTableColumns } from "@/hooks/core/useTableColumns";
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 { confirmToggleStatus } from "@/hooks/core/useConfirm";
import DeptAPI, {
type DeptForm,
type DeptPageQuery,
type DeptTable,
} from "@/api/module_system/dept";
import { useAuth } from "@/hooks/core/useAuth";
import { useUserStore } from "@stores";
import {
formatTree,
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
} from "@utils";
import { formatTree, renderTableOperationCell, type TableOperationAction } 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 type FaForm from "@/components/forms/fa-form/index.vue";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
defineOptions({
name: "Dept",
@@ -159,7 +151,7 @@ function buildDeptRowActions(
onAddChild: (parentId: number) => void;
onDetail: (id: number) => void;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onDelete: (id: number, name: string) => void;
}
): TableOperationAction[] {
const all: TableOperationAction[] = [
@@ -189,7 +181,7 @@ function buildDeptRowActions(
label: "删除",
artType: "delete",
perm: "module_system:dept:delete",
run: () => ctx.onDelete(row.id!),
run: () => ctx.onDelete(row.id!, row.name ?? ""),
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
@@ -262,15 +254,15 @@ async function loadDeptData() {
tableData.value = tree;
deptOptions.value = formatTree(tree);
} catch (e: unknown) {
console.error(e);
if (import.meta.env.DEV) console.error(e);
} finally {
loading.value = false;
}
}
async function deleteDeptRow(id: number) {
async function deleteDeptRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await DeptAPI.deleteDept([id]);
await userStore.getUserInfo();
selectedRows.value = [];
@@ -498,7 +490,10 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
selectedRows.value.map((r) => String(r.name ?? r.id))
);
batchDeleting.value = true;
await DeptAPI.deleteDept(ids);
await userStore.getUserInfo();
@@ -1,32 +1,58 @@
<!-- 字典数据抽屉:Art 搜索 + useTable;固定 dict_type / dict_type_id 由父级传入 -->
<!-- 字典数据面板(内嵌版):替换原来的 DataDrawer,直接嵌入页面右侧 -->
<template>
<FaDrawer
v-model="drawerVisible"
:title="'【' + dictLabel + '】字典数据'"
direction="rtl"
:size="drawerSize"
>
<div class="drawer-content">
<FaSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="dictDataSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
<div class="dict-data-panel flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- 未选择字典类型时的占位提示 -->
<div
v-if="!dictTypeId"
class="flex-1 flex flex-col items-center justify-center text-gray-400 select-none"
>
<div class="text-6xl mb-4 opacity-30">
<svg
width="80"
height="80"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="8" y1="9" x2="16" y2="9" />
<line x1="8" y1="13" x2="14" y2="13" />
</svg>
</div>
<p class="text-sm">请在左侧选择一个字典类型</p>
<p class="text-xs mt-1 opacity-50">选择后将在右侧展示该字典的数据列表</p>
</div>
<template v-else>
<ElCard class="fa-table-card" :style="{ 'margin-top': '0' }">
<template #header>
<div class="flex items-center">
<span class="text-base font-medium">字典数据【{{ dictLabel }}】</span>
<span class="text-gray-400 ml-1">({{ dictType }})</span>
</div>
</template>
<div class="mb-3">
<FaSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="dictDataSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
</div>
<ElCard
class="fa-table-card drawer-table-card"
:style="{ 'margin-top': showSearchBar ? '12px' : '0' }"
>
<FaTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
@@ -34,20 +60,13 @@
@refresh="refreshData"
>
<template #left>
<FaTableHeaderLeft
:remove-ids="selectedIds"
:perm-create="['module_system:dict_data:create']"
:perm-export="['module_system:dict_data:export']"
:perm-delete="['module_system:dict_data:delete']"
:perm-patch="['module_system:dict_data:patch']"
:delete-loading="batchDeleting"
:create-loading="createLoading"
:more-loading="moreLoading"
@add="handleAdd"
@export="openExport"
@delete="handleBatchDelete"
@more="handleMoreClick"
/>
<ElButton
v-if="hasAuth('module_system:dict_data:create')"
type="primary"
@click="handleAdd"
>
新增</ElButton
>
</template>
</FaTableHeader>
@@ -57,7 +76,6 @@
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="onTableSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
@@ -178,52 +196,33 @@
<ElSwitch
v-model="formData.status"
inline-prompt
:active-value="'0'"
:inactive-value="'1'"
:active-value="0"
:inactive-value="1"
/>
</template>
</FaForm>
</template>
</FaDialog>
<FaExportDialog
v-model="exportVisible"
:content-config="dictDataExportContentConfig"
:query-params="exportQueryParams"
:page-data="data"
:selection-data="selectedRows"
/>
</div>
</FaDrawer>
</template>
</div>
</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";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { confirmDelete, confirmBatchDelete, confirmToggleStatus } from "@/hooks/core/useConfirm";
import { cleanEmptyArrayParams, stripPaginationParams } from "@/utils/query";
import type { ColumnOption } from "@/types/component";
import { computed, h, onMounted, reactive, ref } from "vue";
import DictAPI, {
type DictDataForm,
type DictDataPageQuery,
type DictDataTable,
} from "@/api/module_system/dict";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import { useDictStore, useAppStore } from "@stores";
import { DeviceEnum } from "@/enums/settings/device.enum";
import type { IObject } from "@/components/modal/types";
import { type TableOperationAction } from "@utils";
import { useDictStore } from "@stores";
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 FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
defineOptions({ name: "DictDataDrawer", inheritAttrs: false });
defineOptions({ name: "DictDataPanel", inheritAttrs: false });
const props = defineProps<{
dictType: string;
@@ -231,8 +230,6 @@ const props = defineProps<{
dictTypeId: number;
}>();
const drawerVisible = defineModel<boolean>({ required: true });
const TAG_TYPE_STYLE_MAP: Record<string, { background: string; color: string; border: string }> = {
primary: {
background: "var(--el-color-primary-light-9)",
@@ -262,9 +259,7 @@ const TAG_TYPE_STYLE_MAP: Record<string, { background: string; color: string; bo
};
const dictStore = useDictStore();
const appStore = useAppStore();
const { hasAuth } = useAuth();
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "80%" : "60%"));
function getTagPreviewStyle(value?: string) {
const preset = value ? TAG_TYPE_STYLE_MAP[value] : undefined;
@@ -289,13 +284,6 @@ type DictDataSearchForm = {
created_time?: string[];
};
function normalizeDictDataQuery(params: Record<string, unknown>): DictDataPageQuery {
return cleanEmptyArrayParams({ ...params }, [
"created_time",
"updated_time",
]) as unknown as DictDataPageQuery;
}
async function fetchDictDataListMerged(params: Record<string, unknown>) {
const q: DictDataPageQuery = {
page_no: Number(params.current) || Number(params.page_no) || 1,
@@ -305,12 +293,6 @@ async function fetchDictDataListMerged(params: Record<string, unknown>) {
dict_type_id: props.dictTypeId,
status:
params.status !== undefined && params.status !== null ? Number(params.status) : undefined,
created_time: Array.isArray(params.created_time)
? (params.created_time as string[])
: undefined,
updated_time: Array.isArray(params.updated_time)
? (params.updated_time as string[])
: undefined,
};
return DictAPI.listDictData(q);
}
@@ -337,7 +319,7 @@ const dictDataSearchItems = computed<SearchFormItem[]>(() => [
type: "input",
placeholder: "请输入字典标签",
clearable: true,
span: 6,
span: 9,
},
{
label: "状态",
@@ -348,31 +330,13 @@ const dictDataSearchItems = computed<SearchFormItem[]>(() => [
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%" },
},
span: 9,
},
]);
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
useTableSelection<DictDataTable>();
const createLoading = ref(false);
const moreLoading = ref(false);
const statusUpdating = ref<Set<number>>(new Set());
const {
columns,
@@ -380,7 +344,6 @@ const {
data,
loading,
pagination,
searchParams,
getData,
replaceSearchParams,
resetSearchParams,
@@ -400,28 +363,9 @@ const {
dict_type_id: props.dictTypeId,
},
columnsFactory: resolveStatusColumns<DictDataTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "dict_label", label: "标签", minWidth: 150, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 100,
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{
prop: "dict_type",
label: "类型",
minWidth: 180,
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 },
{ prop: "list_class", label: "列表类样式", minWidth: 100, showOverflowTooltip: true },
{ prop: "dict_sort", label: "排序", width: 72 },
{
prop: "is_default",
@@ -432,9 +376,21 @@ const {
false: { type: "danger", text: "否" },
},
},
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 80,
formatter: (row: DictDataTable) =>
h(ElSwitch, {
modelValue: row.status ?? 0,
"onUpdate:modelValue": (val: string | number | boolean) =>
handleStatusChange(row.id!, Number(val)),
activeValue: 0,
inactiveValue: 1,
loading: statusUpdating.value.has(row.id!),
inlinePrompt: true,
}),
},
{
prop: "operation",
label: "操作",
@@ -447,42 +403,6 @@ const {
},
});
const dictDataCrudCols = computed(() =>
columns.value.map((c: ColumnOption<DictDataTable>) => {
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 normalizeDictDataQuery({
...sp,
dict_type: props.dictType,
dict_type_id: props.dictTypeId,
});
});
const dictDataExportContentConfig = computed(() => ({
permPrefix: "module_system:dict_data",
cols: dictDataCrudCols.value,
exportsBlobAction: async (params: IObject) => {
const merged = normalizeDictDataQuery({
...(exportQueryParams.value as unknown as Record<string, unknown>),
...params,
dict_type: props.dictType,
dict_type_id: props.dictTypeId,
} as Record<string, unknown>);
const res = await DictAPI.exportDictData(merged as DictDataPageQuery);
return res.data as Blob;
},
}));
const { dialogVisible } = useCrudDialog();
const detailFormData = ref<DictDataTable>({});
@@ -492,8 +412,6 @@ const dictDataDetailItems: import("@/components/others/fa-descriptions/index.vue
{ label: "数据标签", prop: "dict_label" },
{ label: "数据类型", prop: "dict_type" },
{ label: "数据值", prop: "dict_value" },
{ label: "样式属性", prop: "css_class" },
{ label: "列表样式属性", prop: "list_class" },
{
label: "是否默认",
prop: "is_default",
@@ -510,10 +428,6 @@ const dictDataDetailItems: import("@/components/others/fa-descriptions/index.vue
},
{ label: "排序", prop: "dict_sort" },
{ label: "描述", prop: "description" },
{ label: "创建时间", prop: "created_time" },
{ label: "更新时间", prop: "updated_time" },
{ label: "创建人", prop: "created_by.name" },
{ label: "更新人", prop: "updated_by.name" },
];
const formData = ref<DictDataForm>({
@@ -637,17 +551,15 @@ const initialFormData: DictDataForm = {
dict_type_id: props.dictTypeId,
};
const { exportVisible, openExport } = useImportExport();
onMounted(() => {
if (props.dictTypeId) getData();
});
async function handleSearchBarSearch(params: DictDataSearchForm) {
await searchBarRef.value?.validate?.();
replaceSearchParams({
dict_label: params.dict_label,
status: params.status,
created_time:
Array.isArray(params.created_time) && params.created_time.length === 2
? params.created_time
: undefined,
dict_type: props.dictType,
dict_type_id: props.dictTypeId,
} as Record<string, unknown>);
@@ -679,23 +591,26 @@ async function handleCloseDialog() {
}
async function handleAdd() {
createLoading.value = true;
try {
await handleOpenDialog("create");
} finally {
createLoading.value = false;
}
await handleOpenDialog("create");
}
async function handleOpenDialog(type: "create" | "update" | "detail", id?: number) {
dialogVisible.type = type;
if (id) {
const response = await DictAPI.detailDictData(id);
// 先打开弹窗再加载数据,避免点击后无响应
if (type === "detail") {
dialogVisible.title = "字典数据详情";
detailFormData.value = response.data.data ?? {};
} else if (type === "update") {
dialogVisible.title = "修改字典数据";
Object.assign(formData.value, initialFormData);
}
dictDataFormRenderKey.value += 1;
dialogVisible.visible = true;
const response = await DictAPI.detailDictData(id);
if (type === "detail") {
detailFormData.value = response.data.data ?? {};
} else if (type === "update") {
Object.assign(formData.value, response.data.data);
}
} else {
@@ -705,9 +620,9 @@ async function handleOpenDialog(type: "create" | "update" | "detail", id?: numbe
formData.value.dict_type_id = props.dictTypeId;
formData.value.status = 0;
formData.value.id = undefined;
dictDataFormRenderKey.value += 1;
dialogVisible.visible = true;
}
dictDataFormRenderKey.value += 1;
dialogVisible.visible = true;
}
async function handleSubmit() {
@@ -729,7 +644,7 @@ async function handleSubmit() {
await dictStore.getDict([formData.value.dict_type]);
}
} catch (error: unknown) {
console.error(error);
if (import.meta.env.DEV) console.error(error);
}
});
}
@@ -758,7 +673,7 @@ function buildDictDataRowActions(row: DictDataTable): TableOperationAction[] {
icon: "ri:delete-bin-4-line",
perm: "module_system:dict_data:delete",
run: () => {
if (row.id != null) deleteDictDataRow(row.id);
if (row.id != null) deleteDictDataRow(row.id, row.dict_label ?? "");
},
},
];
@@ -771,71 +686,36 @@ function formatDictDataOperationCell(row: DictDataTable) {
});
}
async function deleteDictDataRow(id: number) {
async function deleteDictDataRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await DictAPI.deleteDictData([id]);
dictStore.clearDictData();
if (props.dictType) await dictStore.getDict([props.dictType]);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
ElMessage.error("删除失败");
}
}
async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
async function handleStatusChange(id: number, newStatus: number) {
statusUpdating.value = new Set([...statusUpdating.value, id]);
try {
await confirmBatchDelete(ids.length);
batchDeleting.value = true;
await DictAPI.deleteDictData(ids);
dictStore.clearDictData();
if (props.dictType) await dictStore.getDict([props.dictType]);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
} finally {
batchDeleting.value = false;
}
}
async function handleMoreClick(status: number) {
const ids = selectedIds.value;
if (!ids.length) {
ElMessage.warning("请先选择要操作的数据");
return;
}
try {
await confirmToggleStatus(status);
moreLoading.value = true;
await DictAPI.batchDictData({ ids, status });
await DictAPI.batchDictData({ ids: [id], status: newStatus });
await refreshData();
dictStore.clearDictData();
if (props.dictType) await dictStore.getDict([props.dictType]);
} catch {
// 用户取消
await refreshData();
} finally {
moreLoading.value = false;
const next = new Set(statusUpdating.value);
next.delete(id);
statusUpdating.value = next;
}
}
</script>
<style lang="scss" scoped>
.drawer-content {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.drawer-table-card {
flex: 1;
min-height: 0;
}
.tag-option-preview {
display: inline-flex;
align-items: center;
@@ -1,60 +1,66 @@
<!-- 字典类型:Fa 布局;操作列最多 3 个外露 +「更多」 -->
<!-- 字典管理:左侧字典类型列表 + 右侧字典数据面板 -->
<template>
<div class="fa-full-height">
<FaSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="dictTypeSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
include-audit
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
<div class="flex-1 flex min-h-0 gap-4">
<!-- Left: Dict Type -->
<div class="w-[660px] flex flex-col min-h-0 overflow-hidden">
<FaSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="dictTypeSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
<ElCard 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:dict_type:create']"
:perm-export="['module_system:dict_type:export']"
:perm-delete="['module_system:dict_type:delete']"
:perm-patch="['module_system:dict_type:patch']"
:delete-loading="batchDeleting"
:create-loading="createLoading"
:more-loading="moreLoading"
@add="handleAdd"
@export="openExport"
@delete="handleBatchDelete"
@more="handleMoreClick"
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
<FaTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<FaTableHeaderLeft
:perm-create="['module_system:dict_type:create']"
@add="handleAdd"
/>
</template>
</FaTableHeader>
<FaTable
ref="faTableRef"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
:row-class-name="dictTypeRowClassName"
@row-click="handleDictTypeRowClick"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</template>
</FaTableHeader>
</ElCard>
</div>
<FaTable
ref="faTableRef"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="onTableSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
<!-- Right: Dict Data -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<DictDataPanel
:key="currentDictTypeId"
:dict-type="currentDictType"
:dict-label="currentDictLabel"
:dict-type-id="currentDictTypeId"
/>
</div>
</div>
<!-- Dict Type CRUD Dialogs -->
<FaDialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
@@ -105,51 +111,21 @@
</FaForm>
</template>
</FaDialog>
<FaExportDialog
v-model="exportVisible"
:content-config="dictTypeExportContentConfig"
:query-params="exportQueryParams"
:page-data="data"
:selection-data="selectedRows"
/>
<DataDrawer
v-if="drawerVisible"
v-model="drawerVisible"
:dict-type="currentDictType"
:dict-label="currentDictLabel"
:dict-type-id="currentDictTypeId"
/>
</div>
</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";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { computed, h, ref, watch } from "vue";
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 DictAPI, {
type DictForm,
type DictPageQuery,
type DictTable,
} from "@/api/module_system/dict";
import DictAPI, { type DictForm, type DictTable } from "@/api/module_system/dict";
import { useDictStore } from "@stores";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { IObject } from "@/components/modal/types";
import { type TableOperationAction } 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 type FaForm from "@/components/forms/fa-form/index.vue";
import DictDataPanel from "./components/DictDataPanel.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import DataDrawer from "./components/DataDrawer.vue";
import { ElMessage } from "element-plus";
defineOptions({
name: "Dict",
@@ -159,9 +135,6 @@ defineOptions({
type DictTypeSearchForm = {
dict_name?: string;
dict_type?: string;
status?: number;
created_time?: string[];
updated_time?: string[];
};
const dictStore = useDictStore();
@@ -170,20 +143,12 @@ const { hasAuth } = useAuth();
const searchForm = ref<DictTypeSearchForm>({
dict_name: undefined,
dict_type: undefined,
status: undefined,
created_time: undefined,
updated_time: undefined,
});
const showSearchBar = ref(true);
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
const searchBarRules: Record<string, unknown> = {};
const statusOptions = ref([
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
]);
const dictTypeSearchItems = computed<SearchFormItem[]>(() => [
{
label: "字典名称",
@@ -191,7 +156,7 @@ const dictTypeSearchItems = computed<SearchFormItem[]>(() => [
type: "input",
placeholder: "请输入字典名称",
clearable: true,
span: 6,
span: 8,
},
{
label: "字典类型",
@@ -199,29 +164,14 @@ const dictTypeSearchItems = computed<SearchFormItem[]>(() => [
type: "input",
placeholder: "请输入字典类型",
clearable: true,
span: 6,
},
{
label: "状态",
key: "status",
type: "select",
props: {
placeholder: "请选择状态",
options: statusOptions.value,
clearable: true,
},
span: 6,
span: 8,
},
]);
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
// ─── 表格多选 ───
const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
useTableSelection<DictTable>();
const createLoading = ref(false);
const moreLoading = ref(false);
const statusUpdating = ref<Set<number>>(new Set());
// ─── 对话框状态 ───
const { dialogVisible } = useCrudDialog();
@@ -242,8 +192,6 @@ const dictDetailItems: import("@/components/others/fa-descriptions/index.vue").D
{ label: "描述", prop: "description" },
{ label: "创建时间", prop: "created_time" },
{ label: "更新时间", prop: "updated_time" },
{ label: "创建人", prop: "created_by.name" },
{ label: "更新人", prop: "updated_by.name" },
];
const formData = ref<DictForm>({
@@ -349,7 +297,6 @@ const {
data,
loading,
pagination,
searchParams,
getData,
replaceSearchParams,
resetSearchParams,
@@ -367,32 +314,34 @@ const {
page_size: 10,
},
columnsFactory: resolveStatusColumns<DictTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "dict_name", label: "字典名称", minWidth: 140, showOverflowTooltip: true },
{ prop: "dict_name", label: "字典名称", minWidth: 100, showOverflowTooltip: true },
{
prop: "dict_type",
label: "字典类型",
minWidth: 180,
minWidth: 120,
formatter: (row: DictTable) =>
h(FaStatusTag, { type: "primary", label: row.dict_type ?? "" }),
},
{
prop: "status",
label: "状态",
width: 88,
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
width: 80,
formatter: (row: DictTable) =>
h(ElSwitch, {
modelValue: row.status ?? 0,
"onUpdate:modelValue": (val: string | number | boolean) =>
handleDictTypeStatusChange(row.id!, Number(val)),
activeValue: 0,
inactiveValue: 1,
loading: statusUpdating.value.has(row.id!),
inlinePrompt: true,
}),
},
{ prop: "description", label: "描述", minWidth: 140, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 220,
width: 180,
fixed: "right",
align: "center",
formatter: (row: DictTable) => formatDictOperationCell(row),
@@ -401,53 +350,31 @@ const {
},
});
const dictTypeCrudCols = computed(() =>
columns.value.map((c: ColumnOption<DictTable>) => {
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 cleanEmptyArrayParams(sp) as unknown as DictPageQuery;
});
const dictTypeExportContentConfig = computed(() => ({
permPrefix: "module_system:dict_type",
cols: dictTypeCrudCols.value,
exportsBlobAction: async (params: IObject) => {
const merged = cleanEmptyArrayParams({
...(exportQueryParams.value as unknown as Record<string, unknown>),
...params,
} as Record<string, unknown>);
const res = await DictAPI.exportDictType(merged as unknown as DictPageQuery);
return res.data as Blob;
},
}));
const { exportVisible, openExport } = useImportExport();
const drawerVisible = ref(false);
// ─── 右侧面板状态 ───
const currentDictType = ref("");
const currentDictLabel = ref("");
const currentDictTypeId = ref(0);
const hasAutoSelected = ref(false);
const selectedDictRowId = ref<number | null>(null);
function dictTypeRowClassName({ row }: { row: DictTable }) {
return row?.id === selectedDictRowId.value ? "dict-type-row-selected" : "";
}
function handleDictTypeRowClick(row: DictTable) {
// 没有字典数据权限则不允许查看数据
if (!hasAuth("module_system:dict_data:query")) return;
selectedDictRowId.value = row.id ?? null;
currentDictType.value = row.dict_type || "";
currentDictLabel.value = row.dict_name || "";
currentDictTypeId.value = row.id ?? 0;
}
async function handleSearchBarSearch(params: DictTypeSearchForm) {
await searchBarRef.value?.validate?.();
replaceSearchParams({
dict_name: params.dict_name,
dict_type: params.dict_type,
status: params.status,
created_time:
Array.isArray(params.created_time) && params.created_time.length === 2
? params.created_time
: undefined,
} as Record<string, unknown>);
getData();
}
@@ -456,23 +383,12 @@ function onResetSearch() {
searchForm.value = {
dict_name: undefined,
dict_type: undefined,
status: undefined,
created_time: undefined,
};
void resetSearchParams();
}
function buildDictRowActions(row: DictTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "dictData",
label: "字典",
artType: "view",
icon: "ri:book-2-line",
iconColor: "var(--el-color-warning)",
perm: "module_system:dict_data:query",
run: () => handleDictDataDrawer(row),
},
{
key: "detail",
label: "详情",
@@ -495,7 +411,7 @@ function buildDictRowActions(row: DictTable): TableOperationAction[] {
icon: "ri:delete-bin-4-line",
perm: "module_system:dict_type:delete",
run: () => {
if (row.id != null) deleteDictTypeRow(row.id);
if (row.id != null) deleteDictTypeRow(row.id, row.dict_name ?? "");
},
},
];
@@ -508,64 +424,57 @@ function formatDictOperationCell(row: DictTable) {
});
}
function handleDictDataDrawer(dictTypeRow: DictTable) {
currentDictType.value = dictTypeRow.dict_type || "";
currentDictLabel.value = dictTypeRow.dict_name || "";
currentDictTypeId.value = dictTypeRow.id ?? 0;
drawerVisible.value = true;
}
async function deleteDictTypeRow(id: number) {
async function deleteDictTypeRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await DictAPI.deleteDictType([id]);
dictStore.clearDictData();
const dictTypes = Object.keys(dictStore.dictData);
if (dictTypes.length > 0) await dictStore.getDict(dictTypes);
faTableRef.value?.elTableRef?.clearSelection();
// 如果删的是当前选中的行,清空右侧面板
if (id === currentDictTypeId.value) {
currentDictTypeId.value = 0;
currentDictType.value = "";
currentDictLabel.value = "";
selectedDictRowId.value = null;
}
await refreshRemove();
} catch {
// 用户取消
}
}
async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
async function handleDictTypeStatusChange(id: number, newStatus: number) {
statusUpdating.value = new Set([...statusUpdating.value, id]);
try {
await confirmBatchDelete(ids.length);
batchDeleting.value = true;
await DictAPI.deleteDictType(ids);
dictStore.clearDictData();
const dictTypes = Object.keys(dictStore.dictData);
if (dictTypes.length > 0) await dictStore.getDict(dictTypes);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
} finally {
batchDeleting.value = false;
}
}
async function handleMoreClick(status: number) {
const ids = selectedIds.value;
if (!ids.length) {
ElMessage.warning("请先选择要操作的数据");
return;
}
try {
await confirmToggleStatus(status);
moreLoading.value = true;
await DictAPI.batchDictType({ ids, status });
await DictAPI.batchDictType({ ids: [id], status: newStatus });
await refreshData();
dictStore.clearDictData();
const dictTypes = Object.keys(dictStore.dictData);
if (dictTypes.length > 0) await dictStore.getDict(dictTypes);
} catch {
// 用户取消
await refreshData();
} finally {
moreLoading.value = false;
const next = new Set(statusUpdating.value);
next.delete(id);
statusUpdating.value = next;
}
}
// 初始加载后自动选中第一行
watch(data, (newData) => {
if (newData && newData.length > 0 && !hasAutoSelected.value) {
const firstRow = newData[0]!;
handleDictTypeRowClick(firstRow);
hasAutoSelected.value = true;
}
if (newData && newData.length === 0) {
currentDictTypeId.value = 0;
currentDictType.value = "";
currentDictLabel.value = "";
selectedDictRowId.value = null;
}
});
</script>
@@ -1,7 +1,7 @@
<!-- 日志管理:登录日志 + 操作日志 -->
<template>
<div class="fa-full-height">
<ElTabs v-model="activeTab" type="card">
<ElTabs v-model="activeTab">
<ElTabPane label="操作日志" name="operation">
<FaSearchBar
v-show="opShowSearchBar"
@@ -16,6 +16,7 @@
:disabled-search="false"
:default-expanded="false"
include-audit
:audit-item-options="{ showCreatedBy: false, showUpdatedBy: false }"
@search="handleOpSearch"
@reset="onOpResetSearch"
/>
@@ -115,6 +116,7 @@
:disabled-search="false"
:default-expanded="false"
include-audit
:audit-item-options="{ showCreatedBy: false, showUpdatedBy: false }"
@search="handleLoginSearch"
@reset="onLoginResetSearch"
/>
@@ -178,26 +180,14 @@
<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";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
import { cleanEmptyArrayParams, stripPaginationParams } from "@/utils/query";
import type { ColumnOption } from "@/types/component";
import OperationLogAPI, {
type OperationLogPageQuery,
type OperationLogTable,
type LoginLogTable,
LoginLogAPI,
} from "@/api/module_system/log";
import { useAuth } from "@/hooks/core/useAuth";
import {
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
type StatusType,
} from "@utils";
import { renderTableOperationCell, type TableOperationAction, type StatusType } 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";
@@ -213,19 +203,24 @@ const { hasAuth } = useAuth();
const activeTab = ref<"operation" | "login">("operation");
function handleIpSearch(ip: string) {
activeTab.value = "operation";
opSearchForm.value.request_ip = ip;
opReplaceSearchParams(buildOpReplaceParams(opSearchForm.value));
opGetData();
}
// ==================== 操作日志 ====================
type OpSearchForm = {
request_path?: string;
request_ip?: string;
created_id?: number;
created_time?: string[];
};
const opSearchForm = ref<OpSearchForm>({
request_path: undefined,
request_ip: undefined,
created_id: undefined,
created_time: undefined,
});
const opShowSearchBar = ref(true);
@@ -255,7 +250,6 @@ function buildOpReplaceParams(p: OpSearchForm): Record<string, unknown> {
return {
request_path: p.request_path,
request_ip: p.request_ip,
created_id: p.created_id,
created_time:
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
};
@@ -311,7 +305,13 @@ const {
label: String(row.response_code ?? ""),
}),
},
{ prop: "request_ip", label: "请求IP", minWidth: 140, showOverflowTooltip: true },
{
prop: "request_ip",
label: "请求IP",
minWidth: 140,
showOverflowTooltip: true,
formatter: (row: OperationLogTable) => row.request_ip ?? "-",
},
{ prop: "process_time", label: "处理时间", minWidth: 120 },
{ prop: "description", label: "描述", minWidth: 120, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
@@ -328,7 +328,7 @@ const {
});
const opCrudCols = computed(() =>
opColumns.value.map((c: ColumnOption<OperationLogTable>) => ({
opColumns!.value.map((c: ColumnOption<OperationLogTable>) => ({
prop: c.prop,
label: c.label,
type:
@@ -383,7 +383,6 @@ function onOpResetSearch() {
opSearchForm.value = {
request_path: undefined,
request_ip: undefined,
created_id: undefined,
created_time: undefined,
};
void opResetSearchParams();
@@ -401,9 +400,9 @@ async function handleOpOpenDetail(id: number) {
opDialogVisible.visible = true;
}
async function deleteOpRow(id: number) {
async function deleteOpRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await OperationLogAPI.delete([id]);
opTableRef.value?.elTableRef?.clearSelection();
await opRefreshRemove();
@@ -430,7 +429,7 @@ function buildOpRowActions(row: OperationLogTable): TableOperationAction[] {
icon: "ri:delete-bin-4-line",
perm: "module_system:log:delete",
run: () => {
if (row.id != null) deleteOpRow(row.id);
if (row.id != null) deleteOpRow(row.id, row.description ?? String(row.id));
},
},
];
@@ -447,7 +446,10 @@ async function handleOpBatchDelete() {
const ids = opSelectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
opSelectedRows.value.map((r) => String(r.description ?? r.id))
);
opBatchDeleting.value = true;
await OperationLogAPI.delete(ids);
opTableRef.value?.elTableRef?.clearSelection();
@@ -564,12 +566,20 @@ const {
label: "登录IP",
minWidth: 140,
formatter: (row: LoginLogTable) =>
h("span", { class: "inline-flex items-center gap-0.5" }, [
row.login_ip ?? "",
row.login_ip
? h(FaCopyButton, { text: row.login_ip, style: { marginLeft: "2px" } })
: null,
]),
row.login_ip
? h("span", { class: "inline-flex items-center gap-0.5" }, [
h(
"span",
{
class: "ip-link-cell",
title: "点击搜索该IP",
onClick: () => handleIpSearch(row.login_ip!),
},
[row.login_ip ?? ""]
),
h(FaCopyButton, { text: row.login_ip, style: { marginLeft: "2px" } }),
])
: "-",
},
{ prop: "login_location", label: "登录地点", minWidth: 160, showOverflowTooltip: true },
{ prop: "request_os", label: "操作系统", minWidth: 120 },
@@ -625,9 +635,9 @@ function handleLoginCloseDialog() {
loginCloseDialog();
}
async function deleteLoginRow(id: number) {
async function deleteLoginRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await LoginLogAPI.delete([id]);
loginTableRef.value?.elTableRef?.clearSelection();
await loginRefreshRemove();
@@ -654,7 +664,7 @@ function buildLoginRowActions(row: LoginLogTable): TableOperationAction[] {
icon: "ri:delete-bin-4-line",
perm: "module_system:login_log:delete",
run: () => {
if (row.id != null) deleteLoginRow(row.id);
if (row.id != null) deleteLoginRow(row.id, row.username ?? String(row.id));
},
},
];
@@ -671,7 +681,12 @@ async function handleLoginBatchDelete() {
const ids = loginSelectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
(loginData.value as LoginLogTable[])
.filter((r) => ids.includes(r.id!))
.map((r) => String(r.username ?? r.id))
);
loginBatchDeleting.value = true;
await LoginLogAPI.delete(ids);
loginTableRef.value?.elTableRef?.clearSelection();
@@ -14,16 +14,20 @@
:disabled-search="false"
:default-expanded="false"
include-audit
:audit-item-options="{ showCreatedBy: false, showUpdatedBy: false }"
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
<ElTabs v-model="menuClientTab" @tab-change="handleMenuClientTabChange">
<ElTabPane label="PC 桌面菜单管理" name="pc" />
<ElTabPane label="APP 移动端菜单管理" name="app" />
<ElTabPane label="Web 菜单" name="pc" />
<ElTabPane label="APP 菜单" name="app" />
</ElTabs>
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
<ElCard
class="fa-table-card"
:style="{ 'margin-top': showSearchBar ? '12px' : '0', transition: 'margin-top 0.2s ease' }"
>
<FaTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
@@ -85,7 +89,7 @@
<FaStatusTag v-if="row?.type === MenuTypeEnum.BUTTON" type="danger" label="按钮" />
<FaStatusTag v-if="row?.type === MenuTypeEnum.EXTLINK" type="info" label="外链" />
</template>
<template #client="{ row }">
<template #scope="{ row }">
<FaStatusTag v-if="row?.scope === MenuClientEnum.PC" type="primary" label="PC" />
<FaStatusTag v-else-if="row?.scope === MenuClientEnum.APP" type="success" label="APP" />
<FaStatusTag v-else type="info" :label="(row?.scope as string) || '—'" />
@@ -164,10 +168,15 @@
<!-- 终端(禁用态+提示) -->
<template #scope>
<ElRadioGroup v-model="formData.scope" :disabled="createParentLocked">
<ElRadio :value="MenuClientEnum.PC">PC 桌面</ElRadio>
<ElRadio :value="MenuClientEnum.APP">APP 移动</ElRadio>
</ElRadioGroup>
<ElSelect
v-model="formData.scope"
:disabled="createParentLocked"
placeholder="请选择终端"
style="width: 100%"
>
<ElOption :value="MenuClientEnum.PC" label="PC 桌面" />
<ElOption :value="MenuClientEnum.APP" label="APP 移动" />
</ElSelect>
<ElText v-if="createParentLocked" type="info" size="small" class="block mt-1">
子级终端与父菜单一致
</ElText>
@@ -228,7 +237,7 @@
</ElButton>
</template>
<template v-else>
<div v-for="(item, index) in formData.params" :key="index">
<div v-for="item in formData.params" :key="item.key">
<ElInput v-model="item.key" placeholder="参数名" :style="'width: 100px'" />
<span class="mx-1">=</span>
<ElInput v-model="item.value" placeholder="参数值" :style="'width: 100px'" />
@@ -348,18 +357,25 @@ import MenuAPI, {
} from "@/api/module_system/menu";
import { MenuClientEnum, MenuTypeEnum } from "@/enums/system/menu.enum";
import { formatTree } from "@utils/common";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import { type TableOperationAction } 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 type FaForm from "@/components/forms/fa-form/index.vue";
import FaMenuRouteIcon from "@/components/others/fa-menu-route-icon/index.vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "@/utils/message";
const { hasAuth } = useAuth();
const userStore = useUserStore();
// 预计算操作权限,避免每行反复调用 hasAuth
const menuPerms = computed(() => ({
create: hasAuth("module_system:menu:create"),
detail: hasAuth("module_system:menu:detail"),
update: hasAuth("module_system:menu:update"),
delete: hasAuth("module_system:menu:delete"),
}));
type MenuSearchForm = {
name?: string;
status?: number;
@@ -381,43 +397,48 @@ function buildMenuRowActions(
onAdd?: (r: MenuTable) => void;
onDetail: (id: number) => void;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onDelete: (id: number, name: string) => void;
}
): TableOperationAction[] {
const { create, detail, update: editPerm, delete: delPerm } = menuPerms.value;
const actions: TableOperationAction[] = [];
if (ctx.onAdd && (row.type === MenuTypeEnum.CATALOG || row.type === MenuTypeEnum.MENU)) {
if (
ctx.onAdd &&
create &&
(row.type === MenuTypeEnum.CATALOG || row.type === MenuTypeEnum.MENU)
) {
actions.push({
key: "add",
label: "新增",
artType: "add",
perm: "module_system:menu:create",
run: () => ctx.onAdd!(row),
});
}
actions.push(
{
if (detail) {
actions.push({
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:menu:detail",
run: () => ctx.onDetail(row.id!),
},
{
});
}
if (editPerm) {
actions.push({
key: "edit",
label: "编辑",
artType: "edit",
perm: "module_system:menu:update",
run: () => ctx.onEdit(row.id!),
},
{
});
}
if (delPerm) {
actions.push({
key: "delete",
label: "删除",
artType: "delete",
perm: "module_system:menu:delete",
run: () => ctx.onDelete(row.id!),
}
);
return actions.filter((a) => a.perm != null && hasAuth(a.perm));
run: () => ctx.onDelete(row.id!, row.title ?? row.name ?? ""),
});
}
return actions;
}
function formatMenuOperationCell(row: MenuTable, ctx: Parameters<typeof buildMenuRowActions>[1]) {
@@ -489,7 +510,7 @@ const menuDetailItems: import("@/components/others/fa-descriptions/index.vue").D
{ label: "编号", prop: "id" },
{ label: "菜单名称", prop: "name" },
{ label: "菜单类型", prop: "type", slot: "type" },
{ label: "终端", prop: "client", slot: "client" },
{ label: "终端", prop: "scope", slot: "scope" },
{ label: "图标", prop: "icon", slot: "icon" },
{ label: "权限标识", prop: "permission" },
{ label: "路由名称", prop: "route_name" },
@@ -611,6 +632,7 @@ const menuDialogFormItems = computed<FormItem[]>(() => {
type: "input",
hidden: t !== MenuTypeEnum.CATALOG && t !== MenuTypeEnum.MENU,
},
{ key: "scope", label: "终端", type: "select", props: { options: MenuClientEnum } },
{ key: "order", label: "排序", type: "number", props: { controlsPosition: "right", min: 1 } },
{ key: "is_iframe", label: "嵌入iframe", type: "input", hidden: t !== MenuTypeEnum.EXTLINK },
{
@@ -637,7 +659,6 @@ const menuDialogFormItems = computed<FormItem[]>(() => {
type: "input",
hidden: t !== MenuTypeEnum.MENU,
},
{ key: "client", label: "终端", type: "input" },
{ key: "affix", label: "常驻标签栏", type: "input", hidden: t === MenuTypeEnum.BUTTON },
{ key: "is_hide_tab", label: "隐藏标签页", type: "input", hidden: t === MenuTypeEnum.BUTTON },
{ key: "show_badge", label: "显示红点角标", type: "input", hidden: t === MenuTypeEnum.BUTTON },
@@ -758,7 +779,7 @@ async function loadMenuData() {
tableData.value = tree;
menuOptions.value = formatTree(filterMenuTypes(tree));
} catch (e: unknown) {
console.error(e);
if (import.meta.env.DEV) console.error(e);
} finally {
loading.value = false;
}
@@ -805,14 +826,9 @@ function toggleExpand() {
});
}
async function deleteMenuRow(id: number) {
async function deleteMenuRow(id: number, name: string) {
try {
await ElMessageBox.confirm("确认删除该项数据?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`确定删除「${name}」吗?`);
await MenuAPI.deleteMenu([id]);
await userStore.getUserInfo();
selectedRows.value = [];
@@ -833,30 +849,26 @@ const { columnChecks, columns } = useTableColumns<MenuTable>(
resolveStatusColumns(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "index", label: "序号", width: 60, fixed: "left" },
{ prop: "name", label: "菜单名称", minWidth: 200, showOverflowTooltip: true },
// ─── 基础信息 ───
{
prop: "icon",
label: "图标",
width: 72,
align: "center",
formatter: (row: MenuTable) =>
row.icon
? h(FaMenuRouteIcon, { icon: row.icon, style: { verticalAlign: "-0.15em" } })
: h("span", { class: "text-g-400" }, "—"),
},
{
prop: "status",
label: "状态",
width: 88,
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
prop: "name",
label: "菜单名称",
minWidth: 220,
showOverflowTooltip: true,
formatter: (row: MenuTable) => {
const isButton = row.type === MenuTypeEnum.BUTTON;
if (isButton || !row.icon) return row.name ?? "—";
const iconVNode = h(FaMenuRouteIcon, {
icon: row.icon,
style: { verticalAlign: "-0.15em", marginRight: "6px" },
});
return h("span", null, [iconVNode, row.name]);
},
},
{
prop: "type",
label: "类型",
width: 88,
width: 80,
align: "center",
status: {
1: { type: "warning", text: "目录" },
@@ -865,93 +877,53 @@ const { columnChecks, columns } = useTableColumns<MenuTable>(
4: { type: "info", text: "外链" },
},
},
{ prop: "title", label: "菜单标题", minWidth: 120, showOverflowTooltip: true },
{ prop: "permission", label: "权限标识", minWidth: 160, showOverflowTooltip: true },
{
prop: "scope",
label: "终端",
width: 88,
width: 80,
align: "center",
status: {
pc: { type: "primary", text: "PC" },
app: { type: "success", text: "APP" },
web: { type: "primary", text: "Web" },
app: { type: "success", text: "App" },
},
},
{ prop: "order", label: "排序", width: 80 },
{
prop: "status",
label: "状态",
width: 80,
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "order", label: "排序", width: 72 },
// ─── 路由配置 ───
{ prop: "route_name", label: "路由名称", minWidth: 100, showOverflowTooltip: true },
{ prop: "route_path", label: "路由路径", minWidth: 140, showOverflowTooltip: true },
{ prop: "permission", label: "权限标识", minWidth: 160, showOverflowTooltip: true },
{ prop: "component_path", label: "组件路径", minWidth: 140, showOverflowTooltip: true },
{ prop: "active_path", label: "激活路径", minWidth: 100, showOverflowTooltip: true },
{ prop: "redirect", label: "重定向", minWidth: 100, showOverflowTooltip: true },
{ prop: "link", label: "外链地址", minWidth: 140, showOverflowTooltip: true },
{
prop: "keep_alive",
label: "是否缓存",
width: 96,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
prop: "component_path",
label: "组件路径",
minWidth: 140,
showOverflowTooltip: true,
visible: false,
},
{
prop: "hidden",
label: "是否隐藏",
width: 96,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
prop: "active_path",
label: "激活路径",
minWidth: 110,
showOverflowTooltip: true,
visible: false,
},
{
prop: "always_show",
label: "显示根路由",
width: 108,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "affix",
label: "固定路由",
width: 96,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{ prop: "title", label: "菜单标题", minWidth: 100, showOverflowTooltip: true },
{
prop: "is_iframe",
label: "嵌入iframe",
width: 100,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "is_hide_tab",
label: "隐藏标签页",
width: 100,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "show_badge",
label: "红点角标",
width: 96,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{ prop: "show_text_badge", label: "文字角标", width: 100, showOverflowTooltip: true },
{ prop: "redirect", label: "重定向", minWidth: 110, showOverflowTooltip: true, visible: false },
{ prop: "link", label: "外链地址", minWidth: 140, showOverflowTooltip: true, visible: false },
{
prop: "params",
label: "路由参数",
minWidth: 100,
showOverflowTooltip: true,
visible: false,
formatter: (row: MenuTable) =>
row.params == null
? "—"
@@ -959,9 +931,114 @@ const { columnChecks, columns } = useTableColumns<MenuTable>(
? JSON.stringify(row.params)
: String(row.params),
},
{ prop: "description", label: "描述", minWidth: 140, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
// ─── 功能开关 ───
{
prop: "keep_alive",
label: "缓存",
width: 72,
align: "center",
visible: false,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "hidden",
label: "隐藏",
width: 72,
align: "center",
visible: false,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "always_show",
label: "常显",
width: 72,
align: "center",
visible: false,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "affix",
label: "固定标签",
width: 88,
align: "center",
visible: false,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "is_iframe",
label: "iframe",
width: 80,
align: "center",
visible: false,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "is_hide_tab",
label: "隐藏标签",
width: 88,
align: "center",
visible: false,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "show_badge",
label: "红点",
width: 72,
align: "center",
visible: false,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
prop: "show_text_badge",
label: "角标文字",
width: 90,
showOverflowTooltip: true,
visible: false,
},
// ─── 元信息 ───
{
prop: "description",
label: "描述",
minWidth: 140,
showOverflowTooltip: true,
visible: false,
},
{
prop: "created_time",
label: "创建时间",
width: 168,
showOverflowTooltip: true,
visible: false,
},
{
prop: "updated_time",
label: "更新时间",
width: 168,
showOverflowTooltip: true,
visible: false,
},
// ─── 操作 ───
{
prop: "operation",
label: "操作",
@@ -1130,16 +1207,19 @@ function handleMenuTypeChange() {
}
async function handleSubmit() {
const allowed = allowedMenuTypeValues.value;
if (!allowed.length) return;
const t = formData.value.type as MenuTypeEnum;
if (!allowed.includes(t)) {
formData.value.type = allowed[0] as MenuForm["type"];
const id = formData.value.id;
// 仅在新增时校验类型是否合法,编辑时类型是已存的无需变更
if (!id) {
const allowed = allowedMenuTypeValues.value;
if (!allowed.length) return;
const t = formData.value.type as MenuTypeEnum;
if (!allowed.includes(t)) {
formData.value.type = allowed[0] as MenuForm["type"];
}
}
dataFormRef.value?.validate(async (valid: boolean) => {
if (!valid) return;
submitLoading.value = true;
const id = formData.value.id;
try {
if (id) {
await MenuAPI.updateMenu(id, { id, ...formData.value });
@@ -1151,7 +1231,7 @@ async function handleSubmit() {
await loadMenuData();
await userStore.getUserInfo();
} catch (error: unknown) {
console.error(error);
if (import.meta.env.DEV) console.error(error);
} finally {
submitLoading.value = false;
}
@@ -1162,11 +1242,10 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条数据吗?`, "批量删除", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmBatchDelete(
ids.length,
selectedRows.value.map((r) => String(r.title ?? r.name ?? r.id))
);
batchDeleting.value = true;
await MenuAPI.deleteMenu(ids);
await userStore.getUserInfo();
@@ -88,12 +88,7 @@
/>
</template>
<template #notice_content>
<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>
</ElScrollbar>
<FaMarkdownRenderer :content="detailFormData.notice_content ?? ''" />
</template>
</FaDescriptions>
</template>
@@ -137,21 +132,16 @@
</template>
<script setup lang="ts">
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, confirmToggleStatus } from "@/hooks/core/useConfirm";
import { confirmToggleStatus } from "@/hooks/core/useConfirm";
import NoticeAPI, { type NoticeForm, type NoticeTable } from "@/api/module_system/notice";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import { type TableOperationAction } from "@utils";
import { useDictStore, useNoticeStore } from "@stores";
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 { ElMessage, ElScrollbar } from "element-plus";
import DOMPurify from "dompurify";
import { ElMessage } from "@/utils/message";
defineOptions({
name: "Notice",
@@ -266,28 +256,6 @@ const noticeDetailItems: import("@/components/others/fa-descriptions/index.vue")
{ label: "更新时间", prop: "updated_time" },
];
/** 详情富文本 HTML(用于预览,已做 XSS 净化) */
const detailContentHtml = computed({
get: () => {
const raw = detailFormData.value.notice_content ?? "";
return DOMPurify.sanitize(raw);
},
set: (v: string) => {
detailFormData.value.notice_content = v;
},
});
/** 详情是否有可视文本 */
const detailHasRenderableContent = computed(() => {
const raw = detailFormData.value.notice_content ?? "";
if (!raw.trim()) return false;
const plain = raw
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return plain.length > 0;
});
const formData = ref<NoticeForm>({
id: undefined,
notice_title: "",
@@ -445,7 +413,6 @@ const {
"2": { type: "warning", text: "公告" },
},
},
{ prop: "notice_content", label: "内容", minWidth: 200, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 140, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
@@ -512,9 +479,9 @@ function onResetSearch() {
void resetSearchParams();
}
async function deleteNoticeRow(id: number) {
async function deleteNoticeRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await NoticeAPI.deleteNotice([id]);
await noticeStore.getNotice();
faTableRef.value?.elTableRef?.clearSelection();
@@ -552,7 +519,7 @@ function buildNoticeRowActions(row: NoticeTable): TableOperationAction[] {
icon: "ri:delete-bin-4-line",
perm: "module_system:notice:delete",
run: () => {
if (row.id != null) deleteNoticeRow(row.id);
if (row.id != null) deleteNoticeRow(row.id, row.notice_title ?? "");
},
},
];
@@ -569,7 +536,12 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
(data.value as NoticeTable[])
.filter((r) => ids.includes(r.id!))
.map((r) => String(r.notice_title ?? r.id))
);
batchDeleting.value = true;
await NoticeAPI.deleteNotice(ids);
await noticeStore.getNotice();
@@ -605,61 +577,3 @@ onMounted(async () => {
await dictStore.getDict(["sys_notice_type"]);
});
</script>
<style scoped lang="scss">
/* 富文本预览区域阅读样式(与 FaWangEditor 输出 HTML 展示一致) */
.notice-html-preview {
box-sizing: border-box;
min-height: 120px;
max-height: min(360px, 45vh);
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: calc(var(--custom-radius) / 3 + 2px);
}
.notice-html-empty {
margin: 0;
font-size: 14px;
color: var(--el-text-color-placeholder);
}
.notice-html-preview :deep(h1),
.notice-html-preview :deep(h2),
.notice-html-preview :deep(h3) {
margin: 12px 0 8px;
}
.notice-html-preview :deep(p) {
margin: 8px 0;
line-height: 1.6;
}
.notice-html-preview :deep(table) {
margin: 12px 0;
}
.notice-html-preview :deep(table th),
.notice-html-preview :deep(table td) {
padding: 8px 12px;
}
.notice-html-preview :deep(pre) {
padding: 12px;
margin: 12px 0;
overflow-x: auto;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
.notice-html-preview :deep(blockquote) {
padding-left: 16px;
margin: 12px 0;
color: var(--el-text-color-regular);
border-left: 4px solid var(--el-color-primary);
}
.notice-html-preview :deep(img) {
max-width: 100%;
height: auto;
}
</style>
@@ -0,0 +1,51 @@
<template>
<div
class="param-field-item flex items-start gap-4 p-4 rounded-lg border bg-(--el-fill-color-blank)"
:class="modified ? 'border-(--el-color-primary)!' : 'border-(--el-border-color-light)'"
>
<div
class="param-field-label w-28 shrink-0 text-sm font-medium leading-9 text-right text-(--el-text-color-regular)"
>
{{ label }}
<ElTag v-if="modified" size="small" type="warning" class="ml-0.5">已修改</ElTag>
</div>
<div class="param-field-control flex-1 min-w-0">
<slot />
</div>
<div class="param-field-actions shrink-0 flex flex-col gap-2">
<ElButton text size="small" :disabled="!modified" @click="$emit('save')">
<FaSvgIcon icon="ri:save-line" class="mr-0.5" />
保存
</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "ParamFieldCard" });
interface Props {
label: string;
modified: boolean;
}
defineProps<Props>();
interface Emits {
(e: "save"): void;
}
defineEmits<Emits>();
</script>
<style scoped lang="scss">
.param-field-item {
transition:
border-color 0.2s,
box-shadow 0.2s;
&:hover {
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
}
}
</style>
@@ -0,0 +1,36 @@
<template>
<div class="param-fields-wrap">
<ElButton v-if="modified" type="primary" size="small" @click="$emit('saveGroup')">
{{ saveText || `保存${title}` }}
</ElButton>
<div class="space-y-4">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "ParamTabPane" });
interface Props {
title: string;
modified: boolean;
saveText?: string;
}
withDefaults(defineProps<Props>(), {
saveText: "",
});
interface Emits {
(e: "saveGroup"): void;
}
defineEmits<Emits>();
</script>
<style scoped lang="scss">
.param-fields-wrap {
max-width: 720px;
}
</style>
@@ -1,563 +1,441 @@
<!-- 系统配置:Fa 布局 + useTable,与 notice 页一致 -->
<template>
<div class="fa-full-height">
<FaSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="paramSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
include-audit
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
<ElCard class="fa-card" header="系统参数配置">
<ElTabs v-model="activeTab" tabPosition="left" class="h-full">
<!-- 品牌标识 -->
<ElTabPane label="品牌标识" name="brand">
<ParamTabPane
title="品牌标识"
:modified="brandModified"
@save-group="saveGroup(fieldsMap.brand)"
>
<ParamFieldCard
v-for="field in fieldsMap.brand"
:key="field.key"
:label="field.label"
:modified="field.modified"
@save="saveField(field)"
>
<FaUpload
v-model="field.localValue"
:style="{ width: '160px', height: '160px' }"
:show-tip="false"
:max-file-size="5"
enable-crop
@update:model-value="onFieldChange(field)"
/>
</ParamFieldCard>
</ParamTabPane>
</ElTabPane>
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
<FaTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<FaTableHeaderLeft
:remove-ids="selectedIds"
:perm-create="['module_system:param:create']"
:perm-export="['module_system:param:export']"
:perm-delete="['module_system:param:delete']"
:delete-loading="batchDeleting"
:perm-patch="['module_system:params:update']"
:more-loading="moreLoading"
:create-loading="createLoading"
@add="handleAdd"
@export="openExport"
@delete="handleBatchDelete"
@more="handleMoreClick"
/>
</template>
</FaTableHeader>
<!-- 网站信息 -->
<ElTabPane label="网站信息" name="website">
<ParamTabPane
title="网站信息"
:modified="websiteModified"
@save-group="saveGroup(fieldsMap.website)"
>
<ParamFieldCard
v-for="field in fieldsMap.website"
:key="field.key"
:label="field.label"
:modified="field.modified"
@save="saveField(field)"
>
<ElInput
v-model="field.localValue"
:placeholder="'输入' + field.label"
clearable
@input="onFieldChange(field)"
/>
</ParamFieldCard>
</ParamTabPane>
</ElTabPane>
<FaTable
ref="faTableRef"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="onTableSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
<!-- 外部链接 -->
<ElTabPane label="外部链接" name="security">
<ParamTabPane
title="外部链接"
:modified="securityModified"
@save-group="saveGroup(fieldsMap.security)"
>
<ParamFieldCard
v-for="field in fieldsMap.security"
:key="field.key"
:label="field.label"
:modified="field.modified"
@save="saveField(field)"
>
<ElInput
v-model="field.localValue"
:placeholder="'输入' + field.label"
clearable
@input="onFieldChange(field)"
/>
</ParamFieldCard>
</ParamTabPane>
</ElTabPane>
<!-- 安全访问 -->
<ElTabPane label="安全访问" name="access">
<ParamTabPane
title="安全访问"
:modified="accessModified"
@save-group="saveGroup(fieldsMap.access)"
>
<template v-if="fieldsMap.access.length > 0">
<!-- IP 归属地查询 -->
<ParamFieldCard
:label="fieldsMap.access[0]!.label"
:modified="fieldsMap.access[0]!.modified"
@save="saveField(fieldsMap.access[0]!)"
>
<div class="flex items-center gap-3">
<ElSwitch
:model-value="fieldsMap.access[0]!.localValue === 'on'"
active-text="开启"
inactive-text="关闭"
@change="(val: string | number | boolean) => onLocationChange(val === true)"
/>
<span class="text-xs text-(--el-text-color-secondary)">
{{
fieldsMap.access[0]!.localValue === "on"
? "登录时将查询客户端 IP 归属地,用于日志记录"
: "IP 归属地查询已关闭,登录日志不记录地理位置"
}}
</span>
</div>
</ParamFieldCard>
<!-- IP 黑名单 -->
<ParamFieldCard
:label="fieldsMap.access[1]!.label"
:modified="fieldsMap.access[1]!.modified"
@save="saveField(fieldsMap.access[1]!)"
>
<div class="flex flex-wrap gap-2 mb-2">
<ElTag
v-for="(ip, idx) in fieldsMap.access[1]!.ipList"
:key="idx"
type="danger"
closable
:disable-transitions="false"
@close="removeIp(fieldsMap.access[1]!, idx)"
>
{{ ip }}
</ElTag>
<span
v-if="fieldsMap.access[1]!.ipList.length === 0"
class="text-xs text-(--el-text-color-placeholder) leading-8"
>暂无 IP</span
>
</div>
<div class="flex gap-2">
<ElInput
v-model="fieldsMap.access[1]!.newIp"
placeholder="输入 IP 地址"
size="small"
class="w-48!"
@keyup.enter="addIp(fieldsMap.access[1]!)"
/>
<ElButton
size="small"
type="primary"
:disabled="!fieldsMap.access[1]!.newIp.trim()"
@click="addIp(fieldsMap.access[1]!)"
>添加</ElButton
>
</div>
</ParamFieldCard>
</template>
</ParamTabPane>
</ElTabPane>
<!-- 演示环境 -->
<ElTabPane label="演示环境" name="demo">
<ParamTabPane
title="演示环境"
:modified="demoModified"
@save-group="saveGroup(fieldsMap.demo)"
>
<template v-if="fieldsMap.demo.length > 0">
<!-- 演示模式 -->
<ParamFieldCard
:label="fieldsMap.demo[0]!.label"
:modified="fieldsMap.demo[0]!.modified"
@save="saveField(fieldsMap.demo[0]!)"
>
<div class="flex items-center gap-3">
<ElSwitch
:model-value="fieldsMap.demo[0]!.localValue === 'on'"
active-text="开启"
inactive-text="关闭"
@change="(val: string | number | boolean) => onDemoChange(val === true)"
/>
<span class="text-xs text-(--el-text-color-secondary)">
{{
fieldsMap.demo[0]!.localValue === "on"
? "演示模式已开启,非白名单 IP 仅允许 GET 请求"
: "演示模式已关闭,所有功能正常访问"
}}
</span>
</div>
</ParamFieldCard>
<!-- IP 白名单 -->
<ParamFieldCard
:label="fieldsMap.demo[1]!.label"
:modified="fieldsMap.demo[1]!.modified"
@save="saveField(fieldsMap.demo[1]!)"
>
<div class="flex flex-wrap gap-2 mb-2">
<ElTag
v-for="(ip, idx) in fieldsMap.demo[1]!.ipList"
:key="idx"
type="success"
closable
:disable-transitions="false"
@close="removeIp(fieldsMap.demo[1]!, idx)"
>
{{ ip }}
</ElTag>
<span
v-if="fieldsMap.demo[1]!.ipList.length === 0"
class="text-xs text-(--el-text-color-placeholder) leading-8"
>暂无 IP</span
>
</div>
<div class="flex gap-2">
<ElInput
v-model="fieldsMap.demo[1]!.newIp"
placeholder="输入 IP 地址"
size="small"
class="w-48!"
@keyup.enter="addIp(fieldsMap.demo[1]!)"
/>
<ElButton
size="small"
type="primary"
:disabled="!fieldsMap.demo[1]!.newIp.trim()"
@click="addIp(fieldsMap.demo[1]!)"
>添加</ElButton
>
</div>
</ParamFieldCard>
</template>
</ParamTabPane>
</ElTabPane>
</ElTabs>
</ElCard>
<FaDialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
width="640px"
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="paramDetailItems"
max-height="75vh"
/>
</template>
<template v-else>
<FaForm
:key="paramFormRenderKey"
scrollbar
max-height="75vh"
ref="dataFormRef"
v-model="formData"
:items="paramDialogFormItems"
: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 #config_type>
<ElRadioGroup v-model="formData.config_type">
<ElRadio :value="true">是</ElRadio>
<ElRadio :value="false">否</ElRadio>
</ElRadioGroup>
</template>
</FaForm>
</template>
</FaDialog>
<FaExportDialog
v-model="exportVisible"
:content-config="paramExportContentConfig"
: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 ParamsAPI, {
type ConfigForm,
type ConfigPageQuery,
type ConfigTable,
} from "@/api/module_system/params";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { ref, computed, reactive, onMounted } from "vue";
import { ElMessage } from "@/utils/message";
import ParamsAPI from "@/api/module_system/params";
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 type FaForm from "@/components/forms/fa-form/index.vue";
import ParamTabPane from "./components/ParamTabPane.vue";
import ParamFieldCard from "./components/ParamFieldCard.vue";
defineOptions({
name: "Params",
inheritAttrs: false,
});
defineOptions({ name: "ParamsSettings" });
const configStore = useConfigStore();
const { hasAuth } = useAuth();
const activeTab = ref("brand");
type ParamSearchForm = {
config_name?: string;
config_key?: string;
config_type?: string;
created_time?: string[];
};
// ─── 字段类型 ───
function normalizeParamQuery(params: Record<string, unknown>): ConfigPageQuery {
const p = cleanEmptyArrayParams({ ...params });
if (p.config_type === "true" || p.config_type === true) p.config_type = true;
else if (p.config_type === "false" || p.config_type === false) p.config_type = false;
return p as unknown as ConfigPageQuery;
interface ParamField {
key: string;
label: string;
localValue: string;
displayValue: string;
id?: number;
configName?: string;
configKey?: string;
ipList: string[];
newIp: string;
modified: boolean;
}
function buildParamReplaceParams(p: ParamSearchForm): Record<string, unknown> {
// ─── 工具函数 ───
function buildField(key: string, label: string): ParamField {
const param = configStore.configData[key];
const raw = param?.config_value ?? "";
const display = typeof raw === "string" && raw.trim() ? raw.trim() : "";
let ipArr: string[] = [];
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) ipArr = parsed;
} catch {
// 不是 JSON 数组,当纯文本处理
}
}
return {
config_name: p.config_name,
config_key: p.config_key,
config_type: p.config_type,
created_time:
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
key,
label,
localValue: display,
displayValue: display,
id: param?.id,
configName: param?.config_name,
configKey: param?.config_key,
ipList: ipArr,
newIp: "",
modified: false,
};
}
const searchForm = ref<ParamSearchForm>({
config_name: undefined,
config_key: undefined,
config_type: undefined,
created_time: undefined,
// ─── 字段分组 ▸ 按配置项组织 ───
const fieldsMap = reactive({
brand: [] as ParamField[],
website: [] as ParamField[],
security: [] as ParamField[],
access: [] as ParamField[],
demo: [] as ParamField[],
});
const showSearchBar = ref(true);
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
const searchBarRules: Record<string, unknown> = {};
const builtinOptions = ref([
{ label: "是", value: "true" },
{ label: "否", value: "false" },
]);
const paramSearchItems = computed<SearchFormItem[]>(() => [
{
label: "配置名称",
key: "config_name",
type: "input",
placeholder: "请输入配置名称",
clearable: true,
span: 6,
},
{
label: "配置键名",
key: "config_key",
type: "input",
placeholder: "请输入配置键名",
clearable: true,
span: 6,
},
{
label: "系统内置",
key: "config_type",
type: "select",
props: {
placeholder: "请选择系统内置",
options: builtinOptions.value,
clearable: true,
},
span: 6,
},
{
label: "状态",
key: "status",
type: "select",
props: {
placeholder: "请选择状态",
options: [
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
],
clearable: true,
},
span: 6,
},
]);
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
// ─── 表格多选 ───
const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
useTableSelection<ConfigTable>();
const createLoading = ref(false);
const moreLoading = ref(false);
// ─── 对话框状态 ───
const { dialogVisible } = useCrudDialog();
const detailFormData = ref<ConfigTable>({} as ConfigTable);
const paramDetailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] =
[
{ label: "配置名称", prop: "config_name" },
{
label: "系统内置",
prop: "config_type",
tag: {
map: { true: { type: "success", text: "是" }, false: { type: "danger", text: "否" } },
},
},
{ label: "配置键", prop: "config_key" },
{ label: "配置值", prop: "config_value" },
{ label: "描述", prop: "description" },
{ label: "创建时间", prop: "created_time" },
{ label: "更新时间", prop: "updated_time" },
{ label: "创建人", prop: "created_by.name" },
{ label: "更新人", prop: "updated_by.name" },
function rebuildFields() {
fieldsMap.brand = [
buildField("logo_url", "Logo"),
buildField("favicon", "Favicon"),
buildField("login_bg", "登录背景图"),
];
const formData = ref<ConfigForm>({
id: undefined,
config_name: "",
config_key: "",
config_value: "",
config_type: false,
description: "",
});
const rules = reactive({
config_name: [{ required: true, message: "请输入系统配置名称", trigger: "blur" }],
config_key: [{ required: true, message: "请输入系统配置键", trigger: "blur" }],
config_value: [{ required: true, message: "请输入系统配置值", trigger: "blur" }],
config_type: [{ required: true, message: "请选择系统配置类型", trigger: "blur" }],
});
const dataFormRef = ref<InstanceType<typeof FaForm> | null>(null);
const paramFormRenderKey = ref(0);
const initialFormData: ConfigForm = {
id: undefined,
config_name: "",
config_key: "",
config_value: "",
config_type: false,
description: "",
};
// ─── CRUD 表单 ───
const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } =
useCrudForm<ConfigForm>({
formData,
initialFormData,
dialogVisible,
dataFormRef,
formRenderKey: paramFormRenderKey,
detailApi: ParamsAPI.detailParams,
createApi: ParamsAPI.createParams,
updateApi: ParamsAPI.updateParams,
titles: { create: "新增系统配置", update: "修改系统配置", detail: "系统配置详情" },
detailFormData,
onCreateSuccess: async () => {
await refreshCreate();
},
onUpdateSuccess: async () => {
await refreshUpdate();
},
onSubmitSuccess: async () => {
configStore.isConfigLoaded = false;
await configStore.getConfig();
},
});
async function handleAdd() {
createLoading.value = true;
try {
await handleOpenDialog("create");
} finally {
createLoading.value = false;
}
}
const paramDialogFormItems = computed<FormItem[]>(() => [
{
label: "配置名称",
key: "config_name",
type: "input",
span: 24,
props: { placeholder: "请输入配置名称", maxlength: 50 },
},
{
label: "配置键",
key: "config_key",
type: "input",
span: 24,
props: { placeholder: "请输入配置键", maxlength: 50 },
},
{
label: "配置值",
key: "config_value",
type: "input",
span: 24,
props: { placeholder: "请输入配置值", maxlength: 100 },
},
{
label: "系统内置",
key: "config_type",
type: "input",
span: 24,
placeholder: "",
},
{
label: "描述",
key: "description",
type: "input",
span: 24,
props: {
type: "textarea",
rows: 4,
maxlength: 100,
showWordLimit: true,
placeholder: "请输入描述",
},
},
]);
const {
columns,
columnChecks,
data,
loading,
pagination,
searchParams,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
refreshCreate,
refreshUpdate,
refreshRemove,
} = useTable({
core: {
apiFn: ParamsAPI.listParams,
apiParams: {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<ConfigTable>[] => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "config_name", label: "配置名称", minWidth: 120, showOverflowTooltip: true },
{ prop: "config_key", label: "配置键", minWidth: 200, showOverflowTooltip: true },
{ prop: "config_value", label: "配置值", minWidth: 200, showOverflowTooltip: true },
{
prop: "config_type",
label: "系统内置",
minWidth: 100,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{ prop: "description", label: "描述", minWidth: 120, 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: "center",
formatter: (row: ConfigTable) => formatParamOperationCell(row),
},
],
},
});
const paramCrudCols = computed(() =>
columns.value.map((c: ColumnOption<ConfigTable>) => {
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 normalizeParamQuery(sp);
});
const paramExportContentConfig = computed(() => ({
permPrefix: "module_system:param",
cols: paramCrudCols.value,
exportsBlobAction: async (params: IObject) => {
const merged = normalizeParamQuery({
...(exportQueryParams.value as unknown as Record<string, unknown>),
...params,
} as Record<string, unknown>);
const res = await ParamsAPI.exportParams(merged as ConfigPageQuery);
return res.data as Blob;
},
}));
const { exportVisible, openExport } = useImportExport();
async function handleSearchBarSearch(params: ParamSearchForm) {
await searchBarRef.value?.validate?.();
replaceSearchParams(buildParamReplaceParams(params));
getData();
}
function onResetSearch() {
searchForm.value = {
config_name: undefined,
config_key: undefined,
config_type: undefined,
created_time: undefined,
};
void resetSearchParams();
}
async function deleteParamRow(id: number) {
try {
await confirmDelete();
await ParamsAPI.deleteParams([id]);
configStore.isConfigLoaded = false;
await configStore.getConfig();
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
}
}
function buildParamRowActions(row: ConfigTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:param:detail",
run: () => {
if (row.id != null) void handleOpenDialog("detail", row.id);
},
},
{
key: "edit",
label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_system:param:update",
run: () => {
if (row.id != null) void handleOpenDialog("update", row.id);
},
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_system:param:delete",
run: () => {
if (row.id != null) deleteParamRow(row.id);
},
},
fieldsMap.website = [
buildField("sys_name", "系统名称"),
buildField("login_title", "登录页标题"),
buildField("login_subtitle", "登录页副标题"),
buildField("version", "系统版本"),
buildField("copyright", "版权信息"),
buildField("keep_record", "备案号"),
buildField("help_doc", "帮助文档地址"),
buildField("git_code", "源码地址"),
];
fieldsMap.security = [buildField("privacy", "隐私政策地址"), buildField("clause", "用户协议")];
fieldsMap.access = [
buildField("ip_location_enable", "IP归属地查询"),
buildField("ip_black_list", "IP 黑名单"),
];
fieldsMap.demo = [
buildField("demo_enable", "演示模式"),
buildField("ip_white_list", "IP 白名单"),
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatParamOperationCell(row: ConfigTable) {
return renderTableOperationCell(buildParamRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 param-table-actions",
});
}
// ─── 计算 ▸ 分组是否有修改 ───
async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
batchDeleting.value = true;
await ParamsAPI.deleteParams(ids);
configStore.isConfigLoaded = false;
await configStore.getConfig();
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
// 用户取消
} finally {
batchDeleting.value = false;
const brandModified = computed(() => fieldsMap.brand.some((f) => f.modified));
const websiteModified = computed(() => fieldsMap.website.some((f) => f.modified));
const securityModified = computed(() => fieldsMap.security.some((f) => f.modified));
const accessModified = computed(() => fieldsMap.access.some((f) => f.modified));
const demoModified = computed(() => fieldsMap.demo.some((f) => f.modified));
// ─── IP 操作 ───
function addIp(field: ParamField) {
const ip = field.newIp.trim();
if (!ip) return;
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
ElMessage.warning("请输入有效的 IP 地址");
return;
}
if (field.ipList.includes(ip)) {
ElMessage.info("该 IP 已存在");
return;
}
field.ipList.push(ip);
field.localValue = JSON.stringify(field.ipList);
field.modified = true;
field.newIp = "";
}
async function handleMoreClick(status: number) {
const ids = selectedIds.value;
if (!ids.length) {
ElMessage.warning("请先选择要操作的数据");
function removeIp(field: ParamField, index: number) {
field.ipList.splice(index, 1);
field.localValue = JSON.stringify(field.ipList);
field.modified = true;
}
// ─── 保存 ───
function onFieldChange(field: ParamField) {
field.modified = field.localValue !== field.displayValue;
}
function onDemoChange(val: boolean) {
const field = fieldsMap.demo[0]!;
if (!field) return;
field.localValue = val ? "on" : "off";
field.modified = field.localValue !== field.displayValue;
}
function onLocationChange(val: boolean) {
const field = fieldsMap.access[0]!;
if (!field) return;
field.localValue = val ? "on" : "off";
field.modified = field.localValue !== field.displayValue;
}
async function saveField(field: ParamField) {
if (!field.id) {
ElMessage.warning(`配置 "${field.label}" 缺少 ID,无法保存`);
return;
}
if (!field.modified) {
ElMessage.info("未检测到修改");
return;
}
try {
await confirmToggleStatus(status);
moreLoading.value = true;
await ParamsAPI.batchParams({ ids, status });
await ParamsAPI.updateParams(field.id, {
config_value: field.localValue,
config_name: field.configName,
config_key: field.configKey,
} as any);
field.modified = false;
field.displayValue = field.localValue;
ElMessage.success(`${field.label} 已保存`);
configStore.isConfigLoaded = false;
await configStore.getConfig();
faTableRef.value?.elTableRef?.clearSelection();
await refreshData();
} catch {
// 用户取消
} finally {
moreLoading.value = false;
ElMessage.error(`${field.label} 保存失败`);
}
}
async function saveGroup(fields: ParamField[]) {
const modified = fields.filter((f) => f.modified && f.id);
if (modified.length === 0) {
ElMessage.info("该分组无未保存的修改");
return;
}
let successCount = 0;
for (const field of modified) {
try {
await ParamsAPI.updateParams(field.id!, {
config_value: field.localValue,
config_name: field.configName,
config_key: field.configKey,
} as any);
field.modified = false;
field.displayValue = field.localValue;
successCount++;
} catch {
ElMessage.error(`${field.label} 保存失败`);
}
}
if (successCount > 0) {
configStore.isConfigLoaded = false;
await configStore.getConfig();
ElMessage.success(`已保存 ${successCount} 项`);
}
}
// ─── 初始化 ───
onMounted(async () => {
if (!configStore.isConfigLoaded) {
await configStore.getConfig();
}
rebuildFields();
});
</script>
@@ -113,28 +113,20 @@
</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 { confirmToggleStatus } from "@/hooks/core/useConfirm";
import PositionAPI, {
type PositionForm,
type PositionPageQuery,
type PositionTable,
} from "@/api/module_system/position";
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 type FaForm from "@/components/forms/fa-form/index.vue";
import { resolveStatusColumns, renderTableOperationCell } from "@utils";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
defineOptions({
name: "Position",
@@ -180,7 +172,7 @@ function buildPositionRowActions(
ctx: {
onDetail: (id: number) => void;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onDelete: (id: number, name: string) => void;
}
): RowAction[] {
const all: RowAction[] = [
@@ -203,7 +195,7 @@ function buildPositionRowActions(
label: "删除",
artType: "delete",
perm: "module_system:position:delete",
run: () => ctx.onDelete(row.id!),
run: () => ctx.onDelete(row.id!, row.name ?? ""),
},
];
return all.filter((a) => hasAuth(a.perm));
@@ -334,7 +326,7 @@ const {
});
const positionCrudCols = computed(() =>
columns.value.map((c: ColumnOption<PositionTable>) => {
columns!.value.map((c: ColumnOption<PositionTable>) => {
const t = (c as { type?: string }).type;
return {
prop: c.prop,
@@ -509,9 +501,9 @@ function onResetSearch() {
void resetSearchParams();
}
async function deletePositionRow(id: number) {
async function deletePositionRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await PositionAPI.deletePosition([id]);
await userStore.getUserInfo();
faTableRef.value?.elTableRef?.clearSelection();
@@ -525,7 +517,10 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
selectedRows.value.map((r) => String(r.name ?? r.id))
);
batchDeleting.value = true;
await PositionAPI.deletePosition(ids);
await userStore.getUserInfo();
@@ -12,11 +12,11 @@
<!-- 数据权限 -->
<ElAside>
<div
class="border-r border-r-(--el-border-color-lighter) b-r-solid h-full p-[20px] box-border"
class="border-r border-r-(--el-border-color-lighter) b-r-solid h-full p-5 box-border"
>
<div class="flex items-center">
<div class="flex gap-[10px]">
<div class="w-[10px] bg-(--el-color-primary)"></div>
<div class="flex gap-2.5">
<div class="w-2.5 bg-(--el-color-primary)"></div>
<div>
<span class="text-[16px]">数据授权</span>
<ElTooltip placement="right">
@@ -70,8 +70,8 @@
<!-- 菜单权限 -->
<ElMain>
<div class="flex gap-[10px]">
<div class="w-[10px] bg-(--el-color-primary)"></div>
<div class="flex gap-2.5">
<div class="w-2.5 bg-(--el-color-primary)"></div>
<div>
<span class="text-[16px]">菜单授权</span>
<ElTooltip placement="right">
@@ -110,13 +110,13 @@ import { computed, ref, watch, onMounted, nextTick } from "vue";
import { QuestionFilled } from "@element-plus/icons-vue";
import type { TreeInstance } from "element-plus";
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_system/menu";
import { DeviceEnum } from "@/enums/settings/device.enum";
import { useUserStore, useAppStore } from "@stores";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
import type FaMenuTreeTable from "@/components/others/fa-menu-tree-table/index.vue";
const props = defineProps<{
roleName: string;
@@ -152,21 +152,15 @@
<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";
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, resolveStatusColumns } from "@utils";
import type { ColumnOption } from "@/types/component";
import { confirmToggleStatus } from "@/hooks/core/useConfirm";
import { type TableOperationAction } from "@utils";
import RoleAPI, {
type RoleForm,
type RoleTable,
type TablePageQuery,
} from "@/api/module_system/role";
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";
@@ -174,7 +168,7 @@ 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 StatusTag from "@/components/others/fa-status-tag/index.vue";
import { ElMessage } from "element-plus";
import { ElMessage } from "@/utils/message";
import FaPermissonDrawer from "./components/FaPermissonDrawer.vue";
defineOptions({
@@ -231,7 +225,7 @@ function buildRoleRowActions(
onPerm: (id: number, name: string) => void;
onDetail: (id: number) => void;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onDelete: (id: number, name: string) => void;
}
): TableOperationAction[] {
const isSys = row.id === 1;
@@ -288,7 +282,7 @@ function buildRoleRowActions(
warnSys();
return;
}
ctx.onDelete(row.id!);
ctx.onDelete(row.id!, row.name ?? "");
},
},
];
@@ -312,8 +306,8 @@ const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
const searchBarRules: Record<string, unknown> = {};
const statusOptions = ref([
{ label: "启用", value: "true" },
{ label: "停用", value: "false" },
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
]);
const roleSearchItems = computed<SearchFormItem[]>(() => [
@@ -355,9 +349,9 @@ function handleOpenAssignPermDialog(roleId: number, roleName: string) {
drawerVisible.value = true;
}
async function deleteRoleRow(id: number) {
async function deleteRoleRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await RoleAPI.deleteRole([id]);
const userStore = useUserStore();
await userStore.getUserInfo();
@@ -549,12 +543,12 @@ const {
columnsFactory: resolveStatusColumns<RoleTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "角色名称", minWidth: 100, showOverflowTooltip: true },
{ prop: "code", label: "角色编码", minWidth: 100, showOverflowTooltip: true },
{ prop: "name", label: "角色名称", minWidth: 120, showOverflowTooltip: true },
{ prop: "code", label: "角色编码", minWidth: 130, showOverflowTooltip: true },
{
prop: "data_scope",
label: "数据权限",
minWidth: 200,
minWidth: 160,
status: {
1: { type: "primary", text: "仅本人数据权限" },
2: { type: "info", text: "本部门数据权限" },
@@ -573,19 +567,19 @@ const {
{
prop: "status",
label: "状态",
width: 88,
width: 80,
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 120, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 220,
width: 230,
fixed: "right",
align: "center",
formatter: (row: RoleTable) => formatRoleOperationCell(row, opCtx),
@@ -595,7 +589,7 @@ const {
});
const roleCrudCols = computed(() =>
columns.value.map((c: ColumnOption<RoleTable>) => {
columns!.value.map((c: ColumnOption<RoleTable>) => {
const t = (c as { type?: string }).type;
return {
prop: c.prop,
@@ -643,7 +637,10 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
selectedRows.value.map((r) => String(r.name ?? r.id))
);
batchDeleting.value = true;
await RoleAPI.deleteRole(ids);
const userStore = useUserStore();
@@ -137,7 +137,7 @@
<ElDropdownItem
v-if="hasAuth('module_system:ticket:delete')"
divided
@click="deleteTicketRow(item.id!)"
@click="deleteTicketRow(item.id!, item.title ?? '')"
>
<ElIcon><Delete /></ElIcon>删除
</ElDropdownItem>
@@ -175,7 +175,7 @@
:form-mode="dialogVisible.type"
:confirm-loading="submitLoading"
@cancel="handleCloseDialog"
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
@confirm="handleDialogConfirm"
>
<template v-if="dialogVisible.type === 'detail'">
<FaDescriptions
@@ -267,6 +267,7 @@
</template>
<template v-else>
<FaForm
v-if="dialogVisible.type === 'create'"
:key="ticketFormRenderKey"
scrollbar
max-height="75vh"
@@ -316,16 +317,49 @@
@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>
<!-- 处理工单:展示工单信息 + 回复处理 -->
<div v-else class="ticket-process-panel">
<!-- 工单信息区 -->
<div class="ticket-info-section">
<div class="ticket-info-header">
<ElTag :type="typeTag(formData.ticket_type!)" size="small" effect="plain">
{{ typeLabel(formData.ticket_type!) }}
</ElTag>
<span class="ticket-info-title">{{ formData.title }}</span>
</div>
<FaMarkdownRenderer
:content="formData.ticket_content ?? ''"
class="ticket-process-content"
/>
</div>
<ElDivider />
<!-- 处理区 -->
<div class="ticket-process-section">
<div class="process-field">
<label class="process-label">状态</label>
<ElRadioGroup v-model="formData.status">
<ElRadio :value="0">待处理</ElRadio>
<ElRadio :value="1">处理中</ElRadio>
<ElRadio :value="2">已完成</ElRadio>
<ElRadio :value="3">已关闭</ElRadio>
</ElRadioGroup>
</div>
<div class="process-field">
<label class="process-label">回复内容</label>
<FaWangEditor
:model-value="formData.reply_content ?? ''"
height="min(38vh, 280px)"
placeholder="请输入回复内容..."
:exclude-keys="[]"
@update:model-value="(v: string) => (formData.reply_content = v)"
/>
</div>
</div>
</div>
</template>
</FaDialog>
</div>
@@ -333,9 +367,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useCrudForm } from "@/hooks/core/useCrudForm";
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
import TicketAPI, {
getTicketComments,
createTicketComment,
@@ -344,7 +376,6 @@ import TicketAPI, {
type TicketTable,
type TicketCommentTable,
} from "@/api/module_system/ticket";
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 type FaForm from "@/components/forms/fa-form/index.vue";
@@ -537,7 +568,13 @@ function typeTag(t: string): any {
return { suggestion: "success", bug: "danger", optimize: "warning", other: "info" }[t] || "info";
}
function statusTagType(s: string): "warning" | "info" | "success" | "danger" | undefined {
return { "0": "warning", "1": "info", "2": "success", "3": "info" }[s] as any;
const map: Record<string, "warning" | "info" | "success" | "danger"> = {
"0": "warning",
"1": "info",
"2": "success",
"3": "info",
};
return map[s];
}
function typeIcon(t: string): string {
return (
@@ -654,9 +691,12 @@ const initialFormData: TicketForm & { reply_content?: string } = {
reply_content: undefined,
};
const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = useCrudForm<
TicketForm & { reply_content?: string }
>({
const {
submitLoading,
handleCloseDialog,
handleOpenDialog,
handleSubmit: crudHandleSubmit,
} = useCrudForm<TicketForm & { reply_content?: string }>({
formData,
initialFormData,
dialogVisible,
@@ -678,6 +718,35 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = use
},
});
/** 对话框确认:创建走 crudHandleSubmit(含表单校验),处理直接调 API */
async function handleDialogConfirm() {
if (dialogVisible.type === "create") {
await crudHandleSubmit();
return;
}
if (dialogVisible.type !== "update") {
handleCloseDialog();
return;
}
const id = formData.value.id;
if (!id) return;
submitLoading.value = true;
try {
const payload: Record<string, unknown> = {};
if (formData.value.status !== undefined) payload.status = formData.value.status;
if (formData.value.reply_content?.trim()) {
payload.reply_content = formData.value.reply_content.trim();
}
await TicketAPI.updateTicket(id, payload);
await fetchData();
handleCloseDialog();
} catch {
// 接口错误由全局拦截器处理
} finally {
submitLoading.value = false;
}
}
const ticketDialogFormItems = computed<FormItem[]>(() => [
{
label: "工单标题",
@@ -714,19 +783,12 @@ const ticketDialogFormItems = computed<FormItem[]>(() => [
span: 24,
placeholder: "",
},
{
label: "回复内容",
key: "reply_content",
type: "input",
span: 24,
placeholder: "",
},
]);
// ─── 操作 ───
async function deleteTicketRow(id: number) {
async function deleteTicketRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await TicketAPI.deleteTicket([id]);
await fetchData();
} catch {
@@ -752,7 +814,10 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
data.value.filter((r) => ids.includes(r.id!)).map((r) => String(r.title ?? r.id))
);
batchDeleting.value = true;
selectedIds.value = [];
await TicketAPI.deleteTicket(ids);
@@ -1031,6 +1096,59 @@ async function handleSubmitComment() {
color: var(--el-text-color-placeholder);
}
/* ─── 处理工单面板 ─── */
.ticket-process-panel {
display: flex;
flex-direction: column;
max-height: 75vh;
overflow-y: auto;
}
.ticket-info-section {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
}
.ticket-info-header {
display: flex;
gap: 10px;
align-items: center;
}
.ticket-info-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.ticket-process-content {
min-height: 120px;
padding: 12px;
background-color: var(--el-fill-color-lighter);
border-radius: calc(var(--custom-radius) / 3 + 2px);
}
.ticket-process-section {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
}
.process-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.process-label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.ticket-html-preview :deep(h1),
.ticket-html-preview :deep(h2),
.ticket-html-preview :deep(h3) {
@@ -205,33 +205,21 @@ defineOptions({
import { h } from "vue";
import { UserFilled } from "@element-plus/icons-vue";
import { ElAvatar } from "element-plus";
import { ResultEnum } from "@/enums/api/result.enum";
import { useAppStore } from "@stores";
import { DeviceEnum } from "@/enums/settings/device.enum";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { confirmDelete, confirmBatchDelete, confirmToggleStatus } from "@/hooks/core/useConfirm";
import { cleanEmptyArrayParams, stripPaginationParams } from "@/utils/query";
import { confirmToggleStatus } from "@/hooks/core/useConfirm";
import UserAPI, {
type UserForm,
type UserInfo,
type UserPageQuery,
} from "@/api/module_system/user";
import {
formatTree,
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
} from "@utils";
import { formatTree, renderTableOperationCell, type TableOperationAction } from "@utils";
import PositionAPI from "@/api/module_system/position";
import DeptAPI from "@/api/module_system/dept";
import RoleAPI from "@/api/module_system/role";
import { useUserStore } from "@stores";
import { useAuth } from "@/hooks/core/useAuth";
import type { ColumnOption } from "@/types/component";
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";
@@ -239,7 +227,7 @@ 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 FaDeptTree from "./components/FaDeptTree.vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "@/utils/message";
const { hasAuth } = useAuth();
const userStore = useUserStore();
@@ -281,7 +269,7 @@ function buildUserRowActions(
onResetPwd: (row: UserInfo) => void;
onDetail: (id: number) => void;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onDelete: (id: number, name: string) => void;
}
): TableOperationAction[] {
const sys = row.is_superuser === true;
@@ -324,7 +312,7 @@ function buildUserRowActions(
disabled: sys,
run: () => {
if (sys) return;
ctx.onDelete(row.id!);
ctx.onDelete(row.id!, row.name ?? row.username ?? "");
},
},
];
@@ -514,7 +502,13 @@ async function handleResetPassword(row: UserInfo) {
const { value } = await ElMessageBox.prompt(
`请输入用户【${row.username ?? ""}】的新密码`,
"重置密码",
{ confirmButtonText: "确定", cancelButtonText: "取消" }
{
confirmButtonText: "确定",
cancelButtonText: "取消",
inputType: "password",
inputErrorMessage: "请输入密码",
draggable: true,
}
);
if (!value || value.length < 6) {
ElMessage.warning("密码至少需要6位字符,请重新输入");
@@ -526,9 +520,9 @@ async function handleResetPassword(row: UserInfo) {
}
}
async function deleteUserRow(id: number) {
async function deleteUserRow(id: number, name: string) {
try {
await confirmDelete();
await confirmDelete(`确定删除「${name}」吗?`);
await UserAPI.deleteUser([id]);
const idSet = [id];
if (userStore.basicInfo.id && idSet.includes(userStore.basicInfo.id)) {
@@ -631,7 +625,7 @@ const {
});
const userCrudCols = computed(() =>
columns.value.map((c: ColumnOption<UserInfo>) => {
(columns?.value ?? []).map((c: ColumnOption<UserInfo>) => {
const t = (c as { type?: string }).type;
return {
prop: c.prop,
@@ -796,7 +790,7 @@ async function handleImportUpload(formDataUpload: FormData) {
}
// 失败分支提示由 axios 拦截器统一处理
} catch (error: unknown) {
console.error(error);
if (import.meta.env.DEV) console.error(error);
// 接口错误已由拦截器提示
} finally {
uploadLoading.value = false;
@@ -882,7 +876,7 @@ async function handleSubmit() {
await userStore.getUserInfo();
}
} catch (error: unknown) {
console.error(error);
if (import.meta.env.DEV) console.error(error);
} finally {
submitLoading.value = false;
}
@@ -893,7 +887,10 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
selectedRows.value.map((r) => String(r.name ?? r.username ?? r.id))
);
batchDeleting.value = true;
await UserAPI.deleteUser(ids);
if (userStore.basicInfo.id && ids.includes(userStore.basicInfo.id)) {
@@ -44,7 +44,7 @@
<FaDialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
width="720px"
width="1100px"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
:form-mode="dialogVisible.type"
@@ -54,23 +54,24 @@
>
<template v-if="dialogVisible.type === 'detail'">
<FaDescriptions
:column="2"
:column="4"
:data="detailFormData"
:items="versionDetailItems"
label-width="120px"
max-height="75vh"
>
<template #status="{ row }">
<ElTag :type="statusTagType(String((row as any)?.status ?? 0))" effect="plain">
{{ statusLabel(String((row as any)?.status ?? 0)) }}
<ElTag :type="statusTagType(String(row?.status ?? 0))" effect="plain">
{{ statusLabel(String(row?.status ?? 0)) }}
</ElTag>
</template>
<template #content="{ row }">
<div
v-if="(row as any)?.content"
class="content-preview"
v-html="(row as any)!.content"
></div>
<FaMarkdownRenderer
v-if="row?.content"
:content="String(row.content)"
height="auto"
max-height="260px"
/>
<span v-else class="text-g-400">—</span>
</template>
</FaDescriptions>
@@ -104,24 +105,41 @@
<FaWangEditor
ref="versionEditorRef"
v-model="versionEditorHtml"
height="400px"
height="300px"
placeholder="请输入更新内容..."
/>
</template>
</FaForm>
</template>
</FaDialog>
<!-- 状态变更弹窗 -->
<ElDialog
v-model="statusDialog.visible"
:title="`变更状态 - ${statusDialog.version}`"
width="380px"
@close="statusDialog.visible = false"
>
<ElRadioGroup v-model="statusDialog.value">
<ElRadio v-for="opt in STATUS_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</ElRadio>
</ElRadioGroup>
<template #footer>
<ElButton @click="statusDialog.visible = false">取消</ElButton>
<ElButton type="primary" :loading="statusDialog.loading" @click="confirmChangeStatus">
确定
</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTable } from "@/hooks/core/useTable";
import { confirmDelete } from "@/hooks/core/useConfirm";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import VersionAPI, { type VersionForm, type VersionTable } from "@/api/module_system/version";
import { Plus } from "@element-plus/icons-vue";
import type { ColumnOption } from "@/types/component";
import { reactive } from "vue";
import type { AuditSearchFormParams } from "@/components/forms/fa-search-bar/auditSearchFormItems";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
@@ -174,7 +192,7 @@ const showSearchBar = ref(true);
const versionBusinessSearchItems = computed(() => [
{
label: "状态",
label: "发布状态",
key: "status",
type: "select",
props: {
@@ -206,9 +224,9 @@ const {
core: {
apiFn: VersionAPI.getVersionList,
apiParams: { page_no: 1, page_size: 10, status: undefined },
columnsFactory: (): ColumnOption<VersionTable>[] => [
columnsFactory: resolveStatusColumns((): ColumnOption<VersionTable>[] => [
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "version", label: "版本号", minWidth: 120, showOverflowTooltip: true },
{ prop: "version", label: "版本号", minWidth: 80, showOverflowTooltip: true },
{ prop: "title", label: "标题", minWidth: 160, showOverflowTooltip: true },
{
prop: "status",
@@ -232,7 +250,7 @@ const {
align: "center",
formatter: (row: VersionTable) => renderVersionOperationCell(row),
},
],
]),
},
});
@@ -256,7 +274,8 @@ function buildVersionRowActions(row: VersionTable): TableOperationAction[] {
key: "status",
label: "变更状态",
artType: "more",
run: () => {}, // dropdown 中不触发
icon: "ri:swap-line",
run: () => void handleChangeStatus(row),
},
{
key: "delete",
@@ -280,13 +299,13 @@ const detailFormData = ref<VersionTable>({});
const versionDetailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] =
[
{ label: "版本号", prop: "version" },
{ label: "标题", prop: "title" },
{ label: "版本号", prop: "version" },
{ label: "状态", prop: "status", slot: "status" },
{ label: "发布日期", prop: "date" },
{ label: "排序", prop: "sort" },
{ label: "备注", prop: "description", span: 2 },
{ label: "更新内容", prop: "content", slot: "content", span: 2 },
{ label: "备注", prop: "description" },
{ label: "更新内容", prop: "content", slot: "content", span: 4 },
{ label: "创建时间", prop: "created_time" },
{ label: "更新时间", prop: "updated_time" },
];
@@ -296,42 +315,42 @@ const versionDialogFormItems: FormItem[] = [
key: "version",
label: "版本号",
type: "input",
span: 12,
span: 8,
props: { placeholder: "请输入版本号", maxlength: 50 },
},
{
key: "title",
label: "标题",
type: "input",
span: 12,
span: 8,
props: { placeholder: "请输入标题", maxlength: 200 },
},
{
key: "date",
label: "发布日期",
type: "date",
span: 12,
span: 8,
props: { placeholder: "请选择日期", valueFormat: "YYYY-MM-DD" },
},
{
key: "status",
label: "状态",
type: "select",
span: 12,
span: 8,
props: { placeholder: "请选择状态" },
},
{
key: "sort",
label: "排序",
type: "number",
span: 12,
span: 8,
props: { controlsPosition: "right", min: 0, max: 9999 },
},
{
key: "description",
label: "备注",
type: "input",
span: 24,
span: 8,
props: { type: "textarea", rows: 3, placeholder: "请输入备注" },
},
{ key: "content", label: "更新内容", type: "input", span: 24 },
@@ -438,7 +457,7 @@ async function handleSubmit() {
dialogVisible.visible = false;
await resetForm();
} catch (error: unknown) {
console.error(error);
if (import.meta.env.DEV) console.error(error);
}
});
}
@@ -457,6 +476,33 @@ const deleteVersionRow = async (row: VersionTable) => {
};
// ─── 状态变更 ───
const statusDialog = reactive({
visible: false,
loading: false,
version: "",
id: 0,
value: 0,
});
const handleChangeStatus = (row: VersionTable) => {
statusDialog.version = row.version ?? "";
statusDialog.id = row.id ?? 0;
statusDialog.value = row.status ?? 0;
statusDialog.visible = true;
};
const confirmChangeStatus = async () => {
statusDialog.loading = true;
try {
await VersionAPI.setVersionStatus(statusDialog.id, { status: statusDialog.value });
statusDialog.visible = false;
await refreshUpdate();
} catch {
// 错误已由全局拦截器处理
} finally {
statusDialog.loading = false;
}
};
</script>
<style scoped lang="scss">
@@ -14,6 +14,7 @@
:disabled-search="false"
:default-expanded="false"
include-audit
:audit-item-options="{ showCreatedBy: false, showUpdatedBy: false }"
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
@@ -248,7 +249,7 @@
class="w-full"
icon="Close"
:disabled="job.status === 3"
@click="handleRemoveJob(job.id)"
@click="handleRemoveJob(job.id, job.name)"
>
移除
</ElButton>
@@ -338,11 +339,8 @@ defineOptions({
import JobAPI, { SchedulerStatus, SchedulerJob, JobLogTable } from "@/api/module_task/cronjob/job";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import { useTable } from "@/hooks/core/useTable";
import type { ColumnOption } from "@/types/component";
import { ElButton, ElDivider, ElMessageBox } from "element-plus";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { ElMessageBox } from "@/utils/message";
import type { TableOperationAction } from "@/utils/table";
import { computed, h, nextTick, onMounted, ref } from "vue";
import { Terminal, TerminalApi } from "vue-web-terminal";
@@ -399,7 +397,7 @@ async function loadSchedulerStatus() {
const statusRes = await JobAPI.getSchedulerStatus();
schedulerStatus.value = statusRes.data.data;
} catch (error: unknown) {
console.error(error);
if (import.meta.env.DEV) console.error(error);
}
}
@@ -436,7 +434,7 @@ async function fetchSchedulerJobs() {
});
await loadSchedulerStatus();
} catch (error: unknown) {
console.error(error);
if (import.meta.env.DEV) console.error(error);
jobList.value = [];
} finally {
jobLoading.value = false;
@@ -527,7 +525,7 @@ function buildLogRowActions(row: JobLogTable): TableOperationAction[] {
label: "删除",
artType: "delete",
perm: "module_task:cronjob:job:delete",
run: () => deleteLogRow(row.id),
run: () => deleteLogRow(row.id, String(row?.job_name ?? row?.id ?? "")),
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
@@ -666,14 +664,10 @@ const {
},
});
async function deleteLogRow(id: number | undefined) {
async function deleteLogRow(id: number | undefined, name: string | number) {
if (id == null) return;
try {
await ElMessageBox.confirm("确认删除该执行记录?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`确定删除执行记录「${name}」吗?`);
await JobAPI.deleteJobLog([id]);
logfaTableRef.value?.elTableRef?.clearSelection();
await refreshLogRemove();
@@ -686,11 +680,10 @@ async function handleLogBatchDelete() {
const ids = logSelectedIds.value;
if (ids.length === 0) return;
try {
await ElMessageBox.confirm("确认删除选中的执行记录?", "批量删除", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmBatchDelete(
ids.length,
logSelectedRows.value.map((r) => String(r?.job_name ?? r?.id ?? ""))
);
logBatchDeleting.value = true;
await JobAPI.deleteJobLog(ids);
logSelectedRows.value = [];
@@ -843,8 +836,8 @@ async function handleSyncJobs() {
try {
await JobAPI.syncJobsToDb();
await refreshJobList();
} catch (error: any) {
console.error(error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
}
}
@@ -852,8 +845,8 @@ async function handleStartScheduler() {
try {
await JobAPI.startScheduler();
await refreshJobList();
} catch (error: any) {
console.error(error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
}
}
@@ -861,8 +854,8 @@ async function handlePauseScheduler() {
try {
await JobAPI.pauseScheduler();
await refreshJobList();
} catch (error: any) {
console.error(error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
}
}
@@ -870,8 +863,8 @@ async function handleResumeScheduler() {
try {
await JobAPI.resumeScheduler();
await refreshJobList();
} catch (error: any) {
console.error(error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
}
}
@@ -884,9 +877,9 @@ async function handleShutdownScheduler() {
});
await JobAPI.shutdownScheduler();
await refreshJobList();
} catch (error: any) {
} catch (error: unknown) {
if (error !== "cancel") {
console.error(error);
if (import.meta.env.DEV) console.error(error);
}
}
}
@@ -907,9 +900,9 @@ async function handleClearAllJobs() {
);
await JobAPI.clearAllJobs();
await refreshJobList();
} catch (error: any) {
} catch (error: unknown) {
if (error !== "cancel") {
console.error(error);
if (import.meta.env.DEV) console.error(error);
}
}
}
@@ -927,8 +920,8 @@ async function handleRefreshConsole() {
type: "normal",
content: data,
});
} catch (error: any) {
console.error(error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
TerminalApi.pushMessage("scheduler-console", {
type: "normal",
class: "error",
@@ -945,8 +938,8 @@ async function handlePauseJob(jobId: string) {
try {
await JobAPI.pauseJob(jobId);
await refreshJobList();
} catch (error: any) {
console.error(error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
}
}
@@ -954,8 +947,8 @@ async function handleResumeJob(jobId: string) {
try {
await JobAPI.resumeJob(jobId);
await refreshJobList();
} catch (error: any) {
console.error(error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
}
}
@@ -963,18 +956,14 @@ async function handleRunJobNow(jobId: string) {
try {
await JobAPI.runJobNow(jobId);
await refreshJobList();
} catch (error: any) {
console.error(error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
}
}
async function handleRemoveJob(jobId: string) {
async function handleRemoveJob(jobId: string, name: string) {
try {
await ElMessageBox.confirm("确认移除该任务?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`确定移除任务「${name}」吗?`);
await JobAPI.removeJob(jobId);
await refreshJobList();
} catch {
@@ -113,7 +113,11 @@
</template>
<template #kwargs>
<div class="dynamic-params">
<div v-for="(item, index) in kwargsList" :key="index" class="param-item">
<div
v-for="(item, index) in kwargsList"
:key="item.key || index"
class="param-item"
>
<ElInput v-model="item.key" placeholder="键" />
<ElInput v-model="item.value" placeholder="值" />
<ElButton
@@ -279,13 +283,9 @@ import type { FormItem } from "@/components/forms/fa-form/index.vue";
import type FaForm from "@/components/forms/fa-form/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 IntervalTab from "@/components/others/fa-interval-tab/index.vue";
import type { ColumnOption } from "@/types/component";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { useTable } from "@/hooks/core/useTable";
import type { TableOperationAction } from "@utils";
import { computed, nextTick, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage } from "@/utils/message";
import { vue3CronPlus } from "vue3-cron-plus";
import "vue3-cron-plus/dist/index.css";
import Codemirror, { CmComponentRef } from "codemirror-editor-vue3";
@@ -296,11 +296,6 @@ import "codemirror/theme/dracula.css";
const dictStore = useDictStore();
const { hasAuth } = useAuth();
const BATCH_DELETE_NODE_MSG =
"确认删除选中的节点吗?\n" +
"此操作将同时删除节点定义并移除调度器中的相关任务。\n" +
"正在运行的任务会被立即移除,待执行任务的日志将被标记为已取消。";
type NodeSearchForm = {
name?: string;
code?: string;
@@ -366,14 +361,10 @@ function onTableSelectionChange(rows: NodeTable[]) {
selectedRows.value = rows;
}
async function deleteNodeRow(id: number | undefined) {
async function deleteNodeRow(id: number | undefined, name: string | number) {
if (id == null) return;
try {
await ElMessageBox.confirm("确认删除该节点吗?将从调度器移除相关任务。", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`确定删除节点「${name}」吗?`);
await NodeAPI.deleteNode([id]);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
@@ -410,7 +401,7 @@ function buildNodeRowActions(row: NodeTable): TableOperationAction[] {
icon: "ri:delete-bin-4-line",
perm: "module_task:cronjob:node:delete",
run: () => {
void deleteNodeRow(row.id);
void deleteNodeRow(row.id, String(row?.name ?? row?.id ?? ""));
},
},
];
@@ -427,11 +418,10 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await ElMessageBox.confirm(BATCH_DELETE_NODE_MSG, "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmBatchDelete(
ids.length,
selectedRows.value.map((r) => String(r?.name ?? r?.id ?? ""))
);
batchDeleting.value = true;
await NodeAPI.deleteNode(ids);
selectedRows.value = [];
@@ -868,8 +858,8 @@ async function handleSubmit() {
} else {
await refreshCreate();
}
} catch (error: any) {
console.error(error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
} finally {
submitLoading.value = false;
}
@@ -931,13 +921,14 @@ async function handleExecuteNode() {
handleCloseExecuteDialog();
await refreshUpdate();
} catch (error: any) {
} catch (error: unknown) {
const errMsg = (error as { response?: { data?: { msg?: string } } })?.response?.data?.msg;
ElMessage.error({
message: error.response?.data?.msg || "调试失败",
message: errMsg || "调试失败",
type: "error",
duration: 3000,
});
console.error(error);
if (import.meta.env.DEV) console.error(error);
} finally {
submitLoading.value = false;
}
@@ -35,7 +35,7 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { ElButton, ElColorPicker, ElIcon, ElScrollbar } from "element-plus";
import { Close } from "@element-plus/icons-vue";
interface Props {
@@ -184,7 +184,7 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted, markRaw, type Component } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "@/utils/message";
import { Panel, VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import { MiniMap } from "@vue-flow/minimap";
@@ -589,15 +589,18 @@ function handleValidate() {
);
}
const escapeHtml = (str: string) =>
str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
if (errors.length > 0) {
ElMessageBox.alert(
`<div :style="'max-height: 300px; overflow-y: auto;'">
`<div style="max-height:300px;overflow-y:auto;">
<strong>错误 (${errors.length}):</strong>
<ul>${errors.map((e) => `<li :style="'color: #f56c6c;'">${e}</li>`).join("")}</ul>
<ul>${errors.map((e) => `<li style="color:#f56c6c;">${escapeHtml(e)}</li>`).join("")}</ul>
${
warnings.length > 0
? `<strong>警告 (${warnings.length}):</strong>
<ul>${warnings.map((w) => `<li :style="'color: #e6a23c;'">${w}</li>`).join("")}</ul>`
<ul>${warnings.map((w) => `<li style="color:#e6a23c;">${escapeHtml(w)}</li>`).join("")}</ul>`
: ""
}
</div>`,
@@ -610,9 +613,9 @@ function handleValidate() {
throw new Error("验证失败");
} else if (warnings.length > 0) {
ElMessageBox.alert(
`<div :style="'max-height: 300px; overflow-y: auto;'">
`<div style="max-height:300px;overflow-y:auto;">
<strong>流程验证通过,但有警告 (${warnings.length}):</strong>
<ul>${warnings.map((w) => `<li :style="'color: #e6a23c;'">${w}</li>`).join("")}</ul>
<ul>${warnings.map((w) => `<li style="color:#e6a23c;">${escapeHtml(w)}</li>`).join("")}</ul>
</div>`,
"流程验证结果",
{
@@ -671,11 +674,7 @@ function handleDeleteNode() {
if (!nodeId) return;
(async () => {
try {
await ElMessageBox.confirm("确定要删除该节点吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`确定删除节点「${selectedNode.value?.data?.label ?? nodeId}」吗?`);
deleteNode(nodeId, getNodes, setNodes, getEdges, setEdges);
handleClosePanel();
saveToHistory(nodes.value as Node[], edges.value as Edge[]);
@@ -699,11 +698,7 @@ function handleDeleteEdge() {
if (!edgeId) return;
(async () => {
try {
await ElMessageBox.confirm("确定要删除该连线吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`确定删除连线「${selectedEdge.value?.label ?? edgeId}」吗?`);
deleteEdge(edgeId, getEdges, setEdges);
handleClosePanel();
saveToHistory(nodes.value as Node[], edges.value as Edge[]);
@@ -767,13 +762,13 @@ const handleFinish = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate?.();
await handleValidate();
formRef.value.validate?.();
handleValidate();
await handleSave();
emit("refresh");
handleClose();
} catch (error) {
console.error("保存流程失败", error);
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("保存流程失败", error);
}
};
@@ -70,17 +70,12 @@ import WorkflowDefinitionAPI, { type WorkflowTable } from "@/api/module_task/wor
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaWorkflowDesignDrawer from "./components/FaWorkflowDesignDrawer.vue";
import { useTable } from "@/hooks/core/useTable";
import type { ColumnOption } from "@/types/component";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { ElMessage, ElMessageBox } from "element-plus";
import type { TableOperationAction } from "@/utils/table";
import { ElMessage, ElMessageBox } from "@/utils/message";
import { computed, ref } from "vue";
const { hasAuth } = useAuth();
const BATCH_DELETE_MSG = "确认删除选中的工作流吗?";
type WorkflowSearchForm = {
name?: string;
code?: string;
@@ -151,14 +146,10 @@ function onTableSelectionChange(rows: WorkflowTable[]) {
selectedRows.value = rows;
}
async function deleteWorkflowRow(id: number | undefined) {
async function deleteWorkflowRow(id: number | undefined, name: string | number) {
if (id == null) return;
try {
await ElMessageBox.confirm("确认删除该工作流吗?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`确定删除工作流「${name}」吗?`);
await WorkflowDefinitionAPI.deleteWorkflow([id]);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
@@ -171,11 +162,10 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await ElMessageBox.confirm(BATCH_DELETE_MSG, "批量删除", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmBatchDelete(
ids.length,
selectedRows.value.map((r) => String(r?.name ?? r?.id ?? ""))
);
batchDeleting.value = true;
await WorkflowDefinitionAPI.deleteWorkflow(ids);
selectedRows.value = [];
@@ -220,7 +210,7 @@ function buildWorkflowRowActions(row: WorkflowTable): TableOperationAction[] {
label: "删除",
artType: "delete",
perm: "module_task:workflow:flow:delete",
run: () => deleteWorkflowRow(row.id),
run: () => deleteWorkflowRow(row.id, String(row?.name ?? row?.id ?? "")),
}
);
return all.filter((a) => a.perm != null && hasAuth(a.perm));
@@ -94,7 +94,11 @@
</template>
<template #kwargs>
<div class="dynamic-params">
<div v-for="(item, index) in kwargsList" :key="index" class="param-item">
<div
v-for="(item, index) in kwargsList"
:key="item.key || index"
class="param-item"
>
<ElInput v-model="item.key" placeholder="键" />
<ElInput v-model="item.value" placeholder="值" />
<ElButton
@@ -159,13 +163,8 @@ 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 { useAuth } from "@/hooks/core/useAuth";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { useTable } from "@/hooks/core/useTable";
import type { ColumnOption } from "@/types/component";
import { ElMessage } from "element-plus";
import type { TableOperationAction } from "@utils";
import { ElMessage } from "@/utils/message";
import type { FormRules } from "element-plus";
import { computed, nextTick, ref } from "vue";
import Codemirror, { CmComponentRef } from "codemirror-editor-vue3";
@@ -294,7 +293,12 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
(data.value as WorkflowNodeTypeTable[])
.filter((r) => r.id != null && selectedIds.value.includes(r.id))
.map((r) => String(r?.name ?? r?.id ?? ""))
);
batchDeleting.value = true;
await WorkflowNodeTypeAPI.deleteWorkflowNodeType(ids);
faTableRef.value?.elTableRef?.clearSelection();