Compare commits
3 Commits
c6d84d45a5
...
3cb3fb5063
| Author | SHA1 | Date |
|---|---|---|
|
|
3cb3fb5063 | |
|
|
7890bca4e2 | |
|
|
99dc835233 |
|
|
@ -89,7 +89,7 @@ i {
|
|||
}
|
||||
|
||||
:root {
|
||||
--header-bg-color: #242f42;
|
||||
--header-bg-color: #00bcd4;
|
||||
--header-text-color: #fff;
|
||||
--active-color: var(--el-color-primary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ export const useSidebarStore = defineStore('sidebar', {
|
|||
state: () => {
|
||||
return {
|
||||
collapse: false,
|
||||
bgColor: localStorage.getItem('sidebar-bg-color') || '#324157',
|
||||
textColor: localStorage.getItem('sidebar-text-color') || '#bfcbd9'
|
||||
bgColor: localStorage.getItem('sidebar-bg-color') || '#a6d3df',
|
||||
textColor: localStorage.getItem('sidebar-text-color') || '#5b6e88'
|
||||
};
|
||||
},
|
||||
getters: {},
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { defineStore } from 'pinia';
|
|||
export const useThemeStore = defineStore('theme', {
|
||||
state: () => {
|
||||
return {
|
||||
primary: '',
|
||||
primary: '#00bcd4',
|
||||
success: '',
|
||||
warning: '',
|
||||
danger: '',
|
||||
info: '',
|
||||
headerBgColor: '#242f42',
|
||||
headerBgColor: '#00bcd4',
|
||||
headerTextColor: '#ffff',
|
||||
};
|
||||
},
|
||||
|
|
@ -20,6 +20,9 @@ export const useThemeStore = defineStore('theme', {
|
|||
const color = localStorage.getItem(`theme-${type}`) || '';
|
||||
if (color) {
|
||||
this.setPropertyColor(color, type); // 设置主题色
|
||||
} else if (type === 'primary') {
|
||||
// 没有保存的主题色时,使用默认的宁静主题色
|
||||
this.setPropertyColor(this.primary, type);
|
||||
}
|
||||
});
|
||||
const headerBgColor = localStorage.getItem('header-bg-color');
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const themeStore = useThemeStore();
|
|||
const sidebar = useSidebarStore();
|
||||
|
||||
const color = reactive({
|
||||
primary: localStorage.getItem('theme-primary') || '#409eff',
|
||||
primary: localStorage.getItem('theme-primary') || '#00bcd4',
|
||||
success: localStorage.getItem('theme-success') || '#67c23a',
|
||||
warning: localStorage.getItem('theme-warning') || '#e6a23c',
|
||||
danger: localStorage.getItem('theme-danger') || '#f56c6c',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ucenter-page">
|
||||
<div class="user-container">
|
||||
<el-card class="user-profile" shadow="hover" :body-style="{ padding: '0px' }">
|
||||
<div class="user-profile-bg"></div>
|
||||
|
|
@ -27,123 +27,168 @@
|
|||
<el-card
|
||||
class="user-content"
|
||||
shadow="hover"
|
||||
:body-style="{ padding: '20px 50px', height: '100%', boxSizing: 'border-box' }"
|
||||
:body-style="{ padding: '20px 30px', height: '100%', boxSizing: 'border-box' }"
|
||||
>
|
||||
<el-tabs tab-position="left" v-model="activeName">
|
||||
<!-- <el-tab-pane name="label1" label="消息通知" class="user-tabpane">
|
||||
<TabsComp />
|
||||
</el-tab-pane> -->
|
||||
<el-tabs tab-position="left" v-model="activeName" class="user-tabs">
|
||||
<el-tab-pane name="label2" label="我的头像" class="user-tabpane">
|
||||
<div class="crop-wrap" v-if="activeName === 'label2'">
|
||||
<vueCropper
|
||||
ref="cropper"
|
||||
:img="imgSrc"
|
||||
:autoCrop="true"
|
||||
:centerBox="true"
|
||||
:full="true"
|
||||
mode="contain"
|
||||
>
|
||||
</vueCropper>
|
||||
<div class="avatar-section">
|
||||
<div class="crop-wrap" v-if="activeName === 'label2'">
|
||||
<vueCropper
|
||||
ref="cropper"
|
||||
:img="imgSrc"
|
||||
:autoCrop="true"
|
||||
:centerBox="true"
|
||||
:full="true"
|
||||
mode="contain"
|
||||
>
|
||||
</vueCropper>
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
<el-button class="crop-demo-btn" type="primary"
|
||||
>选择图片
|
||||
<input class="crop-input" type="file" name="image" accept="image/*" @change="setImage" />
|
||||
</el-button>
|
||||
<el-button type="success" @click="saveAvatar">上传并保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-button class="crop-demo-btn" type="primary"
|
||||
>选择图片
|
||||
<input class="crop-input" type="file" name="image" accept="image/*" @change="setImage" />
|
||||
</el-button>
|
||||
<el-button type="success" @click="saveAvatar">上传并保存</el-button>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="label3" label="修改密码" class="user-tabpane">
|
||||
<el-form class="w500" label-position="top">
|
||||
<el-form-item label="旧密码:">
|
||||
<el-input type="password" v-model="form.old"></el-input>
|
||||
<el-form class="password-form" label-position="top" :model="form" ref="passwordForm">
|
||||
<el-form-item label="旧密码:" prop="old" :rules="[{ required: true, message: '请输入旧密码', trigger: 'blur' }]">
|
||||
<el-input type="password" v-model="form.old" placeholder="请输入旧密码" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码:">
|
||||
<el-input type="password" v-model="form.new"></el-input>
|
||||
<el-form-item label="新密码:" prop="new" :rules="[
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
]">
|
||||
<el-input type="password" v-model="form.new" placeholder="请输入新密码" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码:">
|
||||
<el-input type="password" v-model="form.new1"></el-input>
|
||||
<el-form-item label="确认新密码:" prop="new1" :rules="[
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||
]">
|
||||
<el-input type="password" v-model="form.new1" placeholder="请再次输入新密码" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPassword ">保存</el-button>
|
||||
<el-button type="primary" @click="resetPassword">保存</el-button>
|
||||
</el-form-item>
|
||||
<el-link type="primary" @click="reset_password()">忘记密码?使用验证码重置</el-link>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="label4" v-if="isUserInfoLoaded" label="详细信息" class="user-tabpane">
|
||||
<el-tab-pane name="label4" v-if="isUserInfoLoaded" label="详细信息" class="user-tabpane">
|
||||
<TableEdit :form-data="userInfo" :options="options_edit" :edit="true" :update="updateUserInfo" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="label5" label="第三方账号" class="user-tabpane">
|
||||
<div>
|
||||
<el-select v-model="activePlatformName" placeholder="请选择第三方平台" size="small" style="width: 200px;">
|
||||
<!-- <el-option label="QQ" value="qq"></el-option>
|
||||
<el-option label="Github" value="github"></el-option>
|
||||
<el-option label="gitee" value="gitee"></el-option>
|
||||
<el-option label="google" value="google"></el-option> -->
|
||||
<!-- thirdPartyPlatform -->
|
||||
<template v-for="(item, index) in thirdPartyPlatform" :key="index">
|
||||
<el-option :label="item.label" :value="item.value"></el-option>
|
||||
</template>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="thirdLogin(activePlatformName)">绑定</el-button>
|
||||
</div>
|
||||
<div>
|
||||
<p>已绑定的第三方登录账号</p>
|
||||
<template v-for="(item, index) in thirdPartyUserInfo" :key="index">
|
||||
<el-row :gutter="20" class="user-tabpane">
|
||||
<el-col :span="12">
|
||||
<el-tag>{{ item.third_party_platform }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-avatar :src="item.third_party_user_avatar" size="small"></el-avatar>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-tag>{{ item.third_party_user_name }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-button type="danger" @click="unBindThirdParty(item.ID)">解绑</el-button>
|
||||
</el-col>
|
||||
<!-- <el-tag>{{ item.third_party_platform }}</el-tag>
|
||||
<el-avatar :src="item.third_party_user_avatar" size="small"></el-avatar>
|
||||
<el-tag>{{ item.third_party_user_name }}</el-tag>
|
||||
<el-button type="danger" @click="unBindThirdParty(item.ID)">解绑</el-button> -->
|
||||
</el-row>
|
||||
</template>
|
||||
<div class="third-party-section">
|
||||
<div class="bind-section">
|
||||
<el-select v-model="activePlatformName" placeholder="请选择第三方平台" size="default" style="width: 200px;">
|
||||
<template v-for="(item, index) in availablePlatforms" :key="index">
|
||||
<el-option :label="item.label" :value="item.value">
|
||||
<span class="platform-option">{{ item.label }}</span>
|
||||
</el-option>
|
||||
</template>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="thirdLogin(activePlatformName)" :disabled="!activePlatformName">绑定</el-button>
|
||||
</div>
|
||||
|
||||
<div class="bound-section" v-if="thirdPartyUserInfo.length > 0">
|
||||
<h4>已绑定的第三方登录账号</h4>
|
||||
<div class="bound-accounts">
|
||||
<template v-for="(item, index) in thirdPartyUserInfo" :key="index">
|
||||
<el-card class="account-card" shadow="hover">
|
||||
<div class="account-info">
|
||||
<el-avatar :src="item.third_party_user_avatar" size="medium" class="account-avatar"></el-avatar>
|
||||
<div class="account-details">
|
||||
<el-tag type="primary" class="platform-tag">{{ item.third_party_platform }}</el-tag>
|
||||
<span class="account-name">{{ item.third_party_user_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="danger" size="small" @click="unBindThirdParty(item.ID)">解绑</el-button>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" v-else>
|
||||
<el-empty description="暂无绑定的第三方账号" />
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="label6" label="二次认证" class="user-tabpane">
|
||||
<br />
|
||||
<el-switch v-model="second_auth.password_need_second_auth" active-text="打开" inactive-text="密码登录需进行二次认证" />
|
||||
<br />
|
||||
<br />
|
||||
<el-switch v-model="second_auth.code_need_second_auth" active-text="打开" inactive-text="验证码登录需进行二次认证" />
|
||||
<br />
|
||||
<br />
|
||||
<el-switch v-model="second_auth.third_party_need_second_auth" active-text="打开" inactive-text="第三方登录需进行二次认证" />
|
||||
<br />
|
||||
<br />
|
||||
<el-switch v-model="second_auth.ai_second_auth" active-text="打开" inactive-text="智能增强认证" />
|
||||
<br />
|
||||
<el-button type="primary" @click="updateUserSecondAuthInfo(userInfo)">保存二次认证设置</el-button>
|
||||
|
||||
<div>
|
||||
<p>TOTP密钥</p>
|
||||
<el-button type="primary" @click="GenTOTPSecret" :disabled="gen_totp_secret">生成TOTP密钥</el-button>
|
||||
<div v-if="totp_secret || totp_url" style="margin-top: 20px;">
|
||||
<!-- 提示 -->
|
||||
<p>使用支持TOTP的应用(如Google Authenticator、Authy等)扫描二维码或手动输入密钥进行绑定。</p>
|
||||
<p>请妥善保存您的TOTP密钥,只能查看一次,若丢失请解绑后重新生成。</p>
|
||||
<el-button type="primary" @click="onCopyTOTPSecret" v-if="totp_secret">点击复制密钥</el-button>
|
||||
<vueQr v-if="totp_url" :text="totp_url" :size="200" :margin="20" :background="'#ffffff'" :foreground="'#000000'" />
|
||||
<div class="auth-section">
|
||||
<div class="auth-switches">
|
||||
<div class="switch-item">
|
||||
<el-switch v-model="second_auth.password_need_second_auth" active-text="开启" inactive-text="关闭" />
|
||||
<span class="switch-label">密码登录需进行二次认证</span>
|
||||
</div>
|
||||
<div class="switch-item">
|
||||
<el-switch v-model="second_auth.code_need_second_auth" active-text="开启" inactive-text="关闭" />
|
||||
<span class="switch-label">验证码登录需进行二次认证</span>
|
||||
</div>
|
||||
<div class="switch-item">
|
||||
<el-switch v-model="second_auth.third_party_need_second_auth" active-text="开启" inactive-text="关闭" />
|
||||
<span class="switch-label">第三方登录需进行二次认证</span>
|
||||
</div>
|
||||
<div class="switch-item">
|
||||
<el-switch v-model="second_auth.ai_second_auth" active-text="开启" inactive-text="关闭" />
|
||||
<span class="switch-label">智能增强认证</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-row :gutter="20" class="user-tabpane">
|
||||
<el-col :span="12" v-if="totp_secret_created_at">
|
||||
<el-tag>密钥创建时间:{{ totp_secret_created_at }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="totp_secret_created_at">
|
||||
<el-button type="danger" @click="unBindTOTPSecret">解绑</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" @click="updateUserSecondAuthInfo(userInfo)" class="save-auth-btn">保存二次认证设置</el-button>
|
||||
|
||||
<div class="totp-section" v-if="!totp_secret_created_at">
|
||||
<h4>TOTP 动态口令</h4>
|
||||
<el-button type="primary" @click="GenTOTPSecret" :disabled="gen_totp_secret">生成 TOTP 密钥</el-button>
|
||||
|
||||
<div v-if="totp_secret || totp_url" class="totp-result">
|
||||
<el-alert
|
||||
title="重要提示"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 20px;"
|
||||
>
|
||||
<template #default>
|
||||
<p>使用支持 TOTP 的应用(如 Google Authenticator、Authy 等)扫描二维码或手动输入密钥进行绑定。</p>
|
||||
<p>请妥善保存您的 TOTP 密钥,只能查看一次,若丢失请解绑后重新生成。</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="totp-content">
|
||||
<div class="totp-qrcode" v-if="totp_url">
|
||||
<vueQr :text="totp_url" :size="200" :margin="20" :background="'#ffffff'" :foreground="'#000000'" />
|
||||
</div>
|
||||
<div class="totp-info">
|
||||
<el-input
|
||||
v-if="totp_secret"
|
||||
v-model="totp_secret"
|
||||
readonly
|
||||
style="margin-bottom: 10px;"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="onCopyTOTPSecret" icon="Copy">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="totp-bound" v-if="totp_secret_created_at">
|
||||
<el-alert
|
||||
title="TOTP 已绑定"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default>
|
||||
<p>密钥创建时间:{{ totp_secret_created_at }}</p>
|
||||
<el-button type="danger" @click="unBindTOTPSecret" style="margin-top: 10px;">解绑 TOTP</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
|
@ -152,18 +197,17 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts" name="ucenter">
|
||||
import { reactive, ref,inject } from 'vue';
|
||||
import { reactive, ref, inject, computed } from 'vue';
|
||||
import { VueCropper } from 'vue-cropper';
|
||||
import 'vue-cropper/dist/index.css';
|
||||
import avatar from '@/assets/img/img.jpg';
|
||||
import TabsComp from '../element/tabs.vue';
|
||||
import {GetUserInfoService, genTOTPSecret, unBindTOTPSecretService, getTOTPSecretInfo} from "@/api/user";
|
||||
import { GetUserStatisticService } from "@/api/user";
|
||||
import { UploadFileService } from "@/api/tool";
|
||||
import { UserInfo } from '@/types/user';
|
||||
import { ThirdPartyUserInfo } from '@/types/user';
|
||||
import { FormOption, FormOptionList } from '@/types/form-option';
|
||||
import { avatarEmits, ElMessage } from 'element-plus';
|
||||
import { FormOption } from '@/types/form-option';
|
||||
import { ElMessage, FormInstance } from 'element-plus';
|
||||
import TableEdit from '@/components/table-edit.vue';
|
||||
import {genResetPassword} from "@/api/user";
|
||||
import {updateUserInfoService} from "@/api/user";
|
||||
|
|
@ -172,7 +216,6 @@ import {getThirdPartyUUID,getThirdPartyLoginStatus,getThirdPartyLoginUrl, getThi
|
|||
import vueQr from 'vue-qr/src/packages/vue-qr.vue'
|
||||
|
||||
const name = localStorage.getItem('ms_username');
|
||||
const qqButtonBgImage = ref('https://wiki.connect.qq.com/wp-content/uploads/2016/12/Connect_logo_4.png');
|
||||
const form = reactive({
|
||||
new1: '',
|
||||
new: '',
|
||||
|
|
@ -190,7 +233,7 @@ const isUserInfoLoaded = ref(false);
|
|||
const globalData = inject("globalData");
|
||||
|
||||
const activeName = ref('label2');
|
||||
const activePlatformName = ref('github');
|
||||
const activePlatformName = ref('');
|
||||
const router = useRouter();
|
||||
const avatarImg = ref('');
|
||||
const imgSrc = ref('');
|
||||
|
|
@ -201,6 +244,7 @@ const currentLoginRequest = ref(0); //当前请求次数
|
|||
const totp_secret = ref('');
|
||||
const totp_url = ref('');
|
||||
const totp_secret_created_at = ref('');
|
||||
const passwordForm = ref<FormInstance | null>(null);
|
||||
|
||||
const second_auth = ref({
|
||||
password_need_second_auth: true,
|
||||
|
|
@ -222,6 +266,21 @@ const thirdPartyPlatform = ref([
|
|||
{ label: "Gitea自建", value: "my_gitea"},
|
||||
{ label: "Microsoft", value: "microsoft"}
|
||||
]);
|
||||
|
||||
// 计算可用的平台(排除已绑定的)
|
||||
const availablePlatforms = computed(() => {
|
||||
const boundPlatforms = thirdPartyUserInfo.value.map(item => item.third_party_platform);
|
||||
return thirdPartyPlatform.value.filter(platform => !boundPlatforms.includes(platform.value));
|
||||
});
|
||||
|
||||
// 密码确认验证函数
|
||||
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
|
||||
if (value !== form.new) {
|
||||
callback(new Error('两次输入的密码不一致'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
const onCopyTOTPSecret = () => {
|
||||
navigator.clipboard.writeText(totp_secret.value).then(() => {
|
||||
ElMessage.success('复制成功!');
|
||||
|
|
@ -370,13 +429,17 @@ const updateUserSecondAuthInfo = async (data: any) => {
|
|||
};
|
||||
|
||||
const resetPassword = async () =>{
|
||||
let req={
|
||||
old_password: form.old,
|
||||
new_password: form.new1,
|
||||
email: userInfo.value.Email,
|
||||
type:1
|
||||
}
|
||||
try{
|
||||
if (!passwordForm.value) return;
|
||||
|
||||
try {
|
||||
await passwordForm.value.validate();
|
||||
|
||||
let req={
|
||||
old_password: form.old,
|
||||
new_password: form.new1,
|
||||
email: userInfo.value?.Email,
|
||||
type:1
|
||||
}
|
||||
let result = await genResetPassword(req);
|
||||
if (result["code"] === 0) {
|
||||
//重置成功,返回新token
|
||||
|
|
@ -384,6 +447,8 @@ const resetPassword = async () =>{
|
|||
localStorage.setItem('token', result.data.token);
|
||||
globalData["token"] = result.data.token;
|
||||
ElMessage.success('重置密码成功');
|
||||
// 重置表单
|
||||
passwordForm.value.resetFields();
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(result["msg"]);
|
||||
|
|
@ -662,91 +727,123 @@ const unBindThirdParty = async (id) => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ucenter-page {
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.user-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
position: relative;
|
||||
width: 420px;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-profile:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.user-profile-bg {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
height: 220px;
|
||||
background-image: url('../../assets/img/ucenter-bg.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
width: 500px;
|
||||
margin-right: 20px;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.user-avatar-wrap {
|
||||
position: absolute;
|
||||
top: 135px;
|
||||
top: 155px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
border: 5px solid #fff;
|
||||
border: 6px solid #fff;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 7px 12px 0 rgba(62, 57, 107, 0.16);
|
||||
box-shadow: 0 8px 16px rgba(62, 57, 107, 0.16);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.user-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
text-align: center;
|
||||
padding: 80px 0 30px;
|
||||
padding: 90px 0 30px;
|
||||
}
|
||||
|
||||
.info-name {
|
||||
margin: 0 0 20px;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
color: #373a3c;
|
||||
}
|
||||
|
||||
.info-desc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.info-desc,
|
||||
.info-desc a {
|
||||
font-size: 18px;
|
||||
color: #55595c;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.info-icon i {
|
||||
font-size: 30px;
|
||||
margin: 0 10px;
|
||||
color: #343434;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.user-content {
|
||||
flex: 1;
|
||||
min-height: calc(100vh - 40px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-content:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.user-tabs {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.user-tabpane {
|
||||
padding: 10px 20px;
|
||||
padding: 20px;
|
||||
min-height: 600px;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.crop-wrap {
|
||||
width: 600px;
|
||||
height: 350px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 400px;
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.crop-demo-btn {
|
||||
|
|
@ -755,41 +852,274 @@ const unBindThirdParty = async (id) => {
|
|||
|
||||
.crop-input {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.w500 {
|
||||
width: 500px;
|
||||
.password-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.user-footer {
|
||||
display: flex;
|
||||
border-top: 1px solid rgba(83, 70, 134, 0.1);
|
||||
background: #fafafa;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.user-footer-item {
|
||||
padding: 20px 0;
|
||||
width: 33.3333333333%;
|
||||
padding: 24px 0;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-footer-item:hover {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.user-footer > div + div {
|
||||
border-left: 1px solid rgba(83, 70, 134, 0.1);
|
||||
}
|
||||
|
||||
.user-footer-item :deep(.el-statistic__label) {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-footer-item :deep(.el-statistic__number) {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 第三方账号样式 */
|
||||
.third-party-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.bind-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bound-section h4 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.bound-accounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
padding: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.account-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.account-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.account-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-tag {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 二次认证样式 */
|
||||
.auth-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.auth-switches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.switch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.save-auth-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.totp-section h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.totp-result {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.totp-content {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.totp-qrcode {
|
||||
flex-shrink: 0;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.totp-info {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.totp-bound {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 1200px) {
|
||||
.user-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.user-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crop-wrap {
|
||||
max-width: 100%;
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.password-form {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ucenter-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.user-tabpane {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.bound-accounts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.totp-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bind-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.el-tabs.el-tabs--left {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.el-tabs.el-tabs--left .el-tabs__header {
|
||||
margin: 0;
|
||||
border-right: 1px solid #e4e7ed;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.el-tabs.el-tabs--left .el-tabs__item {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
padding: 0 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.el-tabs.el-tabs--left .el-tabs__item.is-active {
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-right-color: #409eff;
|
||||
}
|
||||
|
||||
.el-tabs.el-tabs--left .el-tabs__item:hover:not(.is-active) {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.el-tabs.el-tabs--left .el-tabs__nav-wrap::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,185 +1,211 @@
|
|||
<template>
|
||||
<div class="db-manage-container">
|
||||
<el-row :gutter="20">
|
||||
|
||||
|
||||
<!-- 右侧主内容区 -->
|
||||
<el-col :span="24">
|
||||
<!-- 顶部下拉选择框 -->
|
||||
<el-row :gutter="20" class="mb-20">
|
||||
<el-col :span="10">
|
||||
<el-select
|
||||
v-model="selectedDatabase"
|
||||
filterable
|
||||
placeholder="请选择数据库"
|
||||
class="w-100"
|
||||
@change="getRunSQLHistory"
|
||||
<el-card class="header-card" shadow="never">
|
||||
<el-row :gutter="20" align="middle">
|
||||
<el-col :span="8">
|
||||
<el-select
|
||||
v-model="selectedDatabase"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择数据库"
|
||||
class="w-100"
|
||||
@change="getRunSQLHistory"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Folder /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="item in databases"
|
||||
:key="item.ID"
|
||||
:label="item.Name"
|
||||
:value="item.ID"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><search /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="item in databases"
|
||||
:key="item.ID"
|
||||
:label="item.Name"
|
||||
:value="item.ID"
|
||||
>
|
||||
<span>{{ item.Name }}</span>
|
||||
<div class="database-option">
|
||||
<span class="db-name">{{ item.Name }}</span>
|
||||
<span class="option-actions">
|
||||
<el-icon @click.stop="editDatabase(item)"><edit /></el-icon>
|
||||
<el-icon @click.stop="deleteDatabase(item)"
|
||||
><delete
|
||||
/></el-icon>
|
||||
<el-tooltip content="编辑" placement="top">
|
||||
<el-icon @click.stop="editDatabase(item)"><Edit /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-icon class="delete-icon" @click.stop="deleteDatabase(item)"><Delete /></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</el-option>
|
||||
<template #append>
|
||||
<el-button icon="plus" @click="showAddDatabaseDialog" />
|
||||
</template>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
<el-icon @click="showAddDatabaseDialog"><FolderAdd /></el-icon>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-select
|
||||
v-model="selectedConnection"
|
||||
filterable
|
||||
placeholder="请选择服务器"
|
||||
class="w-100"
|
||||
</div>
|
||||
</el-option>
|
||||
<template #append>
|
||||
<el-button icon="Plus" @click="showAddDatabaseDialog" />
|
||||
</template>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-select
|
||||
v-model="selectedConnection"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择服务器"
|
||||
class="w-100"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="item in connections"
|
||||
:key="item.domain"
|
||||
:label="item.name"
|
||||
:value="item.domain"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><search /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="item in connections"
|
||||
:key="item.domain"
|
||||
:label="item.name"
|
||||
:value="item.domain"
|
||||
>
|
||||
<span>{{ item.name }}</span>
|
||||
</el-option>
|
||||
<template #append>
|
||||
<el-button circle @click="showAddDatabaseDialog" />
|
||||
</template>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="showSQLHistoryDialog"
|
||||
class="w-100"
|
||||
:loading="executing"
|
||||
>执行历史SQL</el-button
|
||||
>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<span>{{ item.name }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="showSQLHistoryDialog"
|
||||
class="w-100"
|
||||
>
|
||||
<el-icon><Document /></el-icon>
|
||||
执行历史
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button
|
||||
type="success"
|
||||
@click="showAddDatabaseDialog"
|
||||
class="w-100"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建数据库
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- SQL输入框和执行按钮 -->
|
||||
<el-row class="mb-20">
|
||||
<el-col :span="20">
|
||||
<el-input
|
||||
v-model="sqlQuery"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入SQL语句"
|
||||
/>
|
||||
<!-- <SqlEditor v-model="sqlQuery" /> -->
|
||||
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="executeSql"
|
||||
class="w-100"
|
||||
:disabled="executing"
|
||||
>
|
||||
执行
|
||||
<el-card class="sql-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span><el-icon><EditPen /></el-icon> SQL 编辑器</span>
|
||||
<div class="header-actions">
|
||||
<el-button text @click="clearSql">
|
||||
<el-icon><Delete /></el-icon> 清空
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 结果表格 -->
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 100%"
|
||||
height="400px"
|
||||
v-horizontal-scroll="'always'"
|
||||
v-loading="loading"
|
||||
<el-button text @click="formatSql" v-if="false">
|
||||
<el-icon><Operation /></el-icon> 格式化
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<SqlEditor
|
||||
v-model="sqlQuery"
|
||||
@execute="executeSql"
|
||||
/>
|
||||
<div class="sql-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="executeSql"
|
||||
:loading="executing"
|
||||
:disabled="!sqlQuery.trim()"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="column in tableColumns"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
/>
|
||||
</el-table>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
执行 SQL
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="result-card" shadow="never" v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span><el-icon><Tickets /></el-icon> 查询结果</span>
|
||||
<div class="header-actions">
|
||||
<el-button text @click="exportData" v-if="tableData.length > 0">
|
||||
<el-icon><Download /></el-icon> 导出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
:data="tableData"
|
||||
stripe
|
||||
border
|
||||
height="400"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="column in tableColumns"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<template #empty>
|
||||
<el-empty description="暂无数据,请先执行 SQL 查询" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑数据库对话框 -->
|
||||
<el-dialog
|
||||
v-model="databaseDialogVisible"
|
||||
:title="isEditDatabase ? '编辑数据库' : '添加数据库'"
|
||||
width="50%"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="databaseForm" label-width="100px">
|
||||
<el-form-item label="数据库名称" required>
|
||||
<el-input
|
||||
v-model="databaseForm.DB_NAME"
|
||||
placeholder="请输入数据库名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form :model="databaseForm" label-width="100px">
|
||||
<el-form-item label="数据库密码" required>
|
||||
<el-input
|
||||
v-model="databaseForm.DB_Password"
|
||||
type="password"
|
||||
placeholder="请输入数据库密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form :model="databaseForm" label-width="100px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input
|
||||
v-model="databaseForm.DB_User"
|
||||
placeholder="请输入用户名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form :model="databaseForm" label-width="100px">
|
||||
<el-form-item label="IP或域名" required>
|
||||
<el-input
|
||||
v-model="databaseForm.DB_IP"
|
||||
placeholder="请输入数据库ip或域名"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form :model="databaseForm" label-width="100px">
|
||||
<el-form-item label="数据库端口" required>
|
||||
<el-input
|
||||
v-model="databaseForm.DB_Port"
|
||||
placeholder="请输入数据库端口"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form :model="databaseForm" label-width="100px">
|
||||
<el-form-item label="数据库类型" required>
|
||||
<el-select
|
||||
v-model="databaseForm.DB_Type"
|
||||
placeholder="请选择数据库类型"
|
||||
>
|
||||
<el-option label="MySQL" value="0" />
|
||||
<el-option label="PostgreSQL" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form :model="databaseForm" label-width="100px">
|
||||
<el-form-item label="描述信息">
|
||||
<el-form
|
||||
ref="databaseFormRef"
|
||||
:model="databaseForm"
|
||||
:rules="databaseRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数据库名称" prop="DB_NAME">
|
||||
<el-input v-model="databaseForm.DB_NAME" placeholder="请输入数据库名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数据库类型" prop="DB_Type">
|
||||
<el-select v-model="databaseForm.DB_Type" placeholder="请选择数据库类型" class="w-100">
|
||||
<el-option label="MySQL" :value="0" />
|
||||
<el-option label="PostgreSQL" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="IP或域名" prop="DB_IP">
|
||||
<el-input v-model="databaseForm.DB_IP" placeholder="请输入数据库 IP 或域名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="端口" prop="DB_Port">
|
||||
<el-input v-model="databaseForm.DB_Port" placeholder="请输入数据库端口" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用户名" prop="DB_User">
|
||||
<el-input v-model="databaseForm.DB_User" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="密码" prop="DB_Password">
|
||||
<el-input
|
||||
v-model="databaseForm.DB_Password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="描述信息" prop="DB_Desc">
|
||||
<el-input
|
||||
v-model="databaseForm.DB_Desc"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入备注信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
|
@ -192,32 +218,35 @@
|
|||
|
||||
<el-dialog
|
||||
v-model="sqlHistoryDialogVisible"
|
||||
title="SQL执行历史"
|
||||
width="50%"
|
||||
title="SQL 执行历史"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-table :data="sqlHistory" stripe width="100%" height="600px" fit>
|
||||
<el-table-column prop="ID" label="SQLID" width="150"></el-table-column>
|
||||
<el-table-column prop="SQL" label="SQL" width="500"> </el-table-column>
|
||||
<el-table-column label="操作" width="270">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="mini"
|
||||
@click.prevent="useCurrSql(scope.$index)"
|
||||
>引用</el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="mini"
|
||||
@click.prevent="DelSqlHistory(scope.$index)"
|
||||
>删除</el-button
|
||||
>
|
||||
<el-table :data="sqlHistory" stripe height="500">
|
||||
<el-table-column prop="ID" label="ID" width="80" />
|
||||
<el-table-column prop="SQL" label="SQL 语句" min-width="300" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tooltip placement="top" :disabled="row.SQL.length <= 50">
|
||||
<template #content>
|
||||
<div class="sql-tooltip">{{ row.SQL }}</div>
|
||||
</template>
|
||||
<span class="sql-content">{{ row.SQL }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button link type="primary" size="small" @click="useCurrSql($index)">
|
||||
<el-icon><RefreshLeft /></el-icon> 引用
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="DelSqlHistory($index)">
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="sqlHistoryDialogVisible = false">取消</el-button>
|
||||
<el-button @click="sqlHistoryDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
|
@ -233,15 +262,28 @@ import {
|
|||
GetSQLRunHistoryService,
|
||||
} from "@/api/dbm";
|
||||
import { DatabaseConfig, ISQLOperation } from "@/types/dbm";
|
||||
import { Search, Edit, Delete, Plus } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
Edit,
|
||||
Delete,
|
||||
Plus,
|
||||
Monitor,
|
||||
Document,
|
||||
EditPen,
|
||||
Tickets,
|
||||
VideoPlay,
|
||||
RefreshLeft,
|
||||
Download,
|
||||
Operation,
|
||||
Folder,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import SqlEditor from './sqlEditor.vue';
|
||||
|
||||
// 数据库下拉框数据
|
||||
const databaseFormRef = ref<FormInstance>();
|
||||
|
||||
const databases = ref<DatabaseConfig[]>([]);
|
||||
const selectedDatabase = ref("");
|
||||
|
||||
// 连接下拉框数据
|
||||
const connections = ref([
|
||||
{ name: "腾讯服务器", domain: "tx.ljsea.top" },
|
||||
{ name: "阿里云服务器", domain: "al.ljsea.top" },
|
||||
|
|
@ -250,41 +292,32 @@ const connections = ref([
|
|||
const supportdedDBTypes = ref({ 0: "mysql", 1: "postgresql" });
|
||||
const selectedConnection = ref("");
|
||||
|
||||
// SQL查询相关
|
||||
const sqlQuery = ref("");
|
||||
const executing = ref(false);
|
||||
const tableData = ref([]);
|
||||
const tableColumns = ref([]);
|
||||
const tableData = ref<any[]>([]);
|
||||
const tableColumns = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 数据库表列表
|
||||
|
||||
|
||||
// 数据库对话框相关
|
||||
const databaseDialogVisible = ref(false);
|
||||
const sqlHistoryDialogVisible = ref(false);
|
||||
const isEditDatabase = ref(false);
|
||||
const databaseForm = ref<DatabaseConfig>();
|
||||
const sqlHistory = ref<ISQLOperation[]>([]);
|
||||
|
||||
// 连接对话框相关
|
||||
const connectionDialogVisible = ref(false);
|
||||
const isEditConnection = ref(false);
|
||||
const connectionForm = reactive({
|
||||
id: 0,
|
||||
name: "",
|
||||
host: "",
|
||||
port: "",
|
||||
username: "",
|
||||
password: "",
|
||||
const databaseRules = reactive<FormRules<DatabaseConfig>>({
|
||||
DB_NAME: [{ required: true, message: "请输入数据库名称", trigger: "blur" }],
|
||||
DB_IP: [{ required: true, message: "请输入 IP 或域名", trigger: "blur" }],
|
||||
DB_Port: [{ required: true, message: "请输入端口", trigger: "blur" }],
|
||||
DB_User: [{ required: true, message: "请输入用户名", trigger: "blur" }],
|
||||
DB_Password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
||||
DB_Type: [{ required: true, message: "请选择数据库类型", trigger: "change" }],
|
||||
});
|
||||
|
||||
const showSQLHistoryDialog = async () => {
|
||||
sqlHistoryDialogVisible.value = true;
|
||||
console.log('SQL history dialog opened:', sqlHistoryDialogVisible.value);
|
||||
await getRunSQLHistory();
|
||||
};
|
||||
|
||||
// 方法定义
|
||||
const showAddDatabaseDialog = () => {
|
||||
isEditDatabase.value = false;
|
||||
databaseForm.value = {
|
||||
|
|
@ -306,13 +339,85 @@ const showAddDatabaseDialog = () => {
|
|||
databaseDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const editDatabase = (item) => {
|
||||
const editDatabase = (item: DatabaseConfig) => {
|
||||
isEditDatabase.value = true;
|
||||
console.log("editDatabase:", item);
|
||||
databaseForm.value = item;
|
||||
databaseForm.value = { ...item };
|
||||
databaseDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const deleteDatabase = async (item: DatabaseConfig) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除数据库 "${item.Name}"?`, "提示", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
databases.value = databases.value.filter((db) => db.ID !== item.ID);
|
||||
ElMessage.success("删除成功");
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
};
|
||||
|
||||
const saveDatabase = async () => {
|
||||
if (!databaseFormRef.value) return;
|
||||
await databaseFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
|
||||
let req = {};
|
||||
if (isEditDatabase.value) {
|
||||
req = {
|
||||
token: localStorage.getItem("token"),
|
||||
db_id: databaseForm.value!.ID,
|
||||
db_name: databaseForm.value!.DB_NAME,
|
||||
db_type: databaseForm.value!.DB_Type,
|
||||
db_ip: databaseForm.value!.DB_IP,
|
||||
db_port: databaseForm.value!.DB_Port,
|
||||
db_user: databaseForm.value!.DB_User,
|
||||
db_password: databaseForm.value!.DB_Password,
|
||||
db_desc: databaseForm.value!.DB_Desc,
|
||||
};
|
||||
await UpdateDBManageService(req)
|
||||
.then((res: any) => {
|
||||
if (res.code === 0) {
|
||||
ElMessage.success("更新成功");
|
||||
GetDBManageList();
|
||||
} else {
|
||||
ElMessage.error("更新失败");
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("请求错误:", error);
|
||||
});
|
||||
} else {
|
||||
req = {
|
||||
token: localStorage.getItem("token"),
|
||||
db_id: databaseForm.value!.ID,
|
||||
db_name: databaseForm.value!.DB_NAME,
|
||||
db_type: databaseForm.value!.DB_Type,
|
||||
db_ip: databaseForm.value!.DB_IP,
|
||||
db_port: databaseForm.value!.DB_Port,
|
||||
db_user: databaseForm.value!.DB_User,
|
||||
db_password: databaseForm.value!.DB_Password,
|
||||
db_desc: databaseForm.value!.DB_Desc,
|
||||
};
|
||||
await AddDBManageService(req)
|
||||
.then((res: any) => {
|
||||
if (res.code === 0) {
|
||||
ElMessage.success("添加成功");
|
||||
GetDBManageList();
|
||||
} else {
|
||||
ElMessage.error("添加失败");
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("请求错误:", error);
|
||||
});
|
||||
}
|
||||
databaseDialogVisible.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const GetDBManageList = async () => {
|
||||
let req = {
|
||||
get_type: 0,
|
||||
|
|
@ -334,16 +439,6 @@ const GetDBManageList = async () => {
|
|||
" - " +
|
||||
databases.value[i].DB_NAME;
|
||||
}
|
||||
ElMessage.success("获取数据库列表成功");
|
||||
console.log("获取数据库列表成功:", databases.value);
|
||||
|
||||
|
||||
// console.log("获取数据库列表成功:", res.data);
|
||||
|
||||
// console.log("获取数据库列表成功:", res.data);
|
||||
// console.log("获取数据库列表成功:", res.data);
|
||||
} else {
|
||||
console.error("获取数据库列表失败:", res.message);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
|
|
@ -352,89 +447,28 @@ const GetDBManageList = async () => {
|
|||
};
|
||||
GetDBManageList();
|
||||
|
||||
const useCurrSql = (index) => {
|
||||
const useCurrSql = (index: number) => {
|
||||
sqlQuery.value = sqlHistory.value[index].SQL;
|
||||
sqlHistoryDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const DelSqlHistory = (index) => {
|
||||
sqlHistory.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const deleteDatabase = (item) => {
|
||||
// 实际项目中这里应该调用API删除
|
||||
databases.value = databases.value.filter((db) => db.ID !== item.id);
|
||||
};
|
||||
|
||||
const saveDatabase = () => {
|
||||
let req = {};
|
||||
if (isEditDatabase.value) {
|
||||
// 更新
|
||||
// const index = databases.value.findIndex(db => db.ID === databaseForm.id);
|
||||
// if (index !== -1) {
|
||||
// databases.value[index].Name = databaseForm.name;
|
||||
// }]
|
||||
req = {
|
||||
token: localStorage.getItem("token"),
|
||||
db_id: databaseForm.value.ID,
|
||||
db_name: databaseForm.value.DB_NAME,
|
||||
db_type: databaseForm.value.DB_Type,
|
||||
db_ip: databaseForm.value.DB_IP,
|
||||
db_port: databaseForm.value.DB_Port,
|
||||
db_user: databaseForm.value.DB_User,
|
||||
db_password: databaseForm.value.DB_Password,
|
||||
db_desc: databaseForm.value.DB_Desc,
|
||||
};
|
||||
UpdateDBManageService(req)
|
||||
.then((res: any) => {
|
||||
if (res.code === 0) {
|
||||
ElMessage.success("更新成功");
|
||||
GetDBManageList();
|
||||
} else {
|
||||
ElMessage.error("更新失败");
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("请求错误:", error);
|
||||
});
|
||||
} else {
|
||||
// 新增
|
||||
// databases.value.push({
|
||||
// id: databases.value.length + 1,
|
||||
// name: databaseForm.name,
|
||||
// });
|
||||
req = {
|
||||
token: localStorage.getItem("token"),
|
||||
db_id: databaseForm.value.ID,
|
||||
db_name: databaseForm.value.DB_NAME,
|
||||
db_type: databaseForm.value.DB_Type,
|
||||
db_ip: databaseForm.value.DB_IP,
|
||||
db_port: databaseForm.value.DB_Port,
|
||||
db_user: databaseForm.value.DB_User,
|
||||
db_password: databaseForm.value.DB_Password,
|
||||
db_desc: databaseForm.value.DB_Desc,
|
||||
};
|
||||
AddDBManageService(req)
|
||||
.then((res: any) => {
|
||||
if (res.code === 0) {
|
||||
ElMessage.success("添加成功");
|
||||
GetDBManageList();
|
||||
} else {
|
||||
ElMessage.error("添加失败");
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("请求错误:", error);
|
||||
});
|
||||
const DelSqlHistory = async (index: number) => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确认删除这条 SQL 记录?", "提示", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
sqlHistory.value.splice(index, 1);
|
||||
ElMessage.success("删除成功");
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
databaseDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const getRunSQLHistory = async () => {
|
||||
if (!selectedDatabase.value) {
|
||||
ElMessage({
|
||||
type: "error",
|
||||
message: "请选择数据库",
|
||||
});
|
||||
ElMessage.error("请先选择数据库");
|
||||
return;
|
||||
}
|
||||
let req = {
|
||||
|
|
@ -446,12 +480,6 @@ const getRunSQLHistory = async () => {
|
|||
.then((res: any) => {
|
||||
if (res.code === 0) {
|
||||
sqlHistory.value = res.data;
|
||||
ElMessage({
|
||||
type: "success",
|
||||
message: "获取SQL执行历史成功",
|
||||
});
|
||||
} else {
|
||||
console.error("获取SQL执行历史失败:", res.message);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
|
|
@ -459,12 +487,13 @@ const getRunSQLHistory = async () => {
|
|||
});
|
||||
};
|
||||
|
||||
const executeSql = () => {
|
||||
const executeSql = async () => {
|
||||
if (!selectedDatabase.value) {
|
||||
ElMessage({
|
||||
type: "error",
|
||||
message: "请选择数据库",
|
||||
});
|
||||
ElMessage.error("请先选择数据库");
|
||||
return;
|
||||
}
|
||||
if (!sqlQuery.value.trim()) {
|
||||
ElMessage.warning("请输入 SQL 语句");
|
||||
return;
|
||||
}
|
||||
executing.value = true;
|
||||
|
|
@ -477,70 +506,148 @@ const executeSql = () => {
|
|||
db_id: selectedDatabase.value,
|
||||
};
|
||||
try {
|
||||
RunSQLService(req)
|
||||
await RunSQLService(req)
|
||||
.then((res: any) => {
|
||||
if (res.code === 0) {
|
||||
tableData.value = res.data.Rows;
|
||||
tableColumns.value = res.data.Columns;
|
||||
// let keys = Object.keys(res.data.Rows[0]);
|
||||
//console.log( 'columns:',tableColumns.value);
|
||||
// tableColumns.value = res.columns;
|
||||
// tableColumns.value = keys.map((key) => {
|
||||
// return { prop: key, label: key };
|
||||
// });
|
||||
// console.log(res.data);
|
||||
ElMessage({
|
||||
type: "success",
|
||||
message: "执行SQL成功",
|
||||
});
|
||||
tableData.value = res.data.Rows || [];
|
||||
tableColumns.value = res.data.Columns || [];
|
||||
ElMessage.success("执行 SQL 成功");
|
||||
} else {
|
||||
console.error("执行SQL失败:", res.message);
|
||||
ElMessage({
|
||||
type: "error",
|
||||
message: res.message,
|
||||
});
|
||||
ElMessage.error(res.message || "执行 SQL 失败");
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("请求错误:", error);
|
||||
ElMessage.error("请求失败");
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
executing.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
executing.value = false;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const clearSql = () => {
|
||||
sqlQuery.value = "";
|
||||
};
|
||||
|
||||
const formatSql = () => {
|
||||
ElMessage.info("格式化功能开发中");
|
||||
};
|
||||
|
||||
const exportData = () => {
|
||||
if (tableData.value.length === 0) return;
|
||||
try {
|
||||
const headers = tableColumns.value.map((col) => col.label).join(",");
|
||||
const csvContent =
|
||||
headers +
|
||||
"\n" +
|
||||
tableData.value
|
||||
.map((row) =>
|
||||
tableColumns.value.map((col) => JSON.stringify(row[col.prop] ?? "")).join(",")
|
||||
)
|
||||
.join("\n");
|
||||
const blob = new Blob(["" + csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `query_result_${Date.now()}.csv`;
|
||||
link.click();
|
||||
ElMessage.success("导出成功");
|
||||
} catch (e) {
|
||||
ElMessage.error("导出失败");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.db-manage-container {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.table-list {
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
.header-card,
|
||||
.sql-card,
|
||||
.result-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mb-20 {
|
||||
margin-bottom: 20px;
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.w-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.database-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.database-option .db-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option-actions {
|
||||
float: right;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.el-option:hover .option-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.option-actions .el-icon {
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.option-actions .el-icon:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.option-actions .delete-icon:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.sql-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sql-content {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sql-tooltip {
|
||||
max-width: 600px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,88 +1,187 @@
|
|||
<template>
|
||||
<div ref="editorContainer" class="sql-editor-container" />
|
||||
<codemirror
|
||||
v-model="code"
|
||||
:placeholder="placeholder"
|
||||
:style="{ height: '200px' }"
|
||||
:autofocus="false"
|
||||
:indent-with-tab="true"
|
||||
:tab-size="2"
|
||||
:extensions="extensions"
|
||||
@keydown="handleKeydown"
|
||||
@update:value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, type Ref } from 'vue';
|
||||
import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { oneDark } from '@codemirror/theme-one-dark'; // 可选主题
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { Codemirror } from 'vue-codemirror';
|
||||
import { sql, MySQL } from '@codemirror/lang-sql';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { autocompletion, completeFromList } from '@codemirror/autocomplete';
|
||||
import { basicSetup } from 'codemirror';
|
||||
|
||||
const sqlKeywords = [
|
||||
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'INSERT', 'INTO', 'VALUES',
|
||||
'UPDATE', 'SET', 'DELETE', 'CREATE', 'TABLE', 'ALTER', 'DROP', 'TRUNCATE',
|
||||
'DATABASE', 'USE', 'INDEX', 'VIEW', 'PROCEDURE', 'FUNCTION', 'RETURN',
|
||||
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'FULL', 'ON', 'GROUP', 'BY',
|
||||
'HAVING', 'ORDER', 'ASC', 'DESC', 'LIMIT', 'OFFSET', 'DISTINCT', 'AS',
|
||||
'UNION', 'ALL', 'EXCEPT', 'INTERSECT', 'IN', 'EXISTS', 'BETWEEN', 'LIKE',
|
||||
'IS', 'NULL', 'TRUE', 'FALSE', 'DEFAULT', 'PRIMARY', 'KEY', 'FOREIGN',
|
||||
'REFERENCES', 'CHECK', 'CONSTRAINT', 'UNIQUE', 'AUTO_INCREMENT', 'SERIAL',
|
||||
'CASCADE', 'RESTRICT', 'NO', 'ACTION', 'WITH', 'TIME', 'ZONE', 'PARTITION',
|
||||
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'MERGE', 'UPSERT', 'REPLACE',
|
||||
'BEGIN', 'COMMIT', 'ROLLBACK', 'TRANSACTION', 'SAVEPOINT', 'LOCK',
|
||||
'GRANT', 'REVOKE', 'DENY', 'EXEC', 'EXECUTE', 'DECLARE', 'SET',
|
||||
'IF', 'ELSE', 'CASE', 'WHEN', 'THEN', 'END', 'WHILE', 'FOR', 'LOOP',
|
||||
'CURSOR', 'OPEN', 'FETCH', 'CLOSE', 'DEALLOCATE', 'PREPARE', 'EXECUTE',
|
||||
'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN', 'USE', 'HELP', 'SOURCE',
|
||||
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'ROUND', 'FLOOR', 'CEIL', 'CEILING',
|
||||
'ABS', 'MOD', 'POWER', 'SQRT', 'EXP', 'LOG', 'LOG10', 'LN', 'RAND',
|
||||
'CONCAT', 'SUBSTRING', 'LEFT', 'RIGHT', 'TRIM', 'LTRIM', 'RTRIM',
|
||||
'UPPER', 'LOWER', 'INITCAP', 'LENGTH', 'CHAR_LENGTH', 'CHARACTER_LENGTH',
|
||||
'POSITION', 'STRPOS', 'INSTR', 'LOCATE', 'REPLACE', 'TRANSLATE',
|
||||
'DATE', 'TIME', 'DATETIME', 'TIMESTAMP', 'YEAR', 'MONTH', 'DAY',
|
||||
'HOUR', 'MINUTE', 'SECOND', 'NOW', 'CURDATE', 'CURRENT_DATE',
|
||||
'CURTIME', 'CURRENT_TIME', 'CURRENT_TIMESTAMP', 'DATE_ADD', 'DATE_SUB',
|
||||
'DATEDIFF', 'TIMESTAMPDIFF', 'DATE_FORMAT', 'STR_TO_DATE',
|
||||
'CAST', 'CONVERT', 'COALESCE', 'NULLIF', 'IFNULL', 'ISNULL',
|
||||
'FIRST', 'LAST', 'FIRST_VALUE', 'LAST_VALUE', 'NTH_VALUE',
|
||||
'ROW_NUMBER', 'RANK', 'DENSE_RANK', 'PERCENT_RANK', 'CUME_DIST',
|
||||
'NTILE', 'LEAD', 'LAG', 'PERCENTILE_CONT', 'PERCENTILE_DISC',
|
||||
'ARRAY', 'JSON', 'XML', 'GEOMETRY', 'POINT', 'LINESTRING', 'POLYGON',
|
||||
'BOOLEAN', 'BOOL', 'INT', 'INTEGER', 'TINYINT', 'SMALLINT', 'MEDIUMINT',
|
||||
'BIGINT', 'DECIMAL', 'NUMERIC', 'FLOAT', 'DOUBLE', 'REAL', 'BIT',
|
||||
'CHAR', 'VARCHAR', 'BINARY', 'VARBINARY', 'TINYTEXT', 'TEXT',
|
||||
'MEDIUMTEXT', 'LONGTEXT', 'ENUM', 'SET', 'DATE', 'DATETIME', 'TIMESTAMP',
|
||||
'TIME', 'YEAR', 'JSON', 'JSONB', 'XML', 'UUID', 'INET', 'CIDR',
|
||||
'MACADDR', 'TSVECTOR', 'TSQUERY', 'INT4RANGE', 'INT8RANGE', 'NUMRANGE',
|
||||
'TSRANGE', 'TSTZRANGE', 'DATERANGE', 'POINT', 'LINE', 'LSEG', 'BOX',
|
||||
'PATH', 'POLYGON', 'CIRCLE', 'MULTIPOINT', 'MULTILINESTRING',
|
||||
'MULTIPOLYGON', 'GEOMETRYCOLLECTION'
|
||||
];
|
||||
|
||||
const sqlCompletions = sqlKeywords.map((keyword) => ({
|
||||
label: keyword,
|
||||
type: 'keyword',
|
||||
apply: keyword + ' ',
|
||||
}));
|
||||
|
||||
// 定义 props(支持 v-model 双向绑定)
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
(e: 'execute'): void;
|
||||
}>();
|
||||
|
||||
// 编辑器容器引用
|
||||
const editorContainer: Ref<HTMLElement | null> = ref(null);
|
||||
let editorView: EditorView | null = null;
|
||||
const code = ref(props.modelValue || '');
|
||||
const placeholder = ref(props.placeholder || '请输入 SQL 语句');
|
||||
|
||||
// 初始化编辑器
|
||||
const initEditor = () => {
|
||||
if (!editorContainer.value) return;
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
sql({ dialect: MySQL, upperCaseKeywords: true }),
|
||||
autocompletion({
|
||||
override: [completeFromList(sqlCompletions)],
|
||||
defaultKeymap: true,
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Ctrl-Enter',
|
||||
run: () => {
|
||||
emit('execute');
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Cmd-Enter',
|
||||
run: () => {
|
||||
emit('execute');
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
// 配置编辑器扩展
|
||||
const extensions = [
|
||||
basicSetup, // 基础功能(行号、语法高亮、快捷键等)
|
||||
sql(), // SQL 语言支持
|
||||
oneDark, // 主题(可选,移除则使用默认主题)
|
||||
EditorView.lineWrapping, // 自动换行
|
||||
EditorView.updateListener.of((update) => {
|
||||
// 监听内容变化并同步到 modelValue
|
||||
if (update.docChanged) {
|
||||
const newValue = update.state.doc.toString();
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// 初始化编辑器状态(使用 modelValue 作为初始内容)
|
||||
const state = EditorState.create({
|
||||
doc: props.modelValue,
|
||||
extensions,
|
||||
});
|
||||
|
||||
// 创建 EditorView 并挂载到容器
|
||||
editorView = new EditorView({
|
||||
state,
|
||||
parent: editorContainer.value,
|
||||
});
|
||||
const handleChange = (value: string) => {
|
||||
emit('update:modelValue', value);
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
emit('execute');
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 modelValue 变化(外部修改时更新编辑器内容)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (editorView && newValue !== editorView.state.doc.toString()) {
|
||||
editorView.dispatch({
|
||||
changes: { from: 0, to: editorView.state.doc.length, insert: newValue },
|
||||
});
|
||||
if (newValue !== code.value) {
|
||||
code.value = newValue;
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
}
|
||||
);
|
||||
|
||||
// 生命周期:挂载后初始化编辑器
|
||||
onMounted(() => {
|
||||
initEditor();
|
||||
});
|
||||
|
||||
// 生命周期:卸载时销毁编辑器(避免内存泄漏)
|
||||
onUnmounted(() => {
|
||||
editorView?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sql-editor-container {
|
||||
height: 400px; /* 必须指定高度 */
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
:deep(.cm-editor) {
|
||||
height: 100%;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
:deep(.cm-editor:hover) {
|
||||
border-color: #c0c4cc;
|
||||
}
|
||||
|
||||
:deep(.cm-focused) {
|
||||
border-color: #409eff !important;
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
:deep(.cm-scroller) {
|
||||
font-family: '"JetBrains Mono", "Fira Code", Consolas, monospace';
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:deep(.cm-gutters) {
|
||||
background-color: #f5f7fa;
|
||||
border-right: 1px solid #e4e7ed;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
:deep(.cm-activeLineGutter) {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
:deep(.cm-activeLine) {
|
||||
background-color: rgba(64, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
:deep(.cm-tooltip) {
|
||||
background-color: #ffffff !important;
|
||||
border: 1px solid #e4e7ed !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
:deep(.cm-tooltip-autocomplete ul li[aria-selected]) {
|
||||
background-color: #ecf5ff !important;
|
||||
color: #409eff !important;
|
||||
}
|
||||
|
||||
:deep(.cm-completionIcon-keyword)::before {
|
||||
content: 'K';
|
||||
color: #409eff;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue