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
+56
View File
@@ -56,8 +56,64 @@ export const AiChatAPI = {
method: "get",
});
},
// ============ AI 模型配置 ============ //
getModelConfig() {
return request<ApiResponse<AiModelConfigList>>({
url: `${API_PATH}/model`,
method: "get",
});
},
createModelConfig(body: AiModelConfigInput) {
return request<ApiResponse<AiModelConfigItem>>({
url: `${API_PATH}/model`,
method: "post",
data: body,
});
},
updateModelConfig(id: string, body: AiModelConfigInput) {
return request<ApiResponse<AiModelConfigItem>>({
url: `${API_PATH}/model/${id}`,
method: "put",
data: body,
});
},
deleteModelConfig(id: string) {
return request<ApiResponse<null>>({
url: `${API_PATH}/model/${id}`,
method: "delete",
});
},
activateModelConfig(id: string) {
return request<ApiResponse<null>>({
url: `${API_PATH}/model/${id || "__default__"}/activate`,
method: "post",
});
},
};
export interface AiModelConfigInput {
name: string;
base_url: string;
api_key: string;
model_id: string;
temperature: number;
}
export interface AiModelConfigItem extends AiModelConfigInput {
id: string;
created_time: string | null;
}
export interface AiModelConfigList {
items: AiModelConfigItem[];
active_id: string | null;
}
export default AiChatAPI;
export interface ChatSessionMessage {
@@ -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: "调整图片",
+4 -4
View File
@@ -20,13 +20,13 @@ const fastEnterConfig: FastEnterConfig = {
routeName: "FastlinkTutorial",
},
{
name: "使用文档",
description: "使用指南与开发文档",
icon: "ri:book-2-line",
name: "文章列表",
description: "文章管理与查看",
icon: "ri:article-line",
iconColor: "#377dff",
enabled: true,
order: 2,
link: WEB_LINKS.DOCS,
routeName: "FastlinkArticleList",
},
{
name: "定价",
@@ -0,0 +1,66 @@
/** 文章详情 Mock 数据 */
export const ArticleDetail: Record<number, string> = {
452: `
<h2>Node.js + Docker 自动化部署</h2>
<p>本章将介绍 Node.js 使用 Docker、Webhook 自动化部署、蓝绿部署、项目到服务器。</p>
<h3>1、Mac os 安装 Docker 客户端 OrbStack</h3>
<p>我这里使用的是第三方客户端,相比于官方的,较轻量,启动速度快。</p>
<p>OrbStack 是一种快速、轻便且简单的运行 Docker 容器和 Linux 的方法。使用我们的 Docker Desktop 替代方案以光速进行开发。</p>
<h3>2、Dockerfile 编写</h3>
<pre><code class="language-dockerfile">FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
</code></pre>
<h3>3、docker-compose.yml</h3>
<pre><code class="language-yaml">version: '3'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
restart: always
</code></pre>
<h3>4、自动化部署流程</h3>
<p>通过 Webhook 实现代码推送后自动触发构建和部署流程,实现 CI/CD。</p>
`,
451: `
<h2>HTTP 协议</h2>
<h3>概念</h3>
<p>HTTPhypertext transport protocol)协议;中文叫超文本传输协议是一种基于 TCP/IP 的应用层通信协议。</p>
<p>这个协议详细规定了浏览器和万维网服务器之间互相通信的规则。</p>
<h3>协议中主要规定了两个方面的内容</h3>
<ul>
<li><strong>客户端</strong>:用来向服务器发送数据,可以被称之为请求报文</li>
<li><strong>服务端</strong>:向客户端返回数据,可以被称之为响应报文</li>
</ul>
<h3>请求报文的组成</h3>
<ol>
<li>请求行</li>
<li>请求头</li>
<li>空行</li>
<li>请求体</li>
</ol>
`,
450: `
<h2>MongoDB 数据库基本操作</h2>
<h3>简介</h3>
<p>Mongodb 是什么?MongoDB 是一个基于分布式文件存储的数据库,官方地址 https://www.mongodb.com/</p>
<h3>数据库是什么</h3>
<p>数据库(DataBase)是按照数据结构来组织、存储和管理数据的 应用程序。</p>
<p>数据库的主要作用就是管理数据,对数据进行增(c)、删(d)、改(u)、查(r)。</p>
<h3>数据库管理数据的特点</h3>
<p>相比于纯文件管理数据,数据库管理数据有如下特点:</p>
<ol>
<li>速度更快</li>
<li>可扩展性强</li>
<li>数据持久化</li>
<li>支持并发操作</li>
</ol>
`,
};
@@ -298,7 +298,7 @@ export const useSettingsStore = defineStore(
menuOpen.value = open;
};
/** 切换 `refresh`,驱动 `layouts/art-page-content` 内 `v-if="isRefresh"` 重建视图 */
/** 切换 `refresh`,驱动 `layouts/fa-page-content` 内 `v-if="isRefresh"` 重建视图 */
const reload = () => {
refresh.value = !refresh.value;
};
@@ -1,12 +1,10 @@
<!-- 留言墙大抽屉替代独立路由 -->
<template>
<ElDrawer
<FaDrawer
v-model="visible"
title="留言墙"
direction="rtl"
size="min(960px, 96vw)"
append-to-body
destroy-on-close
class="article-comment-wall-drawer"
>
<p class="mt-0 mb-8 text-g-600">每一份留言都记录了您的想法也为我们提供了珍贵的回忆</p>
@@ -41,42 +39,39 @@
</li>
</ul>
<ElDrawer
<FaDrawer
v-model="cardDrawerOpen"
title="详情"
:lock-scroll="false"
:size="360"
append-to-body
modal-class="comment-modal"
>
<template #header>
<h4 class="m-0">详情</h4>
</template>
<template #default>
<div class="drawer-default">
<div
class="relative p-4 aspect-16/12 rounded-md"
:style="{ background: clickItem.color }"
>
<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 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 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>
<div class="drawer-default">
<div class="relative p-4 aspect-16/12 rounded-md" :style="{ background: clickItem.color }">
<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 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 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>
<span class="text-sm text-gray-700">{{ clickItem.userName }}</span>
</div>
<span class="text-sm text-gray-700">{{ clickItem.userName }}</span>
</div>
</div>
</template>
</ElDrawer>
</ElDrawer>
<!-- 评论组件 -->
<div class="mt-6 px-2">
<FaCommentWidget />
</div>
</div>
</FaDrawer>
</FaDrawer>
</template>
<script setup lang="ts">
@@ -0,0 +1,188 @@
<!-- 文章详情弹窗无独立路由 -->
<template>
<FaDialog
v-model="visible"
:title="dialogTitle"
width="min(900px, 96vw)"
:show-footer="false"
class="article-detail-dialog"
>
<div v-loading="loading" class="article-detail-inner">
<ElEmpty v-if="error" :description="error" />
<div
v-else-if="articleHtml"
class="markdown-body article-detail-markdown"
v-highlight
v-html="articleHtml"
/>
</div>
<template #footer>
<div class="flex flex-wrap justify-end gap-2">
<ElButton v-if="articleId != null" @click="emitOpenCommentWall">
<FaSvgIcon icon="ri:message-3-line" class="mr-1" />
留言讨论
</ElButton>
<ElButton @click="visible = false">关闭</ElButton>
<ElButton v-if="articleId != null" type="primary" @click="onEditClick"> 编辑 </ElButton>
</div>
</template>
</FaDialog>
</template>
<script setup lang="ts">
import "./_markdown.scss";
import "./_highlight.scss";
import DOMPurify from "dompurify";
import { ArticleDetail } from "@/mock/temp/articleDetail";
defineOptions({ name: "ArticleDetail" });
const props = defineProps<{
modelValue: boolean;
articleId?: number | null;
}>();
interface Emits {
"update:modelValue": [boolean];
edit: [id: number];
"open-comment-wall": [];
}
const emit = defineEmits<Emits>();
const emitOpenCommentWall = () => {
emit("open-comment-wall");
};
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const articleTitle = ref("");
const articleHtml = shallowRef("");
const loading = ref(false);
const error = ref<string | null>(null);
const dialogTitle = computed(() => articleTitle.value || "文章详情");
const fetchDetail = async () => {
const id = props.articleId;
if (id == null) return;
loading.value = true;
error.value = null;
articleTitle.value = "";
articleHtml.value = "";
try {
// 使用 mock 数据
await new Promise((resolve) => setTimeout(resolve, 300));
const mockHtml = ArticleDetail[id];
if (mockHtml) {
const titleMatch = mockHtml.match(/<h2[^>]*>(.*?)<\/h2>/);
articleTitle.value = titleMatch?.[1]?.replace(/<[^>]*>/g, "") || "文章详情";
articleHtml.value = DOMPurify.sanitize(mockHtml);
} else {
error.value = "暂无该文章内容";
}
} catch (err) {
error.value = "文章加载失败,请检查网络连接";
console.error("获取文章详情失败:", err);
} finally {
loading.value = false;
}
};
const onEditClick = () => {
if (props.articleId != null) {
emit("edit", props.articleId);
}
};
watch(
() => [props.modelValue, props.articleId] as const,
([open, id]) => {
if (open && id != null) {
nextTick(() => fetchDetail());
}
}
);
</script>
<style lang="scss" scoped>
.article-detail-inner {
min-height: 120px;
max-height: 70vh;
overflow-y: auto;
}
.article-detail-markdown {
margin-top: 8px;
:deep(img) {
width: 100%;
border: 1px solid var(--fa-gray-200);
}
:deep(pre) {
position: relative;
&:hover {
.copy-button {
opacity: 1;
}
}
&::before {
position: absolute;
top: 0;
left: 50px;
width: 1px;
height: 100%;
content: "";
background: #0a0a0e;
}
}
:deep(.code-wrapper) {
overflow-x: auto;
}
:deep(.line-number) {
position: sticky;
left: 0;
z-index: 2;
box-sizing: border-box;
display: inline-block;
width: 50px;
margin-right: 10px;
font-size: 14px;
color: #9e9e9e;
text-align: center;
}
:deep(.copy-button) {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
font-size: 20px;
line-height: 40px;
color: #999;
text-align: center;
cursor: pointer;
background-color: #000;
border: none;
border-radius: 8px;
opacity: 0;
transition: all 0.2s;
}
}
</style>
@@ -1,12 +1,10 @@
<!-- 文章详情抽屉无独立路由 -->
<template>
<ElDrawer
<FaDrawer
v-model="visible"
:title="drawerTitle"
direction="rtl"
size="min(900px, 100vw)"
destroy-on-close
append-to-body
class="article-detail-drawer"
>
<div v-loading="loading" class="article-detail-drawer-inner">
@@ -28,7 +26,7 @@
<ElButton v-if="articleId != null" type="primary" @click="onEditClick"> 编辑 </ElButton>
</div>
</template>
</ElDrawer>
</FaDrawer>
</template>
<script setup lang="ts">
@@ -1,12 +1,10 @@
<!-- 文章新增 / 编辑抽屉替代独立发布页 -->
<template>
<ElDrawer
<FaDrawer
v-model="visible"
:title="drawerTitle"
direction="rtl"
size="min(920px, 100vw)"
destroy-on-close
append-to-body
class="article-publish-drawer"
>
<div class="article-publish-drawer-body">
@@ -117,7 +115,7 @@
</template>
</FaResultPage>
</div>
</ElDrawer>
</FaDrawer>
</template>
<script setup lang="ts">
@@ -1,5 +1,3 @@
/* highlight.js 代码块主题:One Dark Pro 风格 */
/* 实际是 highlight.js 主题色(项目自定义文件,误命名 one-dark-pro 后未跟进) */
.hljs {
display: block;
padding: 0.5em;
@@ -14,3 +12,86 @@
.hljs-deletion {
color: #aed07e !important;
}
.hljs-comment,
.hljs-quote {
color: #6f747d;
}
.hljs-doctag,
.hljs-keyword,
.hljs-formula {
color: #c792ea;
}
.hljs-section,
.hljs-name,
.hljs-selector-tag,
.hljs-deletion,
.hljs-subst {
color: #c86068;
}
.hljs-literal {
color: #56b6c2;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-attribute,
.hljs-meta-string {
color: #abb2bf;
}
.hljs-attribute {
color: #c792ea;
}
.hljs-function {
color: #c792ea;
}
.hljs-type {
color: #f07178;
}
.hljs-title {
color: #82aaff !important;
}
.hljs-built_in,
.hljs-class {
color: #82aaff;
}
// 括号
.hljs-params {
color: #a6accd;
}
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-number {
color: #de7e61;
}
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id {
color: #61aeee;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}
@@ -94,7 +94,7 @@
/>
</div>
<ArticleDetailDrawer
<ArticleDetail
v-model="detailDrawerOpen"
:article-id="detailArticleId"
@edit="onDetailEdit"
@@ -114,14 +114,14 @@
<script setup lang="ts">
import { Search } from "@element-plus/icons-vue";
import ArticleCommentWallDrawer from "./components/ArticleCommentWallDrawer.vue";
import ArticleDetailDrawer from "./components/ArticleDetailDrawer.vue";
import ArticleDetail from "./components/ArticleDetail.vue";
import ArticlePublishDrawer from "./components/ArticlePublishDrawer.vue";
import { useDateFormat } from "@vueuse/core";
import { EmojiText } from "@utils";
import { ArticleList } from "@/mock/temp/articleList";
import { useCommon } from "@/hooks/core/useCommon";
defineOptions({ name: "ArticleList" });
defineOptions({ name: "FastlinkArticleList" });
const route = useRoute();
const router = useRouter();
@@ -137,7 +137,13 @@
</div>
</div>
<ElDialog v-model="avatarCropVisible" title="裁剪头像" @closed="onAvatarCropDialogClosed">
<FaDialog
v-model="avatarCropVisible"
title="裁剪头像"
width="960px"
:draggable="false"
@closed="onAvatarCropDialogClosed"
>
<FaCutterImg
v-if="avatarCropVisible && avatarCropSrc"
:key="avatarCropSrc"
@@ -155,7 +161,7 @@
@update:img-url="onAvatarCropConfirm"
@error="onAvatarCropImgError"
/>
</ElDialog>
</FaDialog>
<!-- 右侧表单 -->
<div class="flex-1 overflow-hidden max-md:w-full max-md:mt-3.5">
@@ -819,37 +819,214 @@
<!-- 组件展示 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="page-content mb-5">
<!-- 完整工具栏编辑器 -->
<ElCard class="editor-card">
<template #header>
<div class="card-header">
<span>🛠 完整工具栏编辑器</span>
<div class="header-buttons">
<ElButton size="small" @click="clearFullEditor">清空</ElButton>
<ElButton size="small" @click="getFullEditorContent">获取内容</ElButton>
<ElButton size="small" @click="setFullEditorDemo">设置示例</ElButton>
</div>
</div>
</template>
<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>
<FaWangEditor
ref="fullEditorRef"
v-model="fullEditorHtml"
height="400px"
placeholder="请输入内容,体验完整的编辑功能..."
:exclude-keys="[]"
/>
</ElCard>
<!-- 评论组件 -->
<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>
<!-- 简化工具栏编辑器 -->
<ElCard class="editor-card">
<template #header>
<div class="card-header">
<span> 简化工具栏编辑器</span>
<div class="header-buttons">
<ElButton size="small" @click="clearSimpleEditor">清空</ElButton>
<ElButton size="small" @click="getSimpleEditorContent">获取内容</ElButton>
<ElButton size="small" @click="setSimpleEditorDemo">设置示例</ElButton>
</div>
</div>
</template>
<!-- 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>
<FaWangEditor
ref="simpleEditorRef"
v-model="simpleEditorHtml"
height="400px"
placeholder="请输入内容,体验简化的编辑功能..."
:toolbar-keys="simpleToolbarKeys"
/>
</ElCard>
<!-- 内容对比预览 -->
<ElCard class="preview-card">
<template #header>
<span>📖 内容预览对比</span>
</template>
<ElRow :gutter="20">
<ElCol :span="12">
<h3>完整编辑器内容</h3>
<ElTabs v-model="fullActiveTab">
<ElTabPane label="渲染效果" name="preview">
<div class="content-preview" v-html="fullEditorHtml"></div>
</ElTabPane>
<ElTabPane label="HTML源码" name="html">
<ElInput
v-model="fullEditorHtml"
type="textarea"
:rows="8"
placeholder="HTML源码"
readonly
/>
</ElTabPane>
</ElTabs>
</ElCol>
<ElCol :span="12">
<h3>简化编辑器内容</h3>
<ElTabs v-model="simpleActiveTab">
<ElTabPane label="渲染效果" name="preview">
<div class="content-preview" v-html="simpleEditorHtml"></div>
</ElTabPane>
<ElTabPane label="HTML源码" name="html">
<ElInput
v-model="simpleEditorHtml"
type="textarea"
:rows="8"
placeholder="HTML源码"
readonly
/>
</ElTabPane>
</ElTabs>
</ElCol>
</ElRow>
</ElCard>
<!-- 使用说明 -->
<ElCard class="usage-card">
<template #header>
<span>📚 使用说明</span>
</template>
<ElCollapse v-model="activeCollapse">
<ElCollapseItem title="基础用法" name="basic">
<pre><code class="language-vue">&lt;template&gt;
&lt;ArtWangEditor v-model="content" /&gt;
&lt;/template&gt;
&lt;script setup lang="ts"&gt;
import { ref } from 'vue'
const content = ref('&lt;p&gt;初始内容&lt;/p&gt;')
&lt;/script&gt;</code></pre>
</ElCollapseItem>
<ElCollapseItem title="完整工具栏配置" name="full">
<pre><code class="language-vue">&lt;template&gt;
&lt;!-- 显示所有工具不排除任何功能 --&gt;
&lt;ArtWangEditor
v-model="content"
:exclude-keys="[]"
/&gt;
&lt;/template&gt;</code></pre>
</ElCollapseItem>
<ElCollapseItem title="简化工具栏配置" name="simple">
<pre><code class="language-vue">&lt;template&gt;
&lt;!-- 只显示基础编辑工具 --&gt;
&lt;ArtWangEditor
v-model="content"
:toolbar-keys="[
'bold', 'italic', 'underline', '|',
'bulletedList', 'numberedList', '|',
'insertLink', 'insertImage', '|',
'undo', 'redo'
]"
/&gt;
&lt;/template&gt;</code></pre>
</ElCollapseItem>
<ElCollapseItem title="自定义配置" name="config">
<pre><code class="language-vue">&lt;template&gt;
&lt;ArtWangEditor
v-model="content"
height="600px"
placeholder="请输入您的内容..."
:exclude-keys="['fontFamily', 'fontSize']"
:upload-config="{
maxFileSize: 5 * 1024 * 1024,
maxNumberOfFiles: 5
}"
/&gt;
&lt;/template&gt;</code></pre>
</ElCollapseItem>
<ElCollapseItem title="组件方法调用" name="methods">
<pre><code class="language-vue">&lt;template&gt;
&lt;ArtWangEditor ref="editorRef" v-model="content" /&gt;
&lt;el-button @click="handleClear"&gt;清空&lt;/el-button&gt;
&lt;el-button @click="handleFocus"&gt;聚焦&lt;/el-button&gt;
&lt;el-button @click="handleGetContent"&gt;获取内容&lt;/el-button&gt;
&lt;/template&gt;
&lt;script setup lang="ts"&gt;
import { ref } from 'vue'
const editorRef = ref()
const content = ref('')
const handleClear = () =&gt; {
editorRef.value?.clear()
}
const handleFocus = () =&gt; {
editorRef.value?.focus()
}
const handleGetContent = () =&gt; {
const html = editorRef.value?.getHtml()
console.log('编辑器内容:', html)
}
&lt;/script&gt;</code></pre>
</ElCollapseItem>
<ElCollapseItem title="工具栏配置说明" name="toolbar-config">
<div class="toolbar-explanation">
<h4>完整工具栏 vs 简化工具栏</h4>
<ElRow :gutter="16">
<ElCol :span="12">
<h5> 完整工具栏包含</h5>
<ul>
<li>文本格式加粗斜体下划线字体颜色背景色</li>
<li>段落格式标题引用对齐方式缩进</li>
<li>列表有序列表无序列表待办事项</li>
<li>插入链接图片表格分割线表情</li>
<li>代码代码块行内代码</li>
<li>操作撤销重做全屏清除格式</li>
</ul>
</ElCol>
<ElCol :span="12">
<h5> 简化工具栏包含</h5>
<ul>
<li>基础格式加粗斜体下划线</li>
<li>列表有序列表无序列表</li>
<li>插入链接图片</li>
<li>操作撤销重做</li>
</ul>
<p class="note">适用于简单的文本编辑场景界面更清爽</p>
</ElCol>
</ElRow>
</div>
</ElCollapseItem>
</ElCollapse>
</ElCard>
</div>
</ElTabPane>
</ElTabs>
@@ -1022,44 +1199,6 @@ 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 别名,兼容改版前后检索词) */
@@ -1113,6 +1252,185 @@ function handleAnchorClick(ev: MouseEvent) {
ev.preventDefault();
scrollToAnchor(href.slice(1));
}
const fullEditorRef = ref();
const simpleEditorRef = ref();
const fullActiveTab = ref("preview");
const simpleActiveTab = ref("preview");
const activeCollapse = ref(["basic"]);
/**
* 简化工具栏配置
* 只包含基础的编辑功能
*/
const simpleToolbarKeys = [
"bold",
"italic",
"underline",
"|",
"bulletedList",
"numberedList",
"|",
"insertLink",
"insertImage",
"|",
"undo",
"redo",
];
// 完整编辑器内容
const fullEditorHtml = ref(`<h1>🎨 完整工具栏编辑器示例</h1>
<p>这个编辑器包含所有功能,您可以体验丰富的格式编辑功能。</p>
<h2>✨ 文本样式</h2>
<p><strong>这是加粗的文字</strong></p>
<p><em>这是斜体文字</em></p>
<p><u>这是下划线文字</u></p>
<p><span style="color: rgb(194, 79, 74);">这是彩色文字</span></p>
<h2>📝 列表和待办</h2>
<ul>
<li>无序列表项 1</li>
<li>无序列表项 2</li>
</ul>
<ol>
<li>有序列表项 1</li>
<li>有序列表项 2</li>
</ol>
<ul class="w-e-todo">
<li class="w-e-todo-item"><input type="checkbox" checked="true" readonly="true" disabled="disabled"><span>已完成的任务</span></li>
<li class="w-e-todo-item"><input type="checkbox" readonly="true" disabled="disabled"><span>待完成的任务</span></li>
</ul>
<h2>💬 引用和表格</h2>
<blockquote>
这是一段引用文字,展示引用格式的效果。
</blockquote>
<table style="border-collapse: collapse; width: 100%;" border="1">
<thead>
<tr><th>功能</th><th>描述</th></tr>
</thead>
<tbody>
<tr><td>完整工具栏</td><td>包含所有编辑功能</td></tr>
<tr><td>自定义配置</td><td>支持灵活的工具栏配置</td></tr>
</tbody>
</table>
<h2>💻 代码块</h2>
<pre><code class="language-javascript">// 完整编辑器支持代码高亮
function createEditor() {
return new WangEditor({
container: '#editor',
toolbar: 'full' // 完整工具栏
});
}</code></pre>
<p>🔗 <a href="https://www.wangeditor.com/" target="_blank">访问官网了解更多</a></p>`);
// 简化编辑器内容
const simpleEditorHtml = ref(`<h1>✨ 简化工具栏编辑器示例</h1>
<p>这个编辑器只包含基础的编辑功能,界面更加简洁。</p>
<h2>基础文本格式</h2>
<p><strong>加粗文字</strong></p>
<p><em>斜体文字</em></p>
<p><u>下划线文字</u></p>
<h2>列表功能</h2>
<ul>
<li>无序列表项 1</li>
<li>无序列表项 2</li>
</ul>
<ol>
<li>有序列表项 1</li>
<li>有序列表项 2</li>
</ol>
<h2>链接和图片</h2>
<p>支持插入 <a href="https://www.wangeditor.com/" target="_blank">链接</a> 和图片。</p>
<p>简化版编辑器专注于基础功能,适合简单的内容编辑需求。</p>`);
/**
* 清空完整编辑器内容
*/
const clearFullEditor = () => {
fullEditorRef.value?.clear();
ElMessage.success("完整编辑器已清空");
};
/**
* 获取完整编辑器内容
*/
const getFullEditorContent = () => {
const content = fullEditorRef.value?.getHtml();
console.log("完整编辑器内容:", content);
ElMessage.success("完整编辑器内容已输出到控制台");
};
/**
* 设置完整编辑器演示内容
*/
const setFullEditorDemo = () => {
const demoContent = `<h2>🎉 完整编辑器演示内容</h2>
<p>这是通过方法设置的演示内容,展示完整编辑器的强大功能。</p>
<ul>
<li>支持丰富的文本格式</li>
<li>包含表格、代码块等高级功能</li>
<li>提供完整的编辑体验</li>
</ul>
<table style="border-collapse: collapse; width: 100%;" border="1">
<tr><th>特性</th><th>状态</th></tr>
<tr><td>完整工具栏</td><td>✅ 已启用</td></tr>
<tr><td>高级功能</td><td>✅ 已启用</td></tr>
</table>`;
fullEditorRef.value?.setHtml(demoContent);
ElMessage.success("已设置完整编辑器演示内容");
};
/**
* 清空简化编辑器内容
*/
const clearSimpleEditor = () => {
simpleEditorRef.value?.clear();
ElMessage.success("简化编辑器已清空");
};
/**
* 获取简化编辑器内容
*/
const getSimpleEditorContent = () => {
const content = simpleEditorRef.value?.getHtml();
console.log("简化编辑器内容:", content);
ElMessage.success("简化编辑器内容已输出到控制台");
};
/**
* 设置简化编辑器演示内容
*/
const setSimpleEditorDemo = () => {
const demoContent = `<h2>⚡ 简化编辑器演示内容</h2>
<p>这是通过方法设置的演示内容,展示简化编辑器的核心功能。</p>
<ul>
<li><strong>基础格式</strong>:加粗、斜体、下划线</li>
<li><em>列表支持</em>:有序和无序列表</li>
<li><u>媒体插入</u>:链接和图片</li>
</ul>
<ol>
<li>界面简洁清爽</li>
<li>功能专注实用</li>
<li>适合快速编辑</li>
</ol>
<p>🔗 <a href="https://example.com" target="_blank">这是一个链接示例</a></p>`;
simpleEditorRef.value?.setHtml(demoContent);
ElMessage.success("已设置简化编辑器演示内容");
};
</script>
<style scoped lang="scss">
@@ -1412,4 +1730,145 @@ function handleAnchorClick(ev: MouseEvent) {
padding: 12px 12px 24px;
}
}
.page-content {
padding: 20px;
}
.editor-card {
margin-bottom: 24px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-buttons {
display: flex;
gap: 8px;
}
.preview-card {
margin-bottom: 24px;
}
.preview-card h3 {
margin: 0 0 16px;
font-size: 16px;
color: var(--el-text-color-primary);
}
.content-preview {
min-height: 200px;
max-height: 300px;
padding: 16px;
overflow-y: auto;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.content-preview :deep(h1),
.content-preview :deep(h2),
.content-preview :deep(h3) {
margin: 16px 0 8px;
}
.content-preview :deep(p) {
margin: 8px 0;
line-height: 1.6;
}
.content-preview :deep(table) {
margin: 16px 0;
}
.content-preview :deep(table th),
.content-preview :deep(table td) {
padding: 8px 12px;
}
.content-preview :deep(pre) {
padding: 12px;
margin: 16px 0;
overflow-x: auto;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
.content-preview :deep(blockquote) {
padding-left: 16px;
margin: 16px 0;
color: var(--el-text-color-regular);
border-left: 4px solid var(--el-color-primary);
}
.usage-card :deep(.el-collapse-item__content) {
padding-bottom: 16px;
}
.usage-card pre {
padding: 16px;
margin: 0;
overflow-x: auto;
background-color: var(--el-fill-color-light);
border-radius: 6px;
}
.usage-card pre code {
font-family: Consolas, Monaco, "Courier New", monospace;
font-size: 14px;
line-height: 1.5;
}
.toolbar-explanation h4 {
margin: 0 0 16px;
color: var(--el-text-color-primary);
}
.toolbar-explanation h5 {
margin: 0 0 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
.toolbar-explanation ul {
padding-left: 20px;
margin: 8px 0 16px;
}
.toolbar-explanation ul li {
margin: 4px 0;
font-size: 13px;
color: var(--el-text-color-regular);
}
.toolbar-explanation .note {
margin: 8px 0 0;
font-size: 12px;
font-style: italic;
color: var(--el-text-color-placeholder);
}
@media (width <= 768px) {
.page-content {
padding: 12px;
}
.card-header {
flex-direction: column;
gap: 12px;
align-items: stretch !important;
}
.header-buttons {
justify-content: center;
}
.preview-card :deep(.el-col) {
margin-bottom: 16px;
}
}
</style>
@@ -0,0 +1,828 @@
<template>
<div class="ai-model-config">
<div v-if="loading" class="loading-tip">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
<template v-else>
<!-- 顶部状态栏 -->
<div class="status-bar">
<div class="status-info">
<span class="status-title">当前使用</span>
<ElTag :type="activeId ? 'primary' : 'info'" effect="dark" size="small">
<ElIcon class="tag-icon"><CircleCheck v-if="activeId" /><Cpu v-else /></ElIcon>
<span>{{ activeModelName }}</span>
</ElTag>
</div>
<ElButton
v-if="items.length > 0"
:disabled="!activeId"
size="small"
plain
@click="handleUseDefault"
>
<ElIcon><RefreshLeft /></ElIcon>
<span>恢复系统默认</span>
</ElButton>
</div>
<!-- 配置列表 -->
<div class="config-section">
<div class="section-header">
<div class="header-left">
<span class="section-title">已配置的模型</span>
<ElTag v-if="items.length > 0" size="small" effect="plain" type="info">
{{ items.length }}
</ElTag>
</div>
</div>
<!-- 空状态 - 直接显示添加按钮作为唯一行动 -->
<div v-if="items.length === 0" class="empty-state">
<div class="empty-illust">
<ElIcon class="empty-icon" :size="56"><Cpu /></ElIcon>
<ElIcon class="empty-icon-bg" :size="100"><ChatLineSquare /></ElIcon>
</div>
<div class="empty-title">添加你的第一个 AI 模型</div>
<div class="empty-desc">
支持 OpenAIDeepSeekOllama 等任何 OpenAI 兼容服务<br />
配置后即可在 AI 助手页一键切换
</div>
<ElButton type="primary" size="large" :icon="Plus" @click="openCreate">
立即添加
</ElButton>
</div>
<!-- 列表 -->
<div v-else class="config-list">
<TransitionGroup name="list" tag="div" class="list-inner">
<div
v-for="item in items"
:key="item.id"
class="config-item"
:class="{
active: item.id === activeId,
expanded: expandedId === item.id,
flash: flashId === item.id,
}"
@click="handleItemClick(item)"
>
<div class="config-item-main">
<div class="item-icon-wrap">
<ElIcon class="item-icon" :size="18">
<CircleCheck v-if="item.id === activeId" />
<ChatLineSquare v-else />
</ElIcon>
</div>
<div class="item-content">
<div class="item-row1">
<span class="item-name">{{ item.name }}</span>
<ElTag v-if="item.id === activeId" type="success" size="small" effect="light">
使用中
</ElTag>
</div>
<div class="item-model">{{ item.model_id }}</div>
</div>
<div class="item-actions" @click.stop>
<ElTooltip content="展开详情" placement="top" :show-after="200">
<ElButton text circle size="small" @click="toggleExpand(item.id)">
<ElIcon :class="{ rotated: expandedId === item.id }">
<ArrowDown />
</ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip content="编辑" placement="top" :show-after="200">
<ElButton text circle size="small" :icon="Edit" @click="openEdit(item)" />
</ElTooltip>
<ElTooltip content="删除" placement="top" :show-after="200">
<ElButton text circle size="small" :icon="Delete" @click="handleDelete(item)" />
</ElTooltip>
</div>
</div>
<!-- 展开详情 -->
<div v-show="expandedId === item.id" class="item-detail">
<div class="detail-row">
<span class="detail-label">Base URL</span>
<span class="detail-value">{{ item.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">API Key</span>
<div class="api-key-wrap">
<span class="detail-value api-key">
{{ showKeyId === item.id ? item.api_key : `****${maskKey(item.api_key)}` }}
</span>
<ElButton
text
size="small"
@click="showKeyId = showKeyId === item.id ? null : item.id"
>
<ElIcon><View v-if="showKeyId !== item.id" /><Hide v-else /></ElIcon>
</ElButton>
<ElButton
text
size="small"
:disabled="!item.api_key"
@click="copyKey(item.api_key)"
>
<ElIcon><CopyDocument /></ElIcon>
</ElButton>
</div>
</div>
<div class="detail-row">
<span class="detail-label">Temperature</span>
<span class="detail-value">{{ item.temperature.toFixed(1) }}</span>
</div>
<div v-if="item.created_time" class="detail-row">
<span class="detail-label">添加于</span>
<span class="detail-value">{{ item.created_time }}</span>
</div>
</div>
</div>
</TransitionGroup>
</div>
</div>
<!-- 底部固定添加按钮 - 始终可见 -->
<div v-if="items.length > 0" class="footer-add">
<ElButton type="primary" plain :icon="Plus" class="add-btn" @click="openCreate">
添加新模型
</ElButton>
</div>
</template>
<!-- 新增/编辑弹窗 -->
<FaDialog
v-model="dialogVisible"
:title="form.id ? '编辑模型' : '新增模型'"
width="520px"
:close-on-click-modal="false"
>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
label-position="right"
@submit.prevent="handleSave"
>
<ElFormItem label="配置名称" prop="name">
<ElInput
v-model="form.name"
placeholder="如:日常对话 / 代码助手"
maxlength="50"
show-word-limit
clearable
autofocus
/>
</ElFormItem>
<ElFormItem label="Base URL" prop="base_url">
<ElInput v-model="form.base_url" placeholder="https://api.openai.com/v1" clearable>
<template #append>
<ElDropdown trigger="click" @command="(v: string) => (form.base_url = v)">
<ElButton text size="small">
预设
<ElIcon><ArrowDown /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="preset in baseUrlPresets"
:key="preset.label"
:command="preset.url"
>
<div class="preset-item">
<span class="preset-label">{{ preset.label }}</span>
<span class="preset-url">{{ preset.url }}</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="API Key" prop="api_key">
<ElInput
v-model="form.api_key"
type="password"
placeholder="sk-..."
show-password
clearable
/>
</ElFormItem>
<ElFormItem label="模型 ID" prop="model_id">
<ElInput
v-model="form.model_id"
placeholder="如:gpt-4o-mini / deepseek-chat"
clearable
/>
</ElFormItem>
<ElFormItem label="Temperature" prop="temperature">
<ElSlider
v-model="form.temperature"
:min="0"
:max="2"
:step="0.1"
show-input
:show-input-controls="false"
/>
<div class="form-tip">越高越有创造性0 更确定</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="handleSave">
<ElIcon><Check /></ElIcon>
<span>{{ form.id ? "保存" : "新增并使用" }}</span>
</ElButton>
</template>
</FaDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import {
Plus,
Edit,
Delete,
Cpu,
ChatLineSquare,
CircleCheck,
ArrowDown,
RefreshLeft,
Loading,
Check,
View,
Hide,
CopyDocument,
} from "@element-plus/icons-vue";
import AiChatAPI, {
type AiModelConfigInput,
type AiModelConfigItem,
type AiModelConfigList,
} from "@/api/module_ai/chat";
const emit = defineEmits<{ changed: [] }>();
const loading = ref(false);
const saving = ref(false);
const items = ref<AiModelConfigItem[]>([]);
const activeId = ref<string | null>(null);
const expandedId = ref<string | null>(null);
const showKeyId = ref<string | null>(null);
const flashId = ref<string | null>(null);
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
const form = reactive<AiModelConfigItem>({
id: "",
name: "",
base_url: "",
api_key: "",
model_id: "",
temperature: 0.7,
created_time: null,
});
// Base URL -
const baseUrlPresets = [
{ label: "OpenAI 官方", url: "https://api.openai.com/v1" },
{ label: "DeepSeek", url: "https://api.deepseek.com/v1" },
{ label: "通义千问", url: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
{ label: "月之暗面 Moonshot", url: "https://api.moonshot.cn/v1" },
{ label: "智谱 GLM", url: "https://open.bigmodel.cn/api/paas/v4" },
{ label: "Ollama (本地)", url: "http://localhost:11434/v1" },
];
const rules: FormRules<AiModelConfigInput> = {
name: [{ required: true, message: "请输入配置名称", trigger: "blur" }],
base_url: [
{ required: true, message: "请输入 Base URL", trigger: "blur" },
{
validator: (_rule, value: string, callback) => {
if (!value) return callback();
if (!/^https?:\/\//.test(value)) {
callback(new Error("必须以 http:// 或 https:// 开头"));
} else {
callback();
}
},
trigger: "blur",
},
],
api_key: [{ required: true, message: "请输入 API Key", trigger: "blur" }],
model_id: [{ required: true, message: "请输入模型 ID", trigger: "blur" }],
temperature: [{ required: true, message: "请设置温度", trigger: "change" }],
};
const activeModelName = computed(() => {
if (!activeId.value) return "系统默认";
const item = items.value.find((i) => i.id === activeId.value);
return item?.name || "系统默认";
});
const loadList = async () => {
loading.value = true;
try {
const res = await AiChatAPI.getModelConfig();
if (res.data?.code === 0 && res.data.data) {
const data: AiModelConfigList = res.data.data;
items.value = data.items || [];
activeId.value = data.active_id;
}
} catch {
ElMessage.error("加载模型配置失败");
} finally {
loading.value = false;
}
};
const resetForm = () => {
form.id = "";
form.name = "";
form.base_url = "";
form.api_key = "";
form.model_id = "";
form.temperature = 0.7;
form.created_time = null;
formRef.value?.clearValidate();
};
const openCreate = () => {
resetForm();
dialogVisible.value = true;
};
const openEdit = (item: AiModelConfigItem) => {
form.id = item.id;
form.name = item.name;
form.base_url = item.base_url;
form.api_key = item.api_key;
form.model_id = item.model_id;
form.temperature = item.temperature;
form.created_time = item.created_time;
formRef.value?.clearValidate();
dialogVisible.value = true;
};
const handleSave = async () => {
if (!formRef.value || saving.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
saving.value = true;
try {
const payload: AiModelConfigInput = {
name: form.name,
base_url: form.base_url,
api_key: form.api_key,
model_id: form.model_id,
temperature: form.temperature,
};
let res;
if (form.id) {
res = await AiChatAPI.updateModelConfig(form.id, payload);
} else {
res = await AiChatAPI.createModelConfig(payload);
}
if (res.data?.code === 0) {
const newId = form.id || res.data.data?.id;
dialogVisible.value = false;
if (!form.id && newId) {
await AiChatAPI.activateModelConfig(newId);
}
resetForm();
emit("changed");
await loadList();
if (newId) flashHighlight(newId);
ElMessage.success(form.id ? "已保存" : "已添加并启用");
} else {
ElMessage.error(res.data?.msg || "保存失败");
}
} catch {
ElMessage.error("保存失败");
} finally {
saving.value = false;
}
};
const flashHighlight = (id: string) => {
flashId.value = id;
setTimeout(() => {
flashId.value = null;
}, 1500);
};
const handleItemClick = async (item: AiModelConfigItem) => {
if (item.id === activeId.value) {
toggleExpand(item.id);
return;
}
try {
const res = await AiChatAPI.activateModelConfig(item.id);
if (res.data?.code === 0) {
activeId.value = item.id;
emit("changed");
} else {
ElMessage.error(res.data?.msg || "切换失败");
}
} catch {
ElMessage.error("切换失败");
}
};
const handleUseDefault = async () => {
try {
const res = await AiChatAPI.activateModelConfig("");
if (res.data?.code === 0) {
activeId.value = null;
emit("changed");
} else {
ElMessage.error(res.data?.msg || "操作失败");
}
} catch {
ElMessage.error("操作失败");
}
};
const handleDelete = async (item: AiModelConfigItem) => {
try {
await ElMessageBox.confirm(`确认删除模型「${item.name}」?此操作不可恢复`, "删除确认", {
type: "warning",
});
} catch {
return;
}
try {
const res = await AiChatAPI.deleteModelConfig(item.id);
if (res.data?.code === 0) {
ElMessage.success("已删除");
if (form.id === item.id) dialogVisible.value = false;
if (expandedId.value === item.id) expandedId.value = null;
emit("changed");
await loadList();
} else {
ElMessage.error(res.data?.msg || "删除失败");
}
} catch {
ElMessage.error("删除失败");
}
};
const toggleExpand = (id: string) => {
expandedId.value = expandedId.value === id ? null : id;
if (expandedId.value !== id) showKeyId.value = null;
};
const copyKey = async (key: string) => {
if (!key) return;
try {
await navigator.clipboard.writeText(key);
ElMessage.success("已复制到剪贴板");
} catch {
ElMessage.error("复制失败");
}
};
const maskKey = (key: string): string => {
if (!key) return "";
return key.length <= 4 ? key : key.slice(-4);
};
defineExpose({ refresh: loadList });
onMounted(loadList);
</script>
<style lang="scss" scoped>
.ai-model-config {
display: flex;
flex-direction: column;
gap: 16px;
}
.loading-tip {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 60px 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
/* 顶部状态栏 */
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.status-info {
display: flex;
gap: 8px;
align-items: center;
}
.status-title {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.tag-icon {
margin-right: 2px;
}
.config-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
gap: 8px;
align-items: center;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.empty-illust {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
margin-bottom: 8px;
}
.empty-icon {
position: relative;
z-index: 1;
color: var(--el-color-primary);
}
.empty-icon-bg {
position: absolute;
color: var(--el-color-primary-light-7);
opacity: 0.4;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-desc {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
/* 列表 */
.config-list {
width: 100%;
}
.list-inner {
display: flex;
flex-direction: column;
gap: 8px;
}
.config-item {
padding: 10px 12px;
cursor: pointer;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
transition: all 0.2s;
&.active {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
.item-icon {
color: var(--el-color-primary);
}
.item-icon-wrap {
background: var(--el-color-primary-light-7);
}
}
&.expanded {
background: var(--el-color-primary-light-9);
}
&:hover {
border-color: var(--el-color-primary-light-5);
.item-actions {
opacity: 1;
}
}
&.flash {
animation: flash 1.5s ease;
}
}
@keyframes flash {
0% {
box-shadow: 0 0 0 0 var(--el-color-primary-light-5);
}
30% {
box-shadow: 0 0 0 6px var(--el-color-primary-light-7);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
.config-item-main {
display: flex;
gap: 12px;
align-items: center;
}
.item-icon-wrap {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--el-fill-color-light);
border-radius: 8px;
transition: background 0.2s;
}
.item-icon {
color: var(--el-text-color-secondary);
transition: color 0.2s;
}
.item-content {
flex: 1;
min-width: 0;
}
.item-row1 {
display: flex;
gap: 8px;
align-items: center;
}
.item-name {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.item-model {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.item-actions {
display: flex;
flex-shrink: 0;
gap: 2px;
opacity: 0;
transition: opacity 0.2s;
}
.config-item.expanded .item-actions {
opacity: 1;
}
.item-detail {
padding: 12px 0 0 44px;
margin-top: 10px;
border-top: 1px dashed var(--el-border-color-light);
}
.detail-row {
display: flex;
gap: 12px;
align-items: center;
padding: 4px 0;
font-size: 12px;
}
.detail-label {
flex-shrink: 0;
width: 80px;
color: var(--el-text-color-secondary);
}
.detail-value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
color: var(--el-text-color-regular);
white-space: nowrap;
&.api-key {
font-family: var(--el-font-family-monospace, monospace);
}
}
.api-key-wrap {
display: flex;
flex: 1;
gap: 4px;
align-items: center;
}
/* 底部添加 */
.footer-add {
display: flex;
justify-content: center;
padding: 8px 0 0;
}
.add-btn {
width: 100%;
border-style: dashed;
}
/* 表单提示 */
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* 预设选项 */
.preset-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0;
}
.preset-label {
font-size: 13px;
font-weight: 500;
}
.preset-url {
font-size: 11px;
color: var(--el-text-color-secondary);
}
/* 列表过渡 */
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.list-leave-to {
opacity: 0;
transform: translateX(20px);
}
.list-move {
transition: transform 0.3s ease;
}
</style>
@@ -23,6 +23,56 @@
/>
</ElForm>
<div class="input-footer">
<div class="input-footer-left">
<ElDropdown
trigger="click"
placement="top-start"
@command="handleSelectModel"
@visible-change="handleDropdownVisible"
>
<div class="model-switcher" :class="{ 'is-active': dropdownVisible }">
<ElIcon class="model-icon"><Cpu /></ElIcon>
<span class="model-name" :title="activeModelName">
{{ activeModelName }}
</span>
<ElIcon class="model-arrow" :class="{ expanded: dropdownVisible }">
<ArrowDown />
</ElIcon>
</div>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="__default__" :class="{ 'is-active': !activeId }">
<ElIcon class="dropdown-icon"><MagicStick /></ElIcon>
<span class="dropdown-label">系统默认</span>
<ElTag v-if="!activeId" type="success" size="small" effect="plain">
使用中
</ElTag>
</ElDropdownItem>
<template v-if="items.length > 0">
<ElDropdownItem
v-for="item in items"
:key="item.id"
:command="item.id"
:class="{ 'is-active': item.id === activeId }"
:disabled="switching"
>
<ElIcon class="dropdown-icon"><ChatLineSquare /></ElIcon>
<div class="dropdown-content">
<div class="dropdown-label">{{ item.name }}</div>
<div class="dropdown-meta">{{ item.model_id }}</div>
</div>
<ElTag v-if="item.id === activeId" type="success" size="small" effect="plain">
使用中
</ElTag>
</ElDropdownItem>
</template>
<ElDropdownItem v-if="items.length === 0" disabled>
<span class="dropdown-empty">暂未配置模型请到"设置 → 配置中心"添加</span>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
<div class="input-actions">
<ElUpload
ref="uploadRef"
@@ -35,10 +85,18 @@
<ElButton :icon="Paperclip" class="upload-btn" circle />
</ElUpload>
<ElButton
:disabled="
(!inputMessage.trim() && uploadedFiles.length === 0) || disabled || sending
"
:loading="sending"
v-if="sending"
class="send-button"
type="danger"
circle
title="停止生成"
@click="handleStop"
>
<ElIcon><VideoPause /></ElIcon>
</ElButton>
<ElButton
v-else
:disabled="(!inputMessage.trim() && uploadedFiles.length === 0) || disabled"
class="send-button"
type="primary"
circle
@@ -57,10 +115,22 @@
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { Promotion, Paperclip, Document, Close } from "@element-plus/icons-vue";
import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
import {
Promotion,
Paperclip,
Document,
Close,
VideoPause,
Cpu,
ArrowDown,
ChatLineSquare,
MagicStick,
} from "@element-plus/icons-vue";
import type { UploadFile } from "element-plus";
import type { UploadedFile } from "../types";
import AiChatAPI, { type AiModelConfigItem, type AiModelConfigList } from "@/api/module_ai/chat";
interface Props {
disabled?: boolean;
@@ -70,6 +140,8 @@ interface Props {
interface Emits {
(e: "send", message: string, files?: UploadedFile[]): void;
(e: "stop"): void;
(e: "model-changed"): void;
}
const props = withDefaults(defineProps<Props>(), {
@@ -80,6 +152,60 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>();
// ============ ============ //
const dropdownVisible = ref(false);
const items = ref<AiModelConfigItem[]>([]);
const activeId = ref<string | null>(null);
const switching = ref(false);
const activeModelName = computed(() => {
if (!activeId.value) return "系统默认";
const item = items.value.find((i) => i.id === activeId.value);
return item?.name || "系统默认";
});
const loadModels = async () => {
try {
const res = await AiChatAPI.getModelConfig();
if (res.data?.code === 0 && res.data.data) {
const data: AiModelConfigList = res.data.data;
items.value = data.items || [];
activeId.value = data.active_id;
}
} catch {
/* 静默失败 */
}
};
const handleDropdownVisible = (visible: boolean) => {
dropdownVisible.value = visible;
if (visible) loadModels();
};
const handleSelectModel = async (command: string) => {
dropdownVisible.value = false;
if (command === activeId.value) return;
switching.value = true;
try {
const res = await AiChatAPI.activateModelConfig(command === "__default__" ? "" : command);
if (res.data?.code === 0) {
activeId.value = command === "__default__" ? null : command;
const newName =
command === "__default__" ? "系统默认" : items.value.find((i) => i.id === command)?.name;
ElMessage.success(`已切换到:${newName}`);
emit("model-changed");
} else {
ElMessage.error(res.data?.msg || "切换失败");
}
} catch {
ElMessage.error("切换模型失败");
} finally {
switching.value = false;
}
};
onMounted(loadModels);
const inputMessage = ref("");
const uploadedFiles = ref<UploadedFile[]>([]);
@@ -129,11 +255,17 @@ const handleSend = () => {
uploadedFiles.value = [];
};
const handleStop = () => {
if (!props.sending) return;
emit("stop");
};
const handleShiftEnter = () => {
inputMessage.value += "\n";
};
defineExpose({
refresh: loadModels,
focus: () => {
const input = document.querySelector(".message-input textarea") as HTMLTextAreaElement;
input?.focus();
@@ -243,9 +375,63 @@ defineExpose({
.input-footer {
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: space-between;
padding-top: 8px;
.input-footer-left {
display: flex;
gap: 8px;
align-items: center;
}
.model-switcher {
display: flex;
gap: 6px;
align-items: center;
height: 30px;
padding: 0 10px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
&.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
.model-icon {
font-size: 14px;
}
.model-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
white-space: nowrap;
}
.model-arrow {
font-size: 12px;
transition: transform 0.2s;
&.expanded {
transform: rotate(180deg);
}
}
}
.input-actions {
display: flex;
gap: 10px;
@@ -308,3 +494,56 @@ defineExpose({
}
}
</style>
<style lang="scss">
/* 下拉菜单项样式 - 因为 scoped 限制无法直接覆盖命令项 */
.el-dropdown-menu {
min-width: 240px;
.el-dropdown-menu__item {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
&.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.dropdown-icon {
flex-shrink: 0;
font-size: 14px;
}
.dropdown-content {
flex: 1;
min-width: 0;
}
.dropdown-label {
font-size: 14px;
font-weight: 500;
}
.dropdown-meta {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.dropdown-empty {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.el-tag {
flex-shrink: 0;
margin-left: auto;
}
}
}
</style>
@@ -13,11 +13,10 @@
class="size-6"
/>
</button>
<span class="navbar-title">FA 助手</span>
</div>
<div class="navbar-right">
<ElButton text :icon="Setting" @click="handleToggleConnection">
{{ isConnected ? "断开连接" : "重新连接" }}
</ElButton>
<ElTag
class="connection-status"
effect="plain"
@@ -30,15 +29,20 @@
</ElIcon>
<span class="status-text">{{ connectionStatusText }}</span>
</ElTag>
<ElButton v-if="hasMessages" text :icon="Delete" @click="handleClearChat">清空对话</ElButton>
<ElButton text :icon="Setting" @click="handleToggleConnection">
{{ isConnected ? "断开连接" : "重新连接" }}
</ElButton>
<ElButton v-if="hasMessages" text :icon="Delete" @click="handleClearChat">
清空对话
</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import { resolveIconForFaSvgIcon } from "@utils";
import { computed } from "vue";
import { Connection, Loading, Warning, Delete, Setting } from "@element-plus/icons-vue";
import { resolveIconForFaSvgIcon } from "@utils";
interface Props {
connectionStatus: "connected" | "connecting" | "disconnected";
@@ -73,17 +77,9 @@ const connectionStatusText = computed(() => {
const hasMessages = computed(() => props.messageCount > 0);
const handleClearChat = () => {
emit("clear-chat");
};
const handleToggleConnection = () => {
emit("toggle-connection");
};
const toggleSidebar = () => {
emit("toggle-sidebar");
};
const handleClearChat = () => emit("clear-chat");
const handleToggleConnection = () => emit("toggle-connection");
const toggleSidebar = () => emit("toggle-sidebar");
</script>
<style lang="scss" scoped>
@@ -91,47 +87,18 @@ const toggleSidebar = () => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
padding: 10px 16px;
.navbar-left {
display: flex;
gap: 12px;
align-items: center;
}
.collapse-btn {
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-regular);
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
transition:
background-color 0.2s,
color 0.2s;
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
/* UnoCSS 图标 SVG 多随 currentColor */
& > div {
color: inherit;
}
.collapse-icon {
width: 20px;
height: 20px;
color: inherit;
}
}
.navbar-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.navbar-right {
@@ -140,45 +107,72 @@ const toggleSidebar = () => {
gap: 12px;
align-items: center;
/* EP 相邻按钮自带 margin-left,叠在 flex gap 上会导致间距忽大忽小 */
:deep(.el-button) {
margin: 0;
}
}
}
.connection-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 12px;
margin: 0;
font-size: 14px;
line-height: 1;
.collapse-btn {
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-regular);
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
transition:
background-color 0.2s,
color 0.2s;
:deep(.el-tag__content) {
display: inline-flex;
gap: 6px;
align-items: center;
}
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.status-icon {
&.connected {
color: var(--el-color-success);
}
&:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
&.connecting {
color: var(--el-color-warning);
}
& > div {
color: inherit;
}
}
&.disconnected {
color: var(--el-color-danger);
}
}
.connection-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 12px;
margin: 0;
font-size: 14px;
line-height: 1;
.status-text {
color: var(--el-text-color-secondary);
}
:deep(.el-tag__content) {
display: inline-flex;
gap: 6px;
align-items: center;
}
.status-icon {
&.connected {
color: var(--el-color-success);
}
&.connecting {
color: var(--el-color-warning);
}
&.disconnected {
color: var(--el-color-danger);
}
}
.status-text {
color: var(--el-text-color-secondary);
}
}
</style>
@@ -135,6 +135,7 @@ interface Props {
interface Emits {
(e: "select-session", session: ChatSession): void;
(e: "new-session"): void;
(e: "open-config"): void;
}
const { currentSessionId, isCollapsed = false } = defineProps<Props>();
@@ -246,11 +247,10 @@ const handleSessionCommand = async (command: string, session: ChatSession) => {
await AiChatAPI.updateSession(session.id, { title: value });
session.title = value;
} catch (error) {
if (error !== "cancel") {
ElMessage.error("重命名失败");
} else {
if (error === "cancel") {
ElMessage.info("已取消重命名");
}
// cancel
}
} else if (command === "delete") {
try {
@@ -265,20 +265,19 @@ const handleSessionCommand = async (command: string, session: ChatSession) => {
sessions.value.splice(index, 1);
}
} catch (error) {
if (error !== "cancel") {
ElMessage.error("删除失败");
} else {
if (error === "cancel") {
ElMessage.info("已取消删除");
}
// cancel
}
}
};
const handleUserCommand = (command: string) => {
if (command === "profile") {
router.push("/profile");
router.push("/fastlink/profile");
} else if (command === "settings") {
ElMessage.info("设置功能开发中");
emit("open-config");
} else if (command === "logout") {
userStore.logout();
}
@@ -8,6 +8,7 @@
:is-collapsed="isSidebarCollapsed"
@select-session="handleSelectSession"
@new-session="handleNewSession"
@open-config="configDrawerVisible = true"
/>
</ElAside>
<ElContainer class="chat-container">
@@ -37,10 +38,13 @@
:sending="sending"
:is-connected="isConnected"
@send="handleSendMessage"
@stop="handleStopMessage"
/>
</ElFooter>
</ElContainer>
</ElContainer>
<FaConfigInfoDrawer v-model="configDrawerVisible" />
</div>
</template>
@@ -59,6 +63,7 @@ import FaSidebar from "./components/FaSidebar.vue";
import FaChatNavbar from "./components/FaChatNavbar.vue";
import FaChatMessages from "./components/FaChatMessages.vue";
import FaChatInput from "./components/FaChatInput.vue";
import FaConfigInfoDrawer from "@/components/layouts/fa-header-bar/widgets/FaConfigInfoDrawer.vue";
//
const messages = ref<ChatMessage[]>([]);
@@ -68,6 +73,7 @@ const connectionStatus = ref<"connected" | "connecting" | "disconnected">("disco
const error = ref("");
const currentSessionId = ref<string | null>(null);
const isSidebarCollapsed = ref(false);
const configDrawerVisible = ref(false);
// Refs
const chatMessagesRef = ref<{ scrollToBottom: () => void }>();
@@ -138,13 +144,29 @@ const toggleConnection = () => {
// ============ ============
const handleWebSocketMessage = (data: string) => {
const text = data || "";
//
if (text === "[DONE]") {
finishLoadingMessages();
sending.value = false;
return;
}
//
if (text === "[STOPPED]") {
finishLoadingMessages();
sending.value = false;
ElMessage.info("已停止生成");
return;
}
const lastMessage = messages.value[messages.value.length - 1];
const content = data || "";
if (lastMessage?.type === "assistant" && lastMessage.loading) {
lastMessage.content += content;
lastMessage.content += text;
} else {
addMessage("assistant", content);
addMessage("assistant", text);
}
chatMessagesRef.value?.scrollToBottom();
@@ -158,6 +180,7 @@ const addMessage = (type: "user" | "assistant", content: string, files?: Uploade
timestamp: Date.now(),
collapsed: content.length > 200,
files,
loading: type === "assistant" ? true : false,
});
};
@@ -211,17 +234,32 @@ const handleSendMessage = async (message: string, files?: UploadedFile[]) => {
files: files?.map((f) => ({ name: f.name, type: f.type, size: f.size })),
})
);
// sending true [DONE] / [STOPPED]
} else {
throw new Error("WebSocket 连接未建立");
}
} catch {
messages.value.pop();
error.value = "发送消息失败,请检查连接状态";
} finally {
sending.value = false;
}
};
//
const handleStopMessage = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
try {
ws.send(
JSON.stringify({
action: "stop",
session_id: currentSessionId.value,
})
);
} catch {
ElMessage.error("停止指令发送失败");
}
};
const createNewSession = async (firstMessage: string): Promise<boolean> => {
try {
const title = firstMessage.slice(0, 20) + (firstMessage.length > 20 ? "..." : "");
@@ -695,7 +695,7 @@ async function runBatchStatus(status: number) {
"批量设置"
);
await DemoAPI.batchDemo({ ids, status });
ElMessage.success("操作成功");
// / axios
faTableRef.value?.elTableRef?.clearSelection();
await refreshData();
} catch {
@@ -706,16 +706,15 @@ async function runBatchStatus(status: number) {
async function handleCrudImportUpload(formData: FormData) {
try {
const res = await DemoAPI.importDemo(formData);
if (res.data.code !== ResultEnum.SUCCESS) {
ElMessage.error(res.data.msg || "导入失败");
return;
if (res.data.code === ResultEnum.SUCCESS) {
ElMessage.success(res.data.msg || "导入成功");
importVisible.value = false;
await refreshData();
}
ElMessage.success(res.data.msg || "导入成功");
importVisible.value = false;
await refreshData();
// SUCCESS axios
} catch (error) {
console.error("[Import]", error);
ElMessage.error("导入失败");
/* 接口错误已由拦截器提示 */
}
}
</script>
@@ -0,0 +1,576 @@
<template>
<div class="fa-full-height">
<FaSearchBarWithAudit
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="businessSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
@search="handleSearch"
@reset="onResetSearch"
/>
<ElCard
shadow="hover"
class="fa-table-card"
:style="{ 'margin-top': showSearchBar ? '12px' : '0' }"
>
<FaTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<FaTableHeaderLeft
:remove-ids="selectedIds"
:perm-create="['module_example:demo_single:create']"
:perm-import="['module_example:demo_single:import']"
:perm-export="['module_example:demo_single:export']"
:perm-delete="['module_example:demo_single:delete']"
:perm-patch="['module_example:demo_single:patch']"
:delete-loading="batchDeleting"
:create-loading="createLoading"
@add="handleAdd"
@import="openImport"
@export="openExport"
@delete="handleBatchDelete"
@more="runBatchStatus"
/>
</template>
</FaTableHeader>
<FaTable
ref="faTableRef"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="onTableSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
<FaDialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
width="920px"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
:form-mode="dialogVisible.type"
:confirm-loading="submitLoading"
@cancel="handleCloseDialog"
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
>
<template v-if="dialogVisible.type === 'detail'">
<FaDescriptions :column="4" :data="detailFormData" :items="detailItems" max-height="70vh" />
</template>
<template v-else>
<FaForm
:key="formRenderKey"
scrollbar
max-height="70vh"
ref="dataFormRef"
v-model="formData"
:items="dialogFormItems"
:rules="rules"
label-suffix=":"
:label-width="100"
label-position="right"
:span="24"
:gutter="16"
:show-reset="false"
:show-submit="false"
class="crud-dialog-art-form"
/>
</template>
<template #footer>
<div class="dialog-footer" style="padding-right: var(--el-dialog-padding-primary)">
<ElButton @click="handleCloseDialog">取消</ElButton>
<ElButton v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">
确定
</ElButton>
<ElButton v-else type="primary" @click="handleCloseDialog">确定</ElButton>
</div>
</template>
</FaDialog>
<FaImportDialog
v-model="importVisible"
:content-config="importContentConfig"
default-template-file-name="demo_single_import_template.xlsx"
@upload="handleCrudImportUpload"
/>
<FaExportDialog
v-model="exportVisible"
:content-config="exportContentConfig"
:query-params="exportQueryParams"
:page-data="data"
:selection-data="selectedRows"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { useTable } from "@/hooks/core/useTable";
import { useImportExport } from "@/hooks/core/useImportExport";
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
import { useTableSelection } from "@/hooks/core/useTableSelection";
import { confirmDelete, confirmBatchDelete, confirmAction } from "@/hooks/core/useConfirm";
import { stripPaginationParams } from "@/utils/query";
import type { IContentConfig, IObject } from "@/components/modal/types";
import type { AuditSearchFormParams } from "@/components/forms/fa-search-bar/auditSearchFormItems";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import type { ColumnOption } from "@/types/component";
import GenDemoSingleAPI, {
type GenDemoSingleForm,
type GenDemoSinglePageQuery,
type GenDemoSingleTable,
} from "@/api/module_example/demo_single";
import { ResultEnum } from "@/enums/api/result.enum";
import { ElMessage } from "element-plus";
defineOptions({
name: "GenDemoSingle",
inheritAttrs: false,
});
const { hasAuth } = useAuth();
//
const STATUS_OPTIONS = [
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
] as const;
const createInitialFormData = (): GenDemoSingleForm => ({
name: undefined,
status: "0",
description: undefined,
});
type GenDemoSingleSearchFormParams = {
name?: string;
status?: string;
} & AuditSearchFormParams;
const searchForm = ref<GenDemoSingleSearchFormParams>({
name: undefined,
status: undefined,
created_id: undefined,
updated_id: undefined,
created_time: [],
updated_time: [],
});
/** 搜索区域默认展开展示 */
const showSearchBar = ref(true);
const searchBarRef = ref<{ validate: () => Promise<boolean> } | null>(null);
const searchBarRules: Record<string, unknown> = {};
/** 业务搜索项(审计四字段由 FaSearchBarWithAudit 自动追加) */
const businessSearchItems = computed(() => [
{
label: "名称",
key: "name",
type: "input",
placeholder: "请输入名称",
clearable: true,
span: 6,
},
{
label: "状态",
key: "status",
type: "select",
props: {
placeholder: "请选择状态",
options: STATUS_OPTIONS,
clearable: true,
},
span: 6,
},
]);
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
useTableSelection<GenDemoSingleTable>();
const createLoading = ref(false);
const PK = "id" as const;
const {
columns,
columnChecks,
data,
loading,
pagination,
searchParams,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
refreshCreate,
refreshUpdate,
refreshRemove,
} = useTable({
core: {
apiFn: GenDemoSingleAPI.getGenDemoSingleList,
apiParams: {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<GenDemoSingleTable>[] => [
{ type: "globalIndex", width: 56, label: "序号" },
{ type: "selection", width: 48, fixed: "left" },
{ prop: "name", label: "名称", minWidth: 120, showOverflowTooltip: true },
{
prop: "status",
label: "状态",
width: 88,
status: {
0: { type: "success", text: "启用" },
1: { type: "info", text: "停用" },
},
},
{ prop: "description", label: "备注/描述", minWidth: 120, showOverflowTooltip: true },
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
{
prop: "created_by",
label: "创建人",
minWidth: 100,
formatter: (row: GenDemoSingleTable) => row.created_by?.name ?? "—",
},
{
prop: "updated_by",
label: "更新人",
minWidth: 100,
formatter: (row: GenDemoSingleTable) => row.updated_by?.name ?? "—",
},
{
prop: "operation",
label: "操作",
width: 220,
fixed: "right",
align: "right",
formatter: (row: GenDemoSingleTable) => formatOperationCell(row),
},
],
},
});
const crudCols = computed(() =>
columns.value.map((c: ColumnOption<GenDemoSingleTable>) => {
const t = (c as { type?: string }).type;
return {
prop: c.prop,
label: c.label,
type: t === "selection" ? ("selection" as const) : ("default" as const),
show: true,
};
})
);
const exportQueryParams = computed(() => {
return stripPaginationParams(searchParams as Record<string, unknown>);
});
const importContentConfig = computed<IContentConfig>(() => ({
permPrefix: "module_example:demo_single",
cols: crudCols.value,
indexAction: async () => ({}),
importTemplate: () => GenDemoSingleAPI.downloadTemplateGenDemoSingle(),
}));
const exportContentConfig = computed(() => ({
permPrefix: "module_example:demo_single",
cols: crudCols.value,
exportsBlobAction: async (params: IObject) => {
const merged = {
...(exportQueryParams.value as unknown as Record<string, unknown>),
...params,
} as unknown as GenDemoSinglePageQuery;
const res = await GenDemoSingleAPI.exportGenDemoSingle(merged);
return res.data as Blob;
},
}));
const { dialogVisible } = useCrudDialog();
const detailFormData = ref<GenDemoSingleTable>({});
const detailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] = [
{ label: "名称", prop: "name" },
{
label: "状态",
prop: "status",
tag: { map: { "0": { type: "success", text: "启用" }, "1": { type: "danger", text: "停用" } } },
},
{ label: "备注/描述", prop: "description" },
{ label: "创建时间", prop: "created_time" },
{ label: "更新时间", prop: "updated_time" },
{ label: "创建人", prop: "created_by.name" },
{ label: "更新人", prop: "updated_by.name" },
];
const formData = ref<GenDemoSingleForm>(createInitialFormData());
const rules = reactive({
name: [{ required: false, message: "请填写名称", trigger: "blur" }],
status: [{ required: true, message: "请填写是否启用(0:启用 1:禁用)", trigger: "blur" }],
description: [{ required: false, message: "请填写备注/描述", trigger: "blur" }],
});
const dialogFormItems: FormItem[] = [
{ key: "name", label: "名称", type: "input", props: { placeholder: "请输入名称" } },
{
key: "status",
label: "状态",
type: "radiogroup",
props: {
options: [
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
],
},
},
{
key: "description",
label: "描述",
type: "input",
props: {
type: "textarea",
rows: 4,
maxlength: 100,
showWordLimit: true,
placeholder: "请输入描述",
},
},
];
const dataFormRef = ref<{
resetFields: () => void;
clearValidate: () => void;
validate: (cb: (valid: boolean) => void) => void;
} | null>(null);
const submitLoading = ref(false);
const formRenderKey = ref(0);
const { importVisible, exportVisible, openImport, openExport } = useImportExport();
const handleSearch = async (params: GenDemoSingleSearchFormParams) => {
await searchBarRef.value?.validate();
replaceSearchParams({
name: params.name,
status: params.status,
created_id: params.created_id ?? undefined,
updated_id: params.updated_id ?? undefined,
created_time:
Array.isArray(params.created_time) && params.created_time.length === 2
? params.created_time
: undefined,
updated_time:
Array.isArray(params.updated_time) && params.updated_time.length === 2
? params.updated_time
: undefined,
} as Record<string, unknown>);
getData();
};
const onResetSearch = async () => {
searchForm.value = {
name: undefined,
status: undefined,
created_id: undefined,
updated_id: undefined,
created_time: [],
updated_time: [],
};
await resetSearchParams();
};
function buildRowActions(row: GenDemoSingleTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_example:demo_single:detail",
run: () => void openDetailDialog(row),
},
{
key: "edit",
label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_example:demo_single:update",
run: () => void openEditDialog("edit", row),
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_example:demo_single:delete",
run: () => deleteRow(row),
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatOperationCell(row: GenDemoSingleTable) {
return renderTableOperationCell(buildRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
});
}
async function openDetailDialog(row: GenDemoSingleTable) {
if (!row[PK]) return;
const response = await GenDemoSingleAPI.getGenDemoSingleDetail(row[PK] as number);
dialogVisible.type = "detail";
dialogVisible.title = "详情";
detailFormData.value = response.data.data ?? { ...row };
dialogVisible.visible = true;
}
async function handleAdd() {
createLoading.value = true;
try {
await openEditDialog("add");
} finally {
createLoading.value = false;
}
}
async function openEditDialog(type: "add" | "edit", row?: GenDemoSingleTable) {
dialogVisible.type = type === "add" ? "create" : "update";
if (type === "add") {
dialogVisible.title = "新增";
Object.assign(formData.value, createInitialFormData());
formData.value[PK] = undefined;
formRenderKey.value += 1;
} else if (row?.[PK]) {
dialogVisible.title = "修改";
formRenderKey.value += 1;
const response = await GenDemoSingleAPI.getGenDemoSingleDetail(row[PK] as number);
Object.assign(formData.value, response.data.data);
}
dialogVisible.visible = true;
}
async function resetForm() {
if (dataFormRef.value) {
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
}
Object.assign(formData.value, createInitialFormData());
}
async function handleCloseDialog() {
dialogVisible.visible = false;
await resetForm();
}
async function handleSubmit() {
dataFormRef.value?.validate(async (valid: boolean) => {
if (!valid) return;
const submitData = { ...formData.value };
const id = formData.value[PK] as number | undefined;
try {
if (id) {
await GenDemoSingleAPI.updateGenDemoSingle(id, { [PK]: id, ...submitData });
await refreshUpdate();
} else {
await GenDemoSingleAPI.createGenDemoSingle(submitData);
await refreshCreate();
}
dialogVisible.visible = false;
await resetForm();
} catch (error: unknown) {
console.error(error);
}
});
}
const deleteRow = async (row: GenDemoSingleTable) => {
if (!row[PK]) return;
try {
await confirmDelete("确定删除该数据吗?此操作不可恢复!");
await GenDemoSingleAPI.deleteGenDemoSingle([row[PK] as number]);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
//
}
};
async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
batchDeleting.value = true;
await GenDemoSingleAPI.deleteGenDemoSingle(ids);
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
//
} finally {
batchDeleting.value = false;
}
}
async function runBatchStatus(status: number) {
const ids = selectedIds.value;
if (ids.length === 0) {
ElMessage.warning("请先在列表中勾选数据");
return;
}
try {
await confirmAction(
`确认对选中的 ${ids.length} 条数据${status === 0 ? "启用" : "停用"}`,
"批量设置"
);
await GenDemoSingleAPI.batchGenDemoSingle({ ids, status });
faTableRef.value?.elTableRef?.clearSelection();
await refreshData();
} catch {
//
}
}
async function handleCrudImportUpload(formData: FormData) {
try {
const res = await GenDemoSingleAPI.importGenDemoSingle(formData);
if (res.data.code === ResultEnum.SUCCESS) {
ElMessage.success(res.data.msg || "导入成功");
importVisible.value = false;
await refreshData();
}
} catch (error) {
console.error("[Import]", error);
}
}
onMounted(() => {
getData();
});
</script>
<style lang="scss" scoped></style>
@@ -9,24 +9,13 @@
>
<ElAlert type="info" :closable="false" show-icon class="mb-3 items-start!">
<template #title>
<span class="text-sm leading-relaxed">
<template v-if="editMode === 'visual'">
选好数据库与单表/主子表填好表名后点右下角创建表即可
</template>
<template v-else>自带示例一键插入会写 DDL 的可直接粘贴支持多条语句</template>
</span>
<span class="text-sm leading-relaxed"
>自带示例一键插入会写 DDL 的可直接粘贴支持多条语句</span
>
</template>
</ElAlert>
<div class="create-table-toolbar mb-3 flex flex-wrap items-center gap-2">
<span class="text-sm text-(--el-text-color-regular) shrink-0">方式</span>
<ElRadioGroup v-model="editMode" size="small">
<ElRadioButton value="visual">表结构推荐</ElRadioButton>
<ElRadioButton value="sql"> SQL</ElRadioButton>
</ElRadioGroup>
</div>
<div v-show="editMode === 'sql'" class="sql-pane">
<div class="sql-pane">
<div class="mb-2 flex flex-wrap items-center gap-2">
<ElDropdown trigger="click" @command="onSqlPresetCommand">
<ElButton type="primary" size="small">
@@ -44,6 +33,7 @@
</ElDropdown>
<span class="text-xs text-(--el-text-color-secondary)">从模板开始比自己写更省事</span>
</div>
<ElScrollbar max-height="min(52vh, 420px)" class="sql-editor-scroll">
<div class="absolute z-36 right-5 top-2">
<ElLink type="primary" @click="copySql">
@@ -62,103 +52,6 @@
</ElScrollbar>
</div>
<div v-show="editMode === 'visual'" class="visual-pane">
<ElScrollbar max-height="min(58vh, 520px)" class="visual-pane-scroll">
<div class="visual-structure max-w-3xl">
<FaDescriptions :column="1" size="small" class="visual-desc" :scrollbar="false">
<template #title>
<span class="text-sm font-medium text-(--el-text-color-primary)">选项</span>
</template>
<ElDescriptionsItem label="数据库" label-class-name="visual-desc-label">
<ElRadioGroup v-model="visual.dialect" size="small">
<ElRadioButton value="mysql">MySQL</ElRadioButton>
<ElRadioButton value="postgres">PostgreSQL</ElRadioButton>
</ElRadioGroup>
</ElDescriptionsItem>
<ElDescriptionsItem label="表类型" label-class-name="visual-desc-label">
<ElRadioGroup v-model="templateKind" size="small">
<ElRadioButton value="single">只要一张表</ElRadioButton>
<ElRadioButton value="masterSub">主表 + 子表明细</ElRadioButton>
</ElRadioGroup>
</ElDescriptionsItem>
</FaDescriptions>
<FaDescriptions :column="1" size="small" class="visual-desc mt-3" :scrollbar="false">
<template #title>
<span class="text-sm font-medium text-(--el-text-color-primary)">主表</span>
</template>
<ElDescriptionsItem label-class-name="visual-desc-label">
<template #label>
<span class="text-(--el-color-danger)">*</span>
表名
</template>
<ElInput
v-model="visual.mainTableName"
placeholder="英文表名,如 gen_demo_order"
clearable
/>
</ElDescriptionsItem>
<ElDescriptionsItem label="说明" label-class-name="visual-desc-label">
<ElInput v-model="visual.mainComment" placeholder="中文说明,可选" clearable />
</ElDescriptionsItem>
</FaDescriptions>
<FaDescriptions
v-if="visual.subEnabled"
:column="1"
size="small"
class="visual-desc mt-3"
:scrollbar="false"
>
<template #title>
<span class="text-sm font-medium text-(--el-text-color-primary)">子表</span>
</template>
<ElDescriptionsItem label-class-name="visual-desc-label">
<template #label>
<span class="text-(--el-color-danger)">*</span>
表名
</template>
<ElInput
v-model="visual.subTableName"
placeholder="如 gen_demo_order_item"
clearable
/>
</ElDescriptionsItem>
<ElDescriptionsItem label="说明" label-class-name="visual-desc-label">
<ElInput v-model="visual.subComment" placeholder="中文说明,可选" clearable />
</ElDescriptionsItem>
<ElDescriptionsItem label="外键列" label-class-name="visual-desc-label">
<ElInput
v-model="visual.fkColumn"
placeholder="子表里指向主表的那列,如 order_id"
clearable
/>
</ElDescriptionsItem>
<ElDescriptionsItem label="对应主表列" label-class-name="visual-desc-label">
<ElInput v-model="visual.fkRefColumn" placeholder="一般是 id" clearable />
</ElDescriptionsItem>
</FaDescriptions>
<div class="mt-3">
<ElLink type="primary" underline="never" @click="syncVisualToSql">
需要手写调整生成 SQL 并切到 SQL
</ElLink>
</div>
</div>
<p class="mb-1 mt-2 text-xs text-(--el-text-color-secondary)">
将要执行的 SQL随上面表格自动更新
</p>
<ElInput
v-model="visualPreviewSql"
type="textarea"
:rows="6"
readonly
class="font-mono text-xs visual-sql-preview"
placeholder="填写表名后会自动生成"
/>
</ElScrollbar>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" :loading="loading" @click="handleConfirm">创建表</ElButton>
@@ -171,7 +64,7 @@
<script setup lang="ts">
import "codemirror/mode/sql/sql.js";
import "codemirror/theme/dracula.css";
import { ref, watch, nextTick, computed } from "vue";
import { ref, watch } from "vue";
import Codemirror from "codemirror-editor-vue3";
import type { EditorConfiguration } from "codemirror";
import type { CmComponentRef } from "codemirror-editor-vue3";
@@ -180,15 +73,6 @@ import { ArrowDown, CopyDocument } from "@element-plus/icons-vue";
import { useClipboard } from "@vueuse/core";
import { useSettingsStore } from "@stores";
import { ThemeMode } from "@/enums/settings/theme.enum";
import { buildSqlFromVisual } from "../utils/buildCreateTableSql";
import type { SqlDialect, VisualBuildState } from "../utils/buildCreateTableSql";
import {
applySubColumns,
mergeGenTableLinkIntoVisual,
visualPresetMasterSub,
visualPresetSingle,
type GenTableCreateLink,
} from "../utils/createTableVisualPresets";
import {
getExampleFromPresetMasterSub,
getExampleFromPresetSingle,
@@ -198,22 +82,12 @@ defineOptions({ name: "CreateTableDialog" });
const visible = defineModel<boolean>({ default: false });
export interface CreateTableSubmitMeta {
fromVisual: boolean;
visualSnapshot?: VisualBuildState;
}
const props = withDefaults(
defineProps<{
loading?: boolean;
/** 代码生成抽屉第三步打开时传入,用于预填「表结构」主/子表名 */
linkFromGen?: GenTableCreateLink | null;
}>(),
{ loading: false, linkFromGen: null }
);
defineProps<{
loading?: boolean;
}>();
interface Emits {
submit: [sql: string, meta?: CreateTableSubmitMeta];
submit: [sql: string];
}
const emit = defineEmits<Emits>();
@@ -221,19 +95,8 @@ const emit = defineEmits<Emits>();
const { copy } = useClipboard();
const settingsStore = useSettingsStore();
const editMode = ref<"sql" | "visual">("visual");
const sqlText = ref("");
const sqlRef = ref<CmComponentRef>();
const visual = ref<VisualBuildState>(visualPresetSingle("mysql"));
const visualPreviewSql = ref("");
/** 单表 / 主子表:与「模板按钮」等价,用语义化文案降低理解成本 */
const templateKind = computed({
get: (): "single" | "masterSub" => (visual.value.subEnabled ? "masterSub" : "single"),
set: (v: string) => {
if (v === "single" || v === "masterSub") applyVisualPreset(v);
},
});
const codeTheme = ref(settingsStore.theme === ThemeMode.DARK ? "dracula" : "default");
@@ -262,132 +125,26 @@ watch(
);
function onDialogOpened() {
dialectWatchSkip = true;
editMode.value = "visual";
sqlText.value = "";
visual.value = visualPresetSingle("mysql");
visualPreviewSql.value = "";
void nextTick(() => {
dialectWatchSkip = false;
applyLinkFromGenIfAny();
});
}
/** 第三步已填主表/子表时:切到表结构模式并带入名称,减少重复输入 */
function applyLinkFromGenIfAny() {
const link = props.linkFromGen;
if (!link) return;
const touched =
(link.table_name || "").trim() ||
(link.table_comment || "").trim() ||
((link.sub_table_name || "").trim() && (link.sub_table_fk_name || "").trim());
if (!touched) return;
visual.value = mergeGenTableLinkIntoVisual(link, visual.value.dialect);
editMode.value = "visual";
visualPreviewSql.value = buildSqlFromVisual(applySubColumns(visual.value));
ElMessage.info({
message: "已带入代码生成里的表名,检查无误后点「创建表」即可",
duration: 2800,
});
}
watch(
visual,
() => {
if (editMode.value !== "visual" || !visible.value) return;
visualPreviewSql.value = buildSqlFromVisual(applySubColumns(visual.value));
},
{ deep: true }
);
watch(editMode, (m) => {
if (m === "sql") {
sqlText.value = buildSqlFromVisual(applySubColumns(visual.value));
void nextTick(() => sqlRef.value?.cminstance?.refresh());
} else {
visualPreviewSql.value = buildSqlFromVisual(applySubColumns(visual.value));
}
});
let dialectWatchSkip = false;
watch(
() => visual.value.dialect,
(_d, prev) => {
if (dialectWatchSkip) return;
if (prev === undefined) return;
const d = visual.value.dialect;
visual.value = visual.value.subEnabled
? applySubColumns(visualPresetMasterSub(d))
: visualPresetSingle(d);
}
);
function applyVisualPreset(kind: "single" | "masterSub") {
const d = visual.value.dialect;
visual.value =
kind === "single" ? visualPresetSingle(d) : applySubColumns(visualPresetMasterSub(d));
}
function syncVisualToSql() {
if (!validateVisual()) return;
sqlText.value = buildSqlFromVisual(applySubColumns(visual.value));
editMode.value = "sql";
void nextTick(() => {
sqlRef.value?.cminstance?.refresh();
});
ElMessage.success("已切换到「写 SQL」,可继续改");
}
function onSqlPresetCommand(cmd: string) {
switch (cmd) {
case "single-mysql":
loadPresetSql("single", "mysql");
sqlText.value = getExampleFromPresetSingle("mysql");
break;
case "single-postgres":
loadPresetSql("single", "postgres");
sqlText.value = getExampleFromPresetSingle("postgres");
break;
case "master-mysql":
loadPresetSql("masterSub", "mysql");
sqlText.value = getExampleFromPresetMasterSub("mysql");
break;
case "master-postgres":
loadPresetSql("masterSub", "postgres");
break;
default:
sqlText.value = getExampleFromPresetMasterSub("postgres");
break;
}
}
/** 表结构模式提交前校验,避免无效请求 */
function validateVisual(): boolean {
const v = applySubColumns(visual.value);
if (!(v.mainTableName || "").trim()) {
ElMessage.warning("请填写主表表名");
return false;
}
if (v.subEnabled) {
if (!(v.subTableName || "").trim()) {
ElMessage.warning("请填写子表表名");
return false;
}
if (!(v.fkColumn || "").trim()) {
ElMessage.warning("请填写子表上的外键列名");
return false;
}
if (!(v.fkRefColumn || "").trim()) {
ElMessage.warning("请填写主表上被引用的列名(一般为 id)");
return false;
}
}
return true;
}
function loadPresetSql(kind: "single" | "masterSub", dialect: SqlDialect) {
sqlText.value =
kind === "single"
? getExampleFromPresetSingle(dialect)
: getExampleFromPresetMasterSub(dialect);
}
function copySql() {
if (!sqlText.value) {
ElMessage.warning("没有可复制的内容");
@@ -398,23 +155,12 @@ function copySql() {
}
function handleConfirm() {
if (editMode.value === "visual" && !validateVisual()) return;
const sql =
editMode.value === "sql"
? sqlText.value.trim()
: buildSqlFromVisual(applySubColumns(visual.value)).trim();
const sql = sqlText.value.trim();
if (!sql) {
ElMessage.error("请填写表名或 SQL");
ElMessage.error("请填写 SQL");
return;
}
if (editMode.value === "visual") {
emit("submit", sql, {
fromVisual: true,
visualSnapshot: applySubColumns(visual.value),
});
} else {
emit("submit", sql);
}
emit("submit", sql);
}
function handleCancel() {
@@ -426,34 +172,4 @@ function handleCancel() {
.sql-pane {
position: relative;
}
.visual-pane :deep(.el-textarea__inner) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.visual-sql-preview {
margin-top: 10px;
}
/* 表结构:描述列表表格化,标签列对齐、内容区可伸缩 */
.visual-structure {
.visual-desc {
:deep(.el-descriptions__label) {
width: 108px;
vertical-align: middle;
}
:deep(.el-descriptions__cell) {
vertical-align: middle;
}
:deep(.el-descriptions__content) {
min-width: 0;
}
:deep(.el-descriptions__title) {
margin-bottom: 8px;
}
}
}
</style>
@@ -280,15 +280,15 @@ const backendModuleDirPreview = computed(() => {
const frontendViewDirPreview = computed(() => {
const pkg = effectivePackageName.value;
const mod = (info.value.module_name || "").trim();
if (!pkg || !mod) return "frontend/src/views/<module_xxx>/<module>/";
return `frontend/src/views/${pkg}/${mod}/`;
if (!pkg || !mod) return "frontend/web/src/views/<module_xxx>/<module>/";
return `frontend/web/src/views/${pkg}/${mod}/`;
});
const frontendApiFilePreview = computed(() => {
const pkg = effectivePackageName.value;
const mod = (info.value.module_name || "").trim();
if (!pkg || !mod) return "frontend/src/api/<module_xxx>/<module>.ts";
return `frontend/src/api/${pkg}/${mod}.ts`;
if (!pkg || !mod) return "frontend/web/src/api/<module_xxx>/<module>.ts";
return `frontend/web/src/api/${pkg}/${mod}.ts`;
});
interface Emits {
@@ -48,16 +48,7 @@
</div>
<template #footer>
<ElButton :icon="Close" @click="emit('close')">关闭</ElButton>
<ElButton
v-if="activeStep < 2"
type="info"
:icon="Finished"
:loading="loading"
@click="emit('save')"
>
保存
</ElButton>
<ElButton type="danger" :icon="Close" @click="emit('close')">关闭</ElButton>
<ElButton v-if="activeStep !== 0" type="success" :icon="Back" @click="emit('prev-step')">
上一步
</ElButton>
@@ -95,7 +86,7 @@
<script setup lang="ts">
import { computed } from "vue";
import type { FormRules } from "element-plus";
import { Close, Right, FolderOpened, Back, Download, Finished } from "@element-plus/icons-vue";
import { Close, Right, FolderOpened, Back, Download } from "@element-plus/icons-vue";
import type { EditorConfiguration } from "codemirror";
import type { GenTableSchema } from "@/api/module_generator/gencode";
import type { DictTable } from "@/api/module_system/dict";
@@ -138,7 +129,6 @@ interface Emits {
close: [];
"prev-step": [];
"next-step": [];
save: [];
"gen-download": [];
"gen-write": [];
"clear-master-sub": [];
@@ -88,7 +88,6 @@
<FaCreateTableDialog
v-model="createTableVisible"
:loading="loading"
:link-from-gen="createTableLinkFromGen"
@submit="handleCreateTableSubmit"
/>
@@ -126,7 +125,6 @@
@close="handleClose"
@prev-step="prevStep"
@next-step="nextStep"
@save="handleSave"
@gen-download="handleGenTable('0', info)"
@gen-write="handleGenTable('1', info)"
@clear-master-sub="clearMasterSub"
@@ -165,7 +163,6 @@ 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";
import FaCreateTableDialog from "./components/FaCreateTableDialog.vue";
import { GENCODE_BASIC_FORM_KEY, GENCODE_CM_KEY } from "./gencodeInjectionKeys";
import type { ColumnOption } from "@/types/component";
@@ -505,9 +502,7 @@ async function handleGenTable(targetGenType: string, row?: GenTableSchema): Prom
loading.value = false;
return;
}
if (row?.id) await confirmWritePaths(row.id);
await GencodeAPI.genCodeToPath(tbNames[0]);
ElMessage.success("已写入项目目录并创建菜单(若尚未存在)");
} else {
// ZIP
const tableNamesArray = Array.isArray(tbNames) ? tbNames : [tbNames];
@@ -539,56 +534,6 @@ async function handleGenTable(targetGenType: string, row?: GenTableSchema): Prom
}
}
function escapeHtml(s: string) {
return s
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
async function confirmWritePaths(tableId: number) {
// preview
await GencodeAPI.updateTable(info as GenTableSchema, tableId);
const previewRes = await GencodeAPI.previewTable(tableId);
const raw = previewRes.data?.data as Record<string, unknown> | undefined;
const keys = raw && typeof raw === "object" ? Object.keys(raw) : [];
const shown = keys.slice(0, 80);
const more =
keys.length > shown.length
? `<div :style="'margin-top:10px;padding:8px 12px;border-radius:6px;background:var(--el-fill-color-light);font-size:12px;color:var(--el-text-color-secondary);text-align:center'">还有 <b :style="'color:var(--el-text-color-primary)'">${keys.length - shown.length}</b> 个文件未列出</div>`
: "";
const listRows = shown
.map((p, i) => {
const bg = i % 2 === 0 ? "var(--el-fill-color-blank)" : "var(--el-fill-color-light)";
return `<div class="gencode-write-path-row" :style="'padding:9px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.45;white-space:nowrap;color:var(--el-text-color-primary);background:${bg};border-bottom:1px solid var(--el-border-color-lighter)'">${escapeHtml(p)}</div>`;
})
.join("");
const listHtml = shown.length
? `<div class="gencode-write-path-list-wrap">${listRows}</div>${more}`
: `<div :style="'padding:16px;border-radius:8px;background:var(--el-fill-color-light);color:var(--el-text-color-secondary);font-size:13px;text-align:center'">未获取到预览路径,仍将继续写入。</div>`;
const tipHtml = `<div :style="'margin-top:12px;padding-top:10px;border-top:1px solid var(--el-border-color-lighter);font-size:12px;line-height:1.5;color:var(--el-text-color-secondary)'">与「代码预览」同源;路径为相对项目根的落盘位置。</div>`;
await ElMessageBox.confirm(
`<div class="gencode-write-confirm-body" :style="'font-family:var(--el-font-family);line-height:1.5;color:var(--el-text-color-primary)'">
<div :style="'margin-bottom:12px'">
<div :style="'font-size:15px;font-weight:600;letter-spacing:0.02em'">将写入以下文件</div>
<div :style="'margin-top:4px;font-size:12px;color:var(--el-text-color-secondary)'"> ${keys.length} · 相对项目根目录</div>
</div>
${listHtml}
${shown.length ? tipHtml : ""}
</div>`,
"写入本地确认",
{
confirmButtonText: "确认写入",
cancelButtonText: "取消",
type: "warning",
dangerouslyUseHTMLString: true,
customClass: "gencode-write-confirm-box",
}
);
}
/** 同步数据库操作 */
async function handleSynchDb(row: GenTableSchema): Promise<void> {
const tableName = row.table_name || "";
@@ -951,8 +896,8 @@ onActivated(async () => {
}
});
/** 创建表(由 CreateTableDialog 提交 SQL;表结构模式成功后可回写第三步主子表配置) */
async function handleCreateTableSubmit(sql: string, meta?: CreateTableSubmitMeta): Promise<void> {
/** 创建表 */
async function handleCreateTableSubmit(sql: string): Promise<void> {
if (!sql || sql.trim() === "") {
ElMessage.error("请输入创建表SQL语句");
return;
@@ -962,26 +907,7 @@ async function handleCreateTableSubmit(sql: string, meta?: CreateTableSubmitMeta
try {
await GencodeAPI.createTable(sql);
createTableVisible.value = false;
if (editVisible.value && activeStep.value === 2 && meta?.fromVisual && meta.visualSnapshot) {
const v = meta.visualSnapshot;
info.table_name = (v.mainTableName || "").trim();
const mc = (v.mainComment || "").trim();
if (mc) info.table_comment = mc;
if (v.subEnabled) {
info.sub_table_name = (v.subTableName || "").trim();
info.sub_table_fk_name = (v.fkColumn || "").trim();
} else {
info.sub_table_name = "";
info.sub_table_fk_name = "";
}
info.master_sub_hint = undefined;
void nextTick(() => {
basicInfo.value?.clearValidate?.(["table_name", "sub_table_name", "sub_table_fk_name"]);
});
}
await listRefresh.refreshCreate();
importVisible.value = true;
await getDbList();
} catch (error) {
console.error("创建表数据失败:", error);
} finally {
@@ -1003,20 +929,6 @@ async function handleImportTable(): Promise<void> {
await GencodeAPI.importTable(tableNames);
importVisible.value = false;
await listRefresh.refreshData();
//
if (tables.value.length === 1) {
await nextTick();
const list = tableListData.value as unknown as GenTableSchema[];
const importedTable = list.find(
(t: GenTableSchema) => t.table_name === tables.value[0]!.table_name
);
if (importedTable) {
await handlePreviewTable(importedTable);
}
} else {
//
ElMessage.success(`成功导入 ${tables.value.length} 个表`);
}
} catch (error) {
console.error("导入表失败:", error);
} finally {
@@ -1078,17 +990,6 @@ let info = reactive<
master_sub_hint: undefined,
});
/** 代码生成抽屉第三步打开时,创建表弹窗从当前表单预填主/子表名(表结构模式) */
const createTableLinkFromGen = computed(() => {
if (!editVisible.value || activeStep.value !== 2) return null;
return {
table_name: info.table_name,
table_comment: info.table_comment,
sub_table_name: info.sub_table_name ?? undefined,
sub_table_fk_name: info.sub_table_fk_name ?? undefined,
};
});
/** 主子表两项同填或同空,且子表名不得与主表相同 */
function validateMasterSubPair(_rule: unknown, _value: unknown, callback: (e?: Error) => void) {
const sn = (info.sub_table_name || "").trim();
@@ -1202,13 +1103,16 @@ async function nextStep(): Promise<void> {
if (activeStep.value < 3) {
nextStepLoading.value = true;
try {
//
//
if (activeStep.value < 2) {
await submitForm({ requireColumns: activeStep.value !== 0 });
}
//
if (activeStep.value === 0) {
//
const basicInfoValid = await basicInfo.value?.validate().catch(() => false);
if (!basicInfoValid) return;
} else if (activeStep.value === 1) {
//
if (!info.columns || info.columns.length === 0) {
ElMessage.error("请配置字段信息");
return;
@@ -1217,7 +1121,6 @@ async function nextStep(): Promise<void> {
activeStep.value++;
//
if (activeStep.value === 2 && info.id) {
await handlePreview({ id: info.id, table_name: info.table_name } as GenTableSchema);
}
@@ -1234,29 +1137,6 @@ function prevStep(): void {
}
}
//
async function handleSave(): Promise<void> {
try {
//
if (activeStep.value === 0) {
//
const basicInfoValid = await basicInfo.value?.validate().catch(() => false);
if (!basicInfoValid) return;
} else if (activeStep.value === 1) {
//
if (!info.columns || info.columns.length === 0) {
ElMessage.error("请配置字段信息");
return;
}
}
//
await submitForm({ requireColumns: activeStep.value !== 0 });
} catch (error) {
console.error("保存失败:", error);
}
}
//
function bulkSet(field: string | string[], value: any): void {
if (!info.columns || !Array.isArray(info.columns)) return;
@@ -64,7 +64,7 @@ import { h, ref, computed } from "vue";
import { useTable } from "@/hooks/core/useTable";
import OnlineAPI, { type OnlineUserTable } from "@/api/module_monitor/online";
import type { ColumnOption } from "@/types/component";
import { ElMessage, ElMessageBox, ElTooltip } from "element-plus";
import { ElMessageBox, ElTooltip } from "element-plus";
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";
@@ -135,7 +135,7 @@ function kickSession(sessionId: string) {
type: "warning",
});
await OnlineAPI.deleteOnline(sessionId);
ElMessage.success("操作成功");
// / axios
await refreshData();
} catch {
//
@@ -270,7 +270,7 @@ function handleClearAll() {
});
clearAllLoading.value = true;
await OnlineAPI.clearOnline();
ElMessage.success("操作成功");
// / axios
await refreshData();
} catch {
//
@@ -295,7 +295,6 @@ import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue"
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import type FaForm from "@/components/forms/fa-form/index.vue";
import FaStatusTag from "@/components/others/fa-status-tag/index.vue";
import { ElMessage } from "element-plus";
defineOptions({ name: "Email" });
@@ -1004,7 +1003,7 @@ async function handleSubmitSend() {
sendSubmitting.value = true;
try {
await EmailAPI.sendEmail(sendFormData.value);
ElMessage.success("发送成功");
// / axios
sendVisible.value = false;
} catch {
/* ignore */
@@ -1057,7 +1056,7 @@ async function handleSubmitTest() {
config_id: testFormData.value.config_id,
to_email: testFormData.value.to_email,
});
ElMessage.success("测试邮件已发送");
// / axios
testVisible.value = false;
} catch {
/* ignore */
@@ -619,14 +619,13 @@ async function deletePkgRow(id: number) {
async function togglePkgStatus(row: PackageTable) {
const newStatus = row.status === 0 ? 1 : 0;
const label = newStatus === 0 ? "启用" : "禁用";
try {
await confirmToggleStatus(newStatus);
await PackageAPI.batchPackageStatus({ ids: [row.id!], status: Number(newStatus) });
ElMessage.success(`${label}成功`);
// axios
await refreshData();
} catch {
//
// /
}
}
@@ -686,7 +685,6 @@ async function handleSaveMenus() {
menuSaveLoading.value = true;
try {
await PackageAPI.setPackageMenus(currentMenuPkgId.value, checkedIds);
ElMessage.success("菜单权限保存成功");
menuDialogVisible.value = false;
} catch {
//
@@ -259,15 +259,7 @@ 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 {
ElTag,
ElMessage,
ElButton,
ElIcon,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
} from "element-plus";
import { ElTag, ElButton, ElIcon, ElDropdown, ElDropdownMenu, ElDropdownItem } from "element-plus";
import { Plus, Edit, Delete, MoreFilled } from "@element-plus/icons-vue";
defineOptions({
@@ -421,10 +413,10 @@ async function doInstall(row: PluginTable) {
if (!row.id) return;
try {
await PluginAPI.install(row.id);
ElMessage.success("安装成功");
// / axios
row.installed = true;
} catch {
ElMessage.error("安装失败");
/* 接口错误已由拦截器提示 */
}
}
@@ -432,10 +424,10 @@ async function doUninstall(row: PluginTable) {
if (!row.id) return;
try {
await PluginAPI.uninstall(row.id);
ElMessage.success("卸载成功");
// / axios
row.installed = false;
} catch {
ElMessage.error("卸载失败");
/* 接口错误已由拦截器提示 */
}
}
@@ -443,10 +435,10 @@ async function doToggle(row: PluginTable) {
if (!row.id) return;
try {
await PluginAPI.toggle(row.id);
ElMessage.success(row.status === 0 ? "已禁用" : "已启用");
// / axios
row.status = row.status === 0 ? 1 : 0;
} catch {
ElMessage.error("操作失败");
/* 接口错误已由拦截器提示 */
}
}
@@ -138,7 +138,9 @@
:show-tip="true"
:enable-preview="true"
:enable-crop="true"
v-bind="brandCropBind('tenant_logo')"
crop-dialog-title="裁剪站点 Logo"
crop-inner-title="调整 Logo"
crop-preview-title="预览"
/>
</ElFormItem>
</ElCol>
@@ -152,7 +154,9 @@
:show-tip="true"
:enable-preview="true"
:enable-crop="true"
v-bind="brandCropBind('tenant_favicon')"
crop-dialog-title="裁剪网站图标"
crop-inner-title="调整图标"
crop-preview-title="预览"
/>
</ElFormItem>
</ElCol>
@@ -166,7 +170,9 @@
:show-tip="true"
:enable-preview="true"
:enable-crop="true"
v-bind="brandCropBind('tenant_login_bg')"
crop-dialog-title="裁剪登录背景"
crop-inner-title="调整背景图"
crop-preview-title="预览"
/>
</ElFormItem>
</ElCol>
@@ -214,6 +220,7 @@ import TenantAPI, {
type TenantTable,
type TenantUpdateForm,
} from "@/api/module_platform/tenant";
import PackageAPI from "@/api/module_platform/package";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction, resolveStatusColumns } from "@utils";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
@@ -221,7 +228,7 @@ import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import type FaForm from "@/components/forms/fa-form/index.vue";
import { ElMessage, ElTabs, ElTabPane, ElForm, ElFormItem, ElRow, ElCol } from "element-plus";
import { h, ref } from "vue";
import { h, ref, computed, onMounted } from "vue";
defineOptions({
name: "Tenant",
@@ -365,7 +372,7 @@ async function toggleTenantStatus(id: number) {
}
await refreshData();
} catch {
ElMessage.error("状态切换失败");
/* 接口错误已由拦截器提示 */
}
}
@@ -551,44 +558,6 @@ const dataFormRef = ref<InstanceType<typeof FaForm> | null>(null);
const submitLoading = ref(false);
const tenantFormRenderKey = ref(0);
function brandCropBind(key: string) {
switch (key) {
case "tenant_favicon":
return {
cropCutWidth: 64,
cropCutHeight: 64,
cropBoxWidth: 380,
cropBoxHeight: 320,
cropDialogTitle: "裁剪网站图标",
cropInnerTitle: "调整图标",
cropPreviewTitle: "预览",
};
case "tenant_logo":
return {
cropCutWidth: 320,
cropCutHeight: 96,
cropBoxWidth: 520,
cropBoxHeight: 360,
cropDialogTitle: "裁剪站点 Logo",
cropInnerTitle: "调整 Logo",
cropPreviewTitle: "预览",
};
case "tenant_login_bg":
return {
cropCutWidth: 960,
cropCutHeight: 540,
cropBoxWidth: 560,
cropBoxHeight: 380,
cropDialogTitle: "裁剪登录背景",
cropInnerTitle: "调整背景图",
cropPreviewTitle: "预览",
cropFileType: "jpeg" as const,
};
default:
return {};
}
}
async function handleAdd() {
createLoading.value = true;
try {
@@ -628,7 +597,27 @@ async function handleCloseDialog() {
const activeTab = ref("basic");
const basicFormItems: FormItem[] = [
const packageOptions = ref<{ label: string; value: number }[]>([]);
const packageLoading = ref(false);
async function fetchPackageOptions() {
packageLoading.value = true;
try {
const res = await PackageAPI.listPackage({ page_no: 1, page_size: 100 });
const list = (res.data?.data?.items ?? res.data?.data ?? []) as { id: number; name: string }[];
packageOptions.value = list.map((p) => ({ label: p.name, value: p.id }));
} catch {
packageOptions.value = [];
} finally {
packageLoading.value = false;
}
}
onMounted(() => {
fetchPackageOptions();
});
const basicFormItems = computed<FormItem[]>(() => [
{
label: "租户名称",
key: "name",
@@ -659,10 +648,16 @@ const basicFormItems: FormItem[] = [
},
},
{
label: "关联套餐ID",
label: "关联套餐",
key: "package_id",
type: "number",
props: { placeholder: "选填", min: 1, style: { width: "100%" } },
type: "select",
props: {
placeholder: "请选择套餐",
options: packageOptions.value,
loading: packageLoading.value,
clearable: true,
style: { width: "100%" },
},
},
{
label: "排序",
@@ -726,7 +721,7 @@ const basicFormItems: FormItem[] = [
valueFormat: "YYYY-MM-DD HH:mm:ss",
},
},
];
]);
const websiteFormItems: FormItem[] = [
{
@@ -697,10 +697,10 @@ function formatTicketOperationCell(row: TicketTable) {
async function closeTicket(id: number) {
try {
await TicketAPI.updateTicket(id, { status: 3 });
ElMessage.success("工单已关闭");
// / axios
await refreshData();
} catch {
ElMessage.error("关闭失败");
/* 接口错误已由拦截器提示 */
}
}
@@ -537,9 +537,8 @@ async function deleteUserRow(id: number) {
const idSet = [id];
if (userStore.basicInfo.id && idSet.includes(userStore.basicInfo.id)) {
userStore.clearUserInfo();
} else {
ElMessage.success("删除成功");
}
// / axios
faTableRef.value?.elTableRef?.clearSelection();
await refreshRemove();
} catch {
@@ -785,12 +784,11 @@ async function handleImportUpload(formDataUpload: FormData) {
ElMessage.success(`${response.data.msg}${response.data.data}`);
importVisible.value = false;
await refreshData();
} else {
ElMessage.error(response.data.msg || "导入失败");
}
// axios
} catch (error: unknown) {
console.error(error);
ElMessage.error("上传失败");
//
} finally {
uploadLoading.value = false;
}
@@ -371,7 +371,7 @@ async function handlePublish(record: WorkflowTable) {
await WorkflowDefinitionAPI.publishWorkflow(record.id, {});
await refreshUpdate();
} catch {
ElMessage.error("发布失败");
/* 接口错误已由拦截器提示 */
}
}
@@ -653,7 +653,7 @@ async function submitForm() {
await refreshCreate();
}
} catch {
ElMessage.error(editingId.value ? "更新失败" : "创建失败");
/* 接口错误已由拦截器提示 */
} finally {
submitting.value = false;
}