chore: naive init

pull/99/head^2
xingyu4j 2025-05-09 13:52:20 +08:00
parent b98328132f
commit c19c6a7faf
32 changed files with 1366 additions and 670 deletions

View File

@ -1,12 +0,0 @@
{
"title": "Demos",
"antd": "Ant Design Vue",
"vben": {
"title": "Project",
"about": "About",
"document": "Document",
"antdv": "Ant Design Vue Version",
"naive-ui": "Naive UI Version",
"element-plus": "Element Plus Version"
}
}

View File

@ -1,12 +0,0 @@
{
"title": "演示",
"antd": "Ant Design Vue",
"vben": {
"title": "项目",
"about": "关于",
"document": "文档",
"antdv": "Ant Design Vue 版本",
"naive-ui": "Naive UI 版本",
"element-plus": "Element Plus 版本"
}
}

View File

@ -8,6 +8,9 @@ import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
/** 手机号正则表达式(中国) */
const MOBILE_REGEX = /(?:0|86|\+86)?1[3-9]\d{9}/;
setupVbenForm<ComponentType>({
config: {
// naive-ui组件的空值为null,不能是undefined否则重置表单时不生效
@ -32,6 +35,25 @@ setupVbenForm<ComponentType>({
}
return true;
},
// 手机号非必填
mobile: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return true;
} else if (!MOBILE_REGEX.test(value)) {
return $t('ui.formRules.phone', [ctx.label]);
}
return true;
},
// 手机号必填
mobileRequired: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
if (!MOBILE_REGEX.test(value)) {
return $t('ui.formRules.phone', [ctx.label]);
}
return true;
},
},
});

View File

@ -1,8 +1,16 @@
import type { Recordable } from '@vben/types';
import { h } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { isFunction, isString } from '@vben/utils';
import { NButton, NImage } from 'naive-ui';
import { NButton, NImage, NPopconfirm, NSwitch } from 'naive-ui';
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import { useVbenForm } from './form';
@ -20,16 +28,32 @@ setupVbenVxeTable({
// 全局禁用vxe-table的表单配置使用formOptions
enabled: false,
},
toolbarConfig: {
import: false, // 是否导入
export: false, // 是否导出
refresh: true, // 是否刷新
print: false, // 是否打印
zoom: true, // 是否缩放
custom: true, // 是否自定义配置
},
customConfig: {
mode: 'modal',
},
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
result: 'list',
total: 'total',
list: 'items',
},
showActiveMsg: true,
showResponseMsg: false,
},
pagerConfig: {
enabled: true,
},
sortConfig: {
multiple: true,
},
round: true,
showOverflow: true,
size: 'small',
@ -56,12 +80,208 @@ setupVbenVxeTable({
},
});
// 表格配置项可以用 cellRender: { name: 'CellDict', props:{dictType: ''} },
vxeUI.renderer.add('CellDict', {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
if (!props) {
return '';
}
// 使用 DictTag 组件替代原来的实现
return h(DictTag, {
type: props.type,
value: row[column.field]?.toString(),
});
},
});
// 表格配置项可以用 cellRender: { name: 'CellSwitch', props: { beforeChange: () => {} } },
// add by 芋艿from https://github.com/vbenjs/vue-vben-admin/blob/main/playground/src/adapter/vxe-table.ts#L97-L123
vxeUI.renderer.add('CellSwitch', {
renderTableDefault({ attrs, props }, { column, row }) {
const loadingKey = `__loading_${column.field}`;
const finallyProps = {
checkedChildren: $t('common.enabled'),
checkedValue: 1,
unCheckedChildren: $t('common.disabled'),
unCheckedValue: 0,
...props,
checked: row[column.field],
loading: row[loadingKey] ?? false,
'onUpdate:checked': onChange,
};
async function onChange(newVal: any) {
row[loadingKey] = true;
try {
const result = await attrs?.beforeChange?.(newVal, row);
if (result !== false) {
row[column.field] = newVal;
}
} finally {
row[loadingKey] = false;
}
}
return h(NSwitch, finallyProps);
},
});
// 注册表格的操作按钮渲染器 cellRender: { name: 'CellOperation', options: ['edit', 'delete'] }
// add by 芋艿from https://github.com/vbenjs/vue-vben-admin/blob/main/playground/src/adapter/vxe-table.ts#L125-L255
vxeUI.renderer.add('CellOperation', {
renderTableDefault({ attrs, options, props }, { column, row }) {
const defaultProps = { size: 'small', type: 'text', ...props };
let align = 'end';
switch (column.align) {
case 'center': {
align = 'center';
break;
}
case 'left': {
align = 'start';
break;
}
default: {
align = 'end';
break;
}
}
const presets: Recordable<Recordable<any>> = {
delete: {
danger: true,
text: $t('common.delete'),
},
edit: {
text: $t('common.edit'),
},
};
const operations: Array<Recordable<any>> = (
options || ['edit', 'delete']
)
.map((opt) => {
if (isString(opt)) {
return presets[opt]
? { code: opt, ...presets[opt], ...defaultProps }
: {
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
...defaultProps,
};
} else {
return { ...defaultProps, ...presets[opt.code], ...opt };
}
})
.map((opt) => {
const optBtn: Recordable<any> = {};
Object.keys(opt).forEach((key) => {
optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
});
return optBtn;
})
.filter((opt) => opt.show !== false);
function renderBtn(opt: Recordable<any>, listen = true) {
return h(
NButton,
{
...props,
...opt,
icon: undefined,
onClick: listen
? () =>
attrs?.onClick?.({
code: opt.code,
row,
})
: undefined,
},
{
default: () => {
const content = [];
if (opt.icon) {
content.push(
h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
);
}
content.push(opt.text);
return content;
},
},
);
}
function renderConfirm(opt: Recordable<any>) {
return h(
NPopconfirm,
{
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
...props,
...opt,
icon: undefined,
onPositiveClick: () => {
attrs?.onClick?.({
code: opt.code,
row,
});
},
},
{
default: () => renderBtn({ ...opt }, false),
description: () =>
h(
'div',
{ class: 'truncate' },
$t('ui.actionMessage.deleteConfirm', [
row[attrs?.nameField || 'name'],
]),
),
},
);
}
const btns = operations.map((opt) =>
opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
);
return h(
'div',
{
class: 'flex table-operations',
style: { justifyContent: align },
},
btns,
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
// add by 星语:数量格式化,例如说:金额
vxeUI.formats.add('formatAmount', {
cellFormatMethod({ cellValue }, digits = 2) {
if (cellValue === null || cellValue === undefined) {
return '';
}
if (isString(cellValue)) {
cellValue = Number.parseFloat(cellValue);
}
// 如果非 number则直接返回空串
if (Number.isNaN(cellValue)) {
return '';
}
return cellValue.toFixed(digits);
},
});
},
useVbenForm,
});
export { useVbenVxeGrid };
// add by 芋艿from https://github.com/vbenjs/vue-vben-admin/blob/main/playground/src/adapter/vxe-table.ts#L264-L270
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
};
export type OnActionClickFn<T = Recordable<any>> = (
params: OnActionClickParams<T>,
) => void;
export type * from '@vben/plugins/vxe-table';

View File

@ -1,3 +1,5 @@
import type { AuthPermissionInfo } from '@vben/types';
import { baseRequestClient, requestClient } from '#/api/request';
export namespace AuthApi {
@ -5,47 +7,151 @@ export namespace AuthApi {
export interface LoginParams {
password?: string;
username?: string;
captchaVerification?: string;
// 绑定社交登录时,需要传递如下参数
socialType?: number;
socialCode?: string;
socialState?: string;
}
/** 登录接口返回值 */
export interface LoginResult {
accessToken: string;
refreshToken: string;
userId: number;
expiresTime: number;
}
export interface RefreshTokenResult {
data: string;
status: number;
/** 租户信息返回值 */
export interface TenantResult {
id: number;
name: string;
}
/** 手机验证码获取接口参数 */
export interface SmsCodeParams {
mobile: string;
scene: number;
}
/** 手机验证码登录接口参数 */
export interface SmsLoginParams {
mobile: string;
code: string;
}
/** 注册接口参数 */
export interface RegisterParams {
username: string;
password: string;
captchaVerification: string;
}
/** 重置密码接口参数 */
export interface ResetPasswordParams {
password: string;
mobile: string;
code: string;
}
/** 社交快捷登录接口参数 */
export interface SocialLoginParams {
type: number;
code: string;
state: string;
}
}
/**
*
*/
/** 登录 */
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('/auth/login', data);
return requestClient.post<AuthApi.LoginResult>('/system/auth/login', data);
}
/**
* accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
/** 刷新 accessToken */
export async function refreshTokenApi(refreshToken: string) {
return baseRequestClient.post(
`/system/auth/refresh-token?refreshToken=${refreshToken}`,
);
}
/** 退出登录 */
export async function logoutApi(accessToken: string) {
return baseRequestClient.post(
'/system/auth/logout',
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
}
/** 获取权限信息 */
export async function getAuthPermissionInfoApi() {
return requestClient.get<AuthPermissionInfo>(
'/system/auth/get-permission-info',
);
}
/** 获取租户列表 */
export async function getTenantSimpleList() {
return requestClient.get<AuthApi.TenantResult[]>(
`/system/tenant/simple-list`,
);
}
/** 使用租户域名,获得租户信息 */
export async function getTenantByWebsite(website: string) {
return requestClient.get<AuthApi.TenantResult>(
`/system/tenant/get-by-website?website=${website}`,
);
}
/** 获取验证码 */
export async function getCaptcha(data: any) {
return baseRequestClient.post('/system/captcha/get', data);
}
/** 校验验证码 */
export async function checkCaptcha(data: any) {
return baseRequestClient.post('/system/captcha/check', data);
}
/** 获取登录验证码 */
export async function sendSmsCode(data: AuthApi.SmsCodeParams) {
return requestClient.post('/system/auth/send-sms-code', data);
}
/** 短信验证码登录 */
export async function smsLogin(data: AuthApi.SmsLoginParams) {
return requestClient.post('/system/auth/sms-login', data);
}
/** 注册 */
export async function register(data: AuthApi.RegisterParams) {
return requestClient.post('/system/auth/register', data);
}
/** 通过短信重置密码 */
export async function smsResetPassword(data: AuthApi.ResetPasswordParams) {
return requestClient.post('/system/auth/reset-password', data);
}
/** 社交授权的跳转 */
export async function socialAuthRedirect(type: number, redirectUri: string) {
return requestClient.get('/system/auth/social-auth-redirect', {
params: {
type,
redirectUri,
},
});
}
/**
* 退
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
withCredentials: true,
});
}
/**
*
*/
export async function getAccessCodesApi() {
return requestClient.get<string[]>('/auth/codes');
/** 社交快捷登录 */
export async function socialLogin(data: AuthApi.SocialLoginParams) {
return requestClient.post<AuthApi.LoginResult>(
'/system/auth/social-login',
data,
);
}

View File

@ -1,3 +1 @@
export * from './auth';
export * from './menu';
export * from './user';

View File

@ -1,10 +0,0 @@
import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/api/request';
/**
*
*/
export async function getAllMenusApi() {
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
}

View File

@ -1,10 +0,0 @@
import type { UserInfo } from '@vben/types';
import { requestClient } from '#/api/request';
/**
*
*/
export async function getUserInfoApi() {
return requestClient.get<UserInfo>('/user/info');
}

View File

@ -3,7 +3,7 @@
*/
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { isTenantEnable, useAppConfig } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import {
authenticateResponseInterceptor,
@ -19,6 +19,7 @@ import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const tenantEnable = isTenantEnable();
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
@ -49,8 +50,16 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
*/
async function doRefreshToken() {
const accessStore = useAccessStore();
const resp = await refreshTokenApi();
const newToken = resp.data;
const refreshToken = accessStore.refreshToken as string;
if (!refreshToken) {
throw new Error('Refresh token is null!');
}
const resp = await refreshTokenApi(refreshToken);
const newToken = resp?.data?.data?.accessToken;
// add by 芋艿:这里一定要抛出 resp.data从而触发 authenticateResponseInterceptor 中,刷新令牌失败!!!
if (!newToken) {
throw resp.data;
}
accessStore.setAccessToken(newToken);
return newToken;
}
@ -66,6 +75,14 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
config.headers.Authorization = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
// 添加租户编号
config.headers['tenant-id'] = tenantEnable
? accessStore.tenantId
: undefined;
// 只有登录时,才设置 visit-tenant-id 访问租户
config.headers['visit-tenant-id'] = tenantEnable
? accessStore.visitTenantId
: undefined;
return config;
},
});
@ -96,7 +113,12 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
// 当前mock接口返回的错误字段是 error 或者 message
const responseData = error?.response?.data ?? {};
const errorMessage = responseData?.error ?? responseData?.message ?? '';
const errorMessage =
responseData?.error ?? responseData?.message ?? responseData.msg ?? '';
// add by 芋艿:特殊:避免 401 “账号未登录”,重复提示。因为,此时会跳转到登录界面,只需提示一次!!!
if (error?.data?.code === 401) {
return;
}
// 如果没有错误信息,则会根据状态码进行提示
message.error(errorMessage || msg);
}),
@ -110,3 +132,17 @@ export const requestClient = createRequestClient(apiURL, {
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
baseRequestClient.addRequestInterceptor({
fulfilled: (config) => {
const accessStore = useAccessStore();
// 添加租户编号
config.headers['tenant-id'] = tenantEnable
? accessStore.tenantId
: undefined;
// 只有登录时,才设置 visit-tenant-id 访问租户
config.headers['visit-tenant-id'] = tenantEnable
? accessStore.visitTenantId
: undefined;
return config;
},
});

View File

@ -1,12 +1,17 @@
<script lang="ts" setup>
import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { AuthenticationLoginExpiredModal, useVbenModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
import { useWatermark } from '@vben/hooks';
import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
import {
AntdProfileOutlined,
BookOpenText,
CircleHelp,
MdiGithub,
} from '@vben/icons';
import {
BasicLayout,
LockScreen,
@ -15,52 +20,43 @@ import {
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import { formatDateTime, openWindow } from '@vben/utils';
import {
getUnreadNotifyMessageCount,
getUnreadNotifyMessageList,
updateAllNotifyMessageRead,
updateNotifyMessageRead,
} from '#/api/system/notify/message';
import { $t } from '#/locales';
import { router } from '#/router';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
const notifications = ref<NotificationItem[]>([
{
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
date: '3小时前',
isRead: true,
message: '描述信息描述信息描述信息',
title: '收到了 14 份新周报',
},
{
avatar: 'https://avatar.vercel.sh/1',
date: '刚刚',
isRead: false,
message: '描述信息描述信息描述信息',
title: '朱偏右 回复了你',
},
{
avatar: 'https://avatar.vercel.sh/1',
date: '2024-01-01',
isRead: false,
message: '描述信息描述信息描述信息',
title: '曲丽丽 评论了你',
},
{
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '代办提醒',
},
]);
import Help from './components/help.vue';
import TenantDropdown from './components/tenant-dropdown.vue';
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
const notifications = ref<NotificationItem[]>([]);
const unreadCount = ref(0);
const showDot = computed(() => unreadCount.value > 0);
const [HelpModal, helpModalApi] = useVbenModal({
connectedComponent: Help,
});
const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: AntdProfileOutlined,
text: $t('ui.widgets.profile'),
},
{
handler: () => {
openWindow(VBEN_DOC_URL, {
@ -81,9 +77,7 @@ const menus = computed(() => [
},
{
handler: () => {
openWindow(`${VBEN_GITHUB_URL}/issues`, {
target: '_blank',
});
helpModalApi.open();
},
icon: CircleHelp,
text: $t('ui.widgets.qa'),
@ -98,20 +92,83 @@ async function handleLogout() {
await authStore.logout(false);
}
function handleNoticeClear() {
/** 获得未读消息数 */
async function handleNotificationGetUnreadCount() {
unreadCount.value = await getUnreadNotifyMessageCount();
}
/** 获得消息列表 */
async function handleNotificationGetList() {
const list = await getUnreadNotifyMessageList();
notifications.value = list.map((item) => ({
avatar: preferences.app.defaultAvatar,
date: formatDateTime(item.createTime) as string,
isRead: false,
id: item.id,
message: item.templateContent,
title: item.templateNickname,
}));
}
/** 跳转我的站内信 */
function handleNotificationViewAll() {
router.push({
name: 'MyNotifyMessage',
});
}
/** 标记所有已读 */
async function handleNotificationMakeAll() {
await updateAllNotifyMessageRead();
unreadCount.value = 0;
notifications.value = [];
}
function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true));
/** 清空通知 */
async function handleNotificationClear() {
handleNotificationMakeAll();
}
/** 标记单个已读 */
async function handleNotificationRead(item: NotificationItem) {
if (!item.id) {
return;
}
await updateNotifyMessageRead([item.id]);
await handleNotificationGetUnreadCount();
notifications.value = notifications.value.filter((n) => n.id !== item.id);
}
/** 处理通知打开 */
function handleNotificationOpen(open: boolean) {
if (!open) {
return;
}
handleNotificationGetList();
handleNotificationGetUnreadCount();
}
// ========== ==========
onMounted(() => {
//
handleNotificationGetUnreadCount();
//
setInterval(
() => {
if (userStore.userInfo) {
handleNotificationGetUnreadCount();
}
},
1000 * 60 * 2,
);
});
watch(
() => preferences.app.watermark,
async (enable) => {
if (enable) {
await updateWatermark({
content: `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
content: `${userStore.userInfo?.id} - ${userStore.userInfo?.nickname}`,
});
} else {
destroyWatermark();
@ -129,9 +186,9 @@ watch(
<UserDropdown
:avatar
:menus
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
:text="userStore.userInfo?.nickname"
:description="userStore.userInfo?.email"
:tag-text="userStore.userInfo?.username"
@logout="handleLogout"
/>
</template>
@ -139,10 +196,16 @@ watch(
<Notification
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@make-all="handleMakeAll"
@clear="handleNotificationClear"
@make-all="handleNotificationMakeAll"
@view-all="handleNotificationViewAll"
@open="handleNotificationOpen"
@read="handleNotificationRead"
/>
</template>
<template #header-right-1>
<TenantDropdown class="w-30 mr-2" />
</template>
<template #extra>
<AuthenticationLoginExpiredModal
v-model:open="accessStore.loginExpired"
@ -155,4 +218,5 @@ watch(
<LockScreen :avatar @to-login="handleLogout" />
</template>
</BasicLayout>
<HelpModal />
</template>

View File

@ -1,14 +0,0 @@
{
"title": "Demos",
"naive": "Naive UI",
"table": "Table",
"form": "Form",
"vben": {
"title": "Project",
"about": "About",
"document": "Document",
"antdv": "Ant Design Vue Version",
"naive-ui": "Naive UI Version",
"element-plus": "Element Plus Version"
}
}

View File

@ -10,5 +10,23 @@
"title": "Dashboard",
"analytics": "Analytics",
"workspace": "Workspace"
},
"action": {
"action": "Action",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"import": "Import",
"export": "Export",
"submit": "Submit",
"cancel": "Cancel",
"confirm": "Confirm",
"reset": "Reset",
"search": "Search"
},
"tenant": {
"placeholder": "Please select tenant",
"success": "Switch tenant success"
}
}

View File

@ -1,14 +0,0 @@
{
"title": "演示",
"naive": "Naive UI",
"table": "Table",
"form": "表单",
"vben": {
"title": "项目",
"about": "关于",
"document": "文档",
"antdv": "Ant Design Vue 版本",
"naive-ui": "Naive UI 版本",
"element-plus": "Element Plus 版本"
}
}

View File

@ -10,5 +10,23 @@
"title": "概览",
"analytics": "分析页",
"workspace": "工作台"
},
"action": {
"action": "操作",
"add": "新增",
"edit": "编辑",
"delete": "删除",
"save": "保存",
"import": "导入",
"export": "导出",
"submit": "提交",
"cancel": "取消",
"confirm": "确认",
"reset": "重置",
"search": "搜索"
},
"tenant": {
"placeholder": "请选择租户",
"success": "切换租户成功"
}
}

View File

@ -8,6 +8,18 @@ import { defineOverridesPreferences } from '@vben/preferences';
export const overridesPreferences = defineOverridesPreferences({
// overrides
app: {
/** 后端路由模式 */
accessMode: 'backend',
name: import.meta.env.VITE_APP_TITLE,
enableRefreshToken: true,
},
footer: {
/** 默认关闭 footer 页脚,因为有一定遮挡 */
enable: false,
fixed: false,
},
copyright: {
companyName: import.meta.env.VITE_APP_TITLE,
companySiteLink: 'https://gitee.com/yudaocode/yudao-ui-admin-vben',
},
});

View File

@ -1,20 +1,21 @@
import type {
AppRouteRecordRaw,
ComponentRecordType,
GenerateMenuAndRoutesOptions,
} from '@vben/types';
import { generateAccessible } from '@vben/access';
import { preferences } from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
import { convertServerMenuToRouteRecordStringComponent } from '@vben/utils';
import { message } from '#/adapter/naive';
import { getAllMenusApi } from '#/api';
import { BasicLayout, IFrameView } from '#/layouts';
import { $t } from '#/locales';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
const accessStore = useAccessStore();
const layoutMap: ComponentRecordType = {
BasicLayout,
@ -24,10 +25,10 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
return await generateAccessible(preferences.app.accessMode, {
...options,
fetchMenuListAsync: async () => {
message.loading(`${$t('common.loadingMenu')}...`, {
duration: 1.5,
});
return await getAllMenusApi();
// 由于 yudao 通过 accessStore 读取,所以不在进行 message.loading 提示
// 补充说明accessStore.accessMenus 一开始是 AppRouteRecordRaw 类型(后端加载),后面被赋值成 MenuRecordRaw 类型(前端转换)
const accessMenus = accessStore.accessMenus as AppRouteRecordRaw[];
return convertServerMenuToRouteRecordStringComponent(accessMenus);
},
// 可以指定没有权限跳转403页面
forbiddenComponent,

View File

@ -1,12 +1,15 @@
import type { Router } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { $t } from '@vben/locales';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { startProgress, stopProgress } from '@vben/utils';
import { message } from '#/adapter/naive';
import { getSimpleDictDataList } from '#/api/system/dict/data';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore } from '#/store';
import { useAuthStore, useDictStore } from '#/store';
import { generateAccess } from './access';
@ -49,6 +52,7 @@ function setupAccessGuard(router: Router) {
const accessStore = useAccessStore();
const userStore = useUserStore();
const authStore = useAuthStore();
const dictStore = useDictStore();
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
@ -89,10 +93,26 @@ function setupAccessGuard(router: Router) {
if (accessStore.isAccessChecked) {
return true;
}
// 加载字典数据(不阻塞加载)
dictStore.setDictCacheByApi(getSimpleDictDataList);
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo.roles ?? [];
let userInfo = userStore.userInfo;
if (!userInfo) {
// add by 芋艿:由于 yudao 是 fetchUserInfo 统一加载用户 + 权限信息,所以将 fetchMenuListAsync
const loading = message.loading(`${$t('common.loadingMenu')}...`);
try {
const authPermissionInfo = await authStore.fetchUserInfo();
if (authPermissionInfo) {
userInfo = authPermissionInfo.user;
}
} finally {
loading.destroy();
}
}
const userRoles = userStore.userRoles ?? [];
// 生成菜单和路由
const { accessibleMenus, accessibleRoutes } = await generateAccess({
@ -106,9 +126,10 @@ function setupAccessGuard(router: Router) {
accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes);
accessStore.setIsAccessChecked(true);
userStore.setUserRoles(userRoles);
const redirectPath = (from.query.redirect ??
(to.path === preferences.app.defaultHomePath
? userInfo.homePath || preferences.app.defaultHomePath
? userInfo?.homePath || preferences.app.defaultHomePath
: to.fullPath)) as string;
return {

View File

@ -8,6 +8,7 @@ import { resetStaticRoutes } from '@vben/utils';
import { createRouterGuard } from './guard';
import { routes } from './routes';
import { setupBaiduTongJi } from './tongji';
/**
* @zh_CN vue-router
@ -33,5 +34,7 @@ const resetRoutes = () => resetStaticRoutes(router, routes);
// 创建路由守卫
createRouterGuard(router);
// 设置百度统计
setupBaiduTongJi(router);
export { resetRoutes, router };

View File

@ -90,6 +90,23 @@ const coreRoutes: RouteRecordRaw[] = [
title: $t('page.auth.register'),
},
},
{
name: 'SocialLogin',
path: 'social-login',
component: () =>
import('#/views/_core/authentication/social-login.vue'),
meta: {
title: $t('page.auth.login'),
},
},
{
name: 'SSOLogin',
path: 'sso-login',
component: () => import('#/views/_core/authentication/sso-login.vue'),
meta: {
title: $t('page.auth.login'),
},
},
],
},
];

View File

@ -34,4 +34,14 @@ const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
/** 有权限校验的路由列表,包含动态路由和静态路由 */
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
export { accessRoutes, coreRouteNames, routes };
// add by 芋艿from https://github.com/vbenjs/vue-vben-admin/blob/main/playground/src/router/routes/index.ts#L38-L45
const componentKeys: string[] = Object.keys(
import.meta.glob('../../views/**/*.vue'),
)
.filter((item) => !item.includes('/modules/'))
.map((v) => {
const path = v.replace('../../views/', '/');
return path.endsWith('.vue') ? path.slice(0, -4) : path;
});
export { accessRoutes, componentKeys, coreRouteNames, routes };

View File

@ -12,6 +12,15 @@ const routes: RouteRecordRaw[] = [
name: 'Dashboard',
path: '/dashboard',
children: [
{
name: 'Workspace',
path: '/workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',
title: $t('page.dashboard.workspace'),
},
},
{
name: 'Analytics',
path: '/analytics',
@ -22,17 +31,18 @@ const routes: RouteRecordRaw[] = [
title: $t('page.dashboard.analytics'),
},
},
{
name: 'Workspace',
path: '/workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',
title: $t('page.dashboard.workspace'),
},
},
],
},
{
name: 'Profile',
path: '/profile',
component: () => import('#/views/_core/profile/index.vue'),
meta: {
icon: 'ant-design:profile-outlined',
title: $t('ui.widgets.profile'),
hideInMenu: true,
},
},
];
export default routes;

View File

@ -1,44 +0,0 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
order: 1000,
title: $t('demos.title'),
},
name: 'Demos',
path: '/demos',
children: [
{
meta: {
title: $t('demos.naive'),
},
name: 'NaiveDemos',
path: '/demos/naive',
component: () => import('#/views/demos/naive/index.vue'),
},
{
meta: {
title: $t('demos.table'),
},
name: 'Table',
path: '/demos/table',
component: () => import('#/views/demos/table/index.vue'),
},
{
meta: {
title: $t('demos.form'),
},
name: 'Form',
path: '/demos/form',
component: () => import('#/views/demos/form/basic.vue'),
},
],
},
];
export default routes;

View File

@ -1,82 +1,81 @@
import type { RouteRecordRaw } from 'vue-router';
import {
VBEN_ANT_PREVIEW_URL,
VBEN_DOC_URL,
VBEN_ELE_PREVIEW_URL,
VBEN_GITHUB_URL,
VBEN_LOGO_URL,
} from '@vben/constants';
import { SvgAntdvLogoIcon } from '@vben/icons';
import { IFrameView } from '#/layouts';
import { $t } from '#/locales';
// import {
// VBEN_DOC_URL,
// VBEN_ELE_PREVIEW_URL,
// VBEN_GITHUB_URL,
// VBEN_LOGO_URL,
// VBEN_NAIVE_PREVIEW_URL,
// } from '@vben/constants';
//
// import { IFrameView } from '#/layouts';
// import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
badgeType: 'dot',
icon: VBEN_LOGO_URL,
order: 9998,
title: $t('demos.vben.title'),
},
name: 'VbenProject',
path: '/vben-admin',
children: [
{
name: 'VbenDocument',
path: '/vben-admin/document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
link: VBEN_DOC_URL,
title: $t('demos.vben.document'),
},
},
{
name: 'VbenGithub',
path: '/vben-admin/github',
component: IFrameView,
meta: {
icon: 'mdi:github',
link: VBEN_GITHUB_URL,
title: 'Github',
},
},
{
name: 'VbenAntd',
path: '/vben-admin/antd',
component: IFrameView,
meta: {
badgeType: 'dot',
icon: SvgAntdvLogoIcon,
link: VBEN_ANT_PREVIEW_URL,
title: $t('demos.vben.antdv'),
},
},
{
name: 'VbenElementPlus',
path: '/vben-admin/ele',
component: IFrameView,
meta: {
badgeType: 'dot',
icon: 'logos:element',
link: VBEN_ELE_PREVIEW_URL,
title: $t('demos.vben.element-plus'),
},
},
],
},
{
name: 'VbenAbout',
path: '/vben-admin/about',
component: () => import('#/views/_core/about/index.vue'),
meta: {
icon: 'lucide:copyright',
title: $t('demos.vben.about'),
order: 9999,
},
},
// {
// meta: {
// badgeType: 'dot',
// icon: VBEN_LOGO_URL,
// order: 9998,
// title: $t('demos.vben.title'),
// },
// name: 'VbenProject',
// path: '/vben-admin',
// children: [
// {
// name: 'VbenDocument',
// path: '/vben-admin/document',
// component: IFrameView,
// meta: {
// icon: 'lucide:book-open-text',
// link: VBEN_DOC_URL,
// title: $t('demos.vben.document'),
// },
// },
// {
// name: 'VbenGithub',
// path: '/vben-admin/github',
// component: IFrameView,
// meta: {
// icon: 'mdi:github',
// link: VBEN_GITHUB_URL,
// title: 'Github',
// },
// },
// {
// name: 'VbenNaive',
// path: '/vben-admin/naive',
// component: IFrameView,
// meta: {
// badgeType: 'dot',
// icon: 'logos:naiveui',
// link: VBEN_NAIVE_PREVIEW_URL,
// title: $t('demos.vben.naive-ui'),
// },
// },
// {
// name: 'VbenElementPlus',
// path: '/vben-admin/ele',
// component: IFrameView,
// meta: {
// badgeType: 'dot',
// icon: 'logos:element',
// link: VBEN_ELE_PREVIEW_URL,
// title: $t('demos.vben.element-plus'),
// },
// },
// ],
// },
// {
// name: 'VbenAbout',
// path: '/vben-admin/about',
// component: () => import('#/views/_core/about/index.vue'),
// meta: {
// icon: 'lucide:copyright',
// title: $t('demos.vben.about'),
// order: 9999,
// },
// },
];
export default routes;
export default routes; // update by 芋艿:不展示

View File

@ -1,4 +1,6 @@
import type { Recordable, UserInfo } from '@vben/types';
import type { AuthPermissionInfo, Recordable, UserInfo } from '@vben/types';
import type { AuthApi } from '#/api';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
@ -10,7 +12,14 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { defineStore } from 'pinia';
import { notification } from '#/adapter/naive';
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
import {
getAuthPermissionInfoApi,
loginApi,
logoutApi,
register,
smsLogin,
socialLogin,
} from '#/api';
import { $t } from '#/locales';
export const useAuthStore = defineStore('auth', () => {
@ -23,9 +32,12 @@ export const useAuthStore = defineStore('auth', () => {
/**
*
* Asynchronously handle the login process
* @param type
* @param params
* @param onSuccess
*/
async function authLogin(
type: 'mobile' | 'register' | 'social' | 'username',
params: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
@ -33,23 +45,30 @@ export const useAuthStore = defineStore('auth', () => {
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
const { accessToken } = await loginApi(params);
const { accessToken, refreshToken } =
type === 'mobile'
? await smsLogin(params as AuthApi.SmsLoginParams)
: type === 'register'
? await register(params as AuthApi.RegisterParams)
: // eslint-disable-next-line unicorn/no-nested-ternary
type === 'social'
? await socialLogin(params as AuthApi.SocialLoginParams)
: await loginApi(params);
// 如果成功获取到 accessToken
if (accessToken) {
// 将 accessToken 存储到 accessStore 中
accessStore.setAccessToken(accessToken);
accessStore.setRefreshToken(refreshToken);
// 获取用户信息并存储到 accessStore 中
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
// 获取用户信息并存储到 userStore、accessStore 中
// TODO @芋艿:清理掉 accessCodes 相关的逻辑
// const [fetchUserInfoResult, accessCodes] = await Promise.all([
// fetchUserInfo(),
// // getAccessCodesApi(),
// ]);
const fetchUserInfoResult = await fetchUserInfo();
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
userInfo = fetchUserInfoResult.user;
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
@ -61,10 +80,10 @@ export const useAuthStore = defineStore('auth', () => {
);
}
if (userInfo?.realName) {
if (userInfo?.nickname) {
notification.success({
content: $t('authentication.loginSuccess'),
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
duration: 3000,
});
}
@ -80,7 +99,10 @@ export const useAuthStore = defineStore('auth', () => {
async function logout(redirect: boolean = true) {
try {
await logoutApi();
const accessToken = accessStore.accessToken as string;
if (accessToken) {
await logoutApi(accessToken);
}
} catch {
// 不做任何处理
}
@ -99,10 +121,16 @@ export const useAuthStore = defineStore('auth', () => {
}
async function fetchUserInfo() {
let userInfo: null | UserInfo = null;
userInfo = await getUserInfoApi();
userStore.setUserInfo(userInfo);
return userInfo;
// 加载
let authPermissionInfo: AuthPermissionInfo | null = null;
authPermissionInfo = await getAuthPermissionInfoApi();
// userStore
userStore.setUserInfo(authPermissionInfo.user);
userStore.setUserRoles(authPermissionInfo.roles);
// accessStore
accessStore.setAccessMenus(authPermissionInfo.menus);
accessStore.setAccessCodes(authPermissionInfo.permissions);
return authPermissionInfo;
}
function $reset() {

View File

@ -1 +1,2 @@
export * from './auth';
export * from './dict';

View File

@ -2,24 +2,100 @@
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, ref } from 'vue';
import type { AuthApi } from '#/api';
import { computed, onMounted, ref } from 'vue';
import { AuthenticationCodeLogin, z } from '@vben/common-ui';
import { isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import { message } from '#/adapter/naive';
import { sendSmsCode } from '#/api';
import { getTenantByWebsite, getTenantSimpleList } from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'CodeLogin' });
const authStore = useAuthStore();
const accessStore = useAccessStore();
const tenantEnable = isTenantEnable();
const loading = ref(false);
const CODE_LENGTH = 6;
const CODE_LENGTH = 4;
const loginRef = ref();
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); //
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
//
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// > store >
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// store
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 使
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
//
accessStore.setTenantId(tenantId);
loginRef.value.getFormApi().setFieldValue('tenantId', tenantId?.toString());
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 组件挂载时获取租户信息 */
onMounted(() => {
fetchTenantList();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.mobile'),
},
fieldName: 'phoneNumber',
fieldName: 'mobile',
label: $t('authentication.mobile'),
rules: z
.string()
@ -40,6 +116,29 @@ const formSchema = computed((): VbenFormSchema[] => {
return text;
},
placeholder: $t('authentication.code'),
handleSendCode: async () => {
loading.value = true;
try {
const formApi = loginRef.value?.getFormApi();
if (!formApi) {
throw new Error('表单未准备好');
}
//
await formApi.validateField('mobile');
const isMobileValid = await formApi.isFieldValid('mobile');
if (!isMobileValid) {
throw new Error('请输入有效的手机号码');
}
//
const { mobile } = await formApi.getValues();
const scene = 21; //
await sendSmsCode({ mobile, scene });
message.success('验证码发送成功');
} finally {
loading.value = false;
}
},
},
fieldName: 'code',
label: $t('authentication.code'),
@ -55,13 +154,17 @@ const formSchema = computed((): VbenFormSchema[] => {
* @param values 登录表单数据
*/
async function handleLogin(values: Recordable<any>) {
// eslint-disable-next-line no-console
console.log(values);
try {
await authStore.authLogin('mobile', values);
} catch (error) {
console.error('Error in handleLogin:', error);
}
}
</script>
<template>
<AuthenticationCodeLogin
ref="loginRef"
:form-schema="formSchema"
:loading="loading"
@submit="handleLogin"

View File

@ -2,40 +2,212 @@
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, ref } from 'vue';
import type { AuthApi } from '#/api';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
import { isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import { message } from '#/adapter/naive';
import { sendSmsCode, smsResetPassword } from '#/api';
import { getTenantByWebsite, getTenantSimpleList } from '#/api/core/auth';
defineOptions({ name: 'ForgetPassword' });
const accessStore = useAccessStore();
const router = useRouter();
const tenantEnable = isTenantEnable();
const loading = ref(false);
const CODE_LENGTH = 4;
const forgetPasswordRef = ref();
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); //
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
//
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// > store >
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// store
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 使
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
//
accessStore.setTenantId(tenantId);
forgetPasswordRef.value
.getFormApi()
.setFieldValue('tenantId', tenantId?.toString());
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 组件挂载时获取租户信息 */
onMounted(() => {
fetchTenantList();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: 'example@example.com',
placeholder: $t('authentication.mobile'),
},
fieldName: 'email',
label: $t('authentication.email'),
fieldName: 'mobile',
label: $t('authentication.mobile'),
rules: z
.string()
.min(1, { message: $t('authentication.emailTip') })
.email($t('authentication.emailValidErrorTip')),
.min(1, { message: $t('authentication.mobileTip') })
.refine((v) => /^\d{11}$/.test(v), {
message: $t('authentication.mobileErrortip'),
}),
},
{
component: 'VbenPinInput',
componentProps: {
codeLength: CODE_LENGTH,
createText: (countdown: number) => {
const text =
countdown > 0
? $t('authentication.sendText', [countdown])
: $t('authentication.sendCode');
return text;
},
placeholder: $t('authentication.code'),
handleSendCode: async () => {
loading.value = true;
try {
const formApi = forgetPasswordRef.value?.getFormApi();
if (!formApi) {
throw new Error('表单未准备好');
}
//
await formApi.validateField('mobile');
const isMobileValid = await formApi.isFieldValid('mobile');
if (!isMobileValid) {
throw new Error('请输入有效的手机号码');
}
//
const { mobile } = await formApi.getValues();
const scene = 23; //
await sendSmsCode({ mobile, scene });
message.success('验证码发送成功');
} finally {
loading.value = false;
}
},
},
fieldName: 'code',
label: $t('authentication.code'),
rules: z.string().length(CODE_LENGTH, {
message: $t('authentication.codeTip', [CODE_LENGTH]),
}),
},
{
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
renderComponentContent() {
return {
strengthText: () => $t('authentication.passwordStrength'),
};
},
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.confirmPassword'),
},
dependencies: {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),
});
},
triggerFields: ['password'],
},
fieldName: 'confirmPassword',
label: $t('authentication.confirmPassword'),
},
];
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('reset email:', value);
/**
* 处理重置密码操作
* @param values 表单数据
*/
async function handleSubmit(values: Recordable<any>) {
loading.value = true;
try {
const { mobile, code, password } = values;
await smsResetPassword({ mobile, code, password });
message.success($t('authentication.resetPasswordSuccess'));
//
router.push('/');
} catch (error) {
console.error('重置密码失败:', error);
} finally {
loading.value = false;
}
}
</script>
<template>
<AuthenticationForgetPassword
ref="forgetPasswordRef"
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"

View File

@ -1,98 +1,192 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { BasicOption } from '@vben/types';
import { computed, markRaw } from 'vue';
import type { AuthApi } from '#/api/core/auth';
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import {
checkCaptcha,
getCaptcha,
getTenantByWebsite,
getTenantSimpleList,
socialAuthRedirect,
} from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
const { query } = useRoute();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const tenantEnable = isTenantEnable();
const captchaEnable = isCaptchaEnable();
const MOCK_USER_OPTIONS: BasicOption[] = [
{
label: 'Super',
value: 'vben',
},
{
label: 'Admin',
value: 'admin',
},
{
label: 'User',
value: 'jack',
},
];
const loginRef = ref();
const verifyRef = ref();
const captchaType = 'blockPuzzle'; // 'blockPuzzle' | 'clickWord'
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); //
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
//
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// > store >
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// store
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 使
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
//
accessStore.setTenantId(tenantId);
loginRef.value.getFormApi().setFieldValue('tenantId', tenantId?.toString());
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 处理登录 */
async function handleLogin(values: any) {
//
if (captchaEnable) {
verifyRef.value.show();
return;
}
//
await authStore.authLogin('username', values);
}
/** 验证码通过,执行登录 */
async function handleVerifySuccess({ captchaVerification }: any) {
try {
await authStore.authLogin('username', {
...(await loginRef.value.getFormApi().getValues()),
captchaVerification,
});
} catch (error) {
console.error('Error in handleLogin:', error);
}
}
/** 处理第三方登录 */
const redirect = query?.redirect;
async function handleThirdLogin(type: number) {
if (type <= 0) {
return;
}
try {
// redirectUri
// tricky: typeredirect encode social-login.vue#getUrlValue() 使
const redirectUri = `${
location.origin
}/auth/social-login?${encodeURIComponent(
`type=${type}&redirect=${redirect || '/'}`,
)}`;
//
window.location.href = await socialAuthRedirect(type, redirectUri);
} catch (error) {
console.error('第三方登录处理失败:', error);
}
}
/** 组件挂载时获取租户信息 */
onMounted(() => {
fetchTenantList();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: MOCK_USER_OPTIONS,
placeholder: $t('authentication.selectAccount'),
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
fieldName: 'selectAccount',
label: $t('authentication.selectAccount'),
rules: z
.string()
.min(1, { message: $t('authentication.selectAccount') })
.optional()
.default('vben'),
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
dependencies: {
trigger(values, form) {
if (values.selectAccount) {
const findUser = MOCK_USER_OPTIONS.find(
(item) => item.value === values.selectAccount,
);
if (findUser) {
form.setValues({
password: '123456',
username: findUser.value,
});
}
}
},
triggerFields: ['selectAccount'],
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
rules: z
.string()
.min(1, { message: $t('authentication.usernameTip') })
.default(import.meta.env.VITE_APP_DEFAULT_USERNAME),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.password'),
placeholder: $t('authentication.passwordTip'),
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: markRaw(SliderCaptcha),
fieldName: 'captcha',
rules: z.boolean().refine((value) => value, {
message: $t('authentication.verifyRequiredTip'),
}),
rules: z
.string()
.min(1, { message: $t('authentication.passwordTip') })
.default(import.meta.env.VITE_APP_DEFAULT_PASSWORD),
},
];
});
</script>
<template>
<AuthenticationLogin
:form-schema="formSchema"
:loading="authStore.loginLoading"
@submit="authStore.authLogin"
/>
<div>
<AuthenticationLogin
ref="loginRef"
:form-schema="formSchema"
:loading="authStore.loginLoading"
@submit="handleLogin"
@third-login="handleThirdLogin"
/>
<Verification
ref="verifyRef"
v-if="captchaEnable"
:captcha-type="captchaType"
:check-captcha-api="checkCaptcha"
:get-captcha-api="getCaptcha"
:img-size="{ width: '400px', height: '200px' }"
mode="pop"
@on-success="handleVerifySuccess"
/>
</div>
</template>

View File

@ -1,18 +1,126 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, h, ref } from 'vue';
import type { AuthApi } from '#/api/core/auth';
import { AuthenticationRegister, z } from '@vben/common-ui';
import { computed, h, onMounted, ref } from 'vue';
import { AuthenticationRegister, Verification, z } from '@vben/common-ui';
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import {
checkCaptcha,
getCaptcha,
getTenantByWebsite,
getTenantSimpleList,
} from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Register' });
const loading = ref(false);
const accessStore = useAccessStore();
const authStore = useAuthStore();
const tenantEnable = isTenantEnable();
const captchaEnable = isCaptchaEnable();
const registerRef = ref();
const verifyRef = ref();
const captchaType = 'blockPuzzle'; // 'blockPuzzle' | 'clickWord'
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); //
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
//
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// > store >
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// store
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 使
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
//
accessStore.setTenantId(tenantId);
registerRef.value
.getFormApi()
.setFieldValue('tenantId', tenantId?.toString());
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 执行注册 */
async function handleRegister(values: any) {
//
if (captchaEnable) {
verifyRef.value.show();
return;
}
//
await authStore.authLogin('register', values);
}
/** 验证码通过,执行注册 */
const handleVerifySuccess = async ({ captchaVerification }: any) => {
try {
await authStore.authLogin('register', {
...(await registerRef.value.getFormApi().getValues()),
captchaVerification,
});
} catch (error) {
console.error('Error in handleRegister:', error);
}
};
/** 组件挂载时获取租户信息 */
onMounted(() => {
fetchTenantList();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
},
{
component: 'VbenInput',
componentProps: {
@ -22,6 +130,15 @@ const formSchema = computed((): VbenFormSchema[] => {
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.nicknameTip'),
},
fieldName: 'nickname',
label: $t('authentication.nickname'),
rules: z.string().min(1, { message: $t('authentication.nicknameTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
@ -67,7 +184,7 @@ const formSchema = computed((): VbenFormSchema[] => {
h(
'a',
{
class: 'vben-link ml-1',
class: 'vben-link ml-1 ',
href: '',
},
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
@ -80,17 +197,25 @@ const formSchema = computed((): VbenFormSchema[] => {
},
];
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('register submit:', value);
}
</script>
<template>
<AuthenticationRegister
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
<div>
<AuthenticationRegister
ref="registerRef"
:form-schema="formSchema"
:loading="loading"
@submit="handleRegister"
/>
<Verification
ref="verifyRef"
v-if="captchaEnable"
:captcha-type="captchaType"
:check-captcha-api="checkCaptcha"
:get-captcha-api="getCaptcha"
:img-size="{ width: '400px', height: '200px' }"
mode="pop"
@on-success="handleVerifySuccess"
/>
</div>
</template>

View File

@ -1,159 +0,0 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { NButton, NCard, useMessage } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { getAllMenusApi } from '#/api';
const message = useMessage();
const [Form, formApi] = useVbenForm({
commonConfig: {
//
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
// 321
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
handleSubmit: (values) => {
message.success(`表单数据:${JSON.stringify(values)}`);
},
schema: [
{
// #/adapter.ts
component: 'ApiSelect',
//
componentProps: {
// options
afterFetch: (data: { name: string; path: string }[]) => {
return data.map((item: any) => ({
label: item.name,
value: item.path,
}));
},
//
api: getAllMenusApi,
},
//
fieldName: 'api',
// label
label: 'ApiSelect',
rules: 'required',
},
{
component: 'ApiTreeSelect',
//
componentProps: {
//
api: getAllMenusApi,
childrenField: 'children',
// options
labelField: 'name',
valueField: 'path',
},
//
fieldName: 'apiTree',
// label
label: 'ApiTreeSelect',
rules: 'required',
},
{
component: 'Input',
fieldName: 'string',
label: 'String',
rules: 'required',
},
{
component: 'InputNumber',
fieldName: 'number',
label: 'Number',
rules: 'required',
},
{
component: 'RadioGroup',
fieldName: 'radio',
label: 'Radio',
componentProps: {
options: [
{ value: 'A', label: 'A' },
{ value: 'B', label: 'B' },
{ value: 'C', label: 'C' },
{ value: 'D', label: 'D' },
{ value: 'E', label: 'E' },
],
},
rules: 'selectRequired',
},
{
component: 'RadioGroup',
fieldName: 'radioButton',
label: 'RadioButton',
componentProps: {
isButton: true,
class: 'flex flex-wrap', // class
options: [
{ value: 'A', label: '选项A' },
{ value: 'B', label: '选项B' },
{ value: 'C', label: '选项C' },
{ value: 'D', label: '选项D' },
{ value: 'E', label: '选项E' },
],
},
rules: 'selectRequired',
},
{
component: 'CheckboxGroup',
fieldName: 'checkbox',
label: 'Checkbox',
componentProps: {
options: [
{ value: 'A', label: '选项A' },
{ value: 'B', label: '选项B' },
{ value: 'C', label: '选项C' },
],
},
rules: 'selectRequired',
},
{
component: 'DatePicker',
fieldName: 'date',
label: 'Date',
rules: 'required',
},
{
component: 'Input',
fieldName: 'textArea',
label: 'TextArea',
componentProps: {
type: 'textarea',
},
rules: 'required',
},
],
});
function setFormValues() {
formApi.setValues({
string: 'string',
number: 123,
radio: 'B',
radioButton: 'C',
checkbox: ['A', 'C'],
date: Date.now(),
});
}
</script>
<template>
<Page
description="表单适配器重新包装了CheckboxGroup和RadioGroup可以通过options属性传递选项数据选项数据将作为子组件的属性"
title="表单演示"
>
<NCard title="基础表单">
<template #header-extra>
<NButton type="primary" @click="setFormValues"></NButton>
</template>
<Form />
</NCard>
</Page>
</template>

View File

@ -1,69 +0,0 @@
<script lang="ts" setup>
import type { NotificationType } from 'naive-ui';
import { Page } from '@vben/common-ui';
import { NButton, NCard, NSpace, useMessage, useNotification } from 'naive-ui';
const notification = useNotification();
const message = useMessage();
function error() {
message.error('Once upon a time you dressed so fine');
}
function warning() {
message.warning('How many roads must a man walk down');
}
function success() {
message.success('Cause you walked hand in hand With another man in my place');
}
function loading() {
message.loading(
'If I were you, I will realize that I love you more than any other guy',
);
}
function notify(type: NotificationType) {
notification[type]({
content: '说点啥呢',
duration: 2500,
keepAliveOnHover: true,
meta: '想不出来',
});
}
</script>
<template>
<Page description="支持多语言,主题功能集成切换等" title="naive组件使用演示">
<NCard class="mb-5" title="按钮">
<NSpace>
<NButton>Default</NButton>
<NButton type="tertiary"> Tertiary </NButton>
<NButton type="primary"> Primary </NButton>
<NButton type="info"> Info </NButton>
<NButton type="success"> Success </NButton>
<NButton type="warning"> Warning </NButton>
<NButton type="error"> Error </NButton>
</NSpace>
</NCard>
<NCard class="mb-5" title="Message">
<NSpace>
<NButton type="error" @click="error"> </NButton>
<NButton type="warning" @click="warning"> </NButton>
<NButton type="success" @click="success"> </NButton>
<NButton type="primary" @click="loading"> </NButton>
</NSpace>
</NCard>
<NCard class="mb-5" title="Notification">
<NSpace>
<NButton type="error" @click="notify('error')"> </NButton>
<NButton type="warning" @click="notify('warning')"> </NButton>
<NButton type="success" @click="notify('success')"> </NButton>
<NButton type="primary" @click="notify('info')"> </NButton>
</NSpace>
</NCard>
</Page>
</template>

View File

@ -1,38 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { NDataTable } from 'naive-ui';
const columns = ref([
{
key: 'no',
title: 'No',
},
{
key: 'title',
title: 'Title',
},
{
key: 'length',
title: 'Length',
},
]);
const data = [
{ length: '4:18', no: 3, title: 'Wonderwall' },
{ length: '4:48', no: 4, title: "Don't Look Back in Anger" },
{ length: '7:27', no: 12, title: 'Champagne Supernova' },
];
</script>
<template>
<Page
description="表单页用于向用户收集或验证信息,基础表单常见于数据项较少的表单场景。"
title="NDataTable"
>
<NDataTable :columns="columns" :data="data" />
</Page>
</template>
<style scoped></style>