feat: 新增AI模型配置功能,优化前端组件与代码规范

1. 新增Redis AI_MODEL_CONFIG枚举与后端AI模型配置CRUD接口
2. 升级vue-img-cutter到3.1.1版本,更新前端依赖
3. 重构前端多处ElMessage提示逻辑,统一由拦截器处理
4. 替换ElDrawer为FaDrawer组件,统一弹窗组件库
5. 重构文章详情、评论组件,新增租户切换器与AI配置面板
6. 优化代码生成模板、菜单树表格逻辑与代码高亮样式
7. 修复前端路由与组件命名问题,更新快速入口配置
This commit is contained in:
zhangtao
2026-06-25 00:14:51 +08:00
parent 18ac9eda84
commit fa680c03d3
56 changed files with 4066 additions and 1380 deletions
@@ -26,9 +26,9 @@
</ElForm>
<ul>
<div class="pb-5 text-lg font-medium">评论 {{ comments.length }}</div>
<div class="pb-5 text-lg font-medium">评论 {{ internalComments.length }}</div>
<FaCommentItem
v-for="comment in comments.slice().reverse()"
v-for="comment in internalComments.slice().reverse()"
:key="comment.id"
:comment="comment"
:show-reply-form="showReplyForm"
@@ -42,16 +42,17 @@
<script setup lang="ts">
import { ref } from "vue";
import { type Comment } from "@/mock/temp/commentDetail";
import { commentList, Comment } from "@/mock/temp/commentDetail";
import { ElMessage } from "element-plus";
defineOptions({ name: "FaCommentWidget" });
interface Props {
/** 评论列表数据,不传则使用 mock 数据 */
comments?: Comment[];
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
comments: () => [],
});
@@ -62,6 +63,11 @@ interface Emits {
const emit = defineEmits<Emits>();
/** 内部评论数据(优先使用 props,否则使用 mock 数据) */
const internalComments = ref<Comment[]>(
props.comments.length > 0 ? props.comments : commentList.value
);
const newComment = ref<Partial<Comment>>({
author: "",
content: "",
@@ -75,16 +81,19 @@ const addComment = () => {
return;
}
emit("add-comment", {
const newCommentData: Comment = {
id: Date.now(),
author: newComment.value.author.trim(),
content: newComment.value.content.trim(),
timestamp: new Date().toISOString(),
replies: [],
});
};
internalComments.value.push(newCommentData);
newComment.value.author = "";
newComment.value.content = "";
emit("add-comment", newCommentData);
ElMessage.success("评论发布成功");
};
@@ -94,12 +103,35 @@ const addReply = (commentId: number, replyAuthor: string, replyContent: string)
return;
}
emit("add-reply", commentId, replyAuthor.trim(), replyContent.trim());
showReplyForm.value = null;
ElMessage.success("回复发布成功");
const comment = findComment(internalComments.value, commentId);
if (comment) {
comment.replies.push({
id: Date.now(),
author: replyAuthor.trim(),
content: replyContent.trim(),
timestamp: new Date().toISOString(),
replies: [],
});
showReplyForm.value = null;
emit("add-reply", commentId, replyAuthor, replyContent);
ElMessage.success("回复发布成功");
}
};
const toggleReply = (commentId: number) => {
showReplyForm.value = showReplyForm.value === commentId ? null : commentId;
};
const findComment = (comments: Comment[], commentId: number): Comment | undefined => {
for (const comment of comments) {
if (comment.id === commentId) {
return comment;
}
const found = findComment(comment.replies, commentId);
if (found) {
return found;
}
}
return undefined;
};
</script>
@@ -23,7 +23,7 @@
</div>
<ul class="pl-2.5" v-if="comment.replies.length > 0">
<CommentItem
<FaCommentItem
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
@@ -172,6 +172,9 @@
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
/>
<!-- 租户切换器(全局可见,1步切换) -->
<FaTenantSwitcher />
<!-- 用户头像、菜单 -->
<FaUserMenu />
</div>
@@ -197,6 +200,7 @@ import { mittBus, themeAnimation } from "@utils";
import { useCommon } from "@/hooks/core/useCommon";
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
import FaUserMenu from "./widgets/FaUserMenu.vue";
import FaTenantSwitcher from "./widgets/FaTenantSwitcher.vue";
defineOptions({ name: "FaHeaderBar" });
@@ -8,6 +8,9 @@
@close="onDrawerClosed"
>
<ElTabs v-model="activeTabRef" type="border-card">
<ElTabPane label="AI 模型" name="aiModel">
<FaAiModelConfigPanel />
</ElTabPane>
<ElTabPane label="接口白名单" name="apiWhitelist">
<ElForm :model="configState" label-suffix=":" label-width="100px" label-position="right">
<!-- 系统配置 -->
@@ -195,6 +198,7 @@
<template #footer>
<ElButton @click="handleCloseDialog">取消</ElButton>
<ElButton
v-if="activeTabRef !== 'aiModel'"
v-hasPerm="['module_system:config:update']"
type="primary"
:disabled="!hasChanges"
@@ -213,6 +217,7 @@ import { useAppStore, useConfigStore } from "@stores";
import { useI18n } from "vue-i18n";
import { ElMessage, ElMessageBox } from "element-plus";
import { DeviceEnum } from "@/enums/settings/device.enum";
import FaAiModelConfigPanel from "@/views/module_ai/chat/components/FaAiModelConfigPanel.vue";
defineOptions({ name: "FaConfigInfoDrawer" });
@@ -1,36 +1,76 @@
<!-- 租户切换器(顶栏版):头像左侧,始终可见 -->
<!-- 顶栏租户切换器:头像左侧,始终可见 -->
<template>
<ElDropdown
v-if="tenantList.length > 1"
trigger="click"
placement="bottom-start"
:disabled="switching"
@command="handleSwitch"
placement="bottom"
@visible-change="(v) => (dropdownVisible = v)"
popper-class="fa-tenant-dropdown"
>
<span class="tenant-btn">
<span class="tenant-icon">🏢</span>
<span class="tenant-name">{{ currentTenantName }}</span>
<FaSvgIcon icon="ri:arrow-down-s-line" class="arrow" />
</span>
<div
class="tenant-switcher"
:class="{ 'is-active': dropdownVisible, 'is-switching': switching }"
:title="`当前租户:${currentTenantName}`"
>
<FaSvgIcon icon="ri:building-2-fill" class="icon" />
<span class="name">{{ currentTenantName }}</span>
<FaSvgIcon
v-if="!switching"
icon="ri:arrow-down-s-line"
class="arrow"
:class="{ rotated: dropdownVisible }"
/>
<ElIcon v-else class="arrow is-loading"><Loading /></ElIcon>
</div>
<template #dropdown>
<ElDropdownMenu>
<div class="dropdown-header">
<span class="dropdown-title">切换租户</span>
<ElTag size="small" effect="plain" type="info"> 共 {{ tenantList.length }} 个 </ElTag>
</div>
<ElDropdownItem
v-for="t in tenantList"
:key="t.id"
:command="t.id"
:class="{ 'is-active': t.id === currentTenant?.id }"
:disabled="switching"
>
<span class="dropdown-row">
<span>{{ t.name }}</span>
<FaSvgIcon v-if="t.id === currentTenant?.id" icon="ri:check-line" class="check" />
</span>
<div class="dropdown-item" :class="{ 'is-current': t.id === currentTenant?.id }">
<div class="item-main">
<FaSvgIcon
:icon="t.id === currentTenant?.id ? 'ri:check-line' : 'ri:building-2-line'"
class="item-icon"
:class="{ active: t.id === currentTenant?.id }"
/>
<div class="item-text">
<div class="item-name">{{ t.name }}</div>
<div v-if="t.code" class="item-code">{{ t.code }}</div>
</div>
</div>
<ElTag v-if="t.id === currentTenant?.id" type="primary" size="small" effect="plain">
当前
</ElTag>
</div>
</ElDropdownItem>
<div class="dropdown-footer-hint">
<FaSvgIcon icon="ri:information-line" class="hint-icon" />
<span>点击其他租户即可切换</span>
</div>
<div v-if="switching" class="dropdown-footer">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>切换中...</span>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { ref, computed } from "vue";
import { ElMessage } from "element-plus";
import { Loading } from "@element-plus/icons-vue";
import { useUserStore } from "@stores";
import { storeToRefs } from "pinia";
@@ -39,29 +79,38 @@ defineOptions({ name: "FaTenantSwitcher" });
const userStore = useUserStore();
const { tenantList, currentTenant } = storeToRefs(userStore);
const dropdownVisible = ref(false);
const switching = ref(false);
const currentTenantName = computed(
() => currentTenant.value?.name || tenantList.value[0]?.name || "—"
);
async function handleSwitch(tenantId: number) {
if (tenantId === currentTenant.value?.id) return;
if (switching.value) return;
// 点击当前租户:给个明确提示(不再静默忽略)
if (tenantId === currentTenant.value?.id) {
ElMessage.info("已是当前租户");
return;
}
switching.value = true;
try {
await userStore.selectTenant(tenantId);
setTimeout(() => window.location.reload(), 200);
} catch {
// 静默失败
switching.value = false;
}
}
</script>
<style scoped>
.tenant-btn {
.tenant-switcher {
display: inline-flex;
gap: 4px;
gap: 6px;
align-items: center;
max-width: 160px;
height: 30px;
padding: 0 10px 0 6px;
max-width: 180px;
height: 32px;
padding: 0 10px;
font-size: 13px;
color: var(--el-text-color-primary);
cursor: pointer;
@@ -69,45 +118,169 @@ async function handleSwitch(tenantId: number) {
background: var(--fa-gray-100);
border: 1px solid var(--fa-gray-300);
border-radius: 6px;
transition:
background 0.15s,
border-color 0.15s;
}
transition: all 0.15s;
.tenant-btn:hover {
background: var(--fa-gray-200);
border-color: var(--el-color-primary-light-5);
}
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
.tenant-icon {
flex-shrink: 0;
font-size: 15px;
line-height: 1;
}
&.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
.tenant-name {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
white-space: nowrap;
}
&.is-switching {
cursor: wait;
opacity: 0.7;
}
.arrow {
flex-shrink: 0;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.icon {
flex-shrink: 0;
font-size: 14px;
}
.dropdown-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
width: 100%;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
white-space: nowrap;
}
.check {
flex-shrink: 0;
color: var(--el-color-primary);
.arrow {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
transition: transform 0.2s;
&.rotated {
transform: rotate(180deg);
}
}
}
</style>
<style lang="scss">
/* 全局样式:下拉面板(scoped 无法深入 ElDropdownMenu) */
.fa-tenant-dropdown {
min-width: 240px;
padding: 0 !important;
.el-dropdown-menu__item {
padding: 0 !important;
&.is-active {
color: inherit;
background: transparent !important;
}
&:not(.is-disabled):hover {
background: var(--fa-gray-200) !important;
}
}
.dropdown-header {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.dropdown-title {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.dropdown-item {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
border-left: 2px solid transparent;
border-radius: 0;
transition: all 0.15s;
&.is-current {
background: var(--el-color-primary-light-9);
border-left-color: var(--el-color-primary);
&:hover {
background: var(--el-color-primary-light-8);
}
}
}
.dropdown-footer-hint {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
padding: 6px 12px 8px;
font-size: 11px;
color: var(--el-text-color-secondary);
border-top: 1px solid var(--el-border-color-lighter);
.hint-icon {
font-size: 12px;
}
}
.item-main {
display: flex;
flex: 1;
gap: 8px;
align-items: center;
min-width: 0;
}
.item-icon {
flex-shrink: 0;
font-size: 15px;
color: var(--el-text-color-secondary);
&.active {
color: var(--el-color-primary);
}
}
.item-text {
min-width: 0;
}
.item-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.item-code {
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.dropdown-footer {
display: flex;
gap: 6px;
align-items: center;
justify-content: center;
padding: 8px 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
border-top: 1px solid var(--el-border-color-lighter);
}
}
</style>
@@ -56,37 +56,6 @@
</div>
</div>
<ul class="py-4 mt-3 border-t border-g-300/80">
<li
v-if="tenantList.length > 1"
class="flex select-none cursor-pointer last:mb-0 hover:bg-(--fa-gray-200) flex-col! items-start! mb-4 p-2! rounded-lg bg-(--fa-gray-100)"
>
<span class="text-xs text-g-500 mb-2 block w-full">当前租户</span>
<ElDropdown trigger="click" @command="handleTenantSwitch">
<span
class="flex items-center cursor-pointer w-full text-sm font-medium text-(--el-color-primary) hover:underline"
>
{{ currentTenantName }}
<FaSvgIcon icon="ri:arrow-down-s-line" class="ml-1 text-xs" />
</span>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="t in tenantList"
:key="t.id"
:command="t.id"
:class="{
'text-(--el-color-primary) font-medium': t.id === currentTenant?.id,
}"
>
<span class="flex items-center justify-between gap-4">
<span>{{ t.name }}</span>
<FaSvgIcon v-if="t.id === currentTenant?.id" icon="ri:check-line" />
</span>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</li>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200)"
@click="goPage('/fastlink/profile')"
@@ -152,7 +121,6 @@ const { t } = useI18n();
const userStore = useUserStore();
const { info: userInfo } = storeToRefs(userStore);
const { tenantList, currentTenant } = storeToRefs(userStore);
const userMenuPopover = ref();
const paramDrawerVisible = ref(false);
@@ -170,22 +138,6 @@ const displayName = computed(
const displayEmail = computed(() => (userInfo.value as { email?: string })?.email || "");
const currentTenantName = computed(
() => currentTenant.value?.name || tenantList.value[0]?.name || "—"
);
async function handleTenantSwitch(tenantId: number) {
closeUserMenu();
try {
await userStore.selectTenant(tenantId);
setTimeout(() => {
window.location.reload();
}, 200);
} catch {
// silently fail
}
}
function openParamConfig(): void {
closeUserMenu();
paramDrawerVisible.value = true;
@@ -1,17 +1,26 @@
<!-- 图片裁剪组件 github: https://github.com/acccccccb/vue-img-cutter/tree/master -->
<!--
图片裁剪组件 - 基于 vue-img-cutter 封装
官方文档: https://gitee.com/GLUESTICK/vue-img-cutter
封装策略:
- 所有 ImgCutter 原生 prop 以对应名称透传(驼峰→短横线映射由 Vue 自动处理)
- 包装层额外提供 title/showPreview/previewTitle/downloadable 等增强功能
- 仅将 ImgCutter 原生 prop 通过 v-bind 传递,避免污染
-->
<template>
<div class="cutter-container">
<div class="cutter-component">
<div class="title">{{ title }}</div>
<div v-if="title" class="title">{{ title }}</div>
<ImgCutter
ref="imgCutterModal"
@cutDown="cutDownImg"
@onPrintImg="cutterPrintImg"
@onImageLoadComplete="handleImageLoadComplete"
@onImageLoadError="handleImageLoadError"
@onClearAll="handleClearAll"
v-bind="cutterProps"
class="img-cutter"
v-bind="imgCutterProps"
@cut-down="onCutDown"
@on-print-img="onPrintImg"
@on-choose-img="onChooseImg"
@on-clear-all="onClearAll"
@on-image-load-complete="onImageLoadComplete"
@on-image-load-error="onImageLoadError"
@error="onError"
>
<template #choose>
<ElButton type="primary" plain v-ripple>选择图片</ElButton>
@@ -20,98 +29,168 @@
<ElButton type="danger" plain v-ripple>清除</ElButton>
</template>
<template #confirm>
<!-- <ElButton type="primary" :style="'margin-left: 10px'">确定</ElButton> -->
<div></div>
<div ref="confirmElRef" />
</template>
</ImgCutter>
</div>
<div v-if="showPreview" class="preview-container">
<div class="title">{{ previewTitle }}</div>
<div v-if="previewTitle" class="title">{{ previewTitle }}</div>
<div
class="preview-box"
:style="{
width: `${cutterProps.cutWidth}px`,
height: `${cutterProps.cutHeight}px`,
width: `${cutWidth}px`,
height: `${cutHeight}px`,
}"
>
<img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" loading="eager" />
<img v-if="temImgPath" class="preview-img" :src="temImgPath" alt="预览图" />
</div>
<div class="preview-actions">
<ElButton
v-if="downloadable"
class="download-btn"
:disabled="!temImgPath"
v-ripple
@click="handleDownload"
>
下载图片
</ElButton>
<ElButton type="primary" :disabled="!temImgPath" v-ripple @click="triggerCrop">
确定
</ElButton>
</div>
<ElButton class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple>
下载图片
</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import ImgCutter from "vue-img-cutter";
import "vue-img-cutter/vue-img-cutter.css";
defineOptions({ name: "FaCutterImg" });
interface Props {
// 基础配置
/** 是否模态框 */
isModal?: boolean;
/** 是否显示工具栏 */
tool?: boolean;
/** 工具栏背景色 */
toolBgc?: string;
/** 标题 */
/* ============================================================
* Props
* ============================================================ */
interface FaCutterImgProps {
// ── 包装层增强 ──
/** 裁剪区域标题 */
title?: string;
/** 预览标题 */
previewTitle?: string;
/** 是否显示预览 */
/** 是否显示预览区域 */
showPreview?: boolean;
// 尺寸相关
/** 容器宽度 */
boxWidth?: number;
/** 容器高度 */
boxHeight?: number;
/** 裁剪宽度 */
cutWidth?: number;
/** 裁剪高度 */
cutHeight?: number;
/** 是否允许大小调整 */
sizeChange?: boolean;
// 移动和缩放
/** 是否允许移动 */
moveAble?: boolean;
/** 是否允许图片移动 */
imgMove?: boolean;
/** 是否允许缩放 */
scaleAble?: boolean;
// 图片相关
/** 是否显示原始图片 */
originalGraph?: boolean;
/** 是否允许跨域 */
crossOrigin?: boolean;
/** 文件类型 */
fileType?: "png" | "jpeg" | "webp";
/** 质量 */
quality?: number;
// 水印
/** 水印文本 */
watermarkText?: string;
/** 水印字体大小 */
watermarkFontSize?: number;
/** 水印颜色 */
watermarkColor?: string;
// 其他功能
/** 是否保存裁剪位置 */
saveCutPosition?: boolean;
/** 是否预览模式 */
previewMode?: boolean;
// 输入图片
/** 预览区域标题 */
previewTitle?: string;
/** 是否显示下载按钮 */
downloadable?: boolean;
/** 远程图片地址(支持 v-model) */
imgUrl?: string;
// ── ImgCutter 原生 prop ──
isModal?: boolean;
showChooseBtn?: boolean;
lockScroll?: boolean;
modalTitle?: string;
boxWidth?: number;
boxHeight?: number;
cutWidth?: number;
cutHeight?: number;
tool?: boolean;
toolBgc?: string;
sizeChange?: boolean;
moveAble?: boolean;
imgMove?: boolean;
originalGraph?: boolean;
crossOrigin?: boolean;
crossOriginHeader?: string;
rate?: string;
/** @deprecated 使用 watermarkText 代替 */
WatermarkText?: string;
/** 水印文字 */
watermarkText?: string;
/** 水印字体 (如 '12px Sans-serif') */
watermarkTextFont?: string;
/** 水印颜色 */
watermarkTextColor?: string;
/** 水印水平位置 (0-1) */
watermarkTextX?: number;
/** 水印垂直位置 (0-1) */
watermarkTextY?: number;
smallToUpload?: boolean;
saveCutPosition?: boolean;
scaleAble?: boolean;
toolBoxOverflow?: boolean;
index?: unknown;
previewMode?: boolean;
fileType?: "png" | "jpeg" | "webp";
quality?: number;
accept?: string;
afterChooseImg?: () => Promise<boolean>;
}
const props = withDefaults(defineProps<FaCutterImgProps>(), {
// ── 包装层 ──
title: "",
showPreview: true,
previewTitle: "",
downloadable: true,
// ── ImgCutter 默认值(对齐官方) ──
isModal: false,
showChooseBtn: true,
lockScroll: true,
modalTitle: "图片裁剪",
boxWidth: 800,
boxHeight: 400,
cutWidth: 200,
cutHeight: 200,
tool: true,
toolBgc: "#fff",
sizeChange: true,
moveAble: true,
imgMove: true,
originalGraph: false,
crossOrigin: false,
crossOriginHeader: "",
rate: undefined,
watermarkText: "",
watermarkTextFont: "12px Sans-serif",
watermarkTextColor: "#ffffff",
watermarkTextX: 0.95,
watermarkTextY: 0.95,
smallToUpload: false,
saveCutPosition: false,
scaleAble: true,
toolBoxOverflow: true,
index: undefined,
previewMode: true,
fileType: "png",
quality: 1,
accept: "image/gif, image/jpeg ,image/png",
});
/* ============================================================
* Emits
* ============================================================ */
interface FaCutterImgEmits {
(e: "update:imgUrl", url: string): void;
(e: "cut-down", result: CutterResult): void;
(e: "error", error: Error): void;
(e: "choose-img", result: unknown): void;
(e: "print-img", result: { dataURL: string }): void;
(e: "clear-all"): void;
(e: "image-load-complete", result: unknown): void;
(e: "image-load-error", error: Error): void;
}
const emit = defineEmits<FaCutterImgEmits>();
/* ============================================================
* Types
* ============================================================ */
interface CutterResult {
fileName: string;
file: File;
@@ -119,64 +198,130 @@ interface CutterResult {
dataURL: string;
}
const props = withDefaults(defineProps<Props>(), {
// 基础配置默认值
isModal: false,
tool: true,
toolBgc: "#fff",
title: "",
previewTitle: "",
showPreview: true,
// 尺寸相关默认值
boxWidth: 700,
boxHeight: 458,
cutWidth: 470,
cutHeight: 270,
sizeChange: true,
// 移动和缩放默认值
moveAble: true,
imgMove: true,
scaleAble: true,
// 图片相关默认值
originalGraph: true,
crossOrigin: true,
fileType: "png",
quality: 0.9,
// 水印默认值
watermarkText: "",
watermarkFontSize: 20,
watermarkColor: "#ffffff",
// 其他功能默认值
saveCutPosition: true,
previewMode: true,
});
interface Emits {
"update:imgUrl": [value: string];
error: [error: any];
imageLoadComplete: [result: CutterResult];
imageLoadError: [error: any];
}
const emit = defineEmits<Emits>();
/* ============================================================
* State
* ============================================================ */
const temImgPath = ref("");
const imgCutterModal = ref();
const confirmElRef = ref<HTMLElement>();
// 计算属性:整合所有ImgCutter的props
const cutterProps = computed(() => ({
...props,
WatermarkText: props.watermarkText,
WatermarkFontSize: props.watermarkFontSize,
WatermarkColor: props.watermarkColor,
}));
/* ============================================================
* Computed: pass only native ImgCutter props
* ============================================================ */
const IMG_CUTTER_PROP_KEYS = new Set([
"isModal",
"showChooseBtn",
"lockScroll",
"modalTitle",
"boxWidth",
"boxHeight",
"cutWidth",
"cutHeight",
"tool",
"toolBgc",
"sizeChange",
"moveAble",
"imgMove",
"originalGraph",
"crossOrigin",
"crossOriginHeader",
"rate",
"WatermarkText",
"WatermarkTextFont",
"WatermarkTextColor",
"WatermarkTextX",
"WatermarkTextY",
"smallToUpload",
"saveCutPosition",
"scaleAble",
"toolBoxOverflow",
"index",
"previewMode",
"fileType",
"quality",
"accept",
"afterChooseImg",
]);
const imgCutterProps = computed(() => {
const result: Record<string, unknown> = {};
for (const key of IMG_CUTTER_PROP_KEYS) {
const k = key as keyof FaCutterImgProps;
if (props[k] != null) {
result[key] = props[k];
}
}
// 水印文字兼容 deprecated WatermarkText
if (props.watermarkText) {
result.WatermarkText = props.watermarkText;
}
// 水印字体映射
if (props.watermarkTextFont) {
result.WatermarkTextFont = props.watermarkTextFont;
}
// 水印颜色映射
if (props.watermarkTextColor) {
result.WatermarkTextColor = props.watermarkTextColor;
}
// 水印位置映射
if (props.watermarkTextX != null) {
result.WatermarkTextX = props.watermarkTextX;
}
if (props.watermarkTextY != null) {
result.WatermarkTextY = props.watermarkTextY;
}
// 比例映射
if (props.rate) {
result.rate = props.rate;
}
return result;
});
/* ============================================================
* Methods: ImgCutter 事件处理 → emit camelCase 事件
* ============================================================ */
function onCutDown(result: CutterResult) {
emit("update:imgUrl", result.dataURL);
emit("cut-down", result);
}
function onPrintImg(result: { dataURL: string }) {
temImgPath.value = result.dataURL;
emit("print-img", result);
}
function onChooseImg(result: unknown) {
emit("choose-img", result);
}
function onClearAll() {
temImgPath.value = "";
emit("clear-all");
}
function onImageLoadComplete(result: unknown) {
emit("image-load-complete", result);
}
function onImageLoadError(error: Error) {
emit("error", error);
emit("image-load-error", error);
}
function onError(error: Error) {
emit("error", error);
}
/* ============================================================
* Methods: 图片预加载
* ============================================================ */
// 图片预加载
function preloadImage(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image();
@@ -187,7 +332,6 @@ function preloadImage(url: string): Promise<void> {
});
}
// 初始化裁剪器
async function initImgCutter() {
if (props.imgUrl) {
try {
@@ -197,21 +341,16 @@ async function initImgCutter() {
src: props.imgUrl,
});
} catch (error) {
emit("error", error);
emit("error", error as Error);
console.error("图片加载失败:", error);
}
}
}
// 生命周期钩子
onMounted(() => {
if (props.imgUrl) {
temImgPath.value = props.imgUrl;
initImgCutter();
}
});
/* ============================================================
* Watch: imgUrl 变化
* ============================================================ */
// 监听图片URL变化
watch(
() => props.imgUrl,
(newVal) => {
@@ -222,39 +361,27 @@ watch(
}
);
// 实时预览
function cutterPrintImg(result: { dataURL: string }) {
temImgPath.value = result.dataURL;
}
onMounted(() => {
if (props.imgUrl) {
temImgPath.value = props.imgUrl;
initImgCutter();
}
});
// 裁剪完成
function cutDownImg(result: CutterResult) {
emit("update:imgUrl", result.dataURL);
}
/* ============================================================
* Methods: 下载
* ============================================================ */
// 图片加载完成
function handleImageLoadComplete(result: any) {
emit("imageLoadComplete", result);
}
// 图片加载失败
function handleImageLoadError(error: any) {
emit("error", error);
emit("imageLoadError", error);
}
// 清除所有
function handleClearAll() {
temImgPath.value = "";
}
// 下载图片
function downloadImg() {
function handleDownload() {
const a = document.createElement("a");
a.href = temImgPath.value;
a.download = "image.png";
a.click();
}
function triggerCrop() {
confirmElRef.value?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
</script>
<style lang="scss" scoped>
@@ -274,7 +401,7 @@ function downloadImg() {
.preview-container {
.preview-box {
background-color: var(--fa-active-color) !important;
background-color: var(--art-active-color) !important;
.preview-img {
width: 100%;
@@ -283,9 +410,11 @@ function downloadImg() {
}
}
.download-btn {
display: block;
margin: 20px auto;
.preview-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 20px;
}
}
@@ -354,66 +483,3 @@ function downloadImg() {
}
}
</style>
<!--
案例
<template>
<div class="page-content">
<ArtCutterImg
v-model:imgUrl="imageUrl"
:boxWidth="530"
:boxHeight="300"
:cutWidth="360"
:cutHeight="200"
:quality="1"
:tool="true"
:watermarkText="'My Watermark'"
watermarkColor="#ff0000"
:showPreview="true"
:originalGraph="false"
:title="'图片裁剪'"
:previewTitle="'预览效果'"
@error="handleError"
@imageLoadComplete="handleLoadComplete"
@imageLoadError="handleLoadError"
/>
</div>
</template>
<script setup lang="ts">
import lockImg from '@imgs/lock/bg_dark.webp'
defineOptions({ name: 'WidgetsImageCrop' })
/**
* 图片 URL
*/
const imageUrl = ref(lockImg)
/**
* 处理裁剪错误
* @param error 错误对象
*/
const handleError = (error: Error) => {
console.error('裁剪错误:', error)
ElMessage.error('图片裁剪失败')
}
/**
* 处理图片加载完成
* @param result 加载结果
*/
const handleLoadComplete = (result: { url: string; width: number; height: number }) => {
console.log('图片加载完成:', result)
}
/**
* 处理图片加载错误
* @param error 错误对象
*/
const handleLoadError = (error: Error) => {
console.error('图片加载失败:', error)
ElMessage.error('图片加载失败')
}
</script>
-->
@@ -11,6 +11,7 @@
destroy-on-close
v-bind="dialogAttrs"
@close="emit('close')"
@closed="emit('closed')"
@opened="emit('opened')"
>
<template #header="{ titleId, titleClass, close }">
@@ -83,6 +84,7 @@ const props = withDefaults(defineProps<Props>(), {
interface Emits {
"update:modelValue": [v: boolean];
close: [];
closed: [];
opened: [];
"fullscreen-change": [isFullscreen: boolean];
/** 点击取消按钮 */
@@ -26,11 +26,9 @@ interface MenuTreeTableProps {
* 负责:搜索过滤、级联勾选、父子联动、展开/收起、初始化选中、对外方法
*/
export function useMenuTreeTable(rawProps: MenuTreeTableProps) {
const props = {
menuTree: rawProps.menuTree,
checkedIds: rawProps.checkedIds ?? [],
loading: rawProps.loading,
};
// 直接使用父组件传入的 props(vue 3 中子组件 props 本身就是 reactive proxy)
// 不要再 reactive() 一次,否则会丢失响应式(只能拿到首次快照)
const props = rawProps;
// ---- 状态 ----
const filterText = ref("");
const isExpanded = ref(true);
@@ -336,22 +334,23 @@ export function useMenuTreeTable(rawProps: MenuTreeTableProps) {
function refresh() {
filterText.value = "";
const tree = props.menuTree;
parentChildLinked.value = checkParentChildLinked(props.checkedIds, tree);
const ids = props.checkedIds ?? [];
parentChildLinked.value = checkParentChildLinked(ids, tree);
tableData.value = filterTableData(tree);
nextTick(() => initSelection(props.checkedIds));
nextTick(() => initSelection(ids));
}
// ---- 数据变化监听 ----
watch(
() => [props.menuTree, props.checkedIds] as const,
() => refresh(),
{ immediate: false }
{ immediate: true, deep: true }
);
watch(filterText, () => {
const tree = filteredMenuTree.value;
tableData.value = filterTableData(tree);
nextTick(() => initSelection(props.checkedIds));
nextTick(() => initSelection(props.checkedIds ?? []));
if (filterText.value) setAllRowsExpanded(true);
});
@@ -1,13 +1,11 @@
<!-- 单图上传组件 -->
<template>
<div class="single-image-upload">
<ElDialog
<FaDialog
v-model="cropVisible"
:title="cropDialogTitle"
width="640px"
append-to-body
destroy-on-close
class="single-image-upload__crop-dialog"
width="960px"
:draggable="false"
@closed="onCropDialogClosed"
>
<FaCutterImg
@@ -28,7 +26,7 @@
@update:img-url="onCropConfirm"
@error="onCropError"
/>
</ElDialog>
</FaDialog>
<ElUpload
v-model:file-list="internalFileList"
@@ -82,6 +80,7 @@ defineOptions({ name: "FaUpload" });
import { ref, watch } from "vue";
import { UploadRawFile, UploadRequestOptions, ElMessage, type UploadUserFile } from "element-plus";
import { CircleCloseFilled } from "@element-plus/icons-vue";
import ParamsAPI from "@/api/module_system/params";
import { dataURLToFile } from "@utils";
@@ -146,11 +145,11 @@ const props = withDefaults(defineProps<Props>(), {
tipText: "",
enablePreview: true,
enableCrop: false,
cropBoxWidth: 520,
cropBoxHeight: 360,
cropCutWidth: 400,
cropCutHeight: 300,
cropQuality: 0.92,
cropBoxWidth: 530,
cropBoxHeight: 300,
cropCutWidth: 360,
cropCutHeight: 200,
cropQuality: 1,
cropFileType: "jpeg",
cropDialogTitle: "裁剪图片",
cropInnerTitle: "调整图片",