mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-01 16:21:20 +00:00
chore: 清理冗余代码与配置,优化项目结构
1. 删除无用文件与废弃代码:移除locale枚举、element-plus插件、sse路由、api token模块等 2. 简化类型导入与依赖:移除大量未使用的类型导入,统一echarts导入方式 3. 优化配置与样式:调整gitignore、样式引入顺序,新增列表动画样式 4. 修复接口与模型:修正接口返回类型、查询参数配置,更新部门模型字段 5. 优化性能与体验:添加图片懒加载,优化加载逻辑与表格渲染 6. 调整环境配置:新增并更新开发/生产环境配置文件
This commit is contained in:
@@ -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月)
|
||||
|
||||
@@ -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";
|
||||
|
||||
+3
-8
@@ -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
@@ -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();
|
||||
|
||||
+114
-234
@@ -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 {
|
||||
|
||||
+15
-20
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user