AI代码生成工具、个人中心
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
This commit is contained in:
@@ -3,8 +3,40 @@ import type { UserInfo } from '@vben/types';
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* 获取当前登录用户信息(个人中心 / 顶栏)
|
||||
*/
|
||||
export async function getUserInfoApi() {
|
||||
return requestClient.get<UserInfo>('admin/my-info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新个人资料(头像/昵称/手机/邮箱/简介)
|
||||
*/
|
||||
export async function updateProfile(data: Record<string, any>) {
|
||||
return requestClient.post<any>('admin/update-profile', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改当前用户密码(字段对齐后端 snake_case)
|
||||
*/
|
||||
export async function changePassword(data: {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}) {
|
||||
return requestClient.post<any>('admin/change-password', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人中心:我的登录日志(分页)
|
||||
*/
|
||||
export async function getLoginLog(data: Record<string, any> = {}) {
|
||||
return requestClient.get<any>('admin/login-log', { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人中心:我的操作日志(分页,可带 endpoint name)
|
||||
*/
|
||||
export async function getOpLog(data: Record<string, any> = {}) {
|
||||
return requestClient.get<any>('admin/op-log', { params: data });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表单头像上传:选图后先 VCropper 1:1 裁剪,再走 upload/image
|
||||
* appearance=profile 时为「点击头像编辑」C 端样式(侧栏资料卡)
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { VCropper } from '@vben/common-ui';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Upload } from 'ant-design-vue';
|
||||
import { Modal, Upload, message } from 'ant-design-vue';
|
||||
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { Icon } from '#/components/icon';
|
||||
@@ -8,33 +15,170 @@ import { Icon } from '#/components/icon';
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/** 裁剪比例,头像固定 1:1 */
|
||||
aspectRatio: {
|
||||
type: String,
|
||||
default: '1:1',
|
||||
},
|
||||
/** 是否开启裁剪,默认开启 */
|
||||
crop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/**
|
||||
* form:表单 picture-card;profile:资料卡点击头像编辑
|
||||
*/
|
||||
appearance: {
|
||||
type: String as () => 'form' | 'profile',
|
||||
default: 'form',
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['update:value']);
|
||||
|
||||
const emits = defineEmits(['update:value', 'change']);
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
defaultValue: props.value,
|
||||
passive: true,
|
||||
});
|
||||
|
||||
const cropOpen = ref(false);
|
||||
const cropSrc = ref('');
|
||||
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
|
||||
let cropObjectUrl: null | string = null;
|
||||
let pendingFileName = 'avatar.png';
|
||||
|
||||
/**
|
||||
* Blob → File,便于复用现有 uploadFile(FormData)
|
||||
*/
|
||||
function blobToFile(blob: Blob, fileName: string): File {
|
||||
const type = blob.type || 'image/png';
|
||||
const name =
|
||||
fileName.replace(/\.\w+$/, '') + (type.includes('png') ? '.png' : '.jpg');
|
||||
return new File([blob], name, { type });
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放裁剪预览 ObjectURL,避免内存泄漏
|
||||
*/
|
||||
function revokeCropSrc() {
|
||||
if (cropObjectUrl) {
|
||||
URL.revokeObjectURL(cropObjectUrl);
|
||||
cropObjectUrl = null;
|
||||
}
|
||||
cropSrc.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开裁剪弹窗
|
||||
*/
|
||||
function openCropper(file: File) {
|
||||
revokeCropSrc();
|
||||
pendingFileName = file.name || 'avatar.png';
|
||||
cropObjectUrl = URL.createObjectURL(file);
|
||||
cropSrc.value = cropObjectUrl;
|
||||
cropOpen.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入头像地址并通知父组件(资料卡侧可立刻落库)
|
||||
*/
|
||||
function applyAvatarUrl(url: string) {
|
||||
mValue.value = url;
|
||||
emits('change', url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认裁剪并上传
|
||||
*/
|
||||
async function handleCropOk() {
|
||||
const cropper = cropperRef.value;
|
||||
if (!cropper) {
|
||||
message.error('裁剪组件未就绪');
|
||||
throw new Error('cropper missing');
|
||||
}
|
||||
try {
|
||||
const blob = await cropper.getCropImage('image/png', 0.92, 'blob');
|
||||
if (!blob) {
|
||||
message.error('裁剪失败,请重试');
|
||||
throw new Error('empty crop');
|
||||
}
|
||||
const file = blobToFile(blob as Blob, pendingFileName);
|
||||
const data: any = await uploadFile({ file });
|
||||
applyAvatarUrl(data.url);
|
||||
cropOpen.value = false;
|
||||
revokeCropSrc();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (
|
||||
(e as Error)?.message !== 'empty crop' &&
|
||||
(e as Error)?.message !== 'cropper missing'
|
||||
) {
|
||||
message.error('上传失败');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCropCancel() {
|
||||
cropOpen.value = false;
|
||||
revokeCropSrc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload 自定义请求:开启裁剪时先弹裁剪框,否则直接上传
|
||||
*/
|
||||
const customRequest = (e: any) => {
|
||||
uploadFile({
|
||||
file: e.file,
|
||||
}).then((data: any) => {
|
||||
mValue.value = data.url;
|
||||
});
|
||||
const file = e.file as File;
|
||||
if (!file) return;
|
||||
if (props.crop && file.type?.startsWith('image/')) {
|
||||
openCropper(file);
|
||||
e.onSuccess?.({});
|
||||
return;
|
||||
}
|
||||
uploadFile({ file })
|
||||
.then((data: any) => {
|
||||
applyAvatarUrl(data.url);
|
||||
e.onSuccess?.(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
e.onError?.(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
mValue.value = '';
|
||||
applyAvatarUrl('');
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<!-- 资料卡:整块头像可点,悬停提示更换 -->
|
||||
<Upload
|
||||
v-if="appearance === 'profile'"
|
||||
class="m-avatar-profile-upload"
|
||||
:custom-request="customRequest"
|
||||
:show-upload-list="false"
|
||||
accept="image/*"
|
||||
list-type="text"
|
||||
>
|
||||
<div class="m-avatar-profile" title="点击更换头像">
|
||||
<img v-if="mValue" :src="mValue" alt="头像" />
|
||||
<Icon v-else class="m-avatar-profile__placeholder" icon="lucide:user" />
|
||||
<span class="m-avatar-profile__mask">
|
||||
<Icon icon="lucide:camera" :size="18" />
|
||||
<span>更换</span>
|
||||
</span>
|
||||
</div>
|
||||
</Upload>
|
||||
<Upload
|
||||
v-else
|
||||
:custom-request="customRequest"
|
||||
:show-upload-list="false"
|
||||
accept="image/*"
|
||||
list-type="picture-card"
|
||||
>
|
||||
<div v-if="mValue" class="m-avatar-wrap">
|
||||
@@ -43,10 +187,31 @@ const handleRemove = (e: Event) => {
|
||||
icon="ant-design:delete-outlined"
|
||||
@click="handleRemove"
|
||||
/>
|
||||
<img :src="value" width="100%" />
|
||||
<img :src="mValue" width="100%" />
|
||||
</div>
|
||||
<Icon v-else icon="ant-design:plus-outlined" />
|
||||
</Upload>
|
||||
|
||||
<Modal
|
||||
v-model:open="cropOpen"
|
||||
title="裁剪头像(1:1)"
|
||||
:centered="true"
|
||||
:width="548"
|
||||
:keyboard="false"
|
||||
:mask-closable="false"
|
||||
ok-text="确认裁剪"
|
||||
cancel-text="取消"
|
||||
destroy-on-close
|
||||
@ok="handleCropOk"
|
||||
@cancel="handleCropCancel"
|
||||
>
|
||||
<VCropper
|
||||
v-if="cropSrc"
|
||||
ref="cropperRef"
|
||||
:img="cropSrc"
|
||||
:aspect-ratio="aspectRatio"
|
||||
/>
|
||||
</Modal>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.m-avatar-wrap {
|
||||
@@ -56,7 +221,72 @@ const handleRemove = (e: Event) => {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
|
||||
.m-avatar-profile-upload {
|
||||
display: block;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
margin: 0 auto;
|
||||
|
||||
:deep(.ant-upload) {
|
||||
display: block;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.m-avatar-profile {
|
||||
position: relative;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px hsl(var(--border)),
|
||||
0 10px 24px hsl(var(--primary) / 0.18);
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
&__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
background: hsl(var(--foreground) / 0.52);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover &__mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
GenerateMenuAndRoutesOptions,
|
||||
} from '@vben/types';
|
||||
|
||||
import type { Router, RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { generateAccessible } from '@vben/access';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
@@ -14,6 +16,38 @@ import { $t } from '#/locales';
|
||||
|
||||
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
|
||||
|
||||
/**
|
||||
* 无需后端菜单授权、登录后人人可进的路由(个人中心等)
|
||||
* 为什么单独挂:accessMode=backend 时只会注册菜单接口返回的路由,本地 modules 里的 Profile 进不去
|
||||
*/
|
||||
function getAlwaysAccessibleRoutes(): RouteRecordRaw[] {
|
||||
return [
|
||||
{
|
||||
name: 'Profile',
|
||||
path: '/profile',
|
||||
component: () => import('#/views/_core/profile/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:user',
|
||||
hideInMenu: true,
|
||||
title: $t('page.auth.profile'),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「始终可访问」路由挂到 Root 下,避免 backend 模式漏注册
|
||||
*/
|
||||
function ensureAlwaysAccessibleRoutes(router: Router) {
|
||||
for (const route of getAlwaysAccessibleRoutes()) {
|
||||
if (route.name && router.hasRoute(route.name)) {
|
||||
continue;
|
||||
}
|
||||
// 挂在 Root(BasicLayout)下,保持顶栏/侧栏布局
|
||||
router.addRoute('Root', route);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
|
||||
|
||||
@@ -22,7 +56,7 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
IFrameView,
|
||||
};
|
||||
|
||||
return await generateAccessible(preferences.app.accessMode, {
|
||||
const result = await generateAccessible(preferences.app.accessMode, {
|
||||
...options,
|
||||
fetchMenuListAsync: async () => {
|
||||
message.loading({
|
||||
@@ -37,6 +71,11 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
layoutMap,
|
||||
pageMap,
|
||||
});
|
||||
|
||||
// backend 模式不会合并本地 modules;个人中心等公共页在此补挂
|
||||
ensureAlwaysAccessibleRoutes(options.router);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export { generateAccess };
|
||||
export { ensureAlwaysAccessibleRoutes, generateAccess };
|
||||
|
||||
@@ -8,7 +8,7 @@ import { startProgress, stopProgress } from '@vben/utils';
|
||||
import { accessRoutes, coreRouteNames } from '#/router/routes';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { generateAccess } from './access';
|
||||
import { ensureAlwaysAccessibleRoutes, generateAccess } from './access';
|
||||
|
||||
/**
|
||||
* 通用守卫配置
|
||||
@@ -87,6 +87,8 @@ function setupAccessGuard(router: Router) {
|
||||
|
||||
// 是否已经生成过动态路由
|
||||
if (accessStore.isAccessChecked) {
|
||||
// 热更新/已登录刷新时补挂个人中心等公共页,避免 backend 模式漏路由
|
||||
ensureAlwaysAccessibleRoutes(router);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,16 +101,7 @@ const routes: RouteRecordRaw[] = [
|
||||
order: 9999,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Profile',
|
||||
path: '/profile',
|
||||
component: () => import('#/views/_core/profile/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:user',
|
||||
hideInMenu: true,
|
||||
title: $t('page.auth.profile'),
|
||||
},
|
||||
},
|
||||
// 个人中心改由 access.ts 在后端权限模式下强制挂载(无需菜单授权)
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
||||
@@ -99,8 +99,23 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
async function fetchUserInfo() {
|
||||
const userInfo = await getUserInfoApi();
|
||||
userStore.setUserInfo(userInfo);
|
||||
return userInfo;
|
||||
// 兼容后端 nick_name:布局/通知组件读 realName
|
||||
const normalized = {
|
||||
...userInfo,
|
||||
realName:
|
||||
(userInfo as any)?.realName ||
|
||||
(userInfo as any)?.nick_name ||
|
||||
'',
|
||||
username:
|
||||
(userInfo as any)?.username ||
|
||||
(userInfo as any)?.phone ||
|
||||
'',
|
||||
roles: (userInfo as any)?.roles ?? [
|
||||
(userInfo as any)?.role_value || 'user',
|
||||
],
|
||||
} as UserInfo;
|
||||
userStore.setUserInfo(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function $reset() {
|
||||
|
||||
@@ -1,65 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import type { BasicOption } from '@vben/types';
|
||||
/**
|
||||
* 个人中心 · 基本设置:昵称/联系方式等(头像在左侧资料卡点击编辑)
|
||||
*/
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { ProfileBaseSetting } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { getUserInfoApi } from '#/api';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { getUserInfoApi, updateProfile } from '#/api/core/user';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const profileBaseSettingRef = ref();
|
||||
|
||||
const MOCK_ROLES_OPTIONS: BasicOption[] = [
|
||||
{
|
||||
label: '管理员',
|
||||
value: 'super',
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
const submitting = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'realName',
|
||||
fieldName: 'nick_name',
|
||||
component: 'Input',
|
||||
label: '姓名',
|
||||
},
|
||||
{
|
||||
fieldName: 'username',
|
||||
component: 'Input',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
fieldName: 'roles',
|
||||
component: 'Select',
|
||||
label: '昵称',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
options: MOCK_ROLES_OPTIONS,
|
||||
placeholder: '请输入昵称',
|
||||
},
|
||||
label: '角色',
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
fieldName: 'phone',
|
||||
component: 'Input',
|
||||
label: '手机号',
|
||||
componentProps: {
|
||||
placeholder: '请输入手机号',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'email',
|
||||
component: 'Input',
|
||||
label: '邮箱',
|
||||
componentProps: {
|
||||
placeholder: '请输入邮箱',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'desc',
|
||||
component: 'Textarea',
|
||||
label: '个人简介',
|
||||
componentProps: {
|
||||
rows: 4,
|
||||
placeholder: '介绍一下自己',
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getUserInfoApi();
|
||||
profileBaseSettingRef.value.getFormApi().setValues(data);
|
||||
/**
|
||||
* 加载当前用户资料并回填表单
|
||||
*/
|
||||
async function loadProfile() {
|
||||
const data = (await getUserInfoApi()) as Recordable<any>;
|
||||
profileBaseSettingRef.value?.getFormApi?.()?.setValues({
|
||||
nick_name: data.nick_name || '',
|
||||
phone: data.phone || '',
|
||||
email: data.email || '',
|
||||
desc: data.desc || '',
|
||||
});
|
||||
userStore.setUserInfo({
|
||||
...data,
|
||||
realName: data.nick_name || data.realName || '',
|
||||
username: data.phone || data.username || '',
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交个人资料
|
||||
*/
|
||||
async function handleSubmit(values: Recordable<any>) {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 头像由左侧资料卡单独上传保存,这里只提交文字资料
|
||||
await updateProfile({
|
||||
nick_name: values.nick_name || '',
|
||||
phone: values.phone || '',
|
||||
email: values.email || '',
|
||||
desc: values.desc || '',
|
||||
});
|
||||
message.success('资料已更新');
|
||||
await authStore.fetchUserInfo();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
|
||||
<div class="profile-section">
|
||||
<div class="profile-section__tip">
|
||||
点击左侧资料卡头像可裁剪更换;此处保存昵称、手机号与简介。
|
||||
</div>
|
||||
<ProfileBaseSetting
|
||||
ref="profileBaseSettingRef"
|
||||
class="profile-section__form"
|
||||
:form-schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-section__tip {
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid hsl(var(--primary) / 0.15);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.profile-section__form {
|
||||
max-width: 520px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +1,413 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
/**
|
||||
* 个人中心(C 端风格):左侧用户卡 + 本地菜单,右侧内容区
|
||||
* 头像在资料卡点击编辑并立即保存;菜单选中态 localStorage 记忆
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Profile } from '@vben/common-ui';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { updateProfile } from '#/api/core/user';
|
||||
import FormAvatar from '#/components/form/components/avatar.vue';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import ProfileBase from './base-setting.vue';
|
||||
import ProfileNotificationSetting from './notification-setting.vue';
|
||||
import ProfileLoginLog from './login-log.vue';
|
||||
import ProfileOpLog from './op-log.vue';
|
||||
import ProfilePasswordSetting from './password-setting.vue';
|
||||
import ProfileSecuritySetting from './security-setting.vue';
|
||||
|
||||
defineOptions({ name: 'Profile' });
|
||||
|
||||
type ProfileMenuKey = 'basic' | 'password' | 'loginLog' | 'opLog';
|
||||
|
||||
interface ProfileMenuItem {
|
||||
key: ProfileMenuKey;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** 左侧菜单选中态持久化 */
|
||||
const MENU_STORAGE_KEY = 'nl_profile_menu';
|
||||
|
||||
const menus: ProfileMenuItem[] = [
|
||||
{
|
||||
key: 'basic',
|
||||
title: '基本设置',
|
||||
desc: '昵称 / 联系方式',
|
||||
icon: 'lucide:user-round',
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
title: '修改密码',
|
||||
desc: '更新登录密码',
|
||||
icon: 'lucide:lock-keyhole',
|
||||
},
|
||||
{
|
||||
key: 'loginLog',
|
||||
title: '登录日志',
|
||||
desc: '近期登录记录',
|
||||
icon: 'lucide:log-in',
|
||||
},
|
||||
{
|
||||
key: 'opLog',
|
||||
title: '操作日志',
|
||||
desc: '账号操作流水',
|
||||
icon: 'lucide:scroll-text',
|
||||
},
|
||||
];
|
||||
|
||||
function readStoredMenuKey(): ProfileMenuKey {
|
||||
const raw = localStorage.getItem(MENU_STORAGE_KEY) as ProfileMenuKey | null;
|
||||
if (raw && menus.some((m) => m.key === raw)) {
|
||||
return raw;
|
||||
}
|
||||
return 'basic';
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const activeKey = ref<ProfileMenuKey>(readStoredMenuKey());
|
||||
const activeMenu = computed(
|
||||
() => menus.find((m) => m.key === activeKey.value) ?? menus[0],
|
||||
);
|
||||
/** 资料卡头像(与 store 同步,上传后立刻落库) */
|
||||
const avatarUrl = ref(
|
||||
(userStore.userInfo as any)?.avatar || preferences.app.defaultAvatar || '',
|
||||
);
|
||||
const avatarSaving = ref(false);
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
watch(activeKey, (val) => {
|
||||
localStorage.setItem(MENU_STORAGE_KEY, val);
|
||||
});
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: '基本设置',
|
||||
value: 'basic',
|
||||
watch(
|
||||
() => (userStore.userInfo as any)?.avatar,
|
||||
(val) => {
|
||||
if (val !== undefined && val !== avatarUrl.value) {
|
||||
avatarUrl.value = val || preferences.app.defaultAvatar || '';
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '安全设置',
|
||||
value: 'security',
|
||||
},
|
||||
{
|
||||
label: '修改密码',
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
label: '新消息提醒',
|
||||
value: 'notice',
|
||||
},
|
||||
]);
|
||||
);
|
||||
|
||||
/**
|
||||
* 侧栏展示用:兼容 nick_name / phone
|
||||
*/
|
||||
const profileUser = computed(() => {
|
||||
const info = userStore.userInfo as Record<string, any> | null;
|
||||
return {
|
||||
name: info?.nick_name || info?.realName || '用户',
|
||||
account: info?.phone || info?.username || '',
|
||||
email: info?.email || '',
|
||||
};
|
||||
});
|
||||
|
||||
function selectMenu(key: ProfileMenuKey) {
|
||||
activeKey.value = key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资料卡头像裁剪上传成功后立刻写库,并刷新顶栏头像
|
||||
*/
|
||||
async function onAvatarChange(url: string) {
|
||||
if (avatarSaving.value) return;
|
||||
avatarSaving.value = true;
|
||||
try {
|
||||
await updateProfile({ avatar: url || '' });
|
||||
message.success('头像已更新');
|
||||
await authStore.fetchUserInfo();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
avatarSaving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Profile
|
||||
v-model:model-value="tabsValue"
|
||||
title="个人中心"
|
||||
:user-info="userStore.userInfo"
|
||||
:tabs="tabs"
|
||||
>
|
||||
<template #content>
|
||||
<ProfileBase v-if="tabsValue === 'basic'" />
|
||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
||||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
||||
</template>
|
||||
</Profile>
|
||||
<Page auto-content-height content-class="!p-0">
|
||||
<div class="profile-page">
|
||||
<aside class="profile-page__aside">
|
||||
<div class="profile-user">
|
||||
<div class="profile-user__glow"></div>
|
||||
<div class="profile-user__avatar">
|
||||
<FormAvatar
|
||||
v-model:value="avatarUrl"
|
||||
appearance="profile"
|
||||
@change="onAvatarChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="profile-user__hint">点击头像更换</div>
|
||||
<div class="profile-user__name">{{ profileUser.name }}</div>
|
||||
<div class="profile-user__meta">
|
||||
<span v-if="profileUser.account">{{ profileUser.account }}</span>
|
||||
<span v-if="profileUser.email" class="dot">·</span>
|
||||
<span v-if="profileUser.email">{{ profileUser.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="profile-page__nav">
|
||||
<button
|
||||
v-for="item in menus"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="profile-page__nav-item"
|
||||
:class="{ 'is-active': activeKey === item.key }"
|
||||
@click="selectMenu(item.key)"
|
||||
>
|
||||
<span class="profile-page__nav-icon">
|
||||
<Icon :icon="item.icon" :size="18" />
|
||||
</span>
|
||||
<span class="profile-page__nav-text">
|
||||
<span class="profile-page__nav-title">{{ item.title }}</span>
|
||||
<span class="profile-page__nav-desc">{{ item.desc }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="profile-page__main">
|
||||
<header class="profile-page__header">
|
||||
<div>
|
||||
<h2>{{ activeMenu?.title }}</h2>
|
||||
<p>{{ activeMenu?.desc }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="profile-page__body">
|
||||
<div class="profile-panel">
|
||||
<ProfileBase v-if="activeKey === 'basic'" />
|
||||
<ProfilePasswordSetting v-else-if="activeKey === 'password'" />
|
||||
<ProfileLoginLog v-else-if="activeKey === 'loginLog'" />
|
||||
<ProfileOpLog v-else-if="activeKey === 'opLog'" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(
|
||||
1100px 460px at 8% -8%,
|
||||
hsl(var(--primary) / 0.14),
|
||||
transparent 58%
|
||||
),
|
||||
radial-gradient(
|
||||
860px 400px at 100% 0%,
|
||||
hsl(var(--primary) / 0.07),
|
||||
transparent 55%
|
||||
),
|
||||
hsl(var(--background));
|
||||
}
|
||||
|
||||
.profile-page__aside {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)) / 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 20px 14px;
|
||||
}
|
||||
|
||||
.profile-user {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0 4px 18px;
|
||||
padding: 22px 16px 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid hsl(var(--primary) / 0.2);
|
||||
background:
|
||||
linear-gradient(
|
||||
145deg,
|
||||
hsl(var(--primary) / 0.16),
|
||||
hsl(var(--primary) / 0.03) 46%,
|
||||
transparent 72%
|
||||
),
|
||||
hsl(var(--card, var(--background)));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-user__glow {
|
||||
position: absolute;
|
||||
top: -40%;
|
||||
right: -30%;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
hsl(var(--primary) / 0.22),
|
||||
transparent 68%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.profile-user__avatar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 76px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-user__hint {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 8px 0 10px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.profile-user__name {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.profile-user__meta {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.profile-user__meta .dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.profile-page__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-page__nav-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.profile-page__nav-item:hover {
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
|
||||
.profile-page__nav-item.is-active {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
box-shadow: 0 8px 24px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.profile-page__nav-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-page__nav-item.is-active .profile-page__nav-icon {
|
||||
background: hsl(var(--primary) / 0.16);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.profile-page__nav-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-page__nav-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-page__nav-desc {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.profile-page__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px 28px 40px;
|
||||
}
|
||||
|
||||
.profile-page__header h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.profile-page__header p {
|
||||
margin: 6px 0 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.profile-page__body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
max-width: 920px;
|
||||
padding: 22px 22px 8px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 12px 32px hsl(var(--foreground) / 0.04);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.profile-page {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-page__aside {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.profile-page__nav {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.profile-page__nav-item {
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
104
apps/web-antd/src/views/_core/profile/login-log.vue
Normal file
104
apps/web-antd/src/views/_core/profile/login-log.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 个人中心 · 登录日志:当前用户登录流水(成功/失败)
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Table, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { getLoginLog } from '#/api/core/user';
|
||||
|
||||
interface LoginLogRow {
|
||||
id: number;
|
||||
created_at: string;
|
||||
ip: string;
|
||||
equipment: string;
|
||||
browser: string;
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<LoginLogRow[]>([]);
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: '登录时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 140 },
|
||||
{ title: '系统', dataIndex: 'equipment', key: 'equipment', width: 140 },
|
||||
{ title: '浏览器', dataIndex: 'browser', key: 'browser', width: 140 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
|
||||
{ title: '说明', dataIndex: 'message', key: 'message' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 拉取登录日志分页
|
||||
*/
|
||||
async function loadList(page = pagination.current, pageSize = pagination.pageSize) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getLoginLog({ page, pageSize });
|
||||
dataSource.value = res?.items ?? [];
|
||||
pagination.total = Number(res?.total ?? 0);
|
||||
pagination.current = page;
|
||||
pagination.pageSize = pageSize;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载登录日志失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTableChange(pager: any) {
|
||||
loadList(pager.current, pager.pageSize);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList(1);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-log">
|
||||
<div class="profile-log__tip">
|
||||
仅展示当前账号的登录记录(含失败尝试)。
|
||||
</div>
|
||||
<Table
|
||||
row-key="id"
|
||||
size="middle"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:pagination="pagination"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag :color="record.status === 0 ? 'success' : 'error'">
|
||||
{{ record.status === 0 ? '成功' : '失败' }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-log__tip {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid hsl(var(--primary) / 0.15);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileNotificationSetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '其他用户的消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'systemMessage',
|
||||
label: '系统消息',
|
||||
description: '系统消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'todoTask',
|
||||
label: '待办任务',
|
||||
description: '待办任务将以站内信的形式通知',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileNotificationSetting :form-schema="formSchema" />
|
||||
</template>
|
||||
119
apps/web-antd/src/views/_core/profile/op-log.vue
Normal file
119
apps/web-antd/src/views/_core/profile/op-log.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 个人中心 · 操作日志:当前用户 API 操作流水
|
||||
* type:0 成功 / 1 失败;name 来自接口注册表
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Table, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { getOpLog } from '#/api/core/user';
|
||||
|
||||
interface OpLogRow {
|
||||
id: number;
|
||||
created_at: string;
|
||||
url: string;
|
||||
method: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
type: number;
|
||||
result_code: string;
|
||||
}
|
||||
|
||||
const METHOD_LABEL: Record<number, string> = {
|
||||
0: 'ANY',
|
||||
1: 'GET',
|
||||
2: 'POST',
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<OpLogRow[]>([]);
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
{ title: '接口', dataIndex: 'url', key: 'url', ellipsis: true },
|
||||
{ title: '方法', dataIndex: 'method', key: 'method', width: 80 },
|
||||
{ title: '操作名', dataIndex: 'name', key: 'name', width: 140 },
|
||||
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
|
||||
{ title: '结果', dataIndex: 'type', key: 'type', width: 90 },
|
||||
{ title: '状态码', dataIndex: 'result_code', key: 'result_code', width: 90 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 拉取操作日志分页
|
||||
*/
|
||||
async function loadList(page = pagination.current, pageSize = pagination.pageSize) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getOpLog({ page, pageSize });
|
||||
dataSource.value = res?.items ?? [];
|
||||
pagination.total = Number(res?.total ?? 0);
|
||||
pagination.current = page;
|
||||
pagination.pageSize = pageSize;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载操作日志失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTableChange(pager: any) {
|
||||
loadList(pager.current, pager.pageSize);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList(1);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-log">
|
||||
<div class="profile-log__tip">
|
||||
仅展示当前账号触发且接口开启「记日志」的请求记录。
|
||||
</div>
|
||||
<Table
|
||||
row-key="id"
|
||||
size="middle"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:pagination="pagination"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'method'">
|
||||
<Tag>{{ METHOD_LABEL[record.method] || 'ANY' }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<Tag :color="record.type === 0 ? 'success' : 'error'">
|
||||
{{ record.type === 0 ? '成功' : '失败' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'name'">
|
||||
{{ record.name || '—' }}
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-log__tip {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid hsl(var(--primary) / 0.15);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 个人中心 · 修改密码
|
||||
*/
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ProfilePasswordSetting, z } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { changePassword } from '#/api/core/user';
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'oldPassword',
|
||||
label: '旧密码',
|
||||
component: 'VbenInputPassword',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '请输入旧密码',
|
||||
},
|
||||
@@ -21,6 +31,7 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
component: 'VbenInputPassword',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请输入新密码',
|
||||
@@ -50,14 +61,53 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
message.success('密码修改成功');
|
||||
/**
|
||||
* 映射为后端 snake_case 后提交
|
||||
*/
|
||||
async function handleSubmit(values: Recordable<any>) {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await changePassword({
|
||||
old_password: values.oldPassword,
|
||||
new_password: values.newPassword,
|
||||
confirm_password: values.confirmPassword,
|
||||
});
|
||||
message.success('密码修改成功');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfilePasswordSetting
|
||||
class="w-1/3"
|
||||
:form-schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
<div class="profile-section">
|
||||
<div class="profile-section__tip">
|
||||
建议使用包含大小写字母与数字的强密码,修改成功后请妥善保管。
|
||||
</div>
|
||||
<ProfilePasswordSetting
|
||||
class="profile-section__form"
|
||||
:form-schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-section__tip {
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid hsl(var(--primary) / 0.15);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.profile-section__form {
|
||||
max-width: 420px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileSecuritySetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '当前密码强度:强',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityPhone',
|
||||
label: '密保手机',
|
||||
description: '已绑定手机:138****8293',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityQuestion',
|
||||
label: '密保问题',
|
||||
description: '未设置密保问题,密保问题可有效保护账户安全',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityEmail',
|
||||
label: '备用邮箱',
|
||||
description: '已绑定邮箱:ant***sign.com',
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
fieldName: 'securityMfa',
|
||||
label: 'MFA 设备',
|
||||
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileSecuritySetting :form-schema="formSchema" />
|
||||
</template>
|
||||
17
apps/web-antd/src/views/system/config/api/endpoint.ts
Normal file
17
apps/web-antd/src/views/system/config/api/endpoint.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'api-endpoint/';
|
||||
|
||||
/**
|
||||
* 接口注册表分页列表(用于操作日志开关管理)
|
||||
*/
|
||||
export async function getApiEndpointList(data: Record<string, any> = {}) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新接口说明 / 是否记日志 / 状态(不改 url、method)
|
||||
*/
|
||||
export async function updateApiEndpoint(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export * from './endpoint';
|
||||
export * from './oss';
|
||||
|
||||
const prefix = 'ai-config/';
|
||||
|
||||
/**
|
||||
|
||||
52
apps/web-antd/src/views/system/config/api/oss.ts
Normal file
52
apps/web-antd/src/views/system/config/api/oss.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'oss-config/';
|
||||
|
||||
/**
|
||||
* OSS 驱动下拉/卡片选项(枚举:local/aliyun/...)
|
||||
*/
|
||||
export async function getOssDriverOptions() {
|
||||
return requestClient.get<any>(`${prefix}driver-options`);
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS 运行时选项:配置卡片列表 + 当前启用 ID
|
||||
*/
|
||||
export async function getOssRuntimeOptions() {
|
||||
return requestClient.get<any>(`${prefix}runtime-options`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用指定 OSS 配置(全局仅一套 active)
|
||||
*/
|
||||
export async function saveOssRuntime(data: { id: number }) {
|
||||
return requestClient.post<any>(`${prefix}save-runtime`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS 配置列表(密钥不回显明文,仅 has_access_key / has_secret_key)
|
||||
*/
|
||||
export async function getOssConfigList(data: Record<string, any> = {}) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增 OSS 配置
|
||||
*/
|
||||
export async function createOssConfig(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 OSS 配置;access_key / secret_key 留空表示不修改
|
||||
*/
|
||||
export async function updateOssConfig(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OSS 配置
|
||||
*/
|
||||
export async function deleteOssConfig(ids: number[]) {
|
||||
return requestClient.post<any>(`${prefix}delete`, { ids });
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 接口管理:表格维护接口名称/说明/是否记操作日志
|
||||
* url、method 由后端路由种子生成,前端只改展示与日志开关
|
||||
*/
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Input, Switch, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { getApiEndpointList, updateApiEndpoint } from '../api';
|
||||
|
||||
interface EndpointRow {
|
||||
id: number;
|
||||
url: string;
|
||||
method: number;
|
||||
name: string;
|
||||
description: string;
|
||||
is_log: number;
|
||||
status: number;
|
||||
controller?: string;
|
||||
}
|
||||
|
||||
const METHOD_MAP: Record<number, { color: string; label: string }> = {
|
||||
0: { label: 'ANY', color: 'default' },
|
||||
1: { label: 'GET', color: 'blue' },
|
||||
2: { label: 'POST', color: 'green' },
|
||||
};
|
||||
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
is_log: true,
|
||||
status: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'url', title: 'URL', minWidth: 200, align: 'left' },
|
||||
{
|
||||
field: 'method',
|
||||
title: '方法',
|
||||
width: 90,
|
||||
slots: { default: 'method' },
|
||||
},
|
||||
{ field: 'name', title: '操作名', minWidth: 120, align: 'left' },
|
||||
{
|
||||
field: 'description',
|
||||
title: '说明',
|
||||
minWidth: 160,
|
||||
align: 'left',
|
||||
showOverflow: true,
|
||||
},
|
||||
{
|
||||
field: 'is_log',
|
||||
title: '记日志',
|
||||
width: 100,
|
||||
slots: { default: 'is_log' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 90,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{
|
||||
field: 'action',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
slots: { default: 'action' },
|
||||
},
|
||||
],
|
||||
height: 560,
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getApiEndpointList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
zoom: true,
|
||||
search: true,
|
||||
},
|
||||
},
|
||||
formOptions: {
|
||||
schema: [
|
||||
{
|
||||
fieldName: 'url',
|
||||
label: 'URL',
|
||||
component: 'Input',
|
||||
componentProps: { placeholder: '接口路径' },
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '操作名',
|
||||
component: 'Input',
|
||||
componentProps: { placeholder: '操作名' },
|
||||
},
|
||||
{
|
||||
fieldName: 'is_log',
|
||||
label: '记日志',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '是', value: 1 },
|
||||
{ label: '否', value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
submitOnChange: false,
|
||||
},
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
title: '编辑接口',
|
||||
class: 'w-[480px]',
|
||||
onConfirm: async () => {
|
||||
if (!editingId.value) return;
|
||||
if (!form.name.trim()) {
|
||||
message.warning('请填写操作名');
|
||||
return;
|
||||
}
|
||||
formModalApi.lock();
|
||||
try {
|
||||
await updateApiEndpoint({
|
||||
id: editingId.value,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
is_log: form.is_log ? 1 : 0,
|
||||
status: form.status ? 1 : 0,
|
||||
});
|
||||
message.success('更新成功');
|
||||
formModalApi.close();
|
||||
gridApi.query();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
formModalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开编辑弹窗:只允许改名称/说明/日志开关/状态
|
||||
*/
|
||||
function openEdit(row: EndpointRow) {
|
||||
editingId.value = row.id;
|
||||
form.name = row.name || '';
|
||||
form.description = row.description || '';
|
||||
form.is_log = row.is_log === 1;
|
||||
form.status = row.status === 1;
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格内快速切换是否记日志(少点一次编辑)
|
||||
*/
|
||||
async function toggleIsLog(row: EndpointRow, checked: boolean) {
|
||||
try {
|
||||
await updateApiEndpoint({
|
||||
id: row.id,
|
||||
is_log: checked ? 1 : 0,
|
||||
});
|
||||
row.is_log = checked ? 1 : 0;
|
||||
message.success(checked ? '已开启日志' : '已关闭日志');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
gridApi.query();
|
||||
}
|
||||
}
|
||||
|
||||
function methodMeta(method: number) {
|
||||
return METHOD_MAP[method] || METHOD_MAP[0];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="api-endpoint">
|
||||
<div class="api-endpoint__hero">
|
||||
<div>
|
||||
<div class="api-endpoint__eyebrow">
|
||||
<Icon icon="lucide:route" :size="14" />
|
||||
操作日志开关
|
||||
</div>
|
||||
<h3>接口注册表</h3>
|
||||
<p>控制各接口是否写入操作日志;路径与方法由系统种子维护。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Grid>
|
||||
<template #method="{ row }">
|
||||
<Tag :color="methodMeta(row.method).color">
|
||||
{{ methodMeta(row.method).label }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #is_log="{ row }">
|
||||
<Switch
|
||||
size="small"
|
||||
:checked="row.is_log === 1"
|
||||
@update:checked="(checked: boolean) => toggleIsLog(row, checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'success' : 'default'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: openEdit.bind(null, row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<FormModal>
|
||||
<div class="endpoint-form">
|
||||
<label>操作名</label>
|
||||
<Input v-model:value="form.name" placeholder="如 创建管理员" />
|
||||
<label>接口说明</label>
|
||||
<Input.TextArea
|
||||
v-model:value="form.description"
|
||||
:rows="3"
|
||||
placeholder="可选"
|
||||
/>
|
||||
<div class="endpoint-form__switches">
|
||||
<div>
|
||||
<span>记录操作日志</span>
|
||||
<Switch v-model:checked="form.is_log" />
|
||||
</div>
|
||||
<div>
|
||||
<span>启用</span>
|
||||
<Switch v-model:checked="form.status" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-endpoint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.api-endpoint__hero {
|
||||
padding: 18px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid hsl(var(--primary) / 0.18);
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.14),
|
||||
hsl(var(--primary) / 0.03) 42%,
|
||||
transparent 70%
|
||||
),
|
||||
hsl(var(--card, var(--background)));
|
||||
}
|
||||
|
||||
.api-endpoint__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.api-endpoint__hero h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.api-endpoint__hero p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.endpoint-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 4px 4px;
|
||||
}
|
||||
|
||||
.endpoint-form label {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.endpoint-form__switches {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.endpoint-form__switches > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
1187
apps/web-antd/src/views/system/config/components/OssConfigManage.vue
Normal file
1187
apps/web-antd/src/views/system/config/components/OssConfigManage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS管理:Tab 切换「存储选择」与「配置管理」
|
||||
* 存储选择未配齐时,引导切到配置管理并打开对应编辑弹窗
|
||||
*/
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Tabs } from 'ant-design-vue';
|
||||
|
||||
import OssConfigManage from './OssConfigManage.vue';
|
||||
import OssRuntimeSelect from './OssRuntimeSelect.vue';
|
||||
import type { OssRuntimeCard } from './OssRuntimeSelect.vue';
|
||||
|
||||
const TAB_STORAGE_KEY = 'nl_system_config_oss_tab';
|
||||
|
||||
type OssTabKey = 'runtime' | 'config';
|
||||
|
||||
const activeTab = ref<OssTabKey>(
|
||||
(localStorage.getItem(TAB_STORAGE_KEY) as OssTabKey) || 'runtime',
|
||||
);
|
||||
/** 从配置管理切回存储选择时强制刷新,拿到最新卡片与启用态 */
|
||||
const runtimeReloadKey = ref(0);
|
||||
const configManageRef = ref<InstanceType<typeof OssConfigManage> | null>(null);
|
||||
|
||||
watch(activeTab, (val, oldVal) => {
|
||||
localStorage.setItem(TAB_STORAGE_KEY, val);
|
||||
if (val === 'runtime' && oldVal === 'config') {
|
||||
runtimeReloadKey.value += 1;
|
||||
}
|
||||
});
|
||||
|
||||
function onConfigChanged() {
|
||||
runtimeReloadKey.value += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前往配置:切到「配置管理」Tab,并打开对应编辑弹窗
|
||||
*/
|
||||
async function goConfigure(item: OssRuntimeCard) {
|
||||
activeTab.value = 'config';
|
||||
await nextTick();
|
||||
// 再等一帧,确保 Tab 面板与 Modal 挂载完成
|
||||
await nextTick();
|
||||
await configManageRef.value?.openEditById?.(item.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="oss-manage-tabs">
|
||||
<Tabs v-model:active-key="activeTab" class="oss-tabs">
|
||||
<Tabs.TabPane key="runtime" tab="存储选择">
|
||||
<OssRuntimeSelect
|
||||
:key="runtimeReloadKey"
|
||||
@configure="goConfigure"
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="config" tab="配置管理">
|
||||
<OssConfigManage ref="configManageRef" @changed="onConfigChanged" />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.oss-manage-tabs {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.oss-tabs :deep(.ant-tabs-nav) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.oss-tabs :deep(.ant-tabs-tab) {
|
||||
font-weight: 600;
|
||||
padding: 10px 4px;
|
||||
}
|
||||
|
||||
.oss-tabs :deep(.ant-tabs-ink-bar) {
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 存储选择:卡片点选启用;未配齐时提示并引导前往编辑弹窗
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Modal, Spin, message } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import { getOssRuntimeOptions, saveOssRuntime } from '../api';
|
||||
|
||||
export interface OssRuntimeCard {
|
||||
id: number;
|
||||
driver: string;
|
||||
driver_name?: string;
|
||||
name: string;
|
||||
remark: string;
|
||||
is_active: number;
|
||||
status: number;
|
||||
has_access_key?: boolean;
|
||||
has_secret_key?: boolean;
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
bucket?: string;
|
||||
domain?: string;
|
||||
path_prefix?: string;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 前往配置管理并打开对应编辑弹窗 */
|
||||
configure: [item: OssRuntimeCard];
|
||||
}>();
|
||||
|
||||
const DRIVER_ICONS: Record<string, string> = {
|
||||
local: 'lucide:hard-drive',
|
||||
aliyun: 'lucide:cloud',
|
||||
qcloud: 'lucide:cloud-cog',
|
||||
qiniu: 'lucide:cloud-upload',
|
||||
huawei: 'lucide:cloud-lightning',
|
||||
aws: 'lucide:boxes',
|
||||
minio: 'lucide:database',
|
||||
baidu: 'lucide:cloud-rain',
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const savingId = ref<number | null>(null);
|
||||
const list = ref<OssRuntimeCard[]>([]);
|
||||
const activeId = ref(0);
|
||||
|
||||
/**
|
||||
* 校验配置是否可启用:本地放行;云驱动检查密钥与必要字段
|
||||
*/
|
||||
function checkConfigReady(item: OssRuntimeCard): {
|
||||
ok: boolean;
|
||||
missing: string[];
|
||||
reason: string;
|
||||
} {
|
||||
if (item.status !== 1) {
|
||||
return {
|
||||
ok: false,
|
||||
missing: [],
|
||||
reason: '该配置已禁用,无法启用',
|
||||
};
|
||||
}
|
||||
if (item.driver === 'local') {
|
||||
return { ok: true, missing: [], reason: '' };
|
||||
}
|
||||
const missing: string[] = [];
|
||||
if (!item.has_access_key) missing.push('AccessKey');
|
||||
if (!item.has_secret_key) missing.push('SecretKey');
|
||||
if (!String(item.bucket || '').trim()) missing.push('Bucket');
|
||||
if (
|
||||
['aliyun', 'huawei', 'aws', 'minio', 'baidu'].includes(item.driver) &&
|
||||
!String(item.endpoint || '').trim()
|
||||
) {
|
||||
missing.push('Endpoint');
|
||||
}
|
||||
if (
|
||||
['qcloud', 'aws', 'huawei'].includes(item.driver) &&
|
||||
!String(item.region || '').trim()
|
||||
) {
|
||||
missing.push('Region');
|
||||
}
|
||||
if (item.driver === 'qiniu' && !String(item.domain || '').trim()) {
|
||||
missing.push('访问域名');
|
||||
}
|
||||
if (missing.length) {
|
||||
return {
|
||||
ok: false,
|
||||
missing,
|
||||
reason: `配置未完善(缺少:${missing.join('、')}),暂时无法使用`,
|
||||
};
|
||||
}
|
||||
return { ok: true, missing: [], reason: '' };
|
||||
}
|
||||
|
||||
function isIncomplete(item: OssRuntimeCard) {
|
||||
return !checkConfigReady(item).ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取配置卡片与当前启用 ID
|
||||
*/
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getOssRuntimeOptions();
|
||||
list.value = data?.items ?? data?.configs ?? [];
|
||||
activeId.value = Number(
|
||||
data?.active_id ??
|
||||
data?.active?.id ??
|
||||
list.value.find((i) => i.is_active === 1)?.id ??
|
||||
0,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载 OSS 配置失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点选启用;未配齐则弹窗引导前往配置
|
||||
*/
|
||||
async function handleActivate(item: OssRuntimeCard) {
|
||||
if (activeId.value === item.id) return;
|
||||
const check = checkConfigReady(item);
|
||||
if (!check.ok) {
|
||||
Modal.confirm({
|
||||
title: '无法启用',
|
||||
content: `${check.reason},是否前往配置?`,
|
||||
okText: '前往配置',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
emit('configure', item);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
savingId.value = item.id;
|
||||
try {
|
||||
await saveOssRuntime({ id: item.id });
|
||||
activeId.value = item.id;
|
||||
list.value = list.value.map((row) => ({
|
||||
...row,
|
||||
is_active: row.id === item.id ? 1 : 0,
|
||||
}));
|
||||
message.success(`已启用:${item.name}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
savingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function driverIcon(driver: string) {
|
||||
return DRIVER_ICONS[driver] || 'lucide:cloud';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Spin :spinning="loading">
|
||||
<div class="oss-runtime">
|
||||
<div class="oss-tip">
|
||||
<Icon icon="lucide:info" :size="16" />
|
||||
<span>
|
||||
在「配置管理」维护各驱动密钥与 Bucket;此处点选当前全局启用的存储。未配齐的卡片点击后可前往完善。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="list.length" class="oss-card-grid">
|
||||
<button
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="oss-card"
|
||||
:class="{
|
||||
'is-active': activeId === item.id,
|
||||
'is-off': item.status !== 1,
|
||||
'is-incomplete': isIncomplete(item) && activeId !== item.id,
|
||||
'is-saving': savingId === item.id,
|
||||
}"
|
||||
:disabled="savingId !== null"
|
||||
@click="handleActivate(item)"
|
||||
>
|
||||
<span v-if="activeId === item.id" class="oss-card__check">
|
||||
<Icon icon="lucide:check" :size="14" />
|
||||
</span>
|
||||
<div class="oss-card__logo">
|
||||
<Icon :icon="driverIcon(item.driver)" :size="22" />
|
||||
</div>
|
||||
<div class="oss-card__body">
|
||||
<div class="oss-card__name-row">
|
||||
<span class="oss-card__name">{{ item.name }}</span>
|
||||
<span v-if="activeId === item.id" class="oss-badge">当前启用</span>
|
||||
<span
|
||||
v-else-if="isIncomplete(item)"
|
||||
class="oss-badge is-warn"
|
||||
>
|
||||
未配齐
|
||||
</span>
|
||||
</div>
|
||||
<div class="oss-card__meta">
|
||||
<code>{{ item.driver_name || item.driver }}</code>
|
||||
<span v-if="item.bucket">{{ item.bucket }}</span>
|
||||
<span v-else-if="item.path_prefix">{{ item.path_prefix }}</span>
|
||||
</div>
|
||||
<div class="oss-card__desc">
|
||||
{{ item.remark || item.domain || item.endpoint || '暂无备注' }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="oss-empty">
|
||||
暂无存储配置,请先到「配置管理」添加
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.oss-runtime {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.oss-tip {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
border: 1px solid hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.oss-tip :deep(svg) {
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.oss-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.oss-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 10px 30px hsl(var(--foreground) / 0.04);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.oss-card:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
box-shadow: 0 14px 34px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.oss-card.is-active {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--primary) / 0.35),
|
||||
0 16px 40px hsl(var(--primary) / 0.16);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
hsl(var(--primary) / 0.1),
|
||||
hsl(var(--card, var(--background))) 48%
|
||||
);
|
||||
}
|
||||
|
||||
.oss-card.is-off {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.oss-card.is-incomplete {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.oss-card.is-saving {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.oss-card__check {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground, 0 0% 100%));
|
||||
box-shadow: 0 2px 8px hsl(var(--primary) / 0.35);
|
||||
}
|
||||
|
||||
.oss-card__check :deep(svg) {
|
||||
color: hsl(var(--primary-foreground, 0 0% 100%));
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.oss-card__logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.oss-card__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.oss-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.oss-card__name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.oss-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.oss-card__meta code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.oss-card__desc {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.oss-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.oss-badge.is-warn {
|
||||
color: hsl(var(--warning, 38 92% 45%));
|
||||
background: hsl(var(--warning, 38 92% 50%) / 0.14);
|
||||
}
|
||||
|
||||
.oss-empty {
|
||||
padding: 28px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.oss-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 系统配置页:左侧自写菜单;AI管理内用 Tab 切换模型选择 / API Key
|
||||
* 系统配置页:左侧自写菜单;右侧按模块渲染 AI / OSS / 接口管理
|
||||
* 左侧菜单与各模块 Tab 均持久化到 localStorage,刷新后回到上次位置
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import AiManage from './components/AiManage.vue';
|
||||
import ApiEndpointManage from './components/ApiEndpointManage.vue';
|
||||
import OssManage from './components/OssManage.vue';
|
||||
|
||||
defineOptions({ name: 'SystemConfig' });
|
||||
|
||||
type ConfigMenuKey = 'ai';
|
||||
type ConfigMenuKey = 'ai' | 'oss' | 'api';
|
||||
|
||||
interface ConfigMenuItem {
|
||||
key: ConfigMenuKey;
|
||||
@@ -21,6 +24,9 @@ interface ConfigMenuItem {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** 左侧菜单选中态持久化键 */
|
||||
const MENU_STORAGE_KEY = 'nl_system_config_menu';
|
||||
|
||||
/** 左侧本地菜单(非后端菜单树);icon 用 iconify,与全站菜单一致 */
|
||||
const menus: ConfigMenuItem[] = [
|
||||
{
|
||||
@@ -29,12 +35,46 @@ const menus: ConfigMenuItem[] = [
|
||||
desc: '模型选择 / API Key',
|
||||
icon: 'lucide:bot',
|
||||
},
|
||||
{
|
||||
key: 'oss',
|
||||
title: 'OSS管理',
|
||||
desc: '存储选择与密钥配置',
|
||||
icon: 'lucide:cloud',
|
||||
},
|
||||
{
|
||||
key: 'api',
|
||||
title: '接口管理',
|
||||
desc: '操作日志开关',
|
||||
icon: 'lucide:route',
|
||||
},
|
||||
];
|
||||
|
||||
const activeKey = ref<ConfigMenuKey>('ai');
|
||||
/**
|
||||
* 从 localStorage 恢复左侧菜单;非法值回落到 AI
|
||||
*/
|
||||
function readStoredMenuKey(): ConfigMenuKey {
|
||||
const raw = localStorage.getItem(MENU_STORAGE_KEY) as ConfigMenuKey | null;
|
||||
if (raw && menus.some((m) => m.key === raw)) {
|
||||
return raw;
|
||||
}
|
||||
return 'ai';
|
||||
}
|
||||
|
||||
const activeKey = ref<ConfigMenuKey>(readStoredMenuKey());
|
||||
const activeMenu = computed(
|
||||
() => menus.find((m) => m.key === activeKey.value) ?? menus[0],
|
||||
);
|
||||
|
||||
watch(activeKey, (val) => {
|
||||
localStorage.setItem(MENU_STORAGE_KEY, val);
|
||||
});
|
||||
|
||||
/**
|
||||
* 切换左侧菜单并写入本地,刷新后仍停留在当前模块
|
||||
*/
|
||||
function selectMenu(key: ConfigMenuKey) {
|
||||
activeKey.value = key;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -57,7 +97,7 @@ const activeMenu = computed(
|
||||
type="button"
|
||||
class="sys-config__nav-item"
|
||||
:class="{ 'is-active': activeKey === item.key }"
|
||||
@click="activeKey = item.key"
|
||||
@click="selectMenu(item.key)"
|
||||
>
|
||||
<span class="sys-config__nav-icon">
|
||||
<Icon :icon="item.icon" :size="18" />
|
||||
@@ -76,6 +116,8 @@ const activeMenu = computed(
|
||||
</header>
|
||||
<div class="sys-config__body">
|
||||
<AiManage v-if="activeKey === 'ai'" />
|
||||
<OssManage v-else-if="activeKey === 'oss'" />
|
||||
<ApiEndpointManage v-else-if="activeKey === 'api'" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user