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

This commit is contained in:
李琦
2026-08-10 18:32:33 +08:00
parent e8f30a1275
commit cbbbe4ab7a
21 changed files with 3266 additions and 183 deletions

View File

@@ -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 });
}

View File

@@ -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-cardprofile资料卡点击头像编辑
*/
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>

View File

@@ -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;
}
// 挂在 RootBasicLayout保持顶栏/侧栏布局
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 };

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
/**
* 个人中心 · 操作日志:当前用户 API 操作流水
* type0 成功 / 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>

View File

@@ -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>

View File

@@ -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>

View 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);
}

View File

@@ -1,5 +1,8 @@
import { requestClient } from '#/api/request';
export * from './endpoint';
export * from './oss';
const prefix = 'ai-config/';
/**

View 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 });
}

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>