mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 13:05:18 +00:00
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:
@@ -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];
|
||||
/** 点击取消按钮 */
|
||||
|
||||
+8
-9
@@ -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: "调整图片",
|
||||
|
||||
@@ -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>HTTP(hypertext 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"><template>
|
||||
<ArtWangEditor v-model="content" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const content = ref('<p>初始内容</p>')
|
||||
</script></code></pre>
|
||||
</ElCollapseItem>
|
||||
|
||||
<ElCollapseItem title="完整工具栏配置" name="full">
|
||||
<pre><code class="language-vue"><template>
|
||||
<!-- 显示所有工具,不排除任何功能 -->
|
||||
<ArtWangEditor
|
||||
v-model="content"
|
||||
:exclude-keys="[]"
|
||||
/>
|
||||
</template></code></pre>
|
||||
</ElCollapseItem>
|
||||
|
||||
<ElCollapseItem title="简化工具栏配置" name="simple">
|
||||
<pre><code class="language-vue"><template>
|
||||
<!-- 只显示基础编辑工具 -->
|
||||
<ArtWangEditor
|
||||
v-model="content"
|
||||
:toolbar-keys="[
|
||||
'bold', 'italic', 'underline', '|',
|
||||
'bulletedList', 'numberedList', '|',
|
||||
'insertLink', 'insertImage', '|',
|
||||
'undo', 'redo'
|
||||
]"
|
||||
/>
|
||||
</template></code></pre>
|
||||
</ElCollapseItem>
|
||||
|
||||
<ElCollapseItem title="自定义配置" name="config">
|
||||
<pre><code class="language-vue"><template>
|
||||
<ArtWangEditor
|
||||
v-model="content"
|
||||
height="600px"
|
||||
placeholder="请输入您的内容..."
|
||||
:exclude-keys="['fontFamily', 'fontSize']"
|
||||
:upload-config="{
|
||||
maxFileSize: 5 * 1024 * 1024,
|
||||
maxNumberOfFiles: 5
|
||||
}"
|
||||
/>
|
||||
</template></code></pre>
|
||||
</ElCollapseItem>
|
||||
|
||||
<ElCollapseItem title="组件方法调用" name="methods">
|
||||
<pre><code class="language-vue"><template>
|
||||
<ArtWangEditor ref="editorRef" v-model="content" />
|
||||
<el-button @click="handleClear">清空</el-button>
|
||||
<el-button @click="handleFocus">聚焦</el-button>
|
||||
<el-button @click="handleGetContent">获取内容</el-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const editorRef = ref()
|
||||
const content = ref('')
|
||||
|
||||
const handleClear = () => {
|
||||
editorRef.value?.clear()
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
const handleGetContent = () => {
|
||||
const html = editorRef.value?.getHtml()
|
||||
console.log('编辑器内容:', html)
|
||||
}
|
||||
</script></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">
|
||||
支持 OpenAI、DeepSeek、Ollama 等任何 OpenAI 兼容服务<br />
|
||||
配置后即可在 AI 助手页一键切换
|
||||
</div>
|
||||
<ElButton type="primary" size="large" :icon="Plus" @click="openCreate">
|
||||
立即添加
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div v-else class="config-list">
|
||||
<TransitionGroup name="list" tag="div" class="list-inner">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="config-item"
|
||||
:class="{
|
||||
active: item.id === activeId,
|
||||
expanded: expandedId === item.id,
|
||||
flash: flashId === item.id,
|
||||
}"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="config-item-main">
|
||||
<div class="item-icon-wrap">
|
||||
<ElIcon class="item-icon" :size="18">
|
||||
<CircleCheck v-if="item.id === activeId" />
|
||||
<ChatLineSquare v-else />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-row1">
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<ElTag v-if="item.id === activeId" type="success" size="small" effect="light">
|
||||
使用中
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="item-model">{{ item.model_id }}</div>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<ElTooltip content="展开详情" placement="top" :show-after="200">
|
||||
<ElButton text circle size="small" @click="toggleExpand(item.id)">
|
||||
<ElIcon :class="{ rotated: expandedId === item.id }">
|
||||
<ArrowDown />
|
||||
</ElIcon>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip content="编辑" placement="top" :show-after="200">
|
||||
<ElButton text circle size="small" :icon="Edit" @click="openEdit(item)" />
|
||||
</ElTooltip>
|
||||
<ElTooltip content="删除" placement="top" :show-after="200">
|
||||
<ElButton text circle size="small" :icon="Delete" @click="handleDelete(item)" />
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 展开详情 -->
|
||||
<div v-show="expandedId === item.id" class="item-detail">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Base URL</span>
|
||||
<span class="detail-value">{{ item.base_url }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">API Key</span>
|
||||
<div class="api-key-wrap">
|
||||
<span class="detail-value api-key">
|
||||
{{ showKeyId === item.id ? item.api_key : `****${maskKey(item.api_key)}` }}
|
||||
</span>
|
||||
<ElButton
|
||||
text
|
||||
size="small"
|
||||
@click="showKeyId = showKeyId === item.id ? null : item.id"
|
||||
>
|
||||
<ElIcon><View v-if="showKeyId !== item.id" /><Hide v-else /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton
|
||||
text
|
||||
size="small"
|
||||
:disabled="!item.api_key"
|
||||
@click="copyKey(item.api_key)"
|
||||
>
|
||||
<ElIcon><CopyDocument /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Temperature</span>
|
||||
<span class="detail-value">{{ item.temperature.toFixed(1) }}</span>
|
||||
</div>
|
||||
<div v-if="item.created_time" class="detail-row">
|
||||
<span class="detail-label">添加于</span>
|
||||
<span class="detail-value">{{ item.created_time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部固定添加按钮 - 始终可见 -->
|
||||
<div 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>
|
||||
+17
-301
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user