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

- 重构工作流模块目录结构,迁移代码文件
- 修复类型断言空值安全问题,添加 ! 操作符
- 优化样式类名,替换 flex-cc 为标准 flex 工具类
- 更新路由标签简化文案,移除冗余注释
- 调整 ruff 配置,放宽行长度限制
- 更新 README 与多语言文案,优化项目描述
- 修复表单、图表组件的类型与样式问题
- 简化搜索表单、数据卡片的布局代码
This commit is contained in:
zhangtao
2026-06-20 05:31:46 +08:00
parent 82b742620b
commit d34d4a4c50
378 changed files with 4149 additions and 6375 deletions
@@ -16,9 +16,12 @@
/>
<div class="px-5 mt-4">
<div v-for="item in totalItems" :key="item.label" class="flex-c mb-5 last:mb-0">
<div class="flex-c justify-start w-3/5 text-sm">
<div class="w-10 h-10 mr-3 text-lg rounded-md flex-cc" :class="item.iconClass">
<div v-for="item in totalItems" :key="item.label" class="flex items-center mb-5 last:mb-0">
<div class="flex items-center justify-start w-3/5 text-sm">
<div
class="w-10 h-10 mr-3 text-lg rounded-md flex items-center justify-center"
:class="item.iconClass"
>
<FaSvgIcon :icon="item.icon" />
</div>
@@ -5,7 +5,9 @@
<h4>今日销售</h4>
<p>销售总结</p>
</div>
<div class="flex-cc h-7.5 min-w-17 border border-g-300 rounded-lg text-g-500 c-p">
<div
class="flex items-center justify-center h-7.5 min-w-17 border border-g-300 rounded-lg text-g-500 cursor-pointer"
>
<FaSvgIcon icon="ri:arrow-up-line" class="text-base mr-1.5" />
<span class="text-xs">导出</span>
</div>
@@ -17,7 +19,7 @@
<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"
>
<div class="size-12 rounded-lg flex-cc bg-theme/10">
<div class="size-12 rounded-lg flex items-center justify-center bg-theme/10">
<FaSvgIcon :icon="item.icon" class="text-xl text-theme" />
</div>
@@ -149,7 +149,7 @@
:views="imageCards.views"
:comments="imageCards.comments"
:date="imageCards.date"
@click="handleImageCardClick(imageCards)"
@click="handleImageCardClick"
/>
</ElCol>
<ElCol :xs="24" :sm="6" :md="5" class="mb-5">
@@ -198,10 +198,10 @@ import AboutProject from "./modules/about-project.vue";
import QuickLinks from "./modules/quick-links.vue";
function handleBannerDemoConfirm() {
console.log("confirm clicked");
// TODO: 接入真实操作
}
function handleBannerDemoCancel() {
console.log("cancel clicked");
// TODO: 接入真实操作
}
// === 卡片演示数据 ← workplace ===
const imageCards = {
@@ -313,8 +313,8 @@ const timelineData = [
function handleMore() {
ElMessage.info("查看更多");
}
function handleImageCardClick(card: typeof imageCards) {
console.log("点击卡片:", card);
function handleImageCardClick() {
// TODO: 接入真实跳转
}
</script>
@@ -1,5 +1,5 @@
<template>
<div class="fa-card p-5 flex-b h-full">
<div class="fa-card p-5 flex justify-between h-full">
<div>
<h2 class="text-2xl font-medium">关于项目</h2>
<p class="text-g-700 mt-1">{{ systemName }} 是一款兼具设计美学与高效开发的后台系统</p>
@@ -7,7 +7,7 @@
<div class="flex flex-wrap gap-3.5 max-w-150 mt-9">
<div
class="w-60 flex-cb h-12.5 px-3.5 border border-g-300 c-p rounded-lg text-sm bg-g-100 duration-300 hover:-translate-y-1 max-sm:w-full"
class="w-60 flex items-center justify-between h-12.5 px-3.5 border border-g-300 cursor-pointer rounded-lg text-sm bg-g-100 duration-300 hover:-translate-y-1 max-sm:w-full"
v-for="link in linkList"
:key="link.label"
@click="goPage(link.url)"
@@ -1,6 +1,6 @@
<template>
<FaBasicBanner
class="justify-center h-53! max-sm:pt-8! max-sm:h-48!"
class="justify-center h-54! max-sm:pt-8! max-sm:h-48!"
:title="bannerTitle"
:subtitle="bannerSubtitle"
boxStyle="bg-theme/10!"
@@ -56,7 +56,7 @@ const userStore = useUserStore();
const userInfo = computed(() => userStore.basicInfo);
const handleBannerClick = (): void => {
console.log("banner clicked");
// TODO: 接入真实跳转或路由
};
const timefix = greetings();
@@ -26,7 +26,7 @@
</div>
<div
v-if="item.icon"
class="size-10 rounded-xl flex-cc"
class="size-10 rounded-xl flex items-center justify-center"
:class="item.iconBg || 'bg-theme/10'"
>
<FaSvgIcon
@@ -83,7 +83,6 @@ interface CardDataItem {
totalLabel?: string;
totalValue?: number | string;
updateTime?: string;
// 动画计数
animatedCount?: number;
}
@@ -154,16 +153,16 @@ const dataList = ref<CardDataItem[]>([
onMounted(() => {
// 更新 UV/PV 动态数据
dataList.value[1].animatedCount = Math.round(transitionUvCount.value);
dataList.value[1].totalValue = visitStats.value.totalUvCount;
dataList.value[2].animatedCount = Math.round(transitionPvCount.value);
dataList.value[2].totalValue = visitStats.value.totalPvCount;
dataList.value[1]!.animatedCount = Math.round(transitionUvCount.value);
dataList.value[1]!.totalValue = visitStats.value.totalUvCount;
dataList.value[2]!.animatedCount = Math.round(transitionPvCount.value);
dataList.value[2]!.totalValue = visitStats.value.totalPvCount;
// 生成增长率显示
const uvRate = visitStats.value.uvGrowthRate;
const pvRate = visitStats.value.pvGrowthRate;
dataList.value[1].change = uvRate > 0 ? `+${uvRate}%` : `${uvRate}%`;
dataList.value[2].change = pvRate > 0 ? `+${pvRate}%` : `${pvRate}%`;
dataList.value[1]!.change = uvRate > 0 ? `+${uvRate}%` : `${uvRate}%`;
dataList.value[2]!.change = pvRate > 0 ? `+${pvRate}%` : `${pvRate}%`;
});
</script>
@@ -1,5 +1,5 @@
<template>
<div class="fa-card p-5 pb-3 h-53 max-sm:h-53 flex flex-col">
<div class="fa-card p-5 pb-3 h-54 max-sm:h-54 flex flex-col">
<div class="fa-card-header">
<div class="title">
<h4>
@@ -10,35 +10,40 @@
</h4>
</div>
</div>
<div class="flex-1 overflow-y-auto -mr-1 pr-1">
<div v-if="quickLinks.length > 0" class="grid grid-cols-4 gap-2">
<ElScrollbar class="flex-1 min-h-0">
<div v-if="quickLinks.length" class="grid grid-cols-4 gap-2">
<div
v-for="item in linksWithColor"
v-for="(item, i) in quickLinks"
:key="item.id || item.href"
class="quick-link-item relative flex flex-col items-center gap-1 py-2 px-1 rounded-lg cursor-pointer hover:bg-(--el-fill-color-lighter) transition-colors"
class="group relative flex flex-col items-center gap-1 py-2 px-1 rounded-lg cursor-pointer hover:bg-(--el-fill-color-lighter) transition-colors"
@click="handleClick(item)"
>
<span
class="flex items-center justify-center w-9 h-9 rounded text-white"
:style="{ backgroundColor: item._color }"
:style="{ backgroundColor: PALETTE[i % PALETTE.length] }"
>
<FaMenuRouteIcon :icon="item.icon || 'menu'" :size="18" />
</span>
<span class="text-xs text-center leading-tight line-clamp-2 text-g-700">{{
item.title
}}</span>
<span class="quick-link-close" @click.stop="handleRemove(item)">
<button
type="button"
class="absolute -top-1 right-0 flex items-center justify-center w-4 h-4 rounded-full bg-(--el-bg-color) text-(--el-text-color-placeholder) opacity-0 group-hover:opacity-100 transition-opacity border-0 cursor-pointer hover:text-(--el-color-danger)! hover:bg-(--el-color-danger-light-9)!"
:aria-label="`移除 ${item.title}`"
@click.stop="handleRemove(item)"
>
<ElIcon :size="10"><Close /></ElIcon>
</span>
</button>
</div>
</div>
<ElEmpty v-else description="暂无链接" :image-size="50" />
</div>
</ElScrollbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { onScopeDispose, ref } from "vue";
import { useRouter } from "vue-router";
import { Close, QuestionFilled } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
@@ -47,69 +52,21 @@ import { quickStartManager, type QuickLink } from "@utils";
const router = useRouter();
const quickLinks = ref<QuickLink[]>(quickStartManager.getQuickLinks());
const sync = (links: QuickLink[]) => {
quickLinks.value = links;
};
quickStartManager.addListener(sync);
onScopeDispose(() => quickStartManager.removeListener(sync));
const QUICK_LINK_PALETTE = ["#4080ff", "#23c343", "#ff9a2e", "#f76560", "#a9aeb8", "#00b42a"];
const linksWithColor = computed(() =>
quickLinks.value.map((item, i) => ({
...item,
_color: QUICK_LINK_PALETTE[i % QUICK_LINK_PALETTE.length],
}))
);
const PALETTE = ["#4080ff", "#23c343", "#ff9a2e", "#f76560", "#a9aeb8", "#00b42a"];
const handleClick = (item: QuickLink) => {
if (item.href) {
router.push(item.href).catch(() => {
ElMessage.warning(`路由 ${item.href} 不存在,请检查配置`);
});
} else {
ElMessage.info(`${item.title} 功能待开发`);
}
if (!item.href) return ElMessage.info(`${item.title} 功能待开发`);
router.push(item.href).catch(() => ElMessage.warning(`路由 ${item.href} 不存在,请检查配置`));
};
const handleRemove = (item: QuickLink) => {
if (item.id) {
quickStartManager.removeQuickLink(item.id);
} else if (item.href) {
quickStartManager.removeQuickLinkByHref(item.href);
}
if (item.id) quickStartManager.removeQuickLink(item.id);
else if (item.href) quickStartManager.removeQuickLinkByHref(item.href);
};
const updateQuickLinks = (links: QuickLink[]) => {
quickLinks.value = links;
};
onMounted(() => {
quickStartManager.addListener(updateQuickLinks);
});
onUnmounted(() => {
quickStartManager.removeListener(updateQuickLinks);
});
</script>
<style scoped>
.quick-link-close {
position: absolute;
top: -4px;
right: -4px;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--el-text-color-placeholder);
cursor: pointer;
background: var(--el-bg-color);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s;
}
.quick-link-item:hover .quick-link-close {
opacity: 1;
}
.quick-link-close:hover {
color: var(--el-color-danger);
background-color: var(--el-color-danger-light-9);
}
</style>
@@ -10,25 +10,23 @@
</div>
</div>
<div class="h-[calc(100%-40px)] overflow-auto">
<ElScrollbar>
<div
class="flex-cb h-17.5 border-b border-g-300 text-sm last:border-b-0"
v-for="(item, index) in list"
:key="index"
>
<div>
<p class="text-sm">
<span class="text-g-800 font-medium">{{ item.username }}</span>
<span class="mx-2 text-g-600">{{ item.type }}</span>
<span class="text-theme">{{ item.target }}</span>
</p>
<p class="text-g-500 mt-1">{{ item.date }}</p>
</div>
<ElCheckbox v-model="item.complate" />
<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"
>
<div>
<p class="text-sm">
<span class="text-g-800 font-medium">{{ item.username }}</span>
<span class="mx-2 text-g-600">{{ item.type }}</span>
<span class="text-theme">{{ item.target }}</span>
</p>
<p class="text-g-500 mt-1">{{ item.date }}</p>
</div>
</ElScrollbar>
</div>
<ElCheckbox v-model="item.complate" />
</div>
</ElScrollbar>
</div>
</template>
@@ -151,24 +151,24 @@ function updateStats() {
const o = randFloat(8350, 0.04);
const m = randFloat(1286, 0.03);
const w = randFloat(356, 0.08);
stats[0].value = "¥" + fmt(Math.round(t));
stats[1].value = fmt(Math.round(o));
stats[2].value = fmt(Math.round(m));
stats[3].value = fmt(Math.round(w));
stats[4].value = "¥" + Math.round(randFloat(468, 0.03));
stats[5].value = randFloat(38.2, 0.06).toFixed(1) + "%";
stats[0].change = randPct();
stats[0].up = Math.random() > 0.3;
stats[1].change = randPct();
stats[1].up = Math.random() > 0.4;
stats[2].change = randPct();
stats[2].up = Math.random() > 0.5;
stats[3].change = randPct();
stats[3].up = Math.random() > 0.6;
stats[4].change = randPct();
stats[4].up = Math.random() > 0.3;
stats[5].change = randPct();
stats[5].up = Math.random() > 0.4;
stats[0]!.value = "¥" + fmt(Math.round(t));
stats[1]!.value = fmt(Math.round(o));
stats[2]!.value = fmt(Math.round(m));
stats[3]!.value = fmt(Math.round(w));
stats[4]!.value = "¥" + Math.round(randFloat(468, 0.03));
stats[5]!.value = randFloat(38.2, 0.06).toFixed(1) + "%";
stats[0]!.change = randPct();
stats[0]!.up = Math.random() > 0.3;
stats[1]!.change = randPct();
stats[1]!.up = Math.random() > 0.4;
stats[2]!.change = randPct();
stats[2]!.up = Math.random() > 0.5;
stats[3]!.change = randPct();
stats[3]!.up = Math.random() > 0.6;
stats[4]!.change = randPct();
stats[4]!.up = Math.random() > 0.3;
stats[5]!.change = randPct();
stats[5]!.up = Math.random() > 0.4;
}
async function toggleFullscreen() {
@@ -187,7 +187,15 @@ function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement;
}
document.addEventListener("fullscreenchange", onFullscreenChange);
// fullscreenchange 是 document 级别事件,必须在 onUnmounted 中清理,
// 否则 keep-alive / 重复进入会累积监听器
onMounted(() => {
document.addEventListener("fullscreenchange", onFullscreenChange);
});
onUnmounted(() => {
document.removeEventListener("fullscreenchange", onFullscreenChange);
});
provide("toggleFullscreen", toggleFullscreen);
provide("isFullscreen", isFullscreen);
@@ -249,14 +257,14 @@ function tick() {
}
for (let i = 0; i < particles.length; i++) {
const a = particles[i];
const a = particles[i]!;
ctx.beginPath();
ctx.arc(a.x, a.y, a.r, 0, Math.PI * 2);
ctx.fillStyle = "rgba(0,212,255,0.25)";
ctx.fill();
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j];
const b = particles[j]!;
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.sqrt(dx * dx + dy * dy);
@@ -36,11 +36,11 @@ let timer = 0;
function tick() {
const bases = [12850, 4320, 1845, 963];
const vals = bases.map((b, i) => Math.round(b + (Math.random() - 0.5) * b * 0.08 * (4 - i)));
const max = vals[0];
const max = vals[0]!;
data.forEach((item, i) => {
item.value = vals[i].toLocaleString();
item.rate = Math.round((vals[i] / max) * 100);
item.pct = (vals[i] / max) * 100;
item.value = vals[i]!.toLocaleString();
item.rate = Math.round((vals[i]! / max) * 100);
item.pct = (vals[i]! / max) * 100;
});
}
@@ -53,8 +53,8 @@ function tick() {
const vals = list.map(() => Math.round(100 + Math.random() * 800));
const max = Math.max(...vals);
list.forEach((item, i) => {
item.value = vals[i] + "单";
item.ratio = vals[i] / max;
item.value = vals[i]! + "单";
item.ratio = vals[i]! / max;
});
list.sort((a, b) => parseInt(b.value) - parseInt(a.value));
}
@@ -30,18 +30,18 @@ const metrics = reactive([
let timer = 0;
function tick() {
metrics[0].base = Math.round(2800 + Math.random() * 800);
metrics[1].base = Math.round(8 + Math.random() * 20);
metrics[2].base = +(0.01 + Math.random() * 0.08).toFixed(2);
metrics[3].base = +(99.95 + Math.random() * 0.05).toFixed(2);
metrics[0].value = (metrics[0].base / 1000).toFixed(1);
metrics[1].value = String(metrics[1].base);
metrics[2].value = String(metrics[2].base);
metrics[3].value = String(metrics[3].base);
metrics[0].pct = (metrics[0].base / 4000) * 100;
metrics[1].pct = (metrics[1].base / 30) * 100;
metrics[2].pct = (metrics[2].base / 0.1) * 100;
metrics[3].pct = (+(metrics[3].base - 99.9) / 0.1) * 100;
metrics[0]!.base = Math.round(2800 + Math.random() * 800);
metrics[1]!.base = Math.round(8 + Math.random() * 20);
metrics[2]!.base = +(0.01 + Math.random() * 0.08).toFixed(2);
metrics[3]!.base = +(99.95 + Math.random() * 0.05).toFixed(2);
metrics[0]!.value = (metrics[0]!.base / 1000).toFixed(1);
metrics[1]!.value = String(metrics[1]!.base);
metrics[2]!.value = String(metrics[2]!.base);
metrics[3]!.value = String(metrics[3]!.base);
metrics[0]!.pct = (metrics[0]!.base / 4000) * 100;
metrics[1]!.pct = (metrics[1]!.base / 30) * 100;
metrics[2]!.pct = (metrics[2]!.base / 0.1) * 100;
metrics[3]!.pct = (+(metrics[3]!.base - 99.9) / 0.1) * 100;
}
onMounted(() => {
@@ -46,8 +46,8 @@ function tick() {
if (!chart || chart.isDisposed()) return;
const n = +(1.5 + Math.random() * 1.5).toFixed(1);
val.value = n;
const d = chart.getOption() as any;
const arr = d.series[0].data as number[];
const d = chart.getOption() as { series: [{ data: number[] }] };
const arr = d.series[0].data;
arr.push(n);
arr.shift();
chart.setOption({ series: [{ data: arr }] });
@@ -47,8 +47,8 @@ function tick() {
if (!chart || chart.isDisposed()) return;
const n = Math.round(4000 + Math.random() * 2500);
val.value = n;
const d = chart.getOption() as any;
const arr = d.series[0].data as number[];
const d = chart.getOption() as { series: [{ data: number[] }] };
const arr = d.series[0].data;
arr.push(n);
arr.shift();
chart.setOption({ series: [{ data: arr }] });
@@ -55,9 +55,6 @@ function rankColor(i: number) {
}
.rank-no {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 10px;
@@ -30,19 +30,19 @@ const data = reactive([
let timer = 0;
function tick() {
data[0].base += Math.round((Math.random() - 0.5) * 120);
data[1].base += Math.round((Math.random() - 0.5) * 60);
data[2].base += Math.round((Math.random() - 0.5) * 40);
data[3].base = +(30 + Math.random() * 8).toFixed(1);
data[0].value = data[0].base.toLocaleString();
data[1].value = data[1].base.toLocaleString();
data[2].value = data[2].base.toLocaleString();
data[3].value = data[3].base.toFixed(1);
const max = Math.max(data[0].base, data[1].base, data[2].base);
data[0].pct = (data[0].base / max) * 100;
data[1].pct = (data[1].base / max) * 100;
data[2].pct = (data[2].base / max) * 100;
data[3].pct = +data[3].value;
data[0]!.base += Math.round((Math.random() - 0.5) * 120);
data[1]!.base += Math.round((Math.random() - 0.5) * 60);
data[2]!.base += Math.round((Math.random() - 0.5) * 40);
data[3]!.base = +(30 + Math.random() * 8).toFixed(1);
data[0]!.value = data[0]!.base.toLocaleString();
data[1]!.value = data[1]!.base.toLocaleString();
data[2]!.value = data[2]!.base.toLocaleString();
data[3]!.value = data[3]!.base.toFixed(1);
const max = Math.max(data[0]!.base, data[1]!.base, data[2]!.base);
data[0]!.pct = (data[0]!.base / max) * 100;
data[1]!.pct = (data[1]!.base / max) * 100;
data[2]!.pct = (data[2]!.base / max) * 100;
data[3]!.pct = +data[3]!.value;
}
onMounted(() => {
@@ -77,7 +77,7 @@ function tick() {
if (!chart || chart.isDisposed()) return;
const d1 = xData.map(() => Math.round(80 + Math.random() * 180));
const d2 = d1.map((v) => Math.round(v * (0.05 + Math.random() * 0.25)));
const d3 = d1.map((v, i) => Math.round(v - d2[i]));
const d3 = d1.map((v, i) => Math.round(v - d2[i]!));
chart.setOption({ series: [{ data: d1 }, { data: d2 }, { data: d3 }] });
}
@@ -99,15 +99,15 @@ function handleResize() {
function tick() {
const v = [
clamp(gauges[0].value + (Math.random() - 0.5) * 8, 10, 90),
clamp(gauges[1].value + (Math.random() - 0.5) * 6, 20, 95),
clamp(gauges[2].value + (Math.random() - 0.5) * 3, 15, 85),
clamp(gauges[0]!.value + (Math.random() - 0.5) * 8, 10, 90),
clamp(gauges[1]!.value + (Math.random() - 0.5) * 6, 20, 95),
clamp(gauges[2]!.value + (Math.random() - 0.5) * 3, 15, 85),
];
gauges[0].value = Math.round(v[0]);
gauges[1].value = Math.round(v[1]);
gauges[2].value = Math.round(v[2]);
gauges[0]!.value = Math.round(v[0]!);
gauges[1]!.value = Math.round(v[1]!);
gauges[2]!.value = Math.round(v[2]!);
charts.forEach((c, i) => {
if (!c.isDisposed()) c.setOption({ series: [{ data: [{ value: gauges[i].value }] }] });
if (!c.isDisposed()) c.setOption({ series: [{ data: [{ value: gauges[i]!.value }] }] });
});
}
@@ -64,16 +64,16 @@ const data = reactive([
let timer = 0;
function tick() {
const orders = data[1].base + Math.round((Math.random() - 0.5) * 80);
const users = data[2].base + Math.round((Math.random() - 0.5) * 60);
data[0].base = orders * 420 + Math.round(Math.random() * 10000);
data[1].base = orders;
data[2].base = users;
data[3].base = +(3.5 + Math.random() * 2).toFixed(2);
data[0].value = (data[0].base / 10000).toFixed(1);
data[1].value = data[1].base.toLocaleString();
data[2].value = data[2].base.toLocaleString();
data[3].value = data[3].base.toFixed(2);
const orders = data[1]!.base + Math.round((Math.random() - 0.5) * 80);
const users = data[2]!.base + Math.round((Math.random() - 0.5) * 60);
data[0]!.base = orders * 420 + Math.round(Math.random() * 10000);
data[1]!.base = orders;
data[2]!.base = users;
data[3]!.base = +(3.5 + Math.random() * 2).toFixed(2);
data[0]!.value = (data[0]!.base / 10000).toFixed(1);
data[1]!.value = data[1]!.base.toLocaleString();
data[2]!.value = data[2]!.base.toLocaleString();
data[3]!.value = data[3]!.base.toFixed(2);
for (const d of data) {
d.change = (Math.random() * 15 - 3).toFixed(1) + "%";
d.up = Math.random() > 0.4;
@@ -3,7 +3,7 @@
<div class="panel-hd"><span class="dot purple" />热销产品 TOP5</div>
<div class="product-list">
<div v-for="(p, i) in topProducts" :key="p.name" class="product-row">
<span class="product-rank" :class="'rank-' + (i + 1)">{{ i + 1 }}</span>
<span class="product-rank flex-cc" :class="'rank-' + (i + 1)">{{ i + 1 }}</span>
<div class="product-info">
<span class="product-name">{{ p.name }}</span>
<span class="product-category">{{ p.category }}</span>
@@ -63,8 +63,13 @@ function tick() {
const data = names.map((name) => ({
value: Math.round(100 + Math.random() * 600),
name,
itemStyle: (chart as any).getOption().series[0].data?.find((d: any) => d.name === name)
?.itemStyle,
itemStyle: (
chart as unknown as {
getOption: () => { series: [{ data: { name: string; itemStyle?: unknown }[] }] };
}
)
.getOption()
.series[0].data?.find((d: { name: string }) => d.name === name)?.itemStyle,
}));
chart.setOption({ series: [{ data }] });
}
@@ -102,8 +102,8 @@ function handleResize() {
function tick() {
if (!chart || chart.isDisposed()) return;
pv.push(Math.round(pv[pv.length - 1] * (0.85 + Math.random() * 0.3)));
uv.push(Math.round(uv[uv.length - 1] * (0.85 + Math.random() * 0.3)));
pv.push(Math.round(pv[pv.length - 1]! * (0.85 + Math.random() * 0.3)));
uv.push(Math.round(uv[uv.length - 1]! * (0.85 + Math.random() * 0.3)));
pv.shift();
uv.shift();
chart.setOption({
@@ -16,7 +16,7 @@
</p>
<p class="mt-1 text-sm">我们为您创建了多个选项,可将它们组合在一起并定制为像素完美的页面</p>
</div>
<div class="flex-b mt-2">
<div class="flex justify-between mt-2">
<div class="flex-1" v-for="(item, index) in list" :key="index">
<p class="text-2xl text-g-900">{{ item.num }}</p>
<p class="text-xs text-g-500">{{ item.name }}</p>
@@ -16,8 +16,10 @@
height="calc(100% - 155px)"
/>
<div class="flex justify-around mt-10">
<div class="flex-c">
<div class="flex-cc size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg">
<div class="flex items-center">
<div
class="flex items-center justify-center size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg"
>
<FaSvgIcon icon="ri:money-cny-circle-line" class="text-xl" />
</div>
<div>
@@ -25,8 +27,10 @@
<span class="text-sm">线上销售</span>
</div>
</div>
<div class="flex-c">
<div class="flex-cc size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg">
<div class="flex items-center">
<div
class="flex items-center justify-center size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg"
>
<FaSvgIcon icon="ri:heart-3-line" class="text-xl" />
</div>
<div>
@@ -15,8 +15,11 @@
height="9rem"
/>
<div class="mt-10 space-y-5">
<div v-for="item in weeklyList" :key="item.title" class="flex-c">
<div class="size-10.5 flex-cc rounded-lg" :class="item.iconBgClass">
<div v-for="item in weeklyList" :key="item.title" class="flex items-center">
<div
class="size-10.5 flex items-center justify-center rounded-lg"
:class="item.iconBgClass"
>
<FaSvgIcon :icon="item.icon" class="text-xl" />
</div>
<div class="ml-2.5">
@@ -21,8 +21,10 @@
centerText="¥300,458"
/>
<div class="flex justify-around">
<div class="flex-c">
<div class="flex-cc size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg">
<div class="flex items-center">
<div
class="flex items-center justify-center size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg"
>
<FaSvgIcon icon="ri:money-cny-circle-line" class="text-xl" />
</div>
<div>
@@ -30,8 +32,10 @@
<span class="text-sm">总收入</span>
</div>
</div>
<div class="flex-c">
<div class="flex-cc size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg">
<div class="flex items-center">
<div
class="flex items-center justify-center size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg"
>
<FaSvgIcon icon="ri:heart-3-line" class="text-xl" />
</div>
<div>
@@ -15,7 +15,7 @@
class="grid grid-cols-5 gap-5 max-2xl:grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1 mb-5 list-none p-0 m-0"
>
<li
class="relative p-4 c-p aspect-16/12 duration-300 hover:-translate-y-1.5 rounded-custom-sm"
class="relative p-4 cursor-pointer aspect-16/12 duration-300 hover:-translate-y-1.5 rounded-custom-sm"
:style="{ background: item.color }"
v-for="item in commentsWithColors"
:key="item.id"
@@ -23,13 +23,13 @@
>
<p class="text-g-600 text-sm">{{ item.date }}</p>
<p class="mt-4 text-sm text-gray-800">{{ item.content }}</p>
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
<div class="flex-c">
<div class="flex-c mr-5 text-xs text-g-600">
<div class="absolute bottom-4 left-0 px-4 flex items-center justify-between w-full">
<div class="flex items-center">
<div class="flex items-center mr-5 text-xs text-g-600">
<FaSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
<span>{{ item.collection }}</span>
</div>
<div class="flex-c mr-5 text-xs text-g-600">
<div class="flex items-center mr-5 text-xs text-g-600">
<FaSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
<span>{{ item.comment }}</span>
</div>
@@ -59,13 +59,13 @@
>
<p class="text-g-500 text-sm">{{ clickItem.date }}</p>
<p class="mt-4 text-sm text-gray-800">{{ clickItem.content }}</p>
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
<div class="flex-c">
<div class="flex-c mr-5 text-xs text-g-600">
<div class="absolute bottom-4 left-0 px-4 flex items-center justify-between w-full">
<div class="flex items-center">
<div class="flex items-center mr-5 text-xs text-g-600">
<FaSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
<span>{{ clickItem.collection }}</span>
</div>
<div class="flex-c mr-5 text-xs text-g-600">
<div class="flex items-center mr-5 text-xs text-g-600">
<FaSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
<span>{{ clickItem.comment }}</span>
</div>
@@ -32,8 +32,9 @@
</template>
<script setup lang="ts">
import "@styles/core/md.scss";
import "@styles/custom/one-dark-pro.scss";
// 第三方 markdown 渲染样式(仅本组件需要,按需 import)
import "@/views/fastlink/article/components/_markdown.scss";
import "@/views/fastlink/article/components/_highlight.scss";
import axios from "axios";
import DOMPurify from "dompurify";
@@ -49,7 +49,7 @@
>
<div
v-if="!cover"
class="flex-cc flex-col w-65 h-40 border border-dashed border-[#d9d9d9] rounded-md"
class="flex items-center justify-center flex-col w-65 h-40 border border-dashed border-[#d9d9d9] rounded-md"
>
<ElIcon class="text-xl! text-g-600!"><Plus /></ElIcon>
<div class="mt-2 text-sm text-g-600">点击上传封面</div>
@@ -296,9 +296,7 @@ const addArticle = async () => {
if (!validateArticle()) return;
try {
const cleanedContent = cleanCodeContent(editorHtml.value);
console.log("新增文章:", { cleanedContent });
cleanCodeContent(editorHtml.value);
await new Promise<void>((resolve) => setTimeout(resolve, 400));
resultPhase.value = "success";
@@ -312,9 +310,7 @@ const editArticle = async () => {
if (!validateArticle()) return;
try {
const cleanedContent = cleanCodeContent(editorHtml.value);
console.log("编辑文章:", { cleanedContent });
cleanCodeContent(editorHtml.value);
await new Promise<void>((resolve) => setTimeout(resolve, 400));
resultPhase.value = "success";
@@ -0,0 +1,16 @@
/* highlight.js 代码块主题:One Dark Pro 风格 */
/* 实际是 highlight.js 主题色(项目自定义文件,误命名 one-dark-pro 后未跟进) */
.hljs {
display: block;
padding: 0.5em;
overflow-x: auto;
color: #a6accd;
}
.hljs-string,
.hljs-section,
.hljs-selector-class,
.hljs-template-variable,
.hljs-deletion {
color: #aed07e !important;
}
File diff suppressed because it is too large Load Diff
@@ -36,7 +36,7 @@
class="grid grid-cols-5 gap-5 max-2xl:grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1"
>
<div
class="group c-p overflow-hidden border border-g-300/60 rounded-custom-sm"
class="group cursor-pointer overflow-hidden border border-g-300/60 rounded-custom-sm"
v-for="item in articleList"
:key="item.id"
@click="toDetail(item)"
@@ -55,8 +55,8 @@
</div>
<div class="px-2 py-1">
<h2 class="text-base text-g-800 font-medium">{{ item.title }}</h2>
<div class="flex-b w-full h-6 mt-1">
<div class="flex-c text-g-500">
<div class="flex justify-between w-full h-6 mt-1">
<div class="flex items-center text-g-500">
<FaSvgIcon icon="ri:time-line" class="mr-1 text-sm" />
<span class="text-sm">{{ useDateFormat(item.create_time, "YYYY-MM-DD") }}</span>
<div class="w-px h-3 bg-g-400 mx-3.5"></div>
@@ -9,7 +9,7 @@
:key="item.version"
class="fa-card-sm rounded-lg p-6 transition-shadow max-md:p-4"
>
<div class="flex-cb gap-3 mb-4 flex-wrap">
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<span class="px-3 py-1 bg-theme/10 text-theme text-sm font-medium rounded-full">
{{ item.version }}
</span>
@@ -22,7 +22,7 @@
<li
v-for="(detail, index) in item.detail"
:key="index"
class="flex-c gap-2 text-sm text-g-600"
class="flex items-center gap-2 text-sm text-g-600"
>
<span class="mt-0.5">•</span>
<span class="text-g-700">{{ detail }}</span>
@@ -1,7 +1,7 @@
<!-- 个人中心(Art 模版布局 + 当前用户接口) -->
<template>
<div class="w-full h-full p-0 bg-transparent border-none shadow-none">
<div class="relative flex-b mt-2.5 max-md:block max-md:mt-1">
<div class="relative flex justify-between mt-2.5 max-md:block max-md:mt-1">
<!-- 左侧卡片 -->
<div class="w-md mr-5 max-md:w-full max-md:mr-0">
<div class="fa-card-sm relative p-9 pb-6 overflow-hidden text-center">
@@ -184,7 +184,7 @@
</ElFormItem>
</ElRow>
<div class="flex-c justify-end [&_.el-button]:w-27.5!">
<div class="flex items-center justify-end [&_.el-button]:w-27.5!">
<ElButton
type="primary"
class="w-22.5"
@@ -236,7 +236,7 @@
/>
</ElFormItem>
<div class="flex-c justify-end [&_.el-button]:w-27.5!">
<div class="flex items-center justify-end [&_.el-button]:w-27.5!">
<ElButton
type="primary"
class="w-22.5"
@@ -13,7 +13,7 @@
class="box-border w-90 h-full p-5 border-r border-g-300 max-md:w-full max-md:h-42 max-md:border-r-0"
>
<div class="pb-5 max-md:hidden!">
<div class="flex-c gap-3">
<div class="flex items-center gap-3">
<ElAvatar :size="50" :src="selectedPerson?.avatar" />
<div>
<div class="text-base font-medium">{{ selectedPerson?.name }}</div>
@@ -24,7 +24,7 @@
<ElInput v-model="searchQuery" placeholder="搜索联系人" prefix-icon="Search" clearable />
</div>
<ElDropdown trigger="click" placement="bottom-start">
<span class="mt-5 c-p">
<span class="mt-5 cursor-pointer">
排序方式
<ElIcon class="el-icon--right">
<ArrowDown />
@@ -43,7 +43,7 @@
<div
v-for="item in personList"
:key="item.id"
class="flex-c p-3 c-p rounded-lg tad-200 hover:bg-active-color/30 mb-1"
class="flex items-center p-3 cursor-pointer rounded-lg transition duration-200 hover:bg-active-color/30 mb-1"
:class="{ 'bg-active-color': selectedPerson?.id === item.id }"
@click="selectPerson(item)"
>
@@ -57,11 +57,11 @@
></div>
</div>
<div class="flex-1 min-w-0">
<div class="flex-cb mb-1">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium">{{ item.name }}</span>
<span class="text-xs text-g-600">{{ item.lastTime }}</span>
</div>
<div class="flex-cb">
<div class="flex items-center justify-between">
<span class="overflow-hidden text-xs text-g-600 text-ellipsis whitespace-nowrap">
{{ item.email }}
</span>
@@ -71,15 +71,15 @@
</ElScrollbar>
</div>
<div class="box-border flex-1 h-full max-md:h-[calc(70%-30px)]">
<div class="flex-cb pt-4 px-4 pb-0 mb-5">
<div class="flex items-center justify-between pt-4 px-4 pb-0 mb-5">
<div>
<span class="text-base font-medium">Art Bot</span>
<div class="flex-c gap-1 mt-1.5">
<div class="flex items-center gap-1 mt-1.5">
<div class="w-2 h-2 rounded-full" :class="isOnline ? 'bg-success' : 'bg-danger'"></div>
<span class="text-xs text-g-600">{{ isOnline ? "在线" : "离线" }}</span>
</div>
</div>
<div class="flex-c gap-2">
<div class="flex items-center gap-2">
<FaIconButton icon="ri:phone-line" circle class="size-11 text-g-600" />
<FaIconButton icon="ri:video-on-line" circle class="size-11 text-g-600" />
<FaIconButton icon="ri:more-2-fill" circle class="size-11 text-g-600" />
@@ -87,9 +87,9 @@
</div>
<div class="flex flex-col h-[calc(100%-85px)]">
<!-- 聊天消息区域 -->
<div
class="flex-1 py-7.5 px-4 overflow-y-auto border-t-d [&::-webkit-scrollbar]:w-1!"
<ElScrollbar
ref="messageContainer"
class="flex-1 py-7.5 px-4 border-t border-(--default-border)"
>
<template v-for="message in messages" :key="message.id">
<div
@@ -119,7 +119,7 @@
</div>
</div>
</template>
</div>
</ElScrollbar>
<!-- 聊天输入区域 -->
<div class="p-4 shrink-0">
@@ -139,10 +139,13 @@
</div>
</template>
</ElInput>
<div class="flex-cb mt-3">
<div class="flex-c">
<FaSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
<FaSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" />
<div class="flex items-center justify-between mt-3">
<div class="flex items-center">
<FaSvgIcon icon="ri:image-line" class="mr-5 cursor-pointer text-g-600 text-lg" />
<FaSvgIcon
icon="ri:emotion-happy-line"
class="mr-5 cursor-pointer text-g-600 text-lg"
/>
</div>
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
</div>
@@ -154,6 +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";
@@ -192,7 +196,7 @@ const selectedPerson = ref<Person | null>(null);
const messageText = ref("");
const messageId = ref(10);
const userAvatar = ref(meAvatar);
const messageContainer = ref<HTMLElement | null>(null);
const messageContainer = ref<InstanceType<typeof ElScrollbar> | null>(null);
/**
* 联系人列表数据
@@ -429,9 +433,8 @@ const sendMessage = () => {
*/
const scrollToBottom = () => {
setTimeout(() => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
}
const wrap = messageContainer.value?.wrapRef;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}, 100);
};
@@ -445,7 +448,8 @@ const openChat = () => {
onMounted(() => {
scrollToBottom();
mittBus.on("openChat", openChat);
selectedPerson.value = personList.value[0];
const first = personList.value[0];
if (first) selectedPerson.value = first;
});
onUnmounted(() => {
@@ -33,7 +33,11 @@
</div>
<div class="grow mb-5">
<div v-for="feat in getFeatures(plan)" :key="feat" class="flex-c mb-2.5 text-sm">
<div
v-for="feat in getFeatures(plan)"
:key="feat"
class="flex items-center mb-2.5 text-sm"
>
<ElIcon class="mr-2.5 text-theme!"><Check /></ElIcon>
<span>{{ feat }}</span>
</div>
@@ -125,7 +129,7 @@ async function loadPackages() {
loading.value = true;
try {
const res = await SelfServiceAPI.getAvailablePackages();
const data = (res.data as any)?.data;
const data = res.data?.data;
packages.value = data?.packages || [];
} catch {
ElMessage.error("加载套餐信息失败");
@@ -142,7 +146,7 @@ async function buyPackage(pkg: AvailablePackage) {
package_id: pkg.id,
order_type: (action || "buy") as "buy" | "renew" | "upgrade" | "downgrade",
});
const data = (res.data as any)?.data;
const data = res.data?.data;
if (data?.amount > 0) {
router.push(`/payment/${data.order_id}`);
} else {
@@ -816,6 +816,42 @@
</div>
</div>
</ElTabPane>
<!-- 组件展示 Tab -->
<ElTabPane :label="t('manualPage.widgetsTab')" name="widgets">
<div class="fa-card-sm mt-2 p-5">
<FaShowcase tag="h1" class="mb-2">{{ t("manualPage.widgetsTitle") }}</FaShowcase>
<p class="mb-6 text-sm text-g-600 dark:text-g-400">
{{ t("manualPage.widgetsIntro") }}
</p>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
<!-- Markdown 渲染 -->
<section class="rounded-lg border border-g-200 p-4 dark:border-g-700">
<FaShowcase tag="h2" class="mb-3 text-base">
{{ t("manualPage.widgetsMarkdown") }}
</FaShowcase>
<FaMarkdownRenderer :content="markdownSample" />
</section>
<!-- 评论组件 -->
<section class="rounded-lg border border-g-200 p-4 dark:border-g-700">
<FaShowcase tag="h2" class="mb-3 text-base">
{{ t("manualPage.widgetsComment") }}
</FaShowcase>
<FaCommentWidget />
</section>
<!-- ECharts 图表 -->
<section class="rounded-lg border border-g-200 p-4 dark:border-g-700">
<FaShowcase tag="h2" class="mb-3 text-base">
{{ t("manualPage.widgetsECharts") }}
</FaShowcase>
<FaECharts :options="echartsOption" height="280px" />
</section>
</div>
</div>
</ElTabPane>
</ElTabs>
</div>
</div>
@@ -980,12 +1016,50 @@ const { DEFAULT_URL, PLAYER_ID } = VIDEO_CONFIG;
// ============ 状态管理 ============
const activeTab = ref<"video" | "manual">("video");
const activeTab = ref<"video" | "manual" | "widgets">("video");
const videoUrl = ref(DEFAULT_URL);
const posterUrl = ref(lockImg);
const scrollbarRef = ref<{ $el?: HTMLElement } | null>(null);
const tocFilter = ref("");
// ============ 组件展示 Tab 状态 ============
const markdownSample = ref(
[
"# Markdown 渲染示例",
"",
"支持 **加粗**、*斜体*、`行内代码`、链接 [FastapiAdmin](https://github.com)。",
"",
"```ts",
"const greeting: string = 'Hello, FastapiAdmin';",
"console.log(greeting);",
"```",
"",
"- 列表项 A",
"- 列表项 B",
"",
"> 代码高亮、表格、引用块均可识别。",
].join("\n")
);
const echartsOption = {
title: { text: "示例图表", left: "center", textStyle: { fontSize: 14 } },
tooltip: { trigger: "axis" },
grid: { left: 40, right: 20, top: 40, bottom: 30 },
xAxis: {
type: "category",
data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
},
yAxis: { type: "value" },
series: [
{
name: "PV",
type: "bar",
data: [120, 200, 150, 80, 70, 110, 130],
itemStyle: { color: "#4080ff" },
},
],
};
// ============ 计算属性 ============
/** 过滤后的目录(标题 + manualTocSearch 别名,兼容改版前后检索词) */
@@ -1,5 +1,5 @@
<template>
<div ref="messagesContainer" class="chat-messages">
<ElScrollbar ref="messagesContainer" class="chat-messages">
<WelcomeScreen v-if="messages.length === 0" @prompt-click="handlePromptClick" />
<div v-else class="messages-list">
<FaMessageItem
@@ -12,11 +12,12 @@
<div v-if="error" class="error-banner">
<ElAlert :title="error" type="error" :closable="true" show-icon @close="handleErrorClose" />
</div>
</div>
</ElScrollbar>
</template>
<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";
@@ -34,13 +35,12 @@ interface Emits {
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const messagesContainer = ref<HTMLElement>();
const messagesContainer = ref<InstanceType<typeof ElScrollbar>>();
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
const wrap = messagesContainer.value?.wrapRef;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
});
};
@@ -71,9 +71,6 @@ defineExpose({
<style lang="scss" scoped>
.chat-messages {
height: 100%;
overflow-y: auto;
/* 与 index chat-main 同底,避免 disabled 灰 + page 色打架 */
background: transparent;
@@ -99,9 +99,6 @@ const toggleSidebar = () => {
align-items: center;
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
@@ -40,11 +40,12 @@
>
{{ message.collapsed ? "展开" : "收起" }}
</ElButton>
<div
class="message-text"
:class="{ collapsed: message.collapsed }"
v-html="formattedContent"
></div>
<div class="message-text" :class="{ collapsed: message.collapsed }">
<FaMarkdownRenderer
:content="message.content"
:max-length="message.collapsed ? 200 : undefined"
/>
</div>
<div
v-if="message.type === 'assistant' && message.loading && !message.content"
class="typing-indicator"
@@ -71,12 +72,6 @@ import {
ArrowUp,
Document,
} from "@element-plus/icons-vue";
import MarkdownIt from "markdown-it";
import markdownItHighlightjs from "markdown-it-highlightjs";
import hljs from "highlight.js";
import DOMPurify from "dompurify";
/* 亮色用 atom-one;暗色由文末全局块覆盖 .hljs 底与字色,避免整段代码发亮底 */
import "highlight.js/styles/atom-one-light.css";
import { useUserStoreHook } from "@stores";
import type { ChatMessage } from "../types";
@@ -95,114 +90,6 @@ const userStore = useUserStoreHook();
const userName = computed(() => userStore.basicInfo.name || "用户");
const md: MarkdownIt = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
highlight(str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
} catch {
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
},
}).use(markdownItHighlightjs);
const defaultRender =
md.renderer.rules.link_open ||
function (tokens: any[], idx: number, options: any, env: any, self: any) {
return self.renderToken(tokens, idx, options, env, self);
};
md.renderer.rules.link_open = function (
tokens: any[],
idx: number,
options: any,
env: any,
self: any
) {
tokens[idx].attrPush(["target", "_blank"]);
tokens[idx].attrPush(["rel", "noopener noreferrer"]);
return defaultRender(tokens, idx, options, env, self);
};
const formattedContent = computed(() => {
if (!props.message.content) return "";
const rawHtml = md.render(props.message.content);
return DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
"a",
"abbr",
"acronym",
"b",
"blockquote",
"br",
"code",
"col",
"colgroup",
"dd",
"del",
"dl",
"dt",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"img",
"li",
"ol",
"p",
"pre",
"s",
"span",
"strike",
"strong",
"sub",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"tt",
"u",
"ul",
"video",
"source",
"div",
],
ALLOWED_ATTR: [
"href",
"title",
"target",
"rel",
"src",
"alt",
"width",
"height",
"class",
"id",
"controls",
"poster",
"type",
"colspan",
"rowspan",
"span",
],
});
});
const handleToggleFold = () => {
emit("toggle-fold");
};
@@ -347,7 +234,6 @@ const formatFileSize = (bytes: number): string => {
font-size: 15px;
line-height: 1.6;
color: var(--el-text-color-primary);
overflow-wrap: break-word;
transition:
max-height 0.25s ease,
opacity 0.2s ease;
@@ -370,74 +256,6 @@ const formatFileSize = (bytes: number): string => {
);
}
}
:deep(pre) {
padding: 12px;
margin: 12px 0;
overflow-x: auto;
background: var(--el-fill-color-light);
border-radius: 6px;
code {
font-family: "Courier New", Courier, monospace;
font-size: 13px;
}
}
:deep(code) {
padding: 2px 6px;
font-family: "Courier New", Courier, monospace;
font-size: 13px;
background: var(--el-fill-color-light);
border-radius: 3px;
}
:deep(p) {
margin: 8px 0;
}
:deep(ul),
:deep(ol) {
padding-left: 24px;
margin: 8px 0;
}
:deep(li) {
margin: 4px 0;
}
:deep(a) {
color: var(--el-color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(blockquote) {
padding: 8px 16px;
margin: 12px 0;
background: var(--el-fill-color-light);
border-left: 4px solid var(--el-color-primary);
}
:deep(table) {
width: 100%;
margin: 12px 0;
border-collapse: collapse;
th,
td {
padding: 8px 12px;
border: 1px solid var(--el-border-color-light);
}
th {
font-weight: 600;
background: var(--el-fill-color-light);
}
}
}
.typing-indicator {
@@ -500,14 +318,6 @@ const formatFileSize = (bytes: number): string => {
&.collapsed::after {
background: linear-gradient(transparent, var(--el-color-primary-light-9));
}
:deep(pre) {
border: 1px solid var(--el-border-color-lighter);
}
:deep(code:not(pre code)) {
border: 1px solid var(--el-border-color-lighter);
}
}
}
}
@@ -536,7 +346,7 @@ const formatFileSize = (bytes: number): string => {
<!-- 暗色下覆盖 highlight.js 亮色主题,避免代码块发白底 -->
<style lang="scss">
html.dark .chat-messages .message-text {
html.dark .chat-messages .message-text .markdown-content {
pre.hljs,
code.hljs {
color: var(--el-text-color-regular) !important;
@@ -7,7 +7,7 @@
</div>
</div>
<div class="sidebar-content">
<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">
@@ -70,7 +70,7 @@
</div>
</div>
</template>
</div>
</ElScrollbar>
<div class="sidebar-footer">
<div v-if="!isCollapsed" class="user-info">
@@ -113,7 +113,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox, ElScrollbar } from "element-plus";
import {
ChatLineRound,
User,
@@ -365,8 +365,6 @@ defineExpose({
.sidebar-content {
flex: 1;
padding: 16px;
overflow-y: auto;
.new-session-section {
margin-bottom: 16px;
@@ -153,9 +153,9 @@ 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 FaSearchBar 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 FaForm 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";
@@ -1,7 +1,7 @@
<!-- 示例 CRUD:与角色页同一套 Fa 布局;弹窗与 Crud 一致(FaDialog + crud-embed-dialog) -->
<template>
<div class="fa-full-height">
<FaSearchBarWithAudit
<FaSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
@@ -13,6 +13,7 @@
:show-search="true"
:disabled-search="false"
:default-expanded="false"
:include-audit="true"
@search="handleSearch"
@reset="onResetSearch"
/>
@@ -153,6 +154,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { useTable } from "@/hooks/core/useTable";
@@ -160,7 +162,7 @@ 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 { cleanEmptyArrayParams, stripPaginationParams } from "@/utils/query";
import { stripPaginationParams } from "@/utils/query";
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";
@@ -172,7 +174,7 @@ import DemoAPI, {
type DemoTable,
} from "@/api/module_example/demo";
import { ResultEnum } from "@/enums/api/result.enum";
import { ElTag, ElMessage } from "element-plus";
import { ElMessage } from "element-plus";
defineOptions({
name: "Demo",
@@ -181,18 +183,34 @@ defineOptions({
const { hasAuth } = useAuth();
type DemoSearchFormParams = { name?: string; status?: string } & AuditSearchFormParams;
// 常量定义
const STATUS_OPTIONS = [
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
] as const;
function normalizeDemoQuery(params: Record<string, unknown>): DemoPageQuery {
return cleanEmptyArrayParams({ ...params }, [
"created_time",
"updated_time",
]) as unknown as DemoPageQuery;
}
const createInitialFormData = (): DemoForm => ({
id: undefined,
name: "",
status: 0,
description: undefined,
int_val: undefined,
bigint_val: undefined,
float_val: undefined,
bool_val: true,
date_val: undefined,
time_val: undefined,
datetime_val: undefined,
text_val: undefined,
json_val: undefined,
});
type DemoSearchFormParams = { name?: string; status?: number; tenant_id?: number } & AuditSearchFormParams;
const searchForm = ref<DemoSearchFormParams>({
name: undefined,
status: undefined,
tenant_id: undefined,
created_id: undefined,
updated_id: undefined,
created_time: [],
@@ -204,11 +222,8 @@ const showSearchBar = ref(true);
const searchBarRef = ref<{ validate: () => Promise<boolean> } | null>(null);
const searchBarRules: Record<string, unknown> = {};
const statusOptions = ref([
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
]);
/** 名称、状态;创建人/更新人/时间由 FaSearchBarWithAudit 追加 */
/** 名称、状态;创建人/更新人/时间由 FaSearchBar 的 includeAudit 属性追加 */
const demoBusinessSearchItems = computed(() => [
{
label: "名称",
@@ -224,7 +239,7 @@ const demoBusinessSearchItems = computed(() => [
type: "select",
props: {
placeholder: "请选择状态",
options: statusOptions.value,
options: STATUS_OPTIONS,
clearable: true,
},
span: 6,
@@ -268,12 +283,9 @@ const {
prop: "status",
label: "状态",
width: 88,
formatter: (row: DemoTable) => {
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "info" as const, text: "停用" };
return h(ElTag, { type: cfg.type }, () => cfg.text);
status: {
0: { type: "success", text: "启用" },
1: { type: "info", text: "停用" },
},
},
{ prop: "int_val", label: "整数", minWidth: 88, showOverflowTooltip: true },
@@ -283,10 +295,10 @@ const {
prop: "bool_val",
label: "布尔",
width: 80,
formatter: (row: DemoTable) =>
h(ElTag, { type: row.bool_val ? "success" : "danger" }, () =>
row.bool_val ? "是" : "否"
),
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{ prop: "date_val", label: "日期", minWidth: 112, showOverflowTooltip: true },
{ prop: "time_val", label: "时间", minWidth: 96, showOverflowTooltip: true },
@@ -342,8 +354,7 @@ const demoCrudCols = computed(() =>
);
const exportQueryParams = computed(() => {
const sp = stripPaginationParams(searchParams as Record<string, unknown>);
return normalizeDemoQuery(sp);
return stripPaginationParams(searchParams as Record<string, unknown>);
});
const demoImportContentConfig = computed<IContentConfig>(() => ({
@@ -357,11 +368,11 @@ const demoExportContentConfig = computed(() => ({
permPrefix: "module_example:demo",
cols: demoCrudCols.value,
exportsBlobAction: async (params: IObject) => {
const merged = normalizeDemoQuery({
const merged = {
...(exportQueryParams.value as unknown as Record<string, unknown>),
...params,
} as Record<string, unknown>);
const res = await DemoAPI.exportDemo(merged as DemoPageQuery);
} as unknown as DemoPageQuery;
const res = await DemoAPI.exportDemo(merged);
return res.data as Blob;
},
}));
@@ -474,21 +485,7 @@ const demoDialogFormItems: FormItem[] = [
{ key: "json_val", label: "元数据", type: "input" /* 由 #json_val 插槽接管 */ },
];
const formData = ref<DemoForm>({
id: undefined,
name: "",
status: 0,
description: undefined,
int_val: undefined,
bigint_val: undefined,
float_val: undefined,
bool_val: true,
date_val: undefined,
time_val: undefined,
datetime_val: undefined,
text_val: undefined,
json_val: undefined,
});
const formData = ref<DemoForm>(createInitialFormData());
const rules = reactive({
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
@@ -506,27 +503,12 @@ const metadataList = ref<{ key: string; value: string }[]>([]);
const { importVisible, exportVisible, openImport, openExport } = useImportExport();
const initialFormData: DemoForm = {
id: undefined,
name: "",
status: 0,
description: undefined,
int_val: undefined,
bigint_val: undefined,
float_val: undefined,
bool_val: true,
date_val: undefined,
time_val: undefined,
datetime_val: undefined,
text_val: undefined,
json_val: undefined,
};
const handleSearch = async (params: DemoSearchFormParams) => {
await searchBarRef.value?.validate();
replaceSearchParams({
name: params.name,
status: params.status,
tenant_id: params.tenant_id,
created_id: params.created_id ?? undefined,
updated_id: params.updated_id ?? undefined,
created_time:
@@ -545,6 +527,7 @@ const onResetSearch = async () => {
searchForm.value = {
name: undefined,
status: undefined,
tenant_id: undefined,
created_id: undefined,
updated_id: undefined,
created_time: [],
@@ -600,8 +583,8 @@ async function openDetailDialog(row: DemoTable) {
async function openEditDialog(type: "add" | "edit", row?: DemoTable) {
dialogVisible.type = type === "add" ? "create" : "update";
if (type === "add") {
dialogVisible.title = "新增示例";
Object.assign(formData.value, initialFormData);
dialogVisible.title = "新增";
Object.assign(formData.value, createInitialFormData());
formData.value.id = undefined;
metadataList.value = [];
demoFormRenderKey.value += 1;
@@ -627,7 +610,7 @@ async function resetForm() {
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
}
Object.assign(formData.value, initialFormData);
Object.assign(formData.value, createInitialFormData());
metadataList.value = [];
}
@@ -178,8 +178,6 @@ import type { CmComponentRef } from "codemirror-editor-vue3";
import { ElMessage } from "element-plus";
import { ArrowDown, CopyDocument } from "@element-plus/icons-vue";
import { useClipboard } from "@vueuse/core";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import FaDescriptions from "@/components/others/fa-descriptions/index.vue";
import { useSettingsStore } from "@stores";
import { ThemeMode } from "@/enums/settings/theme.enum";
import { buildSqlFromVisual } from "../utils/buildCreateTableSql";
@@ -99,7 +99,6 @@ import { Close, Right, FolderOpened, Back, Download, Finished } from "@element-p
import type { EditorConfiguration } from "codemirror";
import type { GenTableSchema } from "@/api/module_generator/gencode";
import type { DictTable } from "@/api/module_system/dict";
import FaDrawer from "@/components/modal/fa-drawer/index.vue";
import type { TreeNode } from "../types";
import GenBasicStep from "./FaGenBasicStep.vue";
import GenColumnsStep from "./FaGenColumnsStep.vue";
@@ -93,8 +93,6 @@
import { ref, computed } from "vue";
import type { FormInstance, TableInstance } from "element-plus";
import type { DBTableSchema, GenTablePageQuery } from "@/api/module_generator/gencode";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import FaPagination from "@/components/others/fa-pagination/index.vue";
defineOptions({ name: "ImportDbTableDialog" });
@@ -161,10 +161,8 @@ import DictAPI, { DictTable } from "@/api/module_system/dict";
import { MenuTypeEnum } from "@/enums";
import { useSettingsStore } from "@stores";
import { useTable } from "@/hooks/core/useTable";
import FaTable from "@/components/tables/fa-table/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/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 FaGenCodeDrawer from "./components/FaGenCodeDrawer.vue";
import FaImportDbTableDialog from "./components/FaImportDbTableDialog.vue";
import { CreateTableSubmitMeta } from "./components/FaCreateTableDialog.vue";
@@ -996,7 +994,7 @@ async function handleImportTable(): Promise<void> {
await nextTick();
const list = tableListData.value as unknown as GenTableSchema[];
const importedTable = list.find(
(t: GenTableSchema) => t.table_name === tables.value[0].table_name
(t: GenTableSchema) => t.table_name === tables.value[0]!.table_name
);
if (importedTable) {
await handlePreviewTable(importedTable);
@@ -1254,7 +1252,7 @@ function bulkSet(field: string | string[], value: any): void {
info.columns.forEach((column) => {
if (column && typeof column === "object") {
fieldsToUpdate.forEach((f) => {
(column as any)[f] = value;
(column as Record<string, unknown>)[f] = value;
});
}
});
-1
View File
@@ -262,7 +262,6 @@
</template>
<script lang="ts" setup>
import FaDescriptions from "@/components/others/fa-descriptions/index.vue";
import { ElMessageBox } from "element-plus";
import CacheAPI, {
type CacheInfo,
@@ -66,10 +66,8 @@ import OnlineAPI, { type OnlineUserTable } from "@/api/module_monitor/online";
import type { ColumnOption } from "@/types/component";
import { ElMessage, ElMessageBox, ElTooltip } from "element-plus";
import { useAuth } from "@/hooks/core/useAuth";
import FaTable from "@/components/tables/fa-table/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/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 FaCopyButton from "@/components/others/fa-copy-button/index.vue";
import FaButtonTable from "@/components/forms/fa-button-table/index.vue";
@@ -221,11 +221,8 @@ 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 FaTable from "@/components/tables/fa-table/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaButtonTable from "@/components/forms/fa-button-table/index.vue";
const { hasAuth } = useAuth();
@@ -10,8 +10,8 @@
<span class="font-medium">CPU使用情况</span>
</div>
</template>
<div class="flex-c flex-col gap-4">
<div class="flex-c gap-6">
<div class="flex items-center flex-col gap-4">
<div class="flex items-center gap-6">
<ElProgress
type="circle"
:percentage="server.cpu?.used || 0"
@@ -49,12 +49,12 @@
<ElCard :loading="loading" shadow="hover" class="server-card">
<template #header>
<div class="flex items-center gap-2">
<FaSvgIcon icon="ri:memory-line" class="text-lg" />
<FaSvgIcon icon="ri:ram-line" class="text-lg" />
<span class="font-medium">内存使用情况</span>
</div>
</template>
<div class="flex-c flex-col gap-4">
<div class="flex-c gap-6">
<div class="flex items-center flex-col gap-4">
<div class="flex items-center gap-6">
<ElProgress
type="circle"
:percentage="server.mem?.usage || 0"
@@ -186,7 +186,6 @@
</template>
<script lang="ts" setup>
import FaDescriptions from "@/components/others/fa-descriptions/index.vue";
import ServerAPI, { type ServerInfo } from "@/api/module_monitor/server";
defineOptions({ name: "ServerMonitor" });
@@ -272,13 +272,13 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useAuth } from "@/hooks/core/useAuth";
import { useTable } from "@/hooks/core/useTable";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import type { ColumnOption } from "@/types/component";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import EmailAPI, {
type EmailConfigTable,
type EmailConfigCreateForm,
@@ -290,9 +290,9 @@ import EmailAPI, {
} from "@/api/module_platform/email";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag, ElMessage } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import { ElMessage } from "element-plus";
defineOptions({ name: "Email" });
@@ -343,7 +343,7 @@ const {
core: {
apiFn: EmailAPI.listConfig,
apiParams: { page_no: 1, page_size: 10 },
columnsFactory: (): ColumnOption<EmailConfigTable>[] => [
columnsFactory: resolveStatusColumns<EmailConfigTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "配置名称", minWidth: 140, showOverflowTooltip: true },
@@ -355,30 +355,28 @@ const {
prop: "use_tls",
label: "SSL/TLS",
width: 90,
formatter: (row: EmailConfigTable) =>
h(ElTag, { type: row.use_tls ? "success" : "info", size: "small" }, () =>
row.use_tls ? "启用" : "禁用"
),
status: {
true: { type: "success", text: "启用" },
false: { type: "info", text: "禁用" },
},
},
{
prop: "is_default",
label: "默认",
width: 70,
formatter: (row: EmailConfigTable) =>
row.is_default
? h(ElTag, { type: "warning", size: "small" }, () => "默认")
: h("span", { class: "text-g-400" }, () => "—"),
status: {
true: { type: "warning", size: "small", text: "默认" },
false: { type: "info", size: "small", text: "—" },
},
},
{
prop: "status",
label: "状态",
width: 70,
formatter: (row: EmailConfigTable) =>
h(
ElTag,
{ type: row.status === ("0" as any) ? "success" : "danger", size: "small" },
() => (row.status === ("0" as any) ? "正常" : "禁用")
),
status: {
"0": { type: "success", size: "small", text: "正常" },
"1": { type: "danger", size: "small", text: "禁用" },
},
},
{
prop: "operation",
@@ -413,7 +411,7 @@ const {
return renderTableOperationCell(actions.filter((a) => a.perm != null && hasAuth(a.perm)));
},
},
],
]),
},
});
@@ -469,7 +467,7 @@ const {
core: {
apiFn: EmailAPI.listTemplate,
apiParams: { page_no: 1, page_size: 10 },
columnsFactory: (): ColumnOption<EmailTemplateTable>[] => [
columnsFactory: resolveStatusColumns<EmailTemplateTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "模板名称", minWidth: 140, showOverflowTooltip: true },
@@ -478,19 +476,17 @@ const {
label: "模板编码",
width: 140,
formatter: (row: EmailTemplateTable) =>
h(ElTag, { type: "info", size: "small" }, () => row.template_code),
h(FaStatusTag, { type: "info", size: "small", label: row.template_code }),
},
{ prop: "subject", label: "邮件主题", minWidth: 200, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 70,
formatter: (row: EmailTemplateTable) =>
h(
ElTag,
{ type: row.status === ("0" as any) ? "success" : "danger", size: "small" },
() => (row.status === ("0" as any) ? "正常" : "禁用")
),
status: {
"0": { type: "success", size: "small", text: "正常" },
"1": { type: "danger", size: "small", text: "禁用" },
},
},
{ prop: "description", label: "备注", minWidth: 120, showOverflowTooltip: true },
{
@@ -519,7 +515,7 @@ const {
return renderTableOperationCell(actions.filter((a) => a.perm != null && hasAuth(a.perm)));
},
},
],
]),
},
});
@@ -569,7 +565,7 @@ const {
core: {
apiFn: EmailAPI.listLog,
apiParams: { page_no: 1, page_size: 10 },
columnsFactory: (): ColumnOption<EmailLogTable>[] => [
columnsFactory: resolveStatusColumns<EmailLogTable>(() => [
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "to_email", label: "收件人", minWidth: 180, showOverflowTooltip: true },
{ prop: "subject", label: "主题", minWidth: 200, showOverflowTooltip: true },
@@ -579,7 +575,7 @@ const {
width: 120,
formatter: (row: EmailLogTable) =>
row.template_code
? h(ElTag, { type: "info", size: "small" }, () => row.template_code!)
? h(FaStatusTag, { type: "info", size: "small", label: row.template_code! })
: h("span", { class: "text-g-400" }, () => "—"),
},
{
@@ -587,16 +583,16 @@ const {
label: "业务类型",
width: 110,
formatter: (row: EmailLogTable) =>
h(ElTag, { size: "small" }, () => bizTypeLabel(row.biz_type)),
h(FaStatusTag, { size: "small", label: bizTypeLabel(row.biz_type) }),
},
{
prop: "status",
label: "发送状态",
width: 90,
formatter: (row: EmailLogTable) =>
h(ElTag, { type: row.status === 1 ? "success" : "danger", size: "small" }, () =>
row.status === 1 ? "成功" : "失败"
),
status: {
1: { type: "success", text: "成功" },
0: { type: "danger", text: "失败" },
},
},
{ prop: "retry_count", label: "重试", width: 60 },
{
@@ -608,7 +604,7 @@ const {
row.error_msg || h("span", { class: "text-g-400" }, () => "—"),
},
{ prop: "created_time", label: "发送时间", width: 168, showOverflowTooltip: true },
],
]),
},
});
@@ -1063,7 +1059,7 @@ async function handleTemplateBatchDelete() {
// ══════════════════ 搜索逻辑 ════════════════════
async function handleConfigSearch(p: ConfigSearchForm) {
replaceConfigSearchParams({ name: p.name || undefined } as any);
replaceConfigSearchParams({ name: p.name || undefined } as unknown as ConfigSearchForm);
getConfigData();
}
function onConfigResetSearch() {
@@ -1075,7 +1071,7 @@ async function handleTemplateSearch(p: TemplateSearchForm) {
replaceTemplateSearchParams({
name: p.name || undefined,
template_code: p.template_code || undefined,
} as any);
} as unknown as TemplateSearchForm);
getTemplateData();
}
function onTemplateResetSearch() {
@@ -1087,7 +1083,7 @@ async function handleLogSearch(p: LogSearchForm) {
replaceLogSearchParams({
to_email: p.to_email || undefined,
template_code: p.template_code || undefined,
} as any);
} as unknown as LogSearchForm);
getLogData();
}
function onLogResetSearch() {
@@ -84,16 +84,15 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, h } from "vue";
import { ElMessageBox, ElButton, ElTabs, ElTabPane, ElTag } from "element-plus";
import { ref, reactive, computed } from "vue";
import { ElMessageBox, ElButton, ElTabs, ElTabPane } from "element-plus";
import { useTable } from "@/hooks/core/useTable";
import { useAuth } from "@/hooks/core/useAuth";
import InvoiceAPI from "@/api/module_platform/invoice";
import type { InvoiceTable } from "@/api/module_platform/invoice";
import type { ColumnOption } from "@/types/component";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
defineOptions({ name: "Invoice" });
@@ -103,18 +102,6 @@ const activeTab = ref("my");
const isSuperAdmin = ref(true);
const platformShowSearchBar = ref(true);
// ══════════════════ 标签函数 ════════════════════
function invoiceStatusLabel(status: number): string {
const map: Record<number, string> = { 0: "待开具", 1: "已开具", 2: "开票失败", 3: "已作废" };
return map[status] || String(status);
}
function invoiceStatusType(status: number): string {
const map: Record<number, string> = { 0: "warning", 1: "success", 2: "danger", 3: "info" };
return map[status] || "info";
}
// ══════════════════ 平台端:全部发票 ════════════════════
function buildPlatformRowActions(row: InvoiceTable): TableOperationAction[] {
@@ -196,7 +183,7 @@ const {
page_no: 1,
page_size: 50,
},
columnsFactory: (): ColumnOption<InvoiceTable>[] => [
columnsFactory: resolveStatusColumns<InvoiceTable>(() => [
{ prop: "id", label: "ID", width: 60 },
{ prop: "tenant_id", label: "租户ID", width: 80 },
{ prop: "invoice_no", label: "发票号", width: 180, showOverflowTooltip: true },
@@ -217,11 +204,11 @@ const {
prop: "status",
label: "状态",
width: 100,
formatter: (row) => {
const t = invoiceStatusType(row.status) as "warning" | "success" | "info" | "danger";
return h(ElTag, { type: t, size: "small" as const }, () =>
invoiceStatusLabel(row.status)
);
status: {
0: { type: "warning", text: "待开具" },
1: { type: "success", text: "已开具" },
2: { type: "danger", text: "开票失败" },
3: { type: "info", text: "已作废" },
},
},
{ prop: "address_info", label: "地址电话", width: 180, showOverflowTooltip: true },
@@ -233,7 +220,7 @@ const {
align: "center",
formatter: (row) => formatPlatformOpCell(row),
},
],
]),
},
});
@@ -288,7 +275,7 @@ const {
page_no: 1,
page_size: 50,
},
columnsFactory: (): ColumnOption<InvoiceTable>[] => [
columnsFactory: resolveStatusColumns<InvoiceTable>(() => [
{ prop: "id", label: "ID", width: 60 },
{ prop: "invoice_no", label: "发票号", width: 180, showOverflowTooltip: true },
{ prop: "title", label: "抬头", minWidth: 180, showOverflowTooltip: true },
@@ -308,11 +295,11 @@ const {
prop: "status",
label: "状态",
width: 100,
formatter: (row) => {
const t = invoiceStatusType(row.status) as "warning" | "success" | "info" | "danger";
return h(ElTag, { type: t, size: "small" as const }, () =>
invoiceStatusLabel(row.status)
);
status: {
0: { type: "warning", text: "待开具" },
1: { type: "success", text: "已开具" },
2: { type: "danger", text: "开票失败" },
3: { type: "info", text: "已作废" },
},
},
{
@@ -322,7 +309,7 @@ const {
showOverflowTooltip: true,
formatter: (row) => row.created_time || "—",
},
],
]),
},
});
@@ -81,15 +81,19 @@
:scrollbar="false"
>
<template #type="{ row }">
<ElTag v-if="row?.type === MenuTypeEnum.CATALOG" type="warning">目录</ElTag>
<ElTag v-if="row?.type === MenuTypeEnum.MENU" type="success">菜单</ElTag>
<ElTag v-if="row?.type === MenuTypeEnum.BUTTON" type="danger">按钮</ElTag>
<ElTag v-if="row?.type === MenuTypeEnum.EXTLINK" type="info">外链</ElTag>
<FaStatusTag v-if="row?.type === MenuTypeEnum.CATALOG" type="warning" label="目录" />
<FaStatusTag v-if="row?.type === MenuTypeEnum.MENU" type="success" label="菜单" />
<FaStatusTag v-if="row?.type === MenuTypeEnum.BUTTON" type="danger" label="按钮" />
<FaStatusTag v-if="row?.type === MenuTypeEnum.EXTLINK" type="info" label="外链" />
</template>
<template #client="{ row }">
<ElTag v-if="row?.client === MenuClientEnum.PC" type="primary">PC</ElTag>
<ElTag v-else-if="row?.client === MenuClientEnum.APP" type="success">APP</ElTag>
<ElTag v-else type="info">{{ row?.client || "—" }}</ElTag>
<FaStatusTag v-if="row?.client === MenuClientEnum.PC" type="primary" label="PC" />
<FaStatusTag
v-else-if="row?.client === MenuClientEnum.APP"
type="success"
label="APP"
/>
<FaStatusTag v-else type="info" :label="(row as unknown as MenuTable)?.client || '—'" />
</template>
<template #icon="{ row }">
<template v-if="row?.icon">
@@ -332,6 +336,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
defineOptions({
name: "SysMenu",
inheritAttrs: false,
@@ -349,13 +354,13 @@ import MenuAPI, {
import { MenuClientEnum, MenuTypeEnum } from "@/enums/system/menu.enum";
import { formatTree } from "@utils/common";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
import { ElTag, ElMessage, ElMessageBox } from "element-plus";
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";
const { hasAuth } = useAuth();
const appStore = useAppStore();
@@ -363,7 +368,7 @@ const userStore = useUserStore();
type MenuSearchForm = {
name?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -376,25 +381,6 @@ function buildMenuListQuery(p: MenuSearchForm): MenuPageQuery {
};
}
function menuTypeTag(row: MenuTable) {
const t = row.type;
if (t === MenuTypeEnum.CATALOG) return h(ElTag, { type: "warning" }, () => "目录");
if (t === MenuTypeEnum.MENU) return h(ElTag, { type: "success" }, () => "菜单");
if (t === MenuTypeEnum.BUTTON) return h(ElTag, { type: "danger" }, () => "按钮");
if (t === MenuTypeEnum.EXTLINK) return h(ElTag, { type: "info" }, () => "外链");
return h("span", "—");
}
function menuClientTag(row: MenuTable) {
if (row.client === MenuClientEnum.PC) return h(ElTag, { type: "primary" }, () => "PC");
if (row.client === MenuClientEnum.APP) return h(ElTag, { type: "success" }, () => "APP");
return h(ElTag, { type: "info" }, () => String(row.client ?? "—"));
}
function ynTag(v: boolean | undefined, yes = "是", no = "否") {
return h(ElTag, { type: v ? "success" : "danger" }, () => (v ? yes : no));
}
function buildMenuRowActions(
row: MenuTable,
ctx: {
@@ -885,118 +871,159 @@ const opCtx = {
onDelete: deleteMenuRow,
};
const { columnChecks, columns } = useTableColumns<MenuTable>(() => [
{ 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,
formatter: (row: MenuTable) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
},
{ prop: "type", label: "类型", width: 88, align: "center", formatter: menuTypeTag },
{ prop: "client", label: "终端", width: 88, align: "center", formatter: menuClientTag },
{
prop: "scope",
label: "可见范围",
width: 96,
align: "center",
formatter: (row: MenuTable) =>
row.scope
? h(ElTag, { type: row.scope === "platform" ? "warning" : "success" }, () =>
row.scope === "platform" ? "仅平台" : "租户可用"
)
: h("span", { class: "text-g-400" }, "—"),
},
{ prop: "order", label: "排序", width: 80 },
{ 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,
formatter: (row: MenuTable) => ynTag(row.keep_alive),
},
{
prop: "hidden",
label: "是否隐藏",
width: 96,
formatter: (row: MenuTable) => ynTag(row.hidden),
},
{
prop: "always_show",
label: "显示根路由",
width: 108,
formatter: (row: MenuTable) => ynTag(row.always_show),
},
{
prop: "affix",
label: "固定路由",
width: 96,
formatter: (row: MenuTable) => ynTag(row.affix),
},
{ prop: "title", label: "菜单标题", minWidth: 100, showOverflowTooltip: true },
{
prop: "is_iframe",
label: "嵌入iframe",
width: 100,
formatter: (row: MenuTable) => ynTag(row.is_iframe),
},
{
prop: "is_hide_tab",
label: "隐藏标签页",
width: 100,
formatter: (row: MenuTable) => ynTag(row.is_hide_tab),
},
{
prop: "show_badge",
label: "红点角标",
width: 96,
formatter: (row: MenuTable) => ynTag(row.show_badge),
},
{ prop: "show_text_badge", label: "文字角标", width: 100, showOverflowTooltip: true },
{
prop: "params",
label: "路由参数",
minWidth: 100,
formatter: (row: MenuTable) =>
row.params == null
? "—"
: typeof row.params === "object"
? 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: "operation",
label: "操作",
width: 220,
fixed: "right",
align: "right",
formatter: (row: MenuTable) => formatMenuOperationCell(row, opCtx),
},
]);
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: "type",
label: "类型",
width: 88,
align: "center",
status: {
1: { type: "warning", text: "目录" },
2: { type: "success", text: "菜单" },
3: { type: "danger", text: "按钮" },
4: { type: "info", text: "外链" },
},
},
{
prop: "client",
label: "终端",
width: 88,
align: "center",
status: {
pc: { type: "primary", text: "PC" },
app: { type: "success", text: "APP" },
},
},
{
prop: "scope",
label: "可见范围",
width: 96,
align: "center",
status: {
platform: { type: "warning", text: "仅平台" },
tenant: { type: "success", text: "租户可用" },
},
},
{ prop: "order", label: "排序", width: 80 },
{ 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: "hidden",
label: "是否隐藏",
width: 96,
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{
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: "params",
label: "路由参数",
minWidth: 100,
formatter: (row: MenuTable) =>
row.params == null
? "—"
: typeof row.params === "object"
? 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: "operation",
label: "操作",
width: 220,
fixed: "right",
align: "right",
formatter: (row: MenuTable) => formatMenuOperationCell(row, opCtx),
},
])
);
const rules = reactive({
name: [
@@ -120,7 +120,7 @@ const methodLabel = computed(() => (selectedMethod.value === "alipay" ? "支付
async function loadOrderStatus() {
try {
const res = await OrderAPI.queryPaymentStatus(orderId.value);
const data = (res.data as any)?.data;
const data = res.data?.data;
if (data?.paid) {
paid.value = true;
orderNo.value = data.order_no || "";
@@ -136,7 +136,7 @@ async function startPay() {
paying.value = true;
try {
const res = await OrderAPI.payOrder(orderId.value, { pay_method: selectedMethod.value });
const data = (res.data as any)?.data;
const data = res.data?.data;
orderNo.value = data.order_no;
amount.value = data.amount;
qrCodeUrl.value = data.qr_code_url || "";
@@ -171,7 +171,7 @@ function startPolling() {
pollTimer = setInterval(async () => {
try {
const res = await OrderAPI.queryPaymentStatus(orderId.value);
const data = (res.data as any)?.data;
const data = res.data?.data;
if (data?.paid) {
paid.value = true;
orderNo.value = data.order_no || orderNo.value;
@@ -134,16 +134,15 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, h } from "vue";
import { ElMessageBox, ElButton, ElTabs, ElTabPane, ElTag } from "element-plus";
import { ref, reactive, computed } from "vue";
import { ElMessageBox, ElButton, ElTabs, ElTabPane } from "element-plus";
import { useTable } from "@/hooks/core/useTable";
import { useAuth } from "@/hooks/core/useAuth";
import OrderAPI from "@/api/module_platform/order";
import type { OrderTable, PaymentRecordTable, RefundTable } from "@/api/module_platform/order";
import type { ColumnOption } from "@/types/component";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
defineOptions({ name: "Order" });
@@ -165,27 +164,11 @@ function orderTypeLabel(t: string): string {
return m[t] || t;
}
function orderStatusLabel(s: number): string {
const m: Record<number, string> = {
0: "待支付",
1: "已支付",
2: "已取消",
3: "已退款",
4: "已过期",
};
return m[s] ?? String(s);
}
function payMethodLabel(m: string): string {
const mp: Record<string, string> = { alipay: "支付宝", wxpay: "微信支付" };
return mp[m] || m;
}
function refundStatusLabel(s: number): string {
const m: Record<number, string> = { 0: "待审核", 1: "已批准", 2: "已驳回", 3: "已完成" };
return m[s] ?? String(s);
}
// ══════════════════ 订单列表 ════════════════════
function buildOrderRowActions(row: OrderTable): TableOperationAction[] {
@@ -254,8 +237,6 @@ const orderSearchItems = computed<SearchFormItem[]>(() => [
},
]);
const orderSearchBarRef = ref();
const {
columns: orderColumns,
columnChecks: orderColumnChecks,
@@ -274,7 +255,7 @@ const {
page_no: 1,
page_size: 20,
},
columnsFactory: (): ColumnOption<OrderTable>[] => [
columnsFactory: resolveStatusColumns<OrderTable>(() => [
{ prop: "id", label: "ID", width: 60 },
{ prop: "order_no", label: "订单号", minWidth: 180, showOverflowTooltip: true },
{ prop: "tenant_id", label: "租户ID", width: 80 },
@@ -283,8 +264,12 @@ const {
prop: "order_type",
label: "类型",
width: 90,
formatter: (row: OrderTable) =>
h(ElTag, { size: "small" as const }, () => orderTypeLabel(row.order_type)),
status: {
new: { type: "info", text: "新购" },
renew: { type: "info", text: "续费" },
upgrade: { type: "info", text: "升级" },
downgrade: { type: "info", text: "降级" },
},
},
{
prop: "amount",
@@ -296,14 +281,12 @@ const {
prop: "status",
label: "状态",
width: 90,
formatter: (row: OrderTable) => {
const typeMap = ["warning", "success", "info", "warning", "danger"] as const;
const t = typeMap[row.status] || "info";
return h(
ElTag,
{ type: t as "warning" | "success" | "info" | "danger", size: "small" as const },
() => orderStatusLabel(row.status)
);
status: {
0: { type: "warning", text: "待支付" },
1: { type: "success", text: "已支付" },
2: { type: "info", text: "已取消" },
3: { type: "danger", text: "已退款" },
4: { type: "danger", text: "已过期" },
},
},
{
@@ -327,7 +310,7 @@ const {
align: "center",
formatter: (row: OrderTable) => formatOrderOpCell(row),
},
],
]),
},
});
@@ -422,7 +405,7 @@ const {
page_no: 1,
page_size: 20,
},
columnsFactory: (): ColumnOption<PaymentRecordTable>[] => [
columnsFactory: resolveStatusColumns<PaymentRecordTable>(() => [
{ prop: "id", label: "ID", width: 60 },
{ prop: "order_id", label: "订单ID", width: 80 },
{
@@ -448,19 +431,14 @@ const {
prop: "status",
label: "状态",
width: 90,
formatter: (row: PaymentRecordTable) =>
h(
ElTag,
{
type: (row.status === 1 ? "success" : "danger") as "success" | "danger",
size: "small" as const,
},
() => (row.status === 1 ? "成功" : "失败")
),
status: {
1: { type: "success", text: "成功" },
0: { type: "danger", text: "失败" },
},
},
{ prop: "pay_time", label: "支付时间", width: 160, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 160, showOverflowTooltip: true },
],
]),
},
});
@@ -516,8 +494,6 @@ const refundSearchItems = computed<SearchFormItem[]>(() => [
},
]);
const refundSearchBarRef = ref();
const {
columns: refundColumns,
columnChecks: refundColumnChecks,
@@ -536,7 +512,7 @@ const {
page_no: 1,
page_size: 20,
},
columnsFactory: (): ColumnOption<RefundTable>[] => [
columnsFactory: resolveStatusColumns<RefundTable>(() => [
{ prop: "id", label: "ID", width: 60 },
{ prop: "refund_no", label: "退款单号", minWidth: 180, showOverflowTooltip: true },
{ prop: "order_id", label: "订单ID", width: 80 },
@@ -551,14 +527,11 @@ const {
prop: "status",
label: "状态",
width: 100,
formatter: (row: RefundTable) => {
const typeMap = ["warning", "success", "danger", "info"] as const;
const t = typeMap[row.status] || "info";
return h(
ElTag,
{ type: t as "warning" | "success" | "info" | "danger", size: "small" as const },
() => refundStatusLabel(row.status)
);
status: {
0: { type: "warning", text: "待审核" },
1: { type: "success", text: "已批准" },
2: { type: "danger", text: "已驳回" },
3: { type: "info", text: "已完成" },
},
},
{
@@ -578,7 +551,7 @@ const {
align: "center",
formatter: (row: RefundTable) => formatRefundOpCell(row),
},
],
]),
},
});
@@ -141,18 +141,15 @@ 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 type { ColumnOption } from "@/types/component";
import PackageAPI, { type PackageForm, type PackageTable } from "@/api/module_platform/package";
import MenuAPI from "@/api/module_platform/menu";
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 type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { ElTag, ElMessage } from "element-plus";
import FaDrawer from "@/components/modal/fa-drawer/index.vue";
import FaMenuTreeTable from "@/components/others/fa-menu-tree-drawer/index.vue";
import FaMenuTreeTable from "@/components/others/fa-menu-tree-table/index.vue";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import { ElMessage } from "element-plus";
defineOptions({
name: "Package",
@@ -311,7 +308,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<PackageTable>[] => [
columnsFactory: resolveStatusColumns<PackageTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "套餐名称", minWidth: 120, showOverflowTooltip: true },
@@ -320,10 +317,10 @@ const {
prop: "status",
label: "状态",
width: 80,
formatter: (row: PackageTable) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "正常" : "禁用"
),
status: {
0: { type: "success", text: "正常" },
1: { type: "danger", text: "禁用" },
},
},
{ prop: "sort", label: "排序", width: 60, align: "center" },
{
@@ -353,7 +350,7 @@ const {
align: "right",
formatter: (row: PackageTable) => formatPkgOperationCell(row, opCtx),
},
],
]),
},
});
@@ -580,9 +577,14 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } =
dialogVisible,
dataFormRef,
formRenderKey: pkgFormRenderKey,
detailApi: PackageAPI.detailPackage as any,
createApi: PackageAPI.createPackage as any,
updateApi: PackageAPI.updatePackage as any,
detailApi: PackageAPI.detailPackage as unknown as (
id: number
) => Promise<{ data: { data?: PackageForm } }>,
createApi: PackageAPI.createPackage as unknown as (form: PackageForm) => Promise<unknown>,
updateApi: PackageAPI.updatePackage as unknown as (
id: number,
form: PackageForm
) => Promise<unknown>,
detailFormData,
titles: { create: "新增套餐", update: "修改套餐", detail: "套餐详情" },
onCreateSuccess: refreshCreate,
@@ -254,9 +254,6 @@ import PluginAPI, { type PluginForm, type PluginTable } from "@/api/module_platf
import { useAuth } from "@/hooks/core/useAuth";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaPagination from "@/components/others/fa-pagination/index.vue";
import {
ElTag,
ElMessage,
@@ -276,10 +273,10 @@ defineOptions({
const { hasAuth } = useAuth();
// ─── 搜索表单 ───
const searchForm = ref<{ name?: string; category?: string; status?: string }>({
const searchForm = ref<{ name?: string; category?: string; status?: number }>({
name: "",
category: "",
status: "",
status: undefined,
});
const showSearchBar = ref(true);
@@ -331,8 +328,7 @@ async function fetchData() {
loading.value = true;
try {
const res = await PluginAPI.list(
{ page_no: pageNo.value, page_size: pageSize.value },
searchForm.value as any
{ page_no: pageNo.value, page_size: pageSize.value, ...searchForm.value }
);
const result = res.data?.data;
data.value = (result?.items as PluginTable[]) || [];
@@ -356,14 +352,14 @@ async function handleSearchBarSearch(params: Record<string, unknown>) {
searchForm.value = {
name: (params.name as string) ?? "",
category: (params.category as string) ?? "",
status: (params.status as string) ?? "",
status: params.status !== undefined ? Number(params.status) : undefined,
};
pageNo.value = 1;
await fetchData();
}
async function onResetSearch() {
searchForm.value = { name: "", category: "", status: "" };
searchForm.value = { name: "", category: "", status: undefined };
pageNo.value = 1;
await fetchData();
}
@@ -580,7 +576,9 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } =
dialogVisible,
dataFormRef,
formRenderKey: pluginFormRenderKey,
detailApi: PluginAPI.detail as any,
detailApi: PluginAPI.detail as unknown as (
id: number
) => Promise<{ data: { data?: PluginForm } }>,
createApi: PluginAPI.create,
updateApi: PluginAPI.update,
titles: { create: "新增插件", update: "编辑插件", detail: "插件详情" },
@@ -274,9 +274,8 @@
</template>
<script setup lang="ts">
import { ref, computed, h, onMounted } from "vue";
import { ref, computed, onMounted } from "vue";
import type { TabPaneName } from "element-plus";
import { ElTag, ElButton } from "element-plus";
import { useRouter } from "vue-router";
import { useTable } from "@/hooks/core/useTable";
import FaStatsCard from "@/components/cards/fa-stats-card/index.vue";
@@ -290,7 +289,7 @@ import type {
SelfServiceOrderItem,
WorkspaceData,
} from "@/api/module_platform/self_service";
import type { ColumnOption } from "@/types/component";
import { resolveStatusColumns } from "@utils";
import type { DescriptionsItem } from "@/components/others/fa-descriptions/index.vue";
defineOptions({ name: "SelfService" });
@@ -354,15 +353,19 @@ const {
page_no: 1,
page_size: 20,
},
columnsFactory: (): ColumnOption<SelfServiceOrderItem>[] => [
columnsFactory: resolveStatusColumns<SelfServiceOrderItem>(() => [
{ prop: "order_no", label: "订单号", minWidth: 200, showOverflowTooltip: true },
{ prop: "package_name", label: "套餐", width: 120 },
{
prop: "order_type",
label: "类型",
width: 100,
formatter: (row: SelfServiceOrderItem) =>
h(ElTag, { size: "small" as const }, () => orderTypeLabel(row.order_type)),
status: {
new: { type: "info", text: "新购" },
renew: { type: "info", text: "续费" },
upgrade: { type: "info", text: "升级" },
downgrade: { type: "info", text: "降级" },
},
},
{
prop: "amount",
@@ -374,14 +377,11 @@ const {
prop: "status",
label: "状态",
width: 100,
formatter: (row: SelfServiceOrderItem) => {
const typeMap = ["warning", "success", "info", "danger"] as const;
const t = typeMap[row.status] || "info";
return h(
ElTag,
{ type: t as "warning" | "success" | "info" | "danger", size: "small" as const },
() => orderStatusLabel(row.status)
);
status: {
0: { type: "warning", text: "待支付" },
1: { type: "success", text: "已支付" },
2: { type: "info", text: "已取消" },
3: { type: "danger", text: "已退款" },
},
},
{
@@ -392,7 +392,7 @@ const {
},
{ prop: "pay_time", label: "支付时间", width: 160, showOverflowTooltip: true },
{ prop: "created_at", label: "创建时间", width: 160, showOverflowTooltip: true },
],
]),
},
});
@@ -435,21 +435,6 @@ function actionTypeTag(
return map[action];
}
function orderTypeLabel(type: string): string {
const map: Record<string, string> = {
buy: "新购",
renew: "续费",
upgrade: "升级",
downgrade: "降级",
};
return map[type] || type;
}
function orderStatusLabel(status: number): string {
const map: Record<number, string> = { 0: "待支付", 1: "已支付", 2: "已取消", 3: "已退款" };
return map[status] || String(status);
}
// ─── 加载 ───
async function loadPackages() {
packagesLoading.value = true;
@@ -497,10 +482,10 @@ async function confirmAction() {
package_id: currentPackage.value.id,
order_type: currentAction.value as "buy" | "renew" | "upgrade" | "downgrade",
});
const orderData = res?.data as { id: number; order_no: string; amount?: number } | undefined;
const orderData = res?.data as { order_id: number; amount?: number } | undefined;
actionDialogVisible.value = false;
if (orderData?.id && (orderData?.amount || 0) > 0) {
router.push(`/payment/${orderData.id}`);
if (orderData?.order_id && (orderData?.amount || 0) > 0) {
router.push(`/payment/${orderData.order_id}`);
} else {
activeTab.value = "orders";
getOrderData();
@@ -206,7 +206,6 @@ import { useTable } from "@/hooks/core/useTable";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
import type { ColumnOption } from "@/types/component";
import TenantAPI, {
type TenantCreateForm,
type TenantForm,
@@ -214,22 +213,12 @@ import TenantAPI, {
type TenantUpdateForm,
} from "@/api/module_platform/tenant";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaUpload from "@/components/others/fa-upload/index.vue";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import {
ElTag,
ElMessage,
ElTabs,
ElTabPane,
ElForm,
ElFormItem,
ElRow,
ElCol,
} from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import { ElMessage, ElTabs, ElTabPane, ElForm, ElFormItem, ElRow, ElCol } from "element-plus";
import { h, ref } from "vue";
defineOptions({
@@ -242,7 +231,7 @@ const { hasAuth } = useAuth();
type TenantSearchForm = {
name?: string;
code?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -420,19 +409,19 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<TenantTable>[] => [
columnsFactory: resolveStatusColumns<TenantTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "租户名称", minWidth: 120, showOverflowTooltip: true },
{ prop: "code", label: "租户编码", minWidth: 80, showOverflowTooltip: true },
{ prop: "code", label: "租户编码", minWidth: 120, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 80,
formatter: (row: TenantTable) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "正常" : "禁用"
),
status: {
0: { type: "success", text: "正常" },
1: { type: "danger", text: "禁用" },
},
},
{ prop: "contact_name", label: "联系人", minWidth: 100, showOverflowTooltip: true },
{ prop: "contact_phone", label: "联系电话", width: 110, showOverflowTooltip: true },
@@ -448,7 +437,7 @@ const {
formatter: (row: TenantTable) =>
renderTableOperationCell(buildTenantRowActions(row, opCtx)),
},
],
]),
},
});
@@ -834,9 +823,9 @@ function onResetSearch() {
async function handleSubmit() {
const formRef = dataFormRef.value;
if (!formRef) return;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const valid = await formRef.validate().catch(() => false);
const valid = await (formRef as unknown as { validate: () => Promise<boolean> })
.validate()
.catch(() => false);
if (!valid) return;
submitLoading.value = true;
const id = formData.value.id as number | undefined;
@@ -24,7 +24,7 @@
>
<ElScrollbar>
<div
class="login-page-panel__scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-6 [-webkit-overflow-scrolling:touch]"
class="login-page-panel__scroll pb-6"
:class="panelAlign === 'center' && 'login-page-panel__scroll--centered'"
>
<div
@@ -176,7 +176,6 @@
</template>
<script setup lang="ts">
import { ElScrollbar } from "element-plus";
import type { LocationQuery, RouteLocationRaw } from "vue-router";
import AuthAPI, {
type CaptchaInfo,
@@ -189,13 +188,13 @@ import { useConfigStore, useAppStore, useSettingsStore, useUserStore } from "@st
import { Auth, HttpError, startOAuthLogin } from "@utils";
import { ElMessage, ElNotification, type FormRules } from "element-plus";
import type { Account, AccountKey } from "./types";
import FaLoginAccountForm from "@/components/views/fa-login/FaLoginAccountForm.vue";
import FaLoginForgetPanel from "@/components/views/fa-login/FaLoginForgetPanel.vue";
import FaLoginMobilePanel from "@/components/views/fa-login/FaLoginMobilePanel.vue";
import FaLoginQrPanel from "@/components/views/fa-login/FaLoginQrPanel.vue";
import FaLoginRegisterPanel from "@/components/views/fa-login/FaLoginRegisterPanel.vue";
import FaAuthTopBar from "@/components/views/fa-login/FaAuthTopBar.vue";
import { useLoginPanelAlign } from "@/components/views/fa-login/useLoginPanelAlign";
import FaLoginAccountForm from "@/components/views/fa-login/forms/FaLoginAccountForm.vue";
import FaLoginForgetPanel from "@/components/views/fa-login/panels/FaLoginForgetPanel.vue";
import FaLoginMobilePanel from "@/components/views/fa-login/panels/FaLoginMobilePanel.vue";
import FaLoginQrPanel from "@/components/views/fa-login/panels/FaLoginQrPanel.vue";
import FaLoginRegisterPanel from "@/components/views/fa-login/panels/FaLoginRegisterPanel.vue";
import FaAuthTopBar from "@/components/views/fa-login/widgets/FaAuthTopBar.vue";
import { useLoginPanelAlign } from "@/components/views/fa-login/composables/useLoginPanelAlign";
defineOptions({ name: "Login" });
@@ -681,5 +680,5 @@ async function submitForget() {
</script>
<style scoped lang="scss">
@use "@styles/custom/fa-login";
@use "../../../../components/views/fa-login/fa-login";
</style>
@@ -119,12 +119,17 @@ import DeptAPI, {
} from "@/api/module_system/dept";
import { useAuth } from "@/hooks/core/useAuth";
import { useUserStore } from "@stores";
import { formatTree, renderTableOperationCell, type TableOperationAction } from "@utils";
import {
formatTree,
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
} from "@utils";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag, ElMessage } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import { ElMessage } from "element-plus";
defineOptions({
name: "Dept",
@@ -136,7 +141,7 @@ const userStore = useUserStore();
type DeptSearchForm = {
name?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -381,33 +386,35 @@ const opCtx = {
onDelete: deleteDeptRow,
};
const { columnChecks, columns } = useTableColumns<DeptTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "部门名称", minWidth: 120, showOverflowTooltip: true },
{ prop: "code", label: "部门编码", minWidth: 120, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 88,
formatter: (row: DeptTable) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
},
{ prop: "order", label: "排序", width: 88, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 220,
fixed: "right",
align: "right",
formatter: (row: DeptTable) => formatDeptOperationCell(row, opCtx),
},
]);
const { columnChecks, columns } = useTableColumns<DeptTable>(
resolveStatusColumns(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "部门名称", minWidth: 120, showOverflowTooltip: true },
{ prop: "code", label: "部门编码", minWidth: 120, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 88,
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "order", label: "排序", width: 88, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
{
prop: "operation",
label: "操作",
width: 220,
fixed: "right",
align: "right",
formatter: (row: DeptTable) => formatDeptOperationCell(row, opCtx),
},
])
);
const deptDialogFormItems = computed<FormItem[]>(() => [
{
@@ -196,6 +196,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -209,15 +210,16 @@ import DictAPI, {
type DictDataTable,
} from "@/api/module_system/dict";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import { useAppStore, useDictStore } from "@stores";
import { DeviceEnum } from "@/enums/settings/device.enum";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag, ElMessage } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import { ElMessage } from "element-plus";
defineOptions({ name: "DictDataDrawer", inheritAttrs: false });
@@ -281,7 +283,7 @@ function getTagPreviewStyle(value?: string) {
type DictDataSearchForm = {
dict_label?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -392,7 +394,7 @@ const {
dict_type: props.dictType,
dict_type_id: props.dictTypeId,
},
columnsFactory: (): ColumnOption<DictDataTable>[] => [
columnsFactory: resolveStatusColumns<DictDataTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "dict_label", label: "标签", minWidth: 150, showOverflowTooltip: true },
@@ -400,19 +402,17 @@ const {
prop: "status",
label: "状态",
width: 100,
formatter: (row: DictDataTable) => {
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "danger" as const, text: "停用" };
return h(ElTag, { type: cfg.type }, () => cfg.text);
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{
prop: "dict_type",
label: "类型",
minWidth: 180,
formatter: (row: DictDataTable) => h(ElTag, { type: "primary" }, () => row.dict_type ?? ""),
formatter: (row: DictDataTable) =>
h(FaStatusTag, { type: "primary", label: row.dict_type ?? "" }),
},
{ prop: "dict_value", label: "值", minWidth: 100, showOverflowTooltip: true },
{ prop: "css_class", label: "样式属性", minWidth: 100, showOverflowTooltip: true },
@@ -422,10 +422,10 @@ const {
prop: "is_default",
label: "是否默认",
width: 100,
formatter: (row: DictDataTable) =>
h(ElTag, { type: row.is_default ? "success" : "danger" }, () =>
row.is_default ? "是" : "否"
),
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
@@ -438,7 +438,7 @@ const {
align: "right",
formatter: (row: DictDataTable) => formatDictDataOperationCell(row),
},
],
]),
},
});
@@ -75,7 +75,7 @@
max-height="70vh"
>
<template #dict_type="{ row }">
<ElTag type="primary">{{ row?.dict_type }}</ElTag>
<FaStatusTag type="primary" :label="(row as unknown as DictTable)?.dict_type" />
</template>
</FaDescriptions>
</template>
@@ -126,6 +126,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -141,14 +142,15 @@ import DictAPI, {
} from "@/api/module_system/dict";
import { useDictStore } from "@stores";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import type FaForm from "@/components/forms/fa-form/index.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import DataDrawer from "./components/DataDrawer.vue";
import { ElTag, ElMessage } from "element-plus";
import { ElMessage } from "element-plus";
defineOptions({
name: "Dict",
@@ -158,7 +160,7 @@ defineOptions({
type DictTypeSearchForm = {
dict_name?: string;
dict_type?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -364,7 +366,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<DictTable>[] => [
columnsFactory: resolveStatusColumns<DictTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "dict_name", label: "字典名称", minWidth: 140, showOverflowTooltip: true },
@@ -372,18 +374,16 @@ const {
prop: "dict_type",
label: "字典类型",
minWidth: 180,
formatter: (row: DictTable) => h(ElTag, { type: "primary" }, () => row.dict_type ?? ""),
formatter: (row: DictTable) =>
h(FaStatusTag, { type: "primary", label: row.dict_type ?? "" }),
},
{
prop: "status",
label: "状态",
width: 88,
formatter: (row: DictTable) => {
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "danger" as const, text: "停用" };
return h(ElTag, { type: cfg.type }, () => cfg.text);
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "description", label: "描述", minWidth: 140, showOverflowTooltip: true },
@@ -397,7 +397,7 @@ const {
align: "right",
formatter: (row: DictTable) => formatDictOperationCell(row),
},
],
]),
},
});
@@ -90,10 +90,16 @@
}}</ElTag>
</template>
<template #request_payload="{ row }">
<FaJsonPretty :value="row?.request_payload as any" height="80px" />
<FaJsonPretty
:value="(row as unknown as OperationLogTable)?.request_payload"
height="80px"
/>
</template>
<template #response_json="{ row }">
<FaJsonPretty :value="row?.response_json as any" height="140px" />
<FaJsonPretty
:value="(row as unknown as OperationLogTable)?.response_json"
height="140px"
/>
</template>
</FaDescriptions>
</FaDialog>
@@ -187,6 +193,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -201,13 +208,17 @@ import OperationLogAPI, {
LoginLogAPI,
} from "@/api/module_system/log";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import {
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
type StatusType,
} from "@utils";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import FaUserTableSelect from "@/components/forms/fa-search-bar/FaUserTableSelect.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import FaCopyButton from "@/components/others/fa-copy-button/index.vue";
import { ElTag } from "element-plus";
defineOptions({
name: "Log",
@@ -306,16 +317,20 @@ const {
label: "请求方法",
minWidth: 100,
formatter: (row: OperationLogTable) =>
h(ElTag, { type: getMethodType(row.request_method) }, () => row.request_method ?? ""),
h(FaStatusTag, {
type: getMethodType(row.request_method),
label: row.request_method ?? "",
}),
},
{
prop: "response_code",
label: "状态码",
minWidth: 100,
formatter: (row: OperationLogTable) =>
h(ElTag, { type: getStatusCodeType(row.response_code) }, () =>
String(row.response_code ?? "")
),
h(FaStatusTag, {
type: getStatusCodeType(row.response_code),
label: String(row.response_code ?? ""),
}),
},
{ prop: "process_time", label: "处理时间", minWidth: 120 },
{ prop: "description", label: "描述", minWidth: 120, showOverflowTooltip: true },
@@ -351,11 +366,11 @@ const opExportContentConfig = computed(() => ({
permPrefix: "module_system:log",
cols: opCrudCols.value,
exportsBlobAction: async (params: IObject) => {
const res = await (OperationLogAPI as any).exportLog(
const res = await OperationLogAPI.export(
cleanEmptyArrayParams({
...(opExportQueryParams.value as unknown as Record<string, unknown>),
...params,
})
}) as OperationLogPageQuery
);
return res.data as Blob;
},
@@ -535,17 +550,17 @@ const {
core: {
apiFn: LoginLogAPI.list,
apiParams: { page_no: 1, page_size: 10 },
columnsFactory: (): ColumnOption<LoginLogTable>[] => [
columnsFactory: resolveStatusColumns<LoginLogTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{
prop: "status",
label: "登录状态",
width: 88,
formatter: (row: LoginLogTable) =>
h(ElTag, { type: row.status === 1 ? "success" : "danger" }, () =>
row.status === 1 ? "成功" : "失败"
),
status: {
1: { type: "success", text: "成功" },
0: { type: "danger", text: "失败" },
},
},
{ prop: "username", label: "用户名", minWidth: 120, showOverflowTooltip: true },
{
@@ -573,7 +588,7 @@ const {
align: "right",
formatter: (row: LoginLogTable) => formatLoginActionCell(row),
},
],
]),
},
});
@@ -685,14 +700,14 @@ watch(activeTab, (tab) => {
// ==================== 通用 ====================
function getStatusCodeType(code?: number) {
function getStatusCodeType(code?: number): StatusType {
if (code === undefined) return "info";
if (code >= 200 && code < 300) return "success";
if (code >= 300 && code < 400) return "warning";
return "danger";
}
function getMethodType(method?: string) {
function getMethodType(method?: string): StatusType {
if (method === undefined) return "info";
if (method === "GET") return "info";
if (method === "POST") return "success";
@@ -85,17 +85,18 @@
max-height="75vh"
>
<template #notice_type="{ row }">
<ElTag :type="row?.notice_type === '1' ? 'primary' : 'warning'">
{{ noticeTypeLabel(row?.notice_type as string) }}
</ElTag>
<FaStatusTag
:type="row?.notice_type === '1' ? 'primary' : 'warning'"
:label="noticeTypeLabel(row?.notice_type as string)"
/>
</template>
<template #notice_content>
<div class="notice-html-preview">
<ElScrollbar class="notice-html-preview" view-class="p-3">
<template v-if="detailHasRenderableContent">
<div v-html="detailContentHtml" />
</template>
<p v-else class="notice-html-empty">暂无内容</p>
</div>
</ElScrollbar>
</template>
</FaDescriptions>
</template>
@@ -161,15 +162,14 @@ import NoticeAPI, {
type NoticeTable,
} from "@/api/module_system/notice";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import { useDictStore, useNoticeStore } from "@stores";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaUserTableSelect from "@/components/forms/fa-search-bar/FaUserTableSelect.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag, ElMessage } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import { ElMessage, ElScrollbar } from "element-plus";
import DOMPurify from "dompurify";
defineOptions({
@@ -184,7 +184,7 @@ const { hasAuth } = useAuth();
type NoticeSearchForm = {
notice_title?: string;
notice_type?: string;
status?: string;
status?: number;
created_time?: string[];
created_id?: number;
};
@@ -457,7 +457,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<NoticeTable>[] => [
columnsFactory: resolveStatusColumns<NoticeTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "notice_title", label: "通知标题", minWidth: 140, showOverflowTooltip: true },
@@ -465,22 +465,19 @@ const {
prop: "status",
label: "状态",
width: 88,
formatter: (row: NoticeTable) => {
const ok = row.status === 0;
const cfg = ok
? { type: "success" as const, text: "启用" }
: { type: "danger" as const, text: "停用" };
return h(ElTag, { type: cfg.type }, () => cfg.text);
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{
prop: "notice_type",
label: "类型",
minWidth: 100,
formatter: (row: NoticeTable) =>
h(ElTag, { type: row.notice_type === "1" ? "primary" : "warning" }, () =>
noticeTypeLabel(row.notice_type)
),
status: {
"1": { type: "primary", text: "通知" },
"2": { type: "warning", text: "公告" },
},
},
{ prop: "notice_content", label: "内容", minWidth: 200, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 140, showOverflowTooltip: true },
@@ -506,7 +503,7 @@ const {
align: "right",
formatter: (row: NoticeTable) => formatNoticeOperationCell(row),
},
],
]),
},
});
@@ -678,8 +675,6 @@ onMounted(async () => {
box-sizing: border-box;
min-height: 120px;
max-height: min(360px, 45vh);
padding: 12px 16px;
overflow-y: auto;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: calc(var(--custom-radius) / 3 + 2px);
@@ -130,10 +130,9 @@ import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { useConfigStore } from "@stores";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
defineOptions({
name: "Params",
@@ -386,10 +385,10 @@ const {
prop: "config_type",
label: "系统内置",
minWidth: 100,
formatter: (row: ConfigTable) =>
h(ElTag, { type: row.config_type ? "success" : "danger" }, () =>
row.config_type ? "是" : "否"
),
status: {
true: { type: "success", text: "是" },
false: { type: "danger", text: "否" },
},
},
{ prop: "description", label: "描述", minWidth: 120, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
@@ -123,6 +123,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -140,19 +141,12 @@ import { useAuth } from "@/hooks/core/useAuth";
import { useUserStore } from "@stores";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaUserTableSelect from "@/components/forms/fa-search-bar/FaUserTableSelect.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import type FaForm from "@/components/forms/fa-form/index.vue";
import FaButtonTable from "@/components/forms/fa-button-table/index.vue";
import {
ElTag,
ElMessage,
ElTooltip,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
} from "element-plus";
import { resolveStatusColumns } from "@utils";
import { ElMessage, ElTooltip, ElDropdown, ElDropdownMenu, ElDropdownItem } from "element-plus";
defineOptions({
name: "Position",
@@ -165,7 +159,7 @@ const userStore = useUserStore();
type PositionSearchForm = {
name?: string;
status?: string;
status?: number;
created_time?: string[];
created_id?: number;
};
@@ -390,7 +384,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<PositionTable>[] => [
columnsFactory: resolveStatusColumns<PositionTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "岗位名称", minWidth: 100, showOverflowTooltip: true },
@@ -398,10 +392,10 @@ const {
prop: "status",
label: "状态",
width: 88,
formatter: (row: PositionTable) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "order", label: "岗位排序", width: 100, showOverflowTooltip: true },
{ prop: "description", label: "描述", minWidth: 120, showOverflowTooltip: true },
@@ -427,7 +421,7 @@ const {
align: "right",
formatter: (row: PositionTable) => formatPositionOperationCell(row, opCtx),
},
],
]),
},
});
@@ -109,10 +109,9 @@
import { computed, ref, watch, onMounted, nextTick } from "vue";
import { QuestionFilled } from "@element-plus/icons-vue";
import type { TreeInstance } from "element-plus";
import FaDrawer from "@/components/modal/fa-drawer/index.vue";
import FaMenuTreeTable from "@/components/others/fa-menu-tree-drawer/index.vue";
import { listToTree, formatTree } from "@utils";
import RoleAPI, { permissionDataType, permissionDeptType } from "@/api/module_system/role";
import { listToTree, formatTree, type CascaderNode } from "@utils";
import FaMenuTreeTable from "@/components/others/fa-menu-tree-table/index.vue";
import RoleAPI, { permissionDataType } from "@/api/module_system/role";
import DeptAPI from "@/api/module_system/dept";
import MenuAPI, { MenuTable } from "@/api/module_platform/menu";
import { DeviceEnum } from "@/enums/settings/device.enum";
@@ -146,7 +145,7 @@ const deptTreeRef = ref<TreeInstance>();
const deptFilterText = ref("");
const dataFormRef = ref();
const loading = ref(false);
const deptTreeData = ref<permissionDeptType[]>([]);
const deptTreeData = ref<CascaderNode[]>([]);
const rawMenuTree = ref<MenuTable[]>([]);
const menuCheckedIds = ref<number[]>([]);
const menuTreeTableRef = ref<InstanceType<typeof FaMenuTreeTable>>();
@@ -1,621 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
/* ============================================================
测试数据:与组件中 MenuTable 结构一致
============================================================ */
const DIR_TYPE = 1;
const MENU_TYPE = 2;
const BTN_TYPE = 3;
const LINK_TYPE = 4;
const PERM_TYPES = [BTN_TYPE, LINK_TYPE];
type MenuTable = {
id?: number;
name?: string;
type?: number;
icon?: string;
children?: MenuTable[];
[key: string]: any;
};
function isPermType(type: number | undefined): boolean {
return type != null && PERM_TYPES.includes(type);
}
function filterTableData(nodes: MenuTable[]): MenuTable[] {
return nodes
.filter((n) => n.type === DIR_TYPE || n.type === MENU_TYPE)
.map((n) => ({
...n,
children: n.children ? filterTableData(n.children) : undefined,
}));
}
function findTableNode(id: number, nodes: MenuTable[]): MenuTable | undefined {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children) {
const found = findTableNode(id, node.children);
if (found) return found;
}
}
return undefined;
}
function findRawNode(id: number, nodes: any[]): any | undefined {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children) {
const found = findRawNode(id, node.children);
if (found) return found;
}
}
return undefined;
}
function findAncestors(nodeId: number, nodes: any[]): any[] {
for (const node of nodes) {
if ((node.type === DIR_TYPE || node.type === MENU_TYPE) && node.children) {
const childIndex = node.children.findIndex((c: any) => c.id === nodeId);
if (childIndex !== -1) {
const tableNode = findTableNode(node.id!, mockTableData);
const parentAncestors = findAncestors(node.id!, mockRawTree);
if (tableNode) {
return [tableNode, ...parentAncestors];
}
return parentAncestors;
}
const ancestors = findAncestors(nodeId, node.children);
if (ancestors.length > 0) {
return ancestors;
}
}
}
return [];
}
function getMenuBtns(row: MenuTable, rawTree: MenuTable[]): MenuTable[] {
const fullNode = findRawNode(row.id!, rawTree);
if (!fullNode?.children) return [];
return (fullNode.children as MenuTable[]).filter((c) => isPermType(c.type));
}
function findParentMenu(menuId: number, menuTreeData: MenuTable[]): MenuTable | null {
for (const menu of menuTreeData) {
if (menu.children) {
for (const child of menu.children) {
if (child.id === menuId) return menu;
const found = findParentMenu(menuId, [child]);
if (found) return found;
}
}
}
return null;
}
function hasAnySelectedDescendant(
node: MenuTable,
checkedBtns: Record<number, number[]>,
tableData: MenuTable[]
): boolean {
if (checkedBtns[node.id!]?.length > 0) return true;
const tableNode = findTableNode(node.id!, tableData);
if (tableNode?.children) {
for (const child of tableNode.children) {
if (
(child.type === MENU_TYPE || child.type === DIR_TYPE) &&
hasAnySelectedDescendant(child, checkedBtns, tableData)
) {
return true;
}
}
}
return false;
}
function checkParentChildLinked(menuIds: number[], menuTreeData: MenuTable[]): boolean {
if (!menuIds.length || !menuTreeData.length) return false;
const menuMap = new Map<number, MenuTable>();
const buildMenuMap = (menus: MenuTable[]) => {
menus.forEach((menu) => {
menuMap.set(menu.id!, menu);
if (menu.children) buildMenuMap(menu.children);
});
};
buildMenuMap(menuTreeData);
let hasParentChildConflict = false;
for (const menuId of menuIds) {
const menu = menuMap.get(menuId);
if (!menu) continue;
if (menu.children && menu.children.length > 0) {
const hasUnselectedChildren = menu.children.some((child) => !menuIds.includes(child.id!));
if (hasUnselectedChildren) {
hasParentChildConflict = true;
break;
}
}
const parentMenu = findParentMenu(menuId, menuTreeData);
if (parentMenu && !menuIds.includes(parentMenu.id!)) {
hasParentChildConflict = true;
break;
}
}
return !hasParentChildConflict;
}
function expandMenuIdsWithAncestors(checkedIds: number[], roots: MenuTable[]): number[] {
const parentById = new Map<number, number | undefined>();
const walk = (nodes: MenuTable[], parent: number | undefined) => {
for (const n of nodes) {
const id = n.id!;
parentById.set(id, parent);
if (n.children?.length) walk(n.children as MenuTable[], id);
}
};
walk(roots, undefined);
const out = new Set<number>();
for (const id of checkedIds) {
let cur: number | undefined = id;
while (cur !== undefined) {
out.add(cur);
cur = parentById.get(cur);
}
}
return [...out];
}
/* ============================================================
测试数据
============================================================ */
const mockRawTree: MenuTable[] = [
{
id: 1,
name: "系统管理",
type: DIR_TYPE,
icon: "Setting",
children: [
{
id: 2,
name: "用户管理",
type: MENU_TYPE,
children: [
{ id: 21, name: "新增用户", type: BTN_TYPE },
{ id: 22, name: "编辑用户", type: BTN_TYPE },
{ id: 23, name: "删除用户", type: BTN_TYPE },
],
},
{
id: 3,
name: "角色管理",
type: MENU_TYPE,
children: [
{ id: 31, name: "新增角色", type: BTN_TYPE },
{ id: 32, name: "编辑角色", type: BTN_TYPE },
],
},
{ id: 4, name: "菜单管理", type: MENU_TYPE, children: [] },
],
},
{
id: 5,
name: "业务管理",
type: DIR_TYPE,
icon: "DataAnalysis",
children: [
{
id: 6,
name: "订单管理",
type: MENU_TYPE,
children: [
{ id: 61, name: "查看订单", type: BTN_TYPE },
{ id: 62, name: "导出订单", type: LINK_TYPE },
],
},
],
},
];
const mockTableData = filterTableData(mockRawTree);
/* ============================================================
Test Suite: 纯逻辑函数
============================================================ */
describe("纯逻辑函数", () => {
describe("isPermType", () => {
it("应识别按钮类型", () => {
expect(isPermType(BTN_TYPE)).toBe(true);
});
it("应识别链接类型", () => {
expect(isPermType(LINK_TYPE)).toBe(true);
});
it("应排除目录类型", () => {
expect(isPermType(DIR_TYPE)).toBe(false);
});
it("应排除菜单类型", () => {
expect(isPermType(MENU_TYPE)).toBe(false);
});
it("应处理 undefined", () => {
expect(isPermType(undefined)).toBe(false);
});
});
describe("filterTableData", () => {
it("应过滤掉所有按钮和链接节点", () => {
const result = filterTableData(mockRawTree);
const ids = flattenIds(result);
expect(ids).not.toContain(21);
expect(ids).not.toContain(22);
expect(ids).not.toContain(31);
expect(ids).not.toContain(61);
expect(ids).not.toContain(62);
});
it("应保留目录和菜单节点", () => {
const result = filterTableData(mockRawTree);
const ids = flattenIds(result);
expect(ids).toContain(1);
expect(ids).toContain(2);
expect(ids).toContain(3);
expect(ids).toContain(4);
expect(ids).toContain(5);
expect(ids).toContain(6);
});
});
describe("findTableNode", () => {
it("应找到根节点", () => {
const node = findTableNode(1, mockTableData);
expect(node).toBeDefined();
expect(node!.name).toBe("系统管理");
});
it("应找到深层节点", () => {
const node = findTableNode(3, mockTableData);
expect(node).toBeDefined();
expect(node!.name).toBe("角色管理");
});
it("找不到时返回 undefined", () => {
expect(findTableNode(999, mockTableData)).toBeUndefined();
});
it("按钮ID应返回 undefined(已在过滤后的数据中移除)", () => {
expect(findTableNode(21, mockTableData)).toBeUndefined();
});
});
describe("findRawNode", () => {
it("应找到原始树中的按钮节点", () => {
const node = findRawNode(21, mockRawTree);
expect(node).toBeDefined();
expect(node!.name).toBe("新增用户");
});
it("应找到原始树中的根节点", () => {
const node = findRawNode(1, mockRawTree);
expect(node).toBeDefined();
expect(node!.name).toBe("系统管理");
});
it("找不到时返回 undefined", () => {
expect(findRawNode(999, mockRawTree)).toBeUndefined();
});
});
describe("findAncestors", () => {
it("应返回节点 2 的祖先 [节点 1]", () => {
const ancestors = findAncestors(2, mockRawTree);
expect(ancestors.length).toBe(1);
expect(ancestors[0].id).toBe(1);
});
it("应返回节点 6 的祖先 [节点 5]", () => {
const ancestors = findAncestors(6, mockRawTree);
expect(ancestors.length).toBe(1);
expect(ancestors[0].id).toBe(5);
});
it("根节点无祖先", () => {
const ancestors = findAncestors(1, mockRawTree);
expect(ancestors.length).toBe(0);
});
it("按钮节点 21 的祖先链应包含节点 1", () => {
const ancestors = findAncestors(21, mockRawTree);
const ancestorIds = ancestors.map((n) => n.id);
expect(ancestorIds).toContain(1);
expect(ancestorIds).toContain(2);
});
});
describe("getMenuBtns", () => {
it("应返回菜单 2 下的按钮列表", () => {
const row = findTableNode(2, mockTableData)!;
const btns = getMenuBtns(row, mockRawTree);
expect(btns.length).toBe(3);
expect(btns.map((b) => b.id)).toEqual([21, 22, 23]);
});
it("菜单 4(无子节点)应返回空数组", () => {
const row = findTableNode(4, mockTableData)!;
const btns = getMenuBtns(row, mockRawTree);
expect(btns.length).toBe(0);
});
});
describe("findParentMenu", () => {
it("应找到按钮 21 的父菜单为 2", () => {
const parent = findParentMenu(21, mockRawTree);
expect(parent).not.toBeNull();
expect(parent!.id).toBe(2);
});
it("应找到菜单 2 的父目录为 1", () => {
const parent = findParentMenu(2, mockRawTree);
expect(parent).not.toBeNull();
expect(parent!.id).toBe(1);
});
it("根节点无父节点", () => {
expect(findParentMenu(1, mockRawTree)).toBeNull();
});
});
describe("hasAnySelectedDescendant", () => {
it("节点自身有选中按钮时应返回 true", () => {
const checkedBtns: Record<number, number[]> = { 2: [21] };
const node = findTableNode(2, mockTableData)!;
expect(hasAnySelectedDescendant(node, checkedBtns, mockTableData)).toBe(true);
});
it("子节点有选中按钮时应返回 true", () => {
const checkedBtns: Record<number, number[]> = { 3: [31] };
const node = findTableNode(1, mockTableData)!;
expect(hasAnySelectedDescendant(node, checkedBtns, mockTableData)).toBe(true);
});
it("无选中时应返回 false", () => {
const checkedBtns: Record<number, number[]> = {};
const node = findTableNode(2, mockTableData)!;
expect(hasAnySelectedDescendant(node, checkedBtns, mockTableData)).toBe(false);
});
});
describe("checkParentChildLinked", () => {
it("父子节点全部选中时返回 true", () => {
const ids = [1, 2, 3, 4, 5, 6, 21, 22, 23, 31, 32, 61, 62];
expect(checkParentChildLinked(ids, mockRawTree)).toBe(true);
});
it("子选中但父未选中时返回 false", () => {
const ids = [2];
expect(checkParentChildLinked(ids, mockRawTree)).toBe(false);
});
it("父选中但子未完全选中时返回 false", () => {
const ids = [1, 2];
// 节点 2 有三个子按钮未选
expect(checkParentChildLinked(ids, mockRawTree)).toBe(false);
});
it("空数组返回 false", () => {
expect(checkParentChildLinked([], mockRawTree)).toBe(false);
});
});
describe("expandMenuIdsWithAncestors", () => {
it("应补充所有祖先节点到结果中", () => {
const ids = expandMenuIdsWithAncestors([21], mockRawTree);
expect(ids).toContain(21);
expect(ids).toContain(2);
expect(ids).toContain(1);
});
it("根节点本身应保留", () => {
const ids = expandMenuIdsWithAncestors([1], mockRawTree);
expect(ids).toEqual([1]);
});
});
});
/* ============================================================
Test Suite: handleSelectAll 行为
============================================================ */
describe("handleSelectAll 行为模拟", () => {
let checkedBtns: Record<number, number[]>;
let selectedMenuIds: Set<number>;
function simulateHandleSelectAll(selectionIds: number[], parentChildLinked: boolean) {
const selection = selectionIds
.map((id) => findTableNode(id, mockTableData))
.filter(Boolean) as MenuTable[];
if (!selection.length) {
checkedBtns = {};
selectedMenuIds.clear();
} else if (parentChildLinked) {
for (const row of selection) {
if (row.type === DIR_TYPE || row.type === MENU_TYPE) {
selectedMenuIds.add(row.id!);
const btns = getMenuBtns(row, mockRawTree);
if (btns.length > 0) {
checkedBtns[row.id!] = btns.map((b) => b.id!);
}
}
}
} else {
for (const row of selection) {
if (row.type === DIR_TYPE || row.type === MENU_TYPE) {
selectedMenuIds.add(row.id!);
}
}
}
}
beforeEach(() => {
checkedBtns = {};
selectedMenuIds = new Set();
});
describe("取消全选", () => {
it("选中行为空时应清空所有按钮状态", () => {
// 预设一些按钮选中状态
checkedBtns = { 2: [21, 22], 6: [61] };
selectedMenuIds = new Set([2, 6]);
simulateHandleSelectAll([], false);
expect(Object.keys(checkedBtns).length).toBe(0);
expect(selectedMenuIds.size).toBe(0);
});
it("即使有父子联动,取消全选也应清空按钮", () => {
checkedBtns = { 2: [21, 22, 23], 3: [31, 32] };
selectedMenuIds = new Set([1, 2, 3]);
simulateHandleSelectAll([], true);
expect(Object.keys(checkedBtns).length).toBe(0);
expect(selectedMenuIds.size).toBe(0);
});
it("选中行为空时再次取消全选应保持清空状态", () => {
simulateHandleSelectAll([], false);
expect(Object.keys(checkedBtns).length).toBe(0);
expect(selectedMenuIds.size).toBe(0);
});
});
describe("全选(无父子联动)", () => {
it("应选中所有菜单行但不清空按钮", () => {
simulateHandleSelectAll([1, 2, 3, 4, 5, 6], false);
// 所有菜单行应被记录
expect(selectedMenuIds.has(1)).toBe(true);
expect(selectedMenuIds.has(2)).toBe(true);
expect(selectedMenuIds.has(6)).toBe(true);
// 无联动时不应自动收集按钮
expect(checkedBtns[2]).toBeUndefined();
expect(checkedBtns[6]).toBeUndefined();
});
});
describe("全选(有父子联动)", () => {
it("应为每个菜单行收集其下的功能按钮", () => {
simulateHandleSelectAll([1, 2, 3, 4, 5, 6], true);
// 应收集菜单 2 的按钮
expect(checkedBtns[2]).toEqual([21, 22, 23]);
// 应收集菜单 3 的按钮
expect(checkedBtns[3]).toEqual([31, 32]);
// 应收集菜单 6 的按钮(含链接类型)
expect(checkedBtns[6]).toEqual([61, 62]);
// 目录 1 和 5 没有功能按钮,应为空数组或 undefined
expect(checkedBtns[1]).toBeUndefined();
});
it("菜单下无按钮时不应设置 checkedBtns", () => {
simulateHandleSelectAll([4], true);
expect(checkedBtns[4]).toBeUndefined();
});
});
describe("部分选择", () => {
it("只选中部分菜单行时只收集这些行的按钮", () => {
simulateHandleSelectAll([2, 6], true);
expect(selectedMenuIds.has(2)).toBe(true);
expect(selectedMenuIds.has(6)).toBe(true);
expect(selectedMenuIds.has(1)).toBe(false);
expect(checkedBtns[2]).toEqual([21, 22, 23]);
expect(checkedBtns[6]).toEqual([61, 62]);
// 未选中的菜单不应有按钮
expect(checkedBtns[3]).toBeUndefined();
});
});
});
/* ============================================================
Test Suite: handleSelectionChange 行为(安全网)
============================================================ */
describe("handleSelectionChange 安全网", () => {
let checkedBtns: Record<number, number[]>;
function simulateHandleSelectionChange(selectionIds: number[]) {
const newSelected = new Set(selectionIds);
if (newSelected.size === 0) {
checkedBtns = {};
}
}
beforeEach(() => {
checkedBtns = { 2: [21, 22], 3: [31] };
});
it("选中行为空时应清空按钮状态", () => {
simulateHandleSelectionChange([]);
expect(Object.keys(checkedBtns).length).toBe(0);
});
it("选中行非空时不应清空按钮", () => {
simulateHandleSelectionChange([2]);
expect(checkedBtns[2]).toEqual([21, 22]);
expect(checkedBtns[3]).toEqual([31]);
});
it("部分选中不应影响按钮状态", () => {
simulateHandleSelectionChange([2, 3]);
expect(checkedBtns[2]).toEqual([21, 22]);
expect(checkedBtns[3]).toEqual([31]);
});
});
/* ============================================================
Test Suite: collectBtnState + cascadeDeselect
============================================================ */
describe("collectBtnState / cascadeDeselect", () => {
it("collectBtnState 应为有按钮的行收集按钮 ID", () => {
const row = findTableNode(2, mockTableData)!;
const btns = getMenuBtns(row, mockRawTree);
expect(btns.length).toBeGreaterThan(0);
const checkedBtns: Record<number, number[]> = {};
checkedBtns[row.id!] = btns.map((b) => b.id!);
expect(checkedBtns[2]).toEqual([21, 22, 23]);
});
it("cascadeDeselect 应清空指定行及其子行的按钮", () => {
const checkedBtns: Record<number, number[]> = {
2: [21, 22, 23],
3: [31, 32],
};
// 模拟级联取消: 清空节点 1 下所有子菜单的按钮
function cascadeDeselect(id: number) {
delete checkedBtns[id];
const node = findTableNode(id, mockTableData);
if (node?.children) {
for (const child of node.children) {
if (child.type === MENU_TYPE || child.type === DIR_TYPE) {
cascadeDeselect(child.id!);
}
}
}
}
cascadeDeselect(1);
expect(Object.keys(checkedBtns).length).toBe(0);
});
it("级联取消不应影响无关行的按钮", () => {
const checkedBtns: Record<number, number[]> = {
2: [21, 22],
6: [61],
};
function cascadeDeselect(id: number) {
delete checkedBtns[id];
const node = findTableNode(id, mockTableData);
if (node?.children) {
for (const child of node.children) {
if (child.type === MENU_TYPE || child.type === DIR_TYPE) {
cascadeDeselect(child.id!);
}
}
}
}
cascadeDeselect(2);
// 节点 2 的按钮被清空
expect(checkedBtns[2]).toBeUndefined();
// 节点 6 不受影响
expect(checkedBtns[6]).toEqual([61]);
});
});
/* ============================================================
辅助函数
============================================================ */
function flattenIds(nodes: MenuTable[]): number[] {
const ids: number[] = [];
const walk = (list: MenuTable[]) => {
list.forEach((n) => {
ids.push(n.id!);
if (n.children) walk(n.children);
});
};
walk(nodes);
return ids;
}
@@ -75,16 +75,25 @@
max-height="75vh"
>
<template #data_scope="{ row }">
<ElTag v-if="row?.data_scope === 1" type="primary">仅本人数据权限</ElTag>
<ElTag v-else-if="row?.data_scope === 2" type="info">本部门数据权限</ElTag>
<ElTag v-else-if="row?.data_scope === 3" type="warning">本部门及以下数据权限</ElTag>
<ElTag v-else-if="row?.data_scope === 4" type="success">全部数据权限</ElTag>
<ElTag v-else type="danger">自定义数据权限</ElTag>
<FaStatusTag v-if="row?.data_scope === 1" type="primary" label="仅本人数据权限" />
<FaStatusTag v-else-if="row?.data_scope === 2" type="info" label="本部门数据权限" />
<FaStatusTag
v-else-if="row?.data_scope === 3"
type="warning"
label="本部门及以下数据权限"
/>
<FaStatusTag v-else-if="row?.data_scope === 4" type="success" label="全部数据权限" />
<FaStatusTag v-else type="danger" label="自定义数据权限" />
</template>
<template #depts="{ row }">
<template v-if="row?.depts && (row.depts as any[]).length > 0">
<template
v-if="
(row as unknown as RoleTable)?.depts &&
(row as unknown as RoleTable).depts!.length > 0
"
>
<ElTag
v-for="dept in row.depts as any[]"
v-for="dept in (row as unknown as RoleTable).depts!"
:key="dept.id"
type="info"
:style="'margin-right: 4px; margin-bottom: 4px'"
@@ -143,6 +152,7 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
@@ -150,7 +160,7 @@ import { useTableSelection } from "@/hooks/core/useTableSelection";
import { useCrudForm } from "@/hooks/core/useCrudForm";
import { confirmDelete, confirmBatchDelete, confirmToggleStatus } from "@/hooks/core/useConfirm";
import { cleanEmptyArrayParams, stripPaginationParams } from "@/utils/query";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { ColumnOption } from "@/types/component";
import RoleAPI, {
type RoleForm,
@@ -161,10 +171,11 @@ import { useAuth } from "@/hooks/core/useAuth";
import { useUserStore } from "@stores";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import { ElTag, ElMessage } from "element-plus";
import type FaForm from "@/components/forms/fa-form/index.vue";
import StatusTag from "@/components/others/fa-status-tag/index.vue";
import { ElMessage } from "element-plus";
import FaPermissonDrawer from "./components/FaPermissonDrawer.vue";
defineOptions({
@@ -176,7 +187,7 @@ const { hasAuth } = useAuth();
type RoleSearchForm = {
name?: string;
status?: string;
status?: number;
created_time?: string[];
};
@@ -194,32 +205,22 @@ function buildRoleReplaceParams(p: RoleSearchForm): Record<string, unknown> {
};
}
function dataScopeTag(row: RoleTable) {
const ds = row.data_scope;
if (ds === 1) return h(ElTag, { type: "primary" }, () => "仅本人数据权限");
if (ds === 2) return h(ElTag, { type: "info" }, () => "本部门数据权限");
if (ds === 3) return h(ElTag, { type: "warning" }, () => "本部门及以下数据权限");
if (ds === 4) return h(ElTag, { type: "success" }, () => "全部数据权限");
return h(ElTag, { type: "danger" }, () => "自定义数据权限");
}
function deptsCell(row: RoleTable) {
const list = row.depts;
if (!list?.length) {
return h("span", { style: { color: "var(--el-text-color-placeholder)" } }, "-");
}
const tags = list
.slice(0, 3)
.map((dept) =>
h(
ElTag,
{ key: dept.id, type: "info", style: { marginRight: "4px", marginBottom: "4px" } },
() => dept.name ?? ""
)
);
const tags = list.slice(0, 3).map((dept) =>
h(StatusTag, {
key: dept.id,
type: "info",
label: dept.name ?? "",
style: { marginRight: "4px", marginBottom: "4px" },
})
);
if (list.length > 3) {
tags.push(
h(ElTag, { type: "info", style: { marginBottom: "4px" } }, () => `+${list.length - 3}`)
h(StatusTag, { type: "info", label: `+${list.length - 3}`, style: { marginBottom: "4px" } })
);
}
return h("span", { class: "inline-flex flex-wrap items-center" }, tags);
@@ -547,7 +548,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<RoleTable>[] => [
columnsFactory: resolveStatusColumns<RoleTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "角色名称", minWidth: 100, showOverflowTooltip: true },
@@ -556,7 +557,13 @@ const {
prop: "data_scope",
label: "数据权限",
minWidth: 200,
formatter: (row: RoleTable) => dataScopeTag(row),
status: {
1: { type: "primary", text: "仅本人数据权限" },
2: { type: "info", text: "本部门数据权限" },
3: { type: "warning", text: "本部门及以下数据权限" },
4: { type: "success", text: "全部数据权限" },
5: { type: "danger", text: "自定义数据权限" },
},
},
{
prop: "depts",
@@ -569,10 +576,10 @@ const {
prop: "status",
label: "状态",
width: 88,
formatter: (row: RoleTable) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
@@ -585,7 +592,7 @@ const {
align: "right",
formatter: (row: RoleTable) => formatRoleOperationCell(row, opCtx),
},
],
]),
},
});
@@ -93,27 +93,29 @@
max-height="75vh"
>
<template #ticket_type="{ row }">
<ElTag :type="typeTag(row?.ticket_type as string)">
{{ typeLabel(row?.ticket_type as string) }}
</ElTag>
<FaStatusTag
:type="typeTag(row?.ticket_type as string)"
:label="typeLabel(row?.ticket_type as string)"
/>
</template>
<template #status="{ row }">
<ElTag :type="statusTag(row?.status as string)">
{{ statusLabel(row?.status as string) }}
</ElTag>
<FaStatusTag
:type="statusTag(row?.status as string)"
:label="statusLabel(row?.status as string)"
/>
</template>
<template #ticket_content>
<div class="ticket-html-preview">
<ElScrollbar class="ticket-html-preview" view-class="p-3">
<template v-if="detailHasRenderableContent">
<div v-html="detailContentHtml" />
</template>
<p v-else class="ticket-html-empty">暂无内容</p>
</div>
</ElScrollbar>
</template>
<template #reply_content>
<div v-if="detailFormData.reply" class="ticket-html-preview">
<ElScrollbar v-if="detailFormData.reply" class="ticket-html-preview" view-class="p-3">
<div v-html="sanitizedReply" />
</div>
</ElScrollbar>
<p v-else class="ticket-html-empty">暂无回复</p>
</template>
</FaDescriptions>
@@ -207,16 +209,13 @@ import TicketAPI, {
type TicketTable,
} from "@/api/module_system/ticket";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { IObject } from "@/components/modal/types";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaUserTableSelect from "@/components/forms/fa-search-bar/FaUserTableSelect.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaWangEditor from "@/components/forms/fa-wang-editor/index.vue";
import { ElTag, ElMessage, ElSelect, ElOption, ElRadioGroup, ElRadio } from "element-plus";
import { h } from "vue";
import type FaForm from "@/components/forms/fa-form/index.vue";
import { ElMessage, ElSelect, ElOption, ElRadioGroup, ElRadio, ElScrollbar } from "element-plus";
import DOMPurify from "dompurify";
defineOptions({
@@ -229,7 +228,7 @@ const { hasAuth } = useAuth();
type TicketSearchForm = {
title?: string;
ticket_type?: string;
status?: string;
status?: number;
created_time?: string[];
created_id?: number;
assigned_id?: number;
@@ -541,7 +540,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<TicketTable>[] => [
columnsFactory: resolveStatusColumns<TicketTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "title", label: "工单标题", minWidth: 180, showOverflowTooltip: true },
@@ -549,15 +548,23 @@ const {
prop: "ticket_type",
label: "工单类型",
width: 100,
formatter: (row: TicketTable) =>
h(ElTag, { type: typeTag(row.ticket_type) }, () => typeLabel(row.ticket_type)),
status: {
suggestion: { type: "success", text: "建议" },
bug: { type: "danger", text: "缺陷" },
optimize: { type: "warning", text: "优化" },
other: { type: "info", text: "其他" },
},
},
{
prop: "status",
label: "状态",
width: 100,
formatter: (row: TicketTable) =>
h(ElTag, { type: statusTag(String(row.status)) }, () => statusLabel(String(row.status))),
status: {
"0": { type: "warning", text: "待处理" },
"1": { type: "info", text: "处理中" },
"2": { type: "success", text: "已完成" },
"3": { type: "info", text: "已关闭" },
},
},
{
prop: "assigned_by",
@@ -581,7 +588,7 @@ const {
align: "right",
formatter: (row: TicketTable) => formatTicketOperationCell(row),
},
],
]),
},
});
@@ -768,8 +775,6 @@ async function handleMoreClick(status: string) {
box-sizing: border-box;
min-height: 120px;
max-height: min(360px, 45vh);
padding: 12px 16px;
overflow-y: auto;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: calc(var(--custom-radius) / 3 + 2px);
@@ -114,19 +114,23 @@
</template>
<!-- 性别 → 三种状态 Tag -->
<template #gender="{ row }">
<ElTag v-if="row?.gender === '0'" type="success">男</ElTag>
<ElTag v-else-if="row?.gender === '1'" type="warning">女</ElTag>
<ElTag v-else type="info">未知</ElTag>
<FaStatusTag v-if="row?.gender === '0'" type="success" label="男" />
<FaStatusTag v-else-if="row?.gender === '1'" type="warning" label="女" />
<FaStatusTag v-else type="info" label="未知" />
</template>
<!-- 角色 → 数组 join 渲染 -->
<template #roles="{ row }">
{{ row?.roles ? (row.roles as any[]).map((item: any) => item.name).join("、") : "" }}
{{
(row as unknown as UserInfo)?.roles
? (row as unknown as UserInfo).roles!.map((item) => item.name).join("、")
: ""
}}
</template>
<!-- 岗位 → 数组 join 渲染 -->
<template #positions="{ row }">
{{
row?.positions
? (row.positions as any[]).map((item: any) => item.name).join("、")
(row as unknown as UserInfo)?.positions
? (row as unknown as UserInfo).positions!.map((item) => item.name).join("、")
: ""
}}
</template>
@@ -209,6 +213,7 @@ defineOptions({
inheritAttrs: false,
});
import { h } from "vue";
import { UserFilled } from "@element-plus/icons-vue";
import { ElAvatar } from "element-plus";
import { DeviceEnum } from "@/enums/settings/device.enum";
@@ -224,22 +229,26 @@ import UserAPI, {
type UserInfo,
type UserPageQuery,
} from "@/api/module_system/user";
import { formatTree, renderTableOperationCell, type TableOperationAction } from "@utils";
import {
formatTree,
renderTableOperationCell,
type TableOperationAction,
resolveStatusColumns,
} from "@utils";
import PositionAPI from "@/api/module_system/position";
import DeptAPI from "@/api/module_system/dept";
import RoleAPI from "@/api/module_system/role";
import { useAppStore, useUserStore } from "@stores";
import { useAuth } from "@/hooks/core/useAuth";
import type { ColumnOption } from "@/types/component";
import FaUserTableSelect from "@/components/forms/fa-search-bar/FaUserTableSelect.vue";
import type { DescriptionsItem } from "@/components/others/fa-descriptions/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import type FaForm from "@/components/forms/fa-form/index.vue";
import type { IContentConfig, IObject } from "@/components/modal/types";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaDeptTree from "./components/FaDeptTree.vue";
import { ElTag, ElMessage, ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
const { hasAuth } = useAuth();
const appStore = useAppStore();
@@ -248,7 +257,7 @@ const userStore = useUserStore();
type UserSearchForm = {
username?: string;
name?: string;
status?: string;
status?: number;
created_id?: number;
created_time?: string[];
};
@@ -592,7 +601,7 @@ const {
page_no: 1,
page_size: 20,
},
columnsFactory: (): import("@/types/component").ColumnOption<UserInfo>[] => [
columnsFactory: resolveStatusColumns<UserInfo>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{
@@ -616,10 +625,10 @@ const {
prop: "status",
label: "状态",
width: 88,
formatter: (row: UserInfo) =>
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
row.status === 0 ? "启用" : "停用"
),
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{
prop: "dept",
@@ -631,10 +640,9 @@ const {
prop: "gender",
label: "性别",
width: 88,
formatter: (row: UserInfo) => {
if (row.gender === "0") return h(ElTag, { type: "success" }, () => "男");
if (row.gender === "1") return h(ElTag, { type: "warning" }, () => "女");
return h(ElTag, { type: "info" }, () => "未知");
status: {
"0": { type: "success", text: "男" },
"1": { type: "warning", text: "女" },
},
},
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
@@ -647,7 +655,7 @@ const {
align: "right",
formatter: (row: UserInfo) => formatUserOperationCell(row, opCtx),
},
],
]),
},
});
@@ -183,17 +183,17 @@
<ElCol :span="6">
<ElButton
v-hasPerm="['module_task:cronjob:job:task']"
:type="job.status === '暂停中' ? 'primary' : 'warning'"
:type="job.status === 1 ? 'primary' : 'warning'"
size="small"
plain
class="w-full"
:icon="job.status === '暂停中' ? 'VideoPlay' : 'VideoPause'"
:disabled="job.status !== '暂停中' && job.status !== '运行中'"
:icon="job.status === 1 ? 'VideoPlay' : 'VideoPause'"
:disabled="job.status !== 1 && job.status !== 0"
@click="
job.status === '暂停中' ? handleResumeJob(job.id) : handlePauseJob(job.id)
job.status === 1 ? handleResumeJob(job.id) : handlePauseJob(job.id)
"
>
{{ job.status === "暂停中" ? "恢复" : "暂停" }}
{{ job.status === 1 ? "恢复" : "暂停" }}
</ElButton>
</ElCol>
<ElCol :span="6">
@@ -204,7 +204,7 @@
plain
class="w-full"
icon="CaretRight"
:disabled="job.status === '已停止' || job.status === '未知'"
:disabled="job.status === 2 || job.status === 3"
@click="handleRunJobNow(job.id)"
>
调试
@@ -231,7 +231,7 @@
plain
class="w-full"
icon="Close"
:disabled="job.status === '未知'"
:disabled="job.status === 3"
@click="handleRemoveJob(job.id)"
>
移除
@@ -343,18 +343,12 @@ defineOptions({
});
import JobAPI, { SchedulerStatus, SchedulerJob, JobLogTable } from "@/api/module_task/cronjob/job";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import FaDrawer from "@/components/modal/fa-drawer/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import FaTable from "@/components/tables/fa-table/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
import FaTableHeaderLeft from "@/components/tables/fa-table-header-left/index.vue";
import FaJsonPretty from "@/components/others/fa-json-pretty/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 { ElDivider, ElMessageBox, ElTag } from "element-plus";
import { computed, h, nextTick, onMounted, ref } from "vue";
import { ElDivider, ElMessageBox } from "element-plus";
import { computed, nextTick, onMounted, ref } from "vue";
import { Terminal, TerminalApi } from "vue-web-terminal";
const schedulerStatus = ref<SchedulerStatus>({
@@ -365,7 +359,7 @@ const schedulerStatus = ref<SchedulerStatus>({
type JobSearchForm = {
name?: string;
status?: string;
status?: number;
};
const searchForm = ref<JobSearchForm>({
@@ -441,7 +435,7 @@ async function fetchSchedulerJobs() {
const statusQ = searchForm.value.status;
jobList.value = list.filter((j) => {
if (nameQ && !(j.name ?? "").includes(nameQ)) return false;
if (!matchesJobStatusFilter(j.status, statusQ)) return false;
if (!matchesJobStatusFilter(j.status.toString(), statusQ?.toString())) return false;
return true;
});
await loadSchedulerStatus();
@@ -473,7 +467,7 @@ onMounted(() => {
});
type LogSearchForm = {
status?: string;
status?: number;
trigger_type?: string;
};
@@ -584,17 +578,25 @@ const {
prop: "trigger_type",
label: "触发方式",
minWidth: 120,
formatter: (row) =>
h(ElTag, { size: "small" }, () => getTriggerTypeLabel(row.trigger_type)),
status: {
cron: { type: "info", text: "Cron表达式" },
interval: { type: "info", text: "时间间隔" },
date: { type: "info", text: "固定日期" },
manual: { type: "info", text: "一次性任务" },
},
},
{
prop: "status",
label: "状态",
minWidth: 80,
formatter: (row) =>
h(ElTag, { type: getLogStatusType(String(row.status)), size: "small" }, () =>
getLogStatusLabel(String(row.status))
),
status: {
pending: { type: "info", text: "待执行" },
running: { type: "primary", text: "执行中" },
success: { type: "success", text: "成功" },
failed: { type: "danger", text: "失败" },
timeout: { type: "warning", text: "超时" },
cancelled: { type: "info", text: "已取消" },
},
},
{
prop: "next_run_time",
@@ -738,30 +740,30 @@ function getSchedulerStatusType(status: string) {
}
}
function getJobStatusType(status: string) {
function getJobStatusType(status: number) {
switch (status) {
case "运行中":
case 0:
return "success";
case "暂停中":
case 1:
return "warning";
case "已停止":
case 2:
return "danger";
case "未知":
case 3:
return "info";
default:
return "info";
}
}
function getJobStatusLabel(status: string) {
function getJobStatusLabel(status: number) {
switch (status) {
case "运行中":
case 0:
return "运行中";
case "暂停中":
case 1:
return "暂停中";
case "已停止":
case 2:
return "已停止";
case "未知":
case 3:
return "未知";
default:
return status;
@@ -776,7 +778,7 @@ function formatTrigger(trigger: string) {
if (trigger.includes("cron")) {
const match = trigger.match(/cron\[([^\]]+)\]/);
if (match) {
const params = match[1];
const params = match[1]!;
// 提取关键参数
const month = params.match(/month='([^']+)'/);
const day = params.match(/day='([^']+)'/);
@@ -974,23 +976,13 @@ async function handleOpenExecutionLogDrawer(job: SchedulerJob) {
getLogData();
}
function getTriggerTypeLabel(type: string | undefined) {
const map: Record<string, string> = {
cron: "Cron表达式",
interval: "时间间隔",
date: "固定日期",
manual: "一次性任务",
};
return map[type || ""] || type || "-";
}
function getJobStatusClass(status: string) {
function getJobStatusClass(status: number) {
switch (status) {
case "运行中":
case 0:
return "running";
case "暂停中":
case 1:
return "paused";
case "已停止":
case 2:
return "stopped";
default:
return "unknown";
@@ -1005,30 +997,6 @@ function getTriggerIcon(trigger: string | undefined) {
return "ri:flashlight-line";
}
function getLogStatusType(status: string): "primary" | "success" | "warning" | "info" | "danger" {
const map: Record<string, "primary" | "success" | "warning" | "info" | "danger"> = {
pending: "info",
running: "primary",
success: "success",
failed: "danger",
timeout: "warning",
cancelled: "info",
};
return map[status] || "info";
}
function getLogStatusLabel(status: string) {
const map: Record<string, string> = {
pending: "待执行",
running: "执行中",
success: "成功",
failed: "失败",
timeout: "超时",
cancelled: "已取消",
};
return map[status] || status;
}
function handleViewJobState(row: JobLogTable) {
const jobState = row.job_state;
if (jobState) {
@@ -278,14 +278,10 @@ defineOptions({
import NodeAPI, { NodeTable, NodeForm, TriggerType } from "@/api/module_task/cronjob/node";
import { useDictStore } from "@stores";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaTable from "@/components/tables/fa-table/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
import FaTableHeaderLeft from "@/components/tables/fa-table-header-left/index.vue";
import FaSearchBar from "@/components/forms/fa-search-bar/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";
@@ -852,7 +848,7 @@ async function handleSubmit() {
await refreshCreate();
}
} catch (error: any) {
console.log(error);
console.error(error);
} finally {
submitLoading.value = false;
}
@@ -968,16 +964,6 @@ onMounted(async () => {
align-items: center;
}
.code-preview {
max-height: 200px;
padding: 10px;
overflow-y: auto;
font-family: monospace;
word-break: break-all;
white-space: pre-wrap;
border-radius: 4px;
}
.execution-log-drawer {
display: flex;
flex-direction: column;
@@ -53,7 +53,7 @@ import { Handle, Position } from "@vue-flow/core";
interface Props {
id?: string;
data?: Record<string, any>;
nodeStatus?: string;
nodeStatus?: number;
}
const props = withDefaults(defineProps<Props>(), {});
@@ -7,7 +7,7 @@
</ElButton>
</div>
<div class="panel-content">
<ElScrollbar class="panel-content" view-class="p-4">
<FaForm
v-model="formData"
:items="edgeFormItems"
@@ -29,15 +29,14 @@
<ElButton type="primary" size="small" @click="handleSave">保存</ElButton>
<ElButton type="danger" size="small" @click="handleDelete">删除连线</ElButton>
</div>
</div>
</ElScrollbar>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { ElButton, ElColorPicker, ElIcon } from "element-plus";
import { ElButton, ElColorPicker, ElIcon, ElScrollbar } from "element-plus";
import { Close } from "@element-plus/icons-vue";
import FaForm from "@/components/forms/fa-form/index.vue";
interface Props {
edge?: Record<string, any>;
@@ -178,8 +177,6 @@ function handleDelete() {
.panel-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.panel-actions {
@@ -7,7 +7,7 @@
</ElButton>
</div>
<div class="panel-content">
<ElScrollbar class="panel-content" view-class="p-4">
<FaForm
v-model="formData"
:items="nodePanelFormItems"
@@ -51,15 +51,22 @@
<ElButton type="primary" size="small" @click="handleSave">保存</ElButton>
<ElButton type="danger" size="small" @click="handleDelete">删除节点</ElButton>
</div>
</div>
</ElScrollbar>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed } from "vue";
import { ElButton, ElInput, ElSelect, ElOption, ElMessage, ElIcon } from "element-plus";
import {
ElButton,
ElInput,
ElSelect,
ElOption,
ElMessage,
ElIcon,
ElScrollbar,
} from "element-plus";
import { Close } from "@element-plus/icons-vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import WorkflowNodeTypeAPI, {
type WorkflowNodeTypeOption,
@@ -229,8 +236,6 @@ onMounted(() => {
.panel-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.field-hint {
@@ -48,7 +48,7 @@
<ElTag
v-for="item in filteredNodes"
:key="item.id"
:type="getCategoryType(item.category) as any"
:type="getCategoryType(item.category)"
effect="plain"
draggable="true"
:style="'justify-content: center; cursor: move; user-select: none'"
@@ -196,11 +196,10 @@ import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
import "@vue-flow/minimap/dist/style.css";
import "element-plus/dist/index.css";
import FaDynamicNode from "./FaDynamicNode.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import type FaForm from "@/components/forms/fa-form/index.vue";
import WorkflowDefinitionAPI, {
type WorkflowTable,
type WorkflowForm,
@@ -456,14 +455,16 @@ const filteredNodes = computed(() => {
return allNodes.value.filter((node) => node.name.toLowerCase().includes(keyword));
});
const getCategoryType = (category: string) => {
const typeMap: Record<string, string> = {
trigger: "warning",
action: "primary",
condition: "success",
control: "info",
type ElTagType = 'success' | 'warning' | 'info' | 'primary' | 'danger';
const getCategoryType = (category: string): ElTagType => {
const typeMap: Record<string, ElTagType> = {
trigger: 'warning',
action: 'primary',
condition: 'success',
control: 'info',
};
return typeMap[category] || "info";
return typeMap[category] || 'info';
};
const getCategoryText = (category: string) => {
@@ -850,10 +851,11 @@ function updateNodeData(
if (nodeIndex === -1) return false;
const updatedNodes = [...currentNodes];
const targetNode = updatedNodes[nodeIndex]!;
updatedNodes[nodeIndex] = {
...updatedNodes[nodeIndex],
...targetNode,
data: {
...updatedNodes[nodeIndex].data,
...targetNode.data,
...data,
},
};
@@ -889,10 +891,10 @@ function updateEdgeData(
const updatedEdges = [...currentEdges];
updatedEdges[edgeIndex] = {
...updatedEdges[edgeIndex],
...updatedEdges[edgeIndex]!,
...data,
data: {
...updatedEdges[edgeIndex].data,
...updatedEdges[edgeIndex]!.data,
...data,
},
};
@@ -119,24 +119,21 @@ defineOptions({
});
import WorkflowDefinitionAPI, { type WorkflowTable } from "@/api/module_task/workflow/definition";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import FaTable from "@/components/tables/fa-table/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
import FaTableHeaderLeft from "@/components/tables/fa-table-header-left/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 { ArrowDown } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, ElTag } from "element-plus";
import { computed, h, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, ref } from "vue";
const BATCH_DELETE_MSG = "确认删除选中的工作流吗?";
type WorkflowSearchForm = {
name?: string;
code?: string;
status?: string;
status?: number;
};
function buildWorkflowReplaceParams(u: WorkflowSearchForm): Record<string, unknown> {
@@ -238,24 +235,6 @@ async function handleBatchDelete() {
}
}
function getStatusType(status: string): "success" | "warning" | "info" | "danger" | "primary" {
const typeMap: Record<string, "success" | "warning" | "info" | "danger" | "primary"> = {
draft: "info",
published: "success",
archived: "warning",
};
return typeMap[status] ?? "info";
}
function getStatusText(status: string) {
const textMap: Record<string, string> = {
draft: "草稿",
published: "已发布",
archived: "已归档",
};
return textMap[status] || status;
}
const {
columns,
columnChecks,
@@ -303,10 +282,11 @@ const {
label: "状态",
minWidth: 100,
align: "center",
formatter: (row) =>
h(ElTag, { type: getStatusType(String(row.status ?? "")) }, () =>
getStatusText(String(row.status ?? ""))
),
status: {
draft: { type: "info", text: "草稿" },
published: { type: "success", text: "已发布" },
archived: { type: "warning", text: "已归档" },
},
},
{
prop: "description",
@@ -117,19 +117,15 @@ import WorkflowNodeTypeAPI, {
type WorkflowNodeTypeForm,
type WorkflowNodeTypeTable,
} from "@/api/module_task/workflow/node-type";
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import FaTable from "@/components/tables/fa-table/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
import FaTableHeaderLeft from "@/components/tables/fa-table-header-left/index.vue";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import FaForm from "@/components/forms/fa-form/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 { useTable } from "@/hooks/core/useTable";
import type { ColumnOption } from "@/types/component";
import { ElMessage, ElMessageBox, ElTag } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import type { FormRules } from "element-plus";
import { computed, h, ref } from "vue";
import { computed, ref } from "vue";
const BATCH_DELETE_MSG = "确认删除选中的编排节点类型吗?";
@@ -309,10 +305,10 @@ const {
label: "启用",
width: 88,
align: "center",
formatter: (row) =>
h(ElTag, { type: row.is_active ? "success" : "info" }, () =>
row.is_active ? "是" : "否"
),
status: {
true: { type: "success", text: "是" },
false: { type: "info", text: "否" },
},
},
{
prop: "created_time",