Merge branch 'v-next-dev' of gitee.com:yudaocode/yudao-ui-admin-vben into v-next-dev

Signed-off-by: puhui999 <puhui999@163.com>
pull/69/head
puhui999 2025-04-09 10:13:27 +00:00 committed by Gitee
commit e5c917f20d
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
56 changed files with 2459 additions and 233 deletions

View File

@ -24,13 +24,27 @@ export namespace AuthApi {
}
/** 手机验证码获取接口参数 */
export interface SmsCodeVO {
export interface SmsCodeParams {
mobile: string;
scene: number;
}
/** 手机验证码登录接口参数 */
export interface SmsLoginVO {
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;
}
@ -84,3 +98,23 @@ export async function getCaptcha(data: any) {
export async function checkCaptcha(data: any) {
return baseRequestClient.post('/system/captcha/check', data);
}
/** 获取登录验证码 */
export const sendSmsCode = (data: AuthApi.SmsCodeParams) => {
return requestClient.post('/system/auth/send-sms-code', data )
}
/** 短信验证码登录 */
export const smsLogin = (data: AuthApi.SmsLoginParams) => {
return requestClient.post('/system/auth/sms-login', data)
}
/** 注册 */
export const register = (data: AuthApi.RegisterParams) => {
return requestClient.post('/system/auth/register', data)
}
/** 通过短信重置密码 */
export const smsResetPassword = (data: AuthApi.ResetPasswordParams) => {
return requestClient.post('/system/auth/reset-password', data)
}

View File

@ -0,0 +1,36 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace InfraJobLogApi {
/** 任务日志信息 */
export interface InfraJobLog {
id?: number;
jobId: number;
handlerName: string;
handlerParam: string;
cronExpression: string;
executeIndex: string;
beginTime: Date;
endTime: Date;
duration: string;
status: number;
createTime?: string;
result: string;
}
}
/** 查询任务日志列表 */
export function getJobLogPage(params: PageParam) {
return requestClient.get<PageResult<InfraJobLogApi.InfraJobLog>>('/infra/job-log/page', { params });
}
/** 查询任务日志详情 */
export function getJobLog(id: number) {
return requestClient.get<InfraJobLogApi.InfraJobLog>(`/infra/job-log/get?id=${id}`);
}
/** 导出定时任务日志 */
export function exportJobLog(params: any) {
return requestClient.download('/infra/job-log/export-excel', { params });
}

View File

@ -0,0 +1,68 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace InfraJobApi {
/** 任务信息 */
export interface InfraJob {
id?: number;
name: string;
status: number;
handlerName: string;
handlerParam: string;
cronExpression: string;
retryCount: number;
retryInterval: number;
monitorTimeout: number;
createTime?: Date;
}
}
/** 查询任务列表 */
export function getJobPage(params: PageParam) {
return requestClient.get<PageResult<InfraJobApi.InfraJob>>('/infra/job/page', { params });
}
/** 查询任务详情 */
export function getJob(id: number) {
return requestClient.get<InfraJobApi.InfraJob>(`/infra/job/get?id=${id}`);
}
/** 新增任务 */
export function createJob(data: InfraJobApi.InfraJob) {
return requestClient.post('/infra/job/create', data);
}
/** 修改定时任务调度 */
export function updateJob(data: InfraJobApi.InfraJob) {
return requestClient.put('/infra/job/update', data);
}
/** 删除定时任务调度 */
export function deleteJob(id: number) {
return requestClient.delete(`/infra/job/delete?id=${id}`);
}
/** 导出定时任务调度 */
export function exportJob(params: any) {
return requestClient.download('/infra/job/export-excel', { params });
}
/** 任务状态修改 */
export function updateJobStatus(id: number, status: number) {
const params = {
id,
status
};
return requestClient.put('/infra/job/update-status', { params });
}
/** 定时任务立即执行一次 */
export function runJob(id: number) {
return requestClient.put(`/infra/job/trigger?id=${id}`);
}
/** 获得定时任务的下 n 次执行时间 */
export function getJobNextTimes(id: number) {
return requestClient.get(`/infra/job/get_next_times?id=${id}`);
}

View File

@ -0,0 +1,188 @@
import { requestClient } from '#/api/request';
export namespace InfraRedisApi {
/** Redis 监控信息 */
export interface InfraRedisMonitorInfo {
info: InfraRedisInfo;
dbSize: number;
commandStats: InfraRedisCommandStats[];
}
/** Redis 信息 */
export interface InfraRedisInfo {
io_threaded_reads_processed: string;
tracking_clients: string;
uptime_in_seconds: string;
cluster_connections: string;
current_cow_size: string;
maxmemory_human: string;
aof_last_cow_size: string;
master_replid2: string;
mem_replication_backlog: string;
aof_rewrite_scheduled: string;
total_net_input_bytes: string;
rss_overhead_ratio: string;
hz: string;
current_cow_size_age: string;
redis_build_id: string;
errorstat_BUSYGROUP: string;
aof_last_bgrewrite_status: string;
multiplexing_api: string;
client_recent_max_output_buffer: string;
allocator_resident: string;
mem_fragmentation_bytes: string;
aof_current_size: string;
repl_backlog_first_byte_offset: string;
tracking_total_prefixes: string;
redis_mode: string;
redis_git_dirty: string;
aof_delayed_fsync: string;
allocator_rss_bytes: string;
repl_backlog_histlen: string;
io_threads_active: string;
rss_overhead_bytes: string;
total_system_memory: string;
loading: string;
evicted_keys: string;
maxclients: string;
cluster_enabled: string;
redis_version: string;
repl_backlog_active: string;
mem_aof_buffer: string;
allocator_frag_bytes: string;
io_threaded_writes_processed: string;
instantaneous_ops_per_sec: string;
used_memory_human: string;
total_error_replies: string;
role: string;
maxmemory: string;
used_memory_lua: string;
rdb_current_bgsave_time_sec: string;
used_memory_startup: string;
used_cpu_sys_main_thread: string;
lazyfree_pending_objects: string;
aof_pending_bio_fsync: string;
used_memory_dataset_perc: string;
allocator_frag_ratio: string;
arch_bits: string;
used_cpu_user_main_thread: string;
mem_clients_normal: string;
expired_time_cap_reached_count: string;
unexpected_error_replies: string;
mem_fragmentation_ratio: string;
aof_last_rewrite_time_sec: string;
master_replid: string;
aof_rewrite_in_progress: string;
lru_clock: string;
maxmemory_policy: string;
run_id: string;
latest_fork_usec: string;
tracking_total_items: string;
total_commands_processed: string;
expired_keys: string;
errorstat_ERR: string;
used_memory: string;
module_fork_in_progress: string;
errorstat_WRONGPASS: string;
aof_buffer_length: string;
dump_payload_sanitizations: string;
mem_clients_slaves: string;
keyspace_misses: string;
server_time_usec: string;
executable: string;
lazyfreed_objects: string;
db0: string;
used_memory_peak_human: string;
keyspace_hits: string;
rdb_last_cow_size: string;
aof_pending_rewrite: string;
used_memory_overhead: string;
active_defrag_hits: string;
tcp_port: string;
uptime_in_days: string;
used_memory_peak_perc: string;
current_save_keys_processed: string;
blocked_clients: string;
total_reads_processed: string;
expire_cycle_cpu_milliseconds: string;
sync_partial_err: string;
used_memory_scripts_human: string;
aof_current_rewrite_time_sec: string;
aof_enabled: string;
process_supervised: string;
master_repl_offset: string;
used_memory_dataset: string;
used_cpu_user: string;
rdb_last_bgsave_status: string;
tracking_total_keys: string;
atomicvar_api: string;
allocator_rss_ratio: string;
client_recent_max_input_buffer: string;
clients_in_timeout_table: string;
aof_last_write_status: string;
mem_allocator: string;
used_memory_scripts: string;
used_memory_peak: string;
process_id: string;
master_failover_state: string;
errorstat_NOAUTH: string;
used_cpu_sys: string;
repl_backlog_size: string;
connected_slaves: string;
current_save_keys_total: string;
gcc_version: string;
total_system_memory_human: string;
sync_full: string;
connected_clients: string;
module_fork_last_cow_size: string;
total_writes_processed: string;
allocator_active: string;
total_net_output_bytes: string;
pubsub_channels: string;
current_fork_perc: string;
active_defrag_key_hits: string;
rdb_changes_since_last_save: string;
instantaneous_input_kbps: string;
used_memory_rss_human: string;
configured_hz: string;
expired_stale_perc: string;
active_defrag_misses: string;
used_cpu_sys_children: string;
number_of_cached_scripts: string;
sync_partial_ok: string;
used_memory_lua_human: string;
rdb_last_save_time: string;
pubsub_patterns: string;
slave_expires_tracked_keys: string;
redis_git_sha1: string;
used_memory_rss: string;
rdb_last_bgsave_time_sec: string;
os: string;
mem_not_counted_for_evict: string;
active_defrag_running: string;
rejected_connections: string;
aof_rewrite_buffer_length: string;
total_forks: string;
active_defrag_key_misses: string;
allocator_allocated: string;
aof_base_size: string;
instantaneous_output_kbps: string;
second_repl_offset: string;
rdb_bgsave_in_progress: string;
used_cpu_user_children: string;
total_connections_received: string;
migrate_cached_sockets: string;
}
/** Redis 命令统计 */
export interface InfraRedisCommandStats {
command: string;
calls: number;
usec: number;
}
}
/** 获取 Redis 监控信息 */
export function getRedisMonitorInfo() {
return requestClient.get<InfraRedisApi.InfraRedisMonitorInfo>('/infra/redis/get-monitor-info');
}

View File

@ -1,17 +1,18 @@
import type { Router } from 'vue-router';
import { DEFAULT_HOME_PATH, 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 'ant-design-vue';
import { getSimpleDictDataList } from '#/api/system/dict/data';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore, useDictStore } from '#/store';
import { generateAccess } from './access';
import { message } from 'ant-design-vue';
import { $t } from '@vben/locales';
import { getSimpleDictDataList } from '#/api/system/dict/data';
/**
*
@ -101,12 +102,15 @@ function setupAccessGuard(router: Router) {
// 当前登录用户拥有的角色标识列表
let userInfo = userStore.userInfo;
if (!userInfo) {
// addy by 芋艿:由于 yudao 是 fetchUserInfo 统一加载用户 + 权限信息,所以将 fetchMenuListAsync
// add by 芋艿:由于 yudao 是 fetchUserInfo 统一加载用户 + 权限信息,所以将 fetchMenuListAsync
const loading = message.loading({
content: `${$t('common.loadingMenu')}...`,
});
try {
userInfo = (await authStore.fetchUserInfo()).user;
const authPermissionInfo = await authStore.fetchUserInfo();
if (authPermissionInfo) {
userInfo = authPermissionInfo.user;
}
} finally {
loading();
}
@ -128,7 +132,7 @@ function setupAccessGuard(router: Router) {
userStore.setUserRoles(userRoles);
const redirectPath = (from.query.redirect ??
(to.path === DEFAULT_HOME_PATH
? userInfo.homePath || DEFAULT_HOME_PATH
? userInfo?.homePath || DEFAULT_HOME_PATH
: to.fullPath)) as string;
return {

View File

@ -1,6 +1,18 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/infra/job/job-log',
component: () => import('#/views/infra/job/logger/index.vue'),
name: 'InfraJobLog',
meta: {
title: '调度日志',
icon: 'ant-design:history-outlined',
activePath: '/infra/job',
keepAlive: false,
hideInMenu: true,
},
},
{
path: '/codegen',
name: 'CodegenEdit',

View File

@ -9,7 +9,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { notification } from 'ant-design-vue';
import { defineStore } from 'pinia';
import { getAuthPermissionInfoApi, loginApi, logoutApi} from '#/api';
import { type AuthApi, getAuthPermissionInfoApi, loginApi, logoutApi, smsLogin, register } from '#/api';
import { $t } from '#/locales';
export const useAuthStore = defineStore('auth', () => {
@ -22,9 +22,12 @@ export const useAuthStore = defineStore('auth', () => {
/**
*
* Asynchronously handle the login process
* @param type
* @param params
* @param onSuccess
*/
async function authLogin(
type: 'mobile' | 'username' | 'register',
params: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
@ -32,7 +35,9 @@ export const useAuthStore = defineStore('auth', () => {
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
const { accessToken, refreshToken } = await loginApi(params);
const { accessToken, refreshToken } = type === 'mobile' ? await smsLogin(params as AuthApi.SmsLoginParams)
: type === 'register' ? await register(params as AuthApi.RegisterParams)
: await loginApi(params);
// 如果成功获取到 accessToken
if (accessToken) {

View File

@ -1,56 +1,70 @@
// TODO @芋艿1代码优化2是不是抽到公共的
// todo @芋艿:公用逻辑
interface TreeNode {
[key: string]: any;
children?: TreeNode[];
}
/**
*
*
* @param {*} data
* @param {*} id id 'id'
* @param {*} parentId 'parentId'
* @param {*} children 'children'
*/
export const handleTree = (data: any[], id?: string, parentId?: string, children?: string) => {
export const handleTree = (
data: TreeNode[],
id: string = 'id',
parentId: string = 'parentId',
children: string = 'children'
): TreeNode[] => {
if (!Array.isArray(data)) {
console.warn('data must be an array')
return []
console.warn('data must be an array');
return [];
}
const config = {
id: id || 'id',
parentId: parentId || 'parentId',
childrenList: children || 'children'
}
const childrenListMap = {}
const nodeIds = {}
const tree: any[] = []
id,
parentId,
childrenList: children
};
const childrenListMap: Record<string | number, TreeNode[]> = {};
const nodeIds: Record<string | number, TreeNode> = {};
const tree: TreeNode[] = [];
// 1. 数据预处理
// 1.1 第一次遍历,生成 childrenListMap 和 nodeIds 映射
for (const d of data) {
const parentId = d[config.parentId]
if (childrenListMap[parentId] == null) {
childrenListMap[parentId] = []
const pId = d[config.parentId];
if (childrenListMap[pId] === undefined) {
childrenListMap[pId] = [];
}
nodeIds[d[config.id]] = d
childrenListMap[parentId].push(d)
nodeIds[d[config.id]] = d;
childrenListMap[pId].push(d);
}
// 1.2 第二次遍历,找出根节点
for (const d of data) {
const parentId = d[config.parentId]
if (nodeIds[parentId] == null) {
tree.push(d)
const pId = d[config.parentId];
if (nodeIds[pId] === undefined) {
tree.push(d);
}
}
for (const t of tree) {
adaptToChildrenList(t)
// 2. 构建树结:递归构建子节点
const adaptToChildrenList = (node: TreeNode): void => {
const nodeId = node[config.id];
if (childrenListMap[nodeId]) {
node[config.childrenList] = childrenListMap[nodeId];
// 递归处理子节点
for (const child of node[config.childrenList]) {
adaptToChildrenList(child);
}
}
};
// 3. 从根节点开始构建完整树
for (const rootNode of tree) {
adaptToChildrenList(rootNode);
}
function adaptToChildrenList(o) {
if (childrenListMap[o[config.id]] !== null) {
o[config.childrenList] = childrenListMap[o[config.id]]
}
if (o[config.childrenList]) {
for (const c of o[config.childrenList]) {
adaptToChildrenList(c)
}
}
}
return tree
return tree;
}

View File

@ -2,24 +2,102 @@
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, ref } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { AuthenticationCodeLogin, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { type AuthApi, sendSmsCode } from '#/api';
import { useAppConfig } from '@vben/hooks';
import { message } from 'ant-design-vue';
import { getTenantSimpleList, getTenantByWebsite } from '#/api/core/auth';
import { useAccessStore } from '@vben/stores';
import { useAuthStore } from '#/store';
const { tenantEnable } = useAppConfig(import.meta.env, import.meta.env.PROD);
defineOptions({ name: 'CodeLogin' });
const authStore = useAuthStore();
const accessStore = useAccessStore();
const loading = ref(false);
const CODE_LENGTH = 6;
const CODE_LENGTH = 4;
const loginRef = ref();
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); //
const fetchTenantList = async () => {
if (!tenantEnable) {
return;
}
try {
//
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// > store >
let tenantId: number | null = 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);
} 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,
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z
.number()
.nullable()
.refine((val) => val != null && val > 0, $t('authentication.tenantTip'))
.default(null),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(values.tenantId);
}
},
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.mobile'),
},
fieldName: 'phoneNumber',
fieldName: 'mobile',
label: $t('authentication.mobile'),
rules: z
.string()
@ -40,6 +118,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 +156,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 { computed, ref, onMounted, h } from 'vue';
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { type AuthApi, sendSmsCode, smsResetPassword } from '#/api';
import { useAppConfig } from '@vben/hooks';
import { message } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import { getTenantSimpleList, getTenantByWebsite } from '#/api/core/auth';
import { useAccessStore } from '@vben/stores';
defineOptions({ name: 'ForgetPassword' });
const { tenantEnable } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
const router = useRouter();
const loading = ref(false);
const CODE_LENGTH = 4;
const forgetPasswordRef = ref();
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); //
const fetchTenantList = async () => {
if (!tenantEnable) {
return;
}
try {
//
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// > store >
let tenantId: number | null = 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);
} 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,
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z
.number()
.nullable()
.refine((val) => val != null && val > 0, $t('authentication.tenantTip'))
.default(null),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(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

@ -2,7 +2,7 @@
import type { VbenFormSchema } from '@vben/common-ui';
import { type AuthApi, checkCaptcha, getCaptcha } from '#/api/core/auth';
import { computed, markRaw, onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
@ -23,9 +23,9 @@ const loginRef = ref();
const verifyRef = ref();
const captchaType = 'blockPuzzle'; // 'blockPuzzle' | 'clickWord'
const tenantList = ref<AuthApi.TenantResult[]>([]); //
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); //
const fetchTenantList = async () => {
if (!tenantEnable) {
return;
@ -67,13 +67,13 @@ const handleLogin = async (values: any) => {
}
//
await authStore.authLogin(values);
await authStore.authLogin('username', values);
}
/** 验证码通过,执行登录 */
const handleVerifySuccess = async ({ captchaVerification }: any) => {
try {
await authStore.authLogin({
await authStore.authLogin('username', {
...(await loginRef.value.getFormApi().getValues()),
captchaVerification,
});

View File

@ -1,18 +1,120 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { type AuthApi, checkCaptcha, getCaptcha } from '#/api/core/auth';
import { computed, h, ref } from 'vue';
import { computed, h, onMounted, ref } from 'vue';
import { AuthenticationRegister, z } from '@vben/common-ui';
import { AuthenticationRegister, Verification, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { useAppConfig } from '@vben/hooks';
import { useAccessStore } from '@vben/stores';
import { useAuthStore } from '#/store';
import { getTenantSimpleList, getTenantByWebsite } from '#/api/core/auth';
const { tenantEnable, captchaEnable } = useAppConfig(import.meta.env, import.meta.env.PROD);
defineOptions({ name: 'Register' });
const loading = ref(false);
const registerRef = ref();
const verifyRef = ref();
const accessStore = useAccessStore();
const authStore = useAuthStore();
const captchaType = 'blockPuzzle'; // 'blockPuzzle' | 'clickWord'
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); //
const fetchTenantList = async () => {
if (!tenantEnable) {
return;
}
try {
//
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// > store >
let tenantId: number | null = 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);
} catch (error) {
console.error('获取租户列表失败:', error);
}
};
/** 执行注册 */
const handleRegister = async (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,
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z
.number()
.nullable()
.refine((val) => val != null && val > 0, $t('authentication.tenantTip'))
.default(null),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(values.tenantId);
}
},
},
},
{
component: 'VbenInput',
componentProps: {
@ -22,6 +124,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: {
@ -80,17 +191,25 @@ const formSchema = computed((): VbenFormSchema[] => {
},
];
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('register submit:', value);
}
</script>
<template>
<div>
<AuthenticationRegister
ref="registerRef"
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
@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

@ -164,7 +164,7 @@ export function useGridColumns<T = InfraApiAccessLogApi.SystemApiAccessLog>(
name: 'CellOperation',
options: [
{
code: 'view',
code: 'detail',
text: '详情',
show: hasAccessByCodes(['infra:api-access-log:query']),
},

View File

@ -32,7 +32,7 @@ async function onExport() {
}
/** 查看 API 访问日志详情 */
function onView(row: InfraApiAccessLogApi.SystemApiAccessLog) {
function onDetail(row: InfraApiAccessLogApi.SystemApiAccessLog) {
detailModalApi.setData(row).open();
}
@ -42,8 +42,8 @@ function onActionClick({
row,
}: OnActionClickParams<InfraApiAccessLogApi.SystemApiAccessLog>) {
switch (code) {
case 'view': {
onView(row);
case 'detail': {
onDetail(row);
break;
}
}

View File

@ -137,7 +137,7 @@ export function useGridColumns<T = InfraApiErrorLogApi.SystemApiErrorLog>(
name: 'CellOperation',
options: [
{
code: 'view',
code: 'detail',
text: '详情',
show: hasAccessByCodes(['infra:api-error-log:query']),
},

View File

@ -33,7 +33,7 @@ async function onExport() {
}
/** 查看 API 错误日志详情 */
function onView(row: InfraApiErrorLogApi.SystemApiErrorLog) {
function onDetail(row: InfraApiErrorLogApi.SystemApiErrorLog) {
detailModalApi.setData(row).open();
}
@ -49,7 +49,7 @@ async function onProcess(id: number, processStatus: number) {
content: $t('ui.actionMessage.operationSuccess'),
key: 'action_process_msg',
});
await onRefresh();
onRefresh();
}
});
}
@ -60,8 +60,8 @@ function onActionClick({
row,
}: OnActionClickParams<InfraApiErrorLogApi.SystemApiErrorLog>) {
switch (code) {
case 'view': {
onView(row);
case 'detail': {
onDetail(row);
break;
}
case 'done': {

View File

@ -1,4 +1,4 @@
import { type VbenFormSchema, z } from '#/adapter/form';
import { type VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraFileApi } from '#/api/infra/file';
@ -7,6 +7,21 @@ import { getRangePickerDefaultProps } from '#/utils/date';
const { hasAccessByCodes } = useAccess();
/** 表单的字段 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'file',
label: '文件上传',
component: 'Upload',
componentProps: {
placeholder: '请选择要上传的文件',
},
rules: 'required',
}
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [

View File

@ -4,7 +4,7 @@ import type { InfraFileApi } from '#/api/infra/file';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, message, Image } from 'ant-design-vue';
import { Plus } from '@vben/icons';
import { Upload } from '@vben/icons';
import Form from './modules/form.vue';
import { $t } from '#/locales';
@ -123,8 +123,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
<Grid table-title="">
<template #toolbar-tools>
<Button type="primary" @click="onUpload">
<Plus class="size-5" />
{{ $t('ui.actionTitle.upload', ['文件']) }}
<Upload class="size-5" />
上传图片
</Button>
</template>
<template #file-content="{ row }">

View File

@ -1,96 +1,81 @@
<script lang="ts" setup>
import type { InfraFileApi } from '#/api/infra/file';
import type { FileType } from 'ant-design-vue/es/upload/interface';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Upload } from 'ant-design-vue';
import { computed, ref } from 'vue';
import { $t } from '#/locales';
import { useVbenForm } from '#/adapter/form';
import { uploadFile } from '#/api/infra/file';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const fileList = ref<any[]>([]);
const uploadData = ref({ path: '' });
//
const formSchema = [
{
fieldName: 'file',
component: 'Upload',
label: '文件上传',
componentProps: {
fileList: fileList.value,
name: 'file',
maxCount: 1,
accept: '.jpg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx',
beforeUpload: (file: File) => {
uploadData.value.path = file.name;
return false; //
},
onChange: ({ fileList }: any) => {
fileList.value = fileList;
},
},
rules: 'required',
},
];
//
const [Form, formApi] = useVbenForm({
layout: 'horizontal',
schema: formSchema,
schema: useFormSchema().map(item => ({ ...item, label: '' })), // label
showDefaultActions: false,
commonConfig: {
hideLabel: true,
}
});
//
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
if (fileList.value.length === 0) {
message.error('请上传文件');
return;
}
modalApi.lock();
//
const data = await formApi.getValues();
try {
const formData = new FormData();
formData.append('file', fileList.value[0].originFileObj);
formData.append('path', uploadData.value.path);
await uploadFile(formData);
await uploadFile(data);
//
await modalApi.close();
emit('success');
message.success({
content: $t('ui.actionMessage.uploadSuccess'),
content: $t('ui.actionMessage.operationSuccess'),
key: 'action_process_msg',
});
} finally {
modalApi.lock(false);
fileList.value = [];
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
//
fileList.value = [];
uploadData.value = { path: '' };
},
});
const getTitle = computed(() => $t('ui.actionTitle.upload', ['文件']));
/** 上传前 */
function beforeUpload(file: FileType) {
formApi.setFieldValue('file', file);
return false;
}
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
<Modal title="上传图片">
<Form class="mx-4">
<template #file>
<div class="w-full">
<!-- 上传区域 -->
<Upload.Dragger
name="file"
:max-count="1"
accept=".jpg,.png,.gif,.webp"
:beforeUpload="beforeUpload"
list-type="picture-card"
>
<p class="ant-upload-drag-icon">
<span class="icon-[ant-design--inbox-outlined] text-2xl"></span>
</p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">
支持 .jpg.png.gif.webp 格式图片文件
</p>
</Upload.Dragger>
</div>
</template>
</Form>
</Modal>
</template>

View File

@ -1,4 +1,4 @@
import { type VbenFormSchema, z } from '#/adapter/form';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraFileConfigApi } from '#/api/infra/file-config';

View File

@ -0,0 +1,222 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraJobApi } from '#/api/infra/job';
import { DICT_TYPE, getDictOptions } from '#/utils/dict';
import { useAccess } from '@vben/access';
import { InfraJobStatusEnum } from '#/utils/constants';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '任务名称',
component: 'Input',
componentProps: {
placeholder: '请输入任务名称',
},
rules: 'required',
},
{
fieldName: 'handlerName',
label: '处理器的名字',
component: 'Input',
componentProps: {
placeholder: '请输入处理器的名字',
// readonly: ({ values }) => !!values.id,
},
rules: 'required',
// TODO @芋艿:在修改场景下,禁止调整
},
{
fieldName: 'handlerParam',
label: '处理器的参数',
component: 'Input',
componentProps: {
placeholder: '请输入处理器的参数',
},
},
{
fieldName: 'cronExpression',
label: 'CRON 表达式',
component: 'Input',
componentProps: {
placeholder: '请输入 CRON 表达式',
},
rules: 'required',
// TODO @芋艿:未来支持动态的 CRON 表达式选择
},
{
fieldName: 'retryCount',
label: '重试次数',
component: 'InputNumber',
componentProps: {
placeholder: '请输入重试次数。设置为 0 时,不进行重试',
min: 0,
class: 'w-full',
},
rules: 'required',
},
{
fieldName: 'retryInterval',
label: '重试间隔',
component: 'InputNumber',
componentProps: {
placeholder: '请输入重试间隔,单位:毫秒。设置为 0 时,无需间隔',
min: 0,
class: 'w-full'
},
rules: 'required',
},
{
fieldName: 'monitorTimeout',
label: '监控超时时间',
component: 'InputNumber',
componentProps: {
placeholder: '请输入监控超时时间,单位:毫秒',
min: 0,
class: 'w-full',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '任务名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入任务名称',
},
},
{
fieldName: 'status',
label: '任务状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_JOB_STATUS, 'number'),
allowClear: true,
placeholder: '请选择任务状态',
},
},
{
fieldName: 'handlerName',
label: '处理器的名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入处理器的名字',
},
},
];
}
/** 表格列配置 */
export function useGridColumns<T = InfraJobApi.InfraJob>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '任务编号',
minWidth: 80,
},
{
field: 'name',
title: '任务名称',
minWidth: 120,
},
{
field: 'status',
title: '任务状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_JOB_STATUS, },
},
},
{
field: 'handlerName',
title: '处理器的名字',
minWidth: 180,
},
{
field: 'handlerParam',
title: '处理器的参数',
minWidth: 140,
},
{
field: 'cronExpression',
title: 'CRON 表达式',
minWidth: 120,
},
{
field: 'operation',
title: '操作',
width: 280,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '任务',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:job:update']),
},
{
code: 'update-status',
text: '开启',
show: (row: any) => hasAccessByCodes(['infra:job:update'])
&& row.status === InfraJobStatusEnum.STOP,
},
{
code: 'update-status',
text: '暂停',
show: (row: any) => hasAccessByCodes(['infra:job:update'])
&& row.status == InfraJobStatusEnum.NORMAL,
},
{
code: 'trigger',
text: '执行',
show: hasAccessByCodes(['infra:job:trigger']),
},
// TODO @芋艿:增加一个“更多”选项
{
code: 'detail',
text: '详细',
show: hasAccessByCodes(['infra:job:query']),
},
{
code: 'log',
text: '日志',
show: hasAccessByCodes(['infra:job:query']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:job:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,207 @@
<script lang="ts" setup>
import type { OnActionClickParams, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraJobApi } from '#/api/infra/job';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus, History } from '@vben/icons';
import { Button, message, Modal } from 'ant-design-vue';
import Form from './modules/form.vue';
import Detail from './modules/detail.vue';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { useRouter } from 'vue-router';
import { InfraJobStatusEnum} from '#/utils/constants';
import { deleteJob, exportJob, getJobPage, runJob, updateJobStatus } from '#/api/infra/job';
import { downloadByData } from '#/utils/download';
import { useGridColumns, useGridFormSchema } from './data';
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportJob(await gridApi.formApi.getValues());
downloadByData(data, '定时任务.xls');
}
/** 创建任务 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑任务 */
function onEdit(row: InfraJobApi.InfraJob) {
formModalApi.setData(row).open();
}
/** 查看任务详情 */
function onDetail(row: InfraJobApi.InfraJob) {
detailModalApi.setData({ id: row.id }).open();
}
/** 更新任务状态 */
async function onUpdateStatus(row: InfraJobApi.InfraJob) {
const status = row.status === InfraJobStatusEnum.STOP ? InfraJobStatusEnum.NORMAL : InfraJobStatusEnum.STOP;
const statusText = status === InfraJobStatusEnum.NORMAL ? '启用' : '停用';
Modal.confirm({
title: '确认操作',
content: `确定${statusText} ${row.name} 吗?`,
onOk: async () => {
await updateJobStatus(row.id as number, status);
message.success({
content: $t('ui.actionMessage.operationSuccess'),
key: 'action_process_msg',
});
onRefresh();
}
});
}
/** 执行一次任务 */
async function onTrigger(row: InfraJobApi.InfraJob) {
Modal.confirm({
title: '确认操作',
content: `确定执行一次 ${row.name} 吗?`,
onOk: async () => {
await runJob(row.id as number);
message.success({
content: $t('ui.actionMessage.operationSuccess'),
key: 'action_process_msg',
});
}
});
}
/** 跳转到任务日志 */
function onLog(row?: InfraJobApi.InfraJob) {
push({
name: 'InfraJobLog',
query: row?.id ? { id: row.id } : {},
});
}
/** 删除任务 */
async function onDelete(row: InfraJobApi.InfraJob) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteJob(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraJobApi.InfraJob>) {
switch (code) {
case 'edit': {
onEdit(row);
break;
}
case 'update-status': {
onUpdateStatus(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
case 'trigger': {
onTrigger(row);
break;
}
case 'detail': {
onDetail(row);
break;
}
case 'log': {
onLog(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getJobPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraJobApi.InfraJob>,
});
</script>
<template>
<Page auto-content-height>
<DocAlert title="定时任务" url="https://doc.iocoder.cn/job/" />
<DocAlert title="异步任务" url="https://doc.iocoder.cn/async-task/" />
<DocAlert title="消息队列" url="https://doc.iocoder.cn/message-queue/" />
<FormModal @success="onRefresh" />
<DetailModal />
<Grid table-title="">
<template #toolbar-tools>
<Button type="primary" @click="onCreate" v-access:code="['infra:job:create']">
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['任务']) }}
</Button>
<Button type="primary" class="ml-2" @click="onExport" v-access:code="['infra:job:export']">
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
<Button type="primary" class="ml-2" @click="onLog(undefined)" v-access:code="['infra:job:query']">
<History class="size-5" />
执行日志
</Button>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,143 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraJobLogApi } from '#/api/infra/job-log';
import { DICT_TYPE, getDictOptions } from '#/utils/dict';
import { useAccess } from '@vben/access';
import dayjs from 'dayjs';
import { formatDateTime } from '@vben/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'handlerName',
label: '处理器的名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入处理器的名字',
},
},
{
fieldName: 'beginTime',
label: '开始执行时间',
component: 'DatePicker',
componentProps: {
allowClear: true,
placeholder: '选择开始执行时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
showTime: {
format: 'HH:mm:ss',
defaultValue: dayjs('00:00:00', 'HH:mm:ss'),
},
},
},
{
fieldName: 'endTime',
label: '结束执行时间',
component: 'DatePicker',
componentProps: {
allowClear: true,
placeholder: '选择结束执行时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
showTime: {
format: 'HH:mm:ss',
defaultValue: dayjs('23:59:59', 'HH:mm:ss'),
},
},
},
{
fieldName: 'status',
label: '任务状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_JOB_LOG_STATUS, 'number'),
allowClear: true,
placeholder: '请选择任务状态',
},
},
];
}
/** 表格列配置 */
export function useGridColumns<T = InfraJobLogApi.InfraJobLog>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 80,
},
{
field: 'jobId',
title: '任务编号',
minWidth: 80,
},
{
field: 'handlerName',
title: '处理器的名字',
minWidth: 180,
},
{
field: 'handlerParam',
title: '处理器的参数',
minWidth: 140,
},
{
field: 'executeIndex',
title: '第几次执行',
minWidth: 100,
},
{
field: 'beginTime',
title: '执行时间',
minWidth: 280,
formatter: ({ row }) => {
return `${formatDateTime(row.beginTime)} ~ ${formatDateTime(row.endTime)}`;
},
},
{
field: 'duration',
title: '执行时长',
minWidth: 120,
formatter: ({ row }) => {
return `${row.duration} 毫秒`;
},
},
{
field: 'status',
title: '任务状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_JOB_LOG_STATUS },
},
},
{
field: 'operation',
title: '操作',
width: 80,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '日志',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '详细',
show: hasAccessByCodes(['infra:job:query']),
},
],
},
},
];
}

View File

@ -0,0 +1,100 @@
<script lang="ts" setup>
import type { OnActionClickParams, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraJobLogApi } from '#/api/infra/job-log';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download } from '@vben/icons';
import { Button } from 'ant-design-vue';
import Detail from './modules/detail.vue';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { useRoute } from 'vue-router';
import { exportJobLog, getJobLogPage } from '#/api/infra/job-log';
import { downloadByData } from '#/utils/download';
import { useGridColumns, useGridFormSchema } from './data';
const { query } = useRoute();
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 导出表格 */
async function onExport() {
const data = await exportJobLog(await gridApi.formApi.getValues());
downloadByData(data, '任务日志.xls');
}
/** 查看日志详情 */
function onDetail(row: InfraJobLogApi.InfraJobLog) {
detailModalApi.setData({ id: row.id }).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraJobLogApi.InfraJobLog>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
}
}
// schemajobId
const formSchema = useGridFormSchema();
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: formSchema,
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getJobLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
jobId: query.id,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraJobLogApi.InfraJobLog>,
});
</script>
<template>
<Page auto-content-height>
<DocAlert title="定时任务" url="https://doc.iocoder.cn/job/" />
<DocAlert title="异步任务" url="https://doc.iocoder.cn/async-task/" />
<DocAlert title="消息队列" url="https://doc.iocoder.cn/message-queue/" />
<DetailModal />
<Grid table-title="">
<template #toolbar-tools>
<Button type="primary" class="ml-2" @click="onExport" v-access:code="['infra:job:export']">
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,68 @@
<script lang="ts" setup>
import type { InfraJobLogApi } from '#/api/infra/job-log';
import { Descriptions } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '#/utils/dict';
import { formatDateTime } from '@vben/utils';
import { getJobLog } from '#/api/infra/job-log';
import { ref } from 'vue';
const formData = ref<InfraJobLogApi.InfraJobLog>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
//
const data = modalApi.getData<{ id: number }>();
if (!data?.id) {
return;
}
modalApi.lock();
try {
formData.value = await getJobLog(data.id);
} finally {
modalApi.lock(false);
}
},
});
</script>
<template>
<Modal title="日志详情" class="w-1/2" :show-cancel-button="false" :show-confirm-button="false">
<Descriptions :column="1" bordered size="middle" class="mx-4" :label-style="{ width: '140px' }">
<Descriptions.Item label="日志编号">
{{ formData?.id }}
</Descriptions.Item>
<Descriptions.Item label="任务编号">
{{ formData?.jobId }}
</Descriptions.Item>
<Descriptions.Item label="处理器的名字">
{{ formData?.handlerName }}
</Descriptions.Item>
<Descriptions.Item label="处理器的参数">
{{ formData?.handlerParam }}
</Descriptions.Item>
<Descriptions.Item label="第几次执行">
{{ formData?.executeIndex }}
</Descriptions.Item>
<Descriptions.Item label="执行时间">
{{ formData?.beginTime ? formatDateTime(formData.beginTime) : '' }} ~
{{ formData?.endTime ? formatDateTime(formData.endTime) : '' }}
</Descriptions.Item>
<Descriptions.Item label="执行时长">
{{ formData?.duration ? formData.duration + ' 毫秒' : '' }}
</Descriptions.Item>
<Descriptions.Item label="任务状态">
<DictTag :type="DICT_TYPE.INFRA_JOB_LOG_STATUS" :value="formData?.status" />
</Descriptions.Item>
<Descriptions.Item label="执行结果">
{{ formData?.result }}
</Descriptions.Item>
</Descriptions>
</Modal>
</template>

View File

@ -0,0 +1,77 @@
<script lang="ts" setup>
import type { InfraJobApi } from '#/api/infra/job';
import { Descriptions, Timeline } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '#/utils/dict';
import { formatDateTime } from '@vben/utils';
import { getJob, getJobNextTimes } from '#/api/infra/job';
import { ref } from 'vue';
const formData = ref<InfraJobApi.InfraJob>(); //
const nextTimes = ref<Date[]>([]); //
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
//
const data = modalApi.getData<{ id: number }>();
if (!data?.id) {
return;
}
modalApi.lock();
try {
formData.value = await getJob(data.id);
//
nextTimes.value = await getJobNextTimes(data.id);
} finally {
modalApi.lock(false);
}
},
});
</script>
<template>
<Modal title="任务详情" class="w-1/2" :show-cancel-button="false" :show-confirm-button="false">
<Descriptions :column="1" bordered size="middle" class="mx-4" :label-style="{ width: '140px' }">
<Descriptions.Item label="任务编号">
{{ formData?.id }}
</Descriptions.Item>
<Descriptions.Item label="任务名称">
{{ formData?.name }}
</Descriptions.Item>
<Descriptions.Item label="任务状态">
<DictTag :type="DICT_TYPE.INFRA_JOB_STATUS" :value="formData?.status" />
</Descriptions.Item>
<Descriptions.Item label="处理器的名字">
{{ formData?.handlerName }}
</Descriptions.Item>
<Descriptions.Item label="处理器的参数">
{{ formData?.handlerParam }}
</Descriptions.Item>
<Descriptions.Item label="Cron 表达式">
{{ formData?.cronExpression }}
</Descriptions.Item>
<Descriptions.Item label="重试次数">
{{ formData?.retryCount }}
</Descriptions.Item>
<Descriptions.Item label="重试间隔">
{{ formData?.retryInterval ? formData.retryInterval + ' 毫秒' : '无间隔' }}
</Descriptions.Item>
<Descriptions.Item label="监控超时时间">
{{ formData?.monitorTimeout && formData.monitorTimeout > 0 ? formData.monitorTimeout + ' 毫秒' : '未开启' }}
</Descriptions.Item>
<Descriptions.Item label="后续执行时间">
<Timeline class="h-[180px]">
<Timeline.Item v-for="(nextTime, index) in nextTimes" :key="index" color="blue">
{{ index + 1 }} {{ formatDateTime(nextTime.toString()) }}
</Timeline.Item>
</Timeline>
</Descriptions.Item>
</Descriptions>
</Modal>
</template>

View File

@ -0,0 +1,80 @@
<script lang="ts" setup>
import type { InfraJobApi } from '#/api/infra/job';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { $t } from '#/locales';
import { computed, ref } from 'vue';
import { useVbenForm } from '#/adapter/form';
import { createJob, getJob, updateJob } from '#/api/infra/job';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<InfraJobApi.InfraJob>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['任务'])
: $t('ui.actionTitle.create', ['任务']);
});
const [Form, formApi] = useVbenForm({
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
commonConfig: {
labelWidth: 140
}
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as InfraJobApi.InfraJob;
try {
await (formData.value?.id
? updateJob(data)
: createJob(data));
//
await modalApi.close();
emit('success');
message.success({
content: $t('ui.actionMessage.operationSuccess'),
key: 'action_process_msg',
});
} finally {
modalApi.lock(false);
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
//
const data = modalApi.getData<InfraJobApi.InfraJob>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getJob(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.lock(false);
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,49 @@
<script lang="ts" setup>
import type { InfraRedisApi } from '#/api/infra/redis';
import { Card } from 'ant-design-vue';
import { Page } from '@vben/common-ui';
import Memory from './modules/memory.vue';
import Commands from './modules/commands.vue';
import Info from './modules/info.vue';
import { DocAlert } from '#/components/doc-alert';
import { onMounted, ref } from 'vue';
import { getRedisMonitorInfo } from '#/api/infra/redis';
const redisData = ref<InfraRedisApi.InfraRedisMonitorInfo>();
/** 统一加载 Redis 数据 */
const loadRedisData = async () => {
try {
redisData.value = await getRedisMonitorInfo();
} catch (error) {
console.error('加载 Redis 数据失败', error);
}
};
onMounted(() => {
loadRedisData();
});
</script>
<template>
<Page auto-content-height>
<DocAlert title="Redis 缓存" url="https://doc.iocoder.cn/redis-cache/" />
<DocAlert title="本地缓存" url="https://doc.iocoder.cn/local-cache/" />
<Card class="mt-5" title="Redis 概览">
<Info :redis-data="redisData" />
</Card>
<div class="mt-5 grid grid-cols-1 md:grid-cols-2 gap-4">
<Card title="内存使用">
<Memory :redis-data="redisData" />
</Card>
<Card title="命令统计">
<Commands :redis-data="redisData" />
</Card>
</div>
</Page>
</template>

View File

@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { InfraRedisApi } from '#/api/infra/redis';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { onMounted, ref, watch } from 'vue';
const props = defineProps<{
redisData?: InfraRedisApi.InfraRedisMonitorInfo;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** 渲染命令统计图表 */
const renderCommandStats = () => {
if (!props.redisData?.commandStats) {
return;
}
//
const commandStats = [] as any[];
const nameList = [] as string[];
props.redisData.commandStats.forEach((row) => {
commandStats.push({
name: row.command,
value: row.calls
});
nameList.push(row.command);
});
//
renderEcharts({
title: {
text: '命令统计',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)'
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 30,
top: 10,
bottom: 20,
data: nameList,
textStyle: {
color: '#a1a1a1'
}
},
series: [
{
name: '命令',
type: 'pie',
radius: [20, 120],
center: ['40%', '60%'],
data: commandStats,
roseType: 'radius',
label: {
show: true
},
emphasis: {
label: {
show: true
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
});
};
/** 监听数据变化,重新渲染图表 */
watch(() => props.redisData, (newVal) => {
if (newVal) {
renderCommandStats();
}
}, { deep: true });
onMounted(() => {
if (props.redisData) {
renderCommandStats();
}
});
</script>
<template>
<div>
<EchartsUI ref="chartRef" height="420px" />
</div>
</template>

View File

@ -0,0 +1,51 @@
<script lang="ts" setup>
import { type InfraRedisApi } from '#/api/infra/redis';
import { Descriptions } from 'ant-design-vue';
defineProps<{
redisData?: InfraRedisApi.InfraRedisMonitorInfo;
}>();
</script>
<template>
<Descriptions :column="6" bordered size="middle" :label-style="{ width: '138px' }">
<Descriptions.Item label="Redis 版本">
{{ redisData?.info?.redis_version }}
</Descriptions.Item>
<Descriptions.Item label="运行模式">
{{ redisData?.info?.redis_mode == 'standalone' ? '单机' : '集群' }}
</Descriptions.Item>
<Descriptions.Item label="端口">
{{ redisData?.info?.tcp_port }}
</Descriptions.Item>
<Descriptions.Item label="客户端数">
{{ redisData?.info?.connected_clients }}
</Descriptions.Item>
<Descriptions.Item label="运行时间(天)">
{{ redisData?.info?.uptime_in_days }}
</Descriptions.Item>
<Descriptions.Item label="使用内存">
{{ redisData?.info?.used_memory_human }}
</Descriptions.Item>
<Descriptions.Item label="使用 CPU">
{{ redisData?.info ? parseFloat(redisData?.info?.used_cpu_user_children).toFixed(2) : '' }}
</Descriptions.Item>
<Descriptions.Item label="内存配置">
{{ redisData?.info?.maxmemory_human }}
</Descriptions.Item>
<Descriptions.Item label="AOF 是否开启">
{{ redisData?.info?.aof_enabled == '0' ? '否' : '是' }}
</Descriptions.Item>
<Descriptions.Item label="RDB 是否成功">
{{ redisData?.info?.rdb_last_bgsave_status }}
</Descriptions.Item>
<Descriptions.Item label="Key 数量">
{{ redisData?.dbSize }}
</Descriptions.Item>
<Descriptions.Item label="网络入口/出口">
{{ redisData?.info?.instantaneous_input_kbps }}kps /
{{ redisData?.info?.instantaneous_output_kbps }}kps
</Descriptions.Item>
</Descriptions>
</template>

View File

@ -0,0 +1,132 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { InfraRedisApi } from '#/api/infra/redis';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { onMounted, ref, watch } from 'vue';
const props = defineProps<{
redisData?: InfraRedisApi.InfraRedisMonitorInfo;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** 解析内存值,移除单位,转为数字 */
const parseMemoryValue = (memStr: string | undefined): number => {
if (!memStr) {
return 0;
}
try {
// "1.2M" 1.2
const str = String(memStr); //
const match = str.match(/^([\d.]+)/);
return match ? parseFloat(match[1] as string) : 0;
} catch (e) {
return 0;
}
};
/** 渲染内存使用图表 */
const renderMemoryChart = () => {
if (!props.redisData?.info) {
return;
}
//
const usedMemory = props.redisData.info.used_memory_human || '0';
const memoryValue = parseMemoryValue(usedMemory);
//
renderEcharts({
title: {
text: '内存使用情况',
left: 'center',
},
tooltip: {
formatter: '{b} <br/>{a} : ' + usedMemory
},
series: [
{
name: '峰值',
type: 'gauge',
min: 0,
max: 100,
splitNumber: 10,
color: '#F5C74E',
radius: '85%',
center: ['50%', '50%'],
startAngle: 225,
endAngle: -45,
axisLine: {
lineStyle: {
color: [
[0.2, '#7FFF00'],
[0.8, '#00FFFF'],
[1, '#FF0000']
],
width: 10
}
},
axisTick: {
length: 5,
lineStyle: {
color: '#76D9D7'
}
},
splitLine: {
length: 20,
lineStyle: {
color: '#76D9D7'
}
},
axisLabel: {
color: '#76D9D7',
distance: 15,
fontSize: 15
},
pointer: {
width: 7,
show: true
},
detail: {
show: true,
offsetCenter: [0, '50%'],
color: 'auto',
fontSize: 30,
formatter: usedMemory
},
progress: {
show: true
},
data: [
{
value: memoryValue,
name: '内存消耗'
}
]
}
]
});
};
/** 监听数据变化,重新渲染图表 */
watch(() => props.redisData, (newVal) => {
if (newVal) {
renderMemoryChart();
}
}, { deep: true });
onMounted(() => {
if (props.redisData) {
renderMemoryChart();
}
});
</script>
<template>
<div>
<EchartsUI ref="chartRef" height="420px" />
</div>
</template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui'
import { DocAlert } from '#/components/doc-alert'
import { IFrame } from '#/components/iframe'
import { DocAlert } from '#/components/doc-alert'
import { ref, onMounted } from 'vue'
import { getConfigKey } from '#/api/infra/config'

View File

@ -0,0 +1,269 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user'
import { message } from 'ant-design-vue'
import { Card, Tag, Divider, Input, Button, Select, Avatar, Empty, Badge } from 'ant-design-vue'
import { Page } from '@vben/common-ui'
import { DocAlert } from '#/components/doc-alert'
import { formatDate } from '@vben/utils'
import { useWebSocket } from '@vueuse/core'
import { getSimpleUserList } from '#/api/system/user'
import { ref, computed, watchEffect, onMounted } from 'vue'
import { useAccessStore } from '@vben/stores'
const accessStore = useAccessStore()
const refreshToken = accessStore.refreshToken as string
const server = ref(
(import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') +
'?token=' +
refreshToken // 使 refreshToken使 accessToken WebSocket 便访
) // WebSocket
const getIsOpen = computed(() => status.value === 'OPEN') // WebSocket
const getTagColor = computed(() => (getIsOpen.value ? 'success' : 'red')) // WebSocket
const getStatusText = computed(() => (getIsOpen.value ? '已连接' : '未连接')) //
/** 发起 WebSocket 连接 */
const { status, data, send, close, open } = useWebSocket(server.value, {
autoReconnect: true,
heartbeat: true
})
/** 监听接收到的数据 */
const messageList = ref([] as { time: number; text: string; type?: string; userId?: string }[]) //
const messageReverseList = computed(() => messageList.value.slice().reverse())
watchEffect(() => {
if (!data.value) {
return
}
try {
// 1.
if (data.value === 'pong') {
// state.recordList.push({
// text: '',
// time: new Date().getTime()
// })
return
}
// 2.1 type
const jsonMessage = JSON.parse(data.value)
const type = jsonMessage.type
const content = JSON.parse(jsonMessage.content)
if (!type) {
message.error('未知的消息类型:' + data.value)
return
}
// 2.2 demo-message-receive
if (type === 'demo-message-receive') {
const single = content.single
messageList.value.push({
text: content.text,
time: new Date().getTime(),
type: single ? 'single' : 'group',
userId: content.fromUserId
})
return
}
// 2.3 notice-push
if (type === 'notice-push') {
messageList.value.push({
text: content.title,
time: new Date().getTime(),
type: 'system'
})
return
}
message.error('未处理消息:' + data.value)
} catch (error) {
message.error('处理消息发生异常:' + data.value)
console.error(error)
}
})
/** 发送消息 */
const sendText = ref('') //
const sendUserId = ref('') //
const handlerSend = () => {
if (!sendText.value.trim()) {
message.warning('消息内容不能为空')
return
}
// 1.1 JSON message
const messageContent = JSON.stringify({
text: sendText.value,
toUserId: sendUserId.value
})
// 1.2 JSON
const jsonMessage = JSON.stringify({
type: 'demo-message-send',
content: messageContent
})
// 2.
send(jsonMessage)
sendText.value = ''
}
/** 切换 websocket 连接状态 */
const toggleConnectStatus = () => {
if (getIsOpen.value) {
close()
} else {
open()
}
}
/** 获取消息类型的徽标颜色 */
const getMessageBadgeColor = (type?: string) => {
switch (type) {
case 'single': return 'blue'
case 'group': return 'green'
case 'system': return 'red'
default: return 'default'
}
}
/** 获取消息类型的文本 */
const getMessageTypeText = (type?: string) => {
switch (type) {
case 'single': return '单发'
case 'group': return '群发'
case 'system': return '系统'
default: return '未知'
}
}
/** 初始化 **/
const userList = ref<SystemUserApi.SystemUser[]>([]) //
onMounted(async () => {
userList.value = await getSimpleUserList()
})
</script>
<template>
<Page>
<DocAlert title="WebSocket 实时通信" url="https://doc.iocoder.cn/websocket/" />
<div class="flex flex-col md:flex-row gap-4 mt-4">
<!-- 左侧建立连接发送消息 -->
<Card :bordered="false" class="w-full md:w-1/2">
<template #title>
<div class="flex items-center">
<Badge :status="getIsOpen ? 'success' : 'error'" />
<span class="ml-2 text-lg font-medium">连接管理</span>
</div>
</template>
<div class="flex items-center mb-4 bg-gray-50 p-3 rounded-lg">
<span class="mr-4 font-medium">连接状态:</span>
<Tag :color="getTagColor" class="px-3 py-1">{{ getStatusText }}</Tag>
</div>
<div class="flex space-x-2 mb-6">
<Input
v-model:value="server"
disabled
class="rounded-md"
size="large">
<template #addonBefore>
<span class="text-gray-600">服务地址</span>
</template>
</Input>
<Button
:type="getIsOpen ? 'default' : 'primary'"
:danger="getIsOpen"
size="large"
class="flex-shrink-0"
@click="toggleConnectStatus"
>
{{ getIsOpen ? '关闭连接' : '开启连接' }}
</Button>
</div>
<Divider>
<span class="text-gray-500">消息发送</span>
</Divider>
<Select
v-model:value="sendUserId"
class="w-full mb-3"
size="large"
placeholder="请选择接收人"
:disabled="!getIsOpen">
<Select.Option key="" value="" label="所有人">
<div class="flex items-center">
<Avatar size="small" class="mr-2"></Avatar>
<span>所有人</span>
</div>
</Select.Option>
<Select.Option
v-for="user in userList"
:key="user.id"
:value="user.id"
:label="user.nickname"
>
<div class="flex items-center">
<Avatar size="small" class="mr-2">{{ user.nickname.slice(0, 1) }}</Avatar>
<span>{{ user.nickname }}</span>
</div>
</Select.Option>
</Select>
<Input.TextArea
v-model:value="sendText"
:auto-size="{ minRows: 3, maxRows: 6 }"
:disabled="!getIsOpen"
class="rounded-lg border-1 border-gray-300"
allowClear
placeholder="请输入你要发送的消息..."
/>
<Button
:disabled="!getIsOpen"
block
class="mt-4"
type="primary"
size="large"
@click="handlerSend">
<template #icon>
<span class="i-ant-design:send-outlined mr-1" />
</template>
发送消息
</Button>
</Card>
<!-- 右侧消息记录 -->
<Card :bordered="false" class="w-full md:w-1/2">
<template #title>
<div class="flex items-center">
<span class="i-ant-design:message-outlined mr-2 text-lg" />
<span class="text-lg font-medium">消息记录</span>
<Tag v-if="messageList.length > 0" class="ml-2">{{ messageList.length }} </Tag>
</div>
</template>
<div class="h-96 overflow-auto p-2 bg-gray-50 rounded-lg">
<Empty v-if="messageList.length === 0" description="暂无消息记录" />
<div v-else class="space-y-3">
<div
v-for="msg in messageReverseList"
:key="msg.time"
class="p-3 bg-white rounded-lg shadow-sm"
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center">
<Badge :color="getMessageBadgeColor(msg.type)" />
<span class="ml-1 text-gray-600 font-medium">{{ getMessageTypeText(msg.type) }}</span>
<span v-if="msg.userId" class="ml-2 text-gray-500"> ID: {{ msg.userId }}</span>
</div>
<span class="text-xs text-gray-400">{{ formatDate(msg.time) }}</span>
</div>
<div class="mt-2 text-gray-800 break-words">
{{ msg.text }}
</div>
</div>
</div>
</div>
</Card>
</div>
</Page>
</template>

View File

@ -105,7 +105,7 @@ export function useGridColumns<T = SystemLoginLogApi.SystemLoginLog>(
name: 'CellOperation',
options: [
{
code: 'view',
code: 'detail',
text: '详情',
show: hasAccessByCodes(['system:login-log:query']),
},

View File

@ -32,7 +32,7 @@ async function onExport() {
}
/** 查看登录日志详情 */
function onView(row: SystemLoginLogApi.SystemLoginLog) {
function onDetail(row: SystemLoginLogApi.SystemLoginLog) {
detailModalApi.setData(row).open();
}
@ -42,8 +42,8 @@ function onActionClick({
row,
}: OnActionClickParams<SystemLoginLogApi.SystemLoginLog>) {
switch (code) {
case 'view': {
onView(row);
case 'detail': {
onDetail(row);
break;
}
}

View File

@ -139,7 +139,7 @@ export function useGridColumns<T = SystemMailLogApi.SystemMailLog>(
name: 'CellOperation',
options: [
{
code: 'view',
code: 'detail',
text: '查看',
show: hasAccessByCodes(['system:mail-log:query']),
}

View File

@ -22,7 +22,7 @@ function onRefresh() {
}
/** 查看邮件日志 */
function onView(row: SystemMailLogApi.SystemMailLog) {
function onDetail(row: SystemMailLogApi.SystemMailLog) {
detailModalApi.setData(row).open();
}
@ -32,8 +32,8 @@ function onActionClick({
row,
}: OnActionClickParams<SystemMailLogApi.SystemMailLog>) {
switch (code) {
case 'view': {
onView(row);
case 'detail': {
onDetail(row);
break;
}
}

View File

@ -154,8 +154,8 @@ export function useGridColumns<T = SystemNotifyMessageApi.SystemNotifyMessage>(
name: 'CellOperation',
options: [
{
code: 'view',
text: '查看',
code: 'detail',
text: '详情',
show: hasAccessByCodes(['system:notify-message:query']),
},
],

View File

@ -22,7 +22,7 @@ function onRefresh() {
}
/** 查看站内信详情 */
function onView(row: SystemNotifyMessageApi.SystemNotifyMessage) {
function onDetail(row: SystemNotifyMessageApi.SystemNotifyMessage) {
detailModalApi.setData(row).open();
}
@ -32,8 +32,8 @@ function onActionClick({
row,
}: OnActionClickParams<SystemNotifyMessageApi.SystemNotifyMessage>) {
switch (code) {
case 'view': {
onView(row);
case 'detail': {
onDetail(row);
break;
}
}

View File

@ -93,8 +93,8 @@ export function useGridColumns<T = SystemNotifyMessageApi.SystemNotifyMessage>(
name: 'CellOperation',
options: [
{
code: 'view',
text: '查看',
code: 'detail',
text: '详情',
show: hasAccessByCodes(['system:notify-message:query']),
},
],

View File

@ -32,7 +32,7 @@ function onRefresh() {
}
/** 查看站内信详情 */
function onView(row: SystemNotifyMessageApi.SystemNotifyMessage) {
function onDetail(row: SystemNotifyMessageApi.SystemNotifyMessage) {
//
if (!row.readStatus) {
handleReadOne(row.id);
@ -102,8 +102,8 @@ function onActionClick({
row,
}: OnActionClickParams<SystemNotifyMessageApi.SystemNotifyMessage>) {
switch (code) {
case 'view': {
onView(row);
case 'detail': {
onDetail(row);
break;
}
}

View File

@ -134,7 +134,7 @@ export function useGridColumns<T = SystemOperateLogApi.SystemOperateLog>(
name: 'CellOperation',
options: [
{
code: 'view',
code: 'detail',
text: '详情',
show: hasAccessByCodes(['system:operate-log:query']),
},

View File

@ -32,7 +32,7 @@ async function onExport() {
}
/** 查看操作日志详情 */
function onView(row: SystemOperateLogApi.SystemOperateLog) {
function onDetail(row: SystemOperateLogApi.SystemOperateLog) {
detailModalApi.setData(row).open();
}
@ -42,8 +42,8 @@ function onActionClick({
row,
}: OnActionClickParams<SystemOperateLogApi.SystemOperateLog>) {
switch (code) {
case 'view': {
onView(row);
case 'detail': {
onDetail(row);
break;
}
}

View File

@ -177,8 +177,8 @@ export function useGridColumns<T = SystemSmsLogApi.SystemSmsLog>(
name: 'CellOperation',
options: [
{
code: 'view',
text: '查看',
code: 'detail',
text: '详情',
show: hasAccessByCodes(['system:sms-log:query']),
},
],

View File

@ -32,7 +32,7 @@ async function onExport() {
}
/** 查看短信日志详情 */
function onView(row: SystemSmsLogApi.SystemSmsLog) {
function onDetail(row: SystemSmsLogApi.SystemSmsLog) {
detailModalApi.setData(row).open();
}
@ -42,8 +42,8 @@ function onActionClick({
row,
}: OnActionClickParams<SystemSmsLogApi.SystemSmsLog>) {
switch (code) {
case 'view': {
onView(row);
case 'detail': {
onDetail(row);
break;
}
}

View File

@ -110,7 +110,7 @@ export function useGridColumns<T = SystemSocialUserApi.SystemSocialUser>(
name: 'CellOperation',
options: [
{
code: 'view',
code: 'detail',
text: '详情',
show: hasAccessByCodes(['system:social-user:query']),
},

View File

@ -21,7 +21,7 @@ function onRefresh() {
}
/** 查看详情 */
function onView(row: SystemSocialUserApi.SystemSocialUser) {
function onDetail(row: SystemSocialUserApi.SystemSocialUser) {
detailModalApi.setData(row).open();
}
@ -31,8 +31,8 @@ function onActionClick({
row,
}: OnActionClickParams<SystemSocialUserApi.SystemSocialUser>) {
switch (code) {
case 'view': {
onView(row);
case 'detail': {
onDetail(row);
break;
}
}

View File

@ -1,11 +1,11 @@
<script lang="ts" setup>
import type { SystemDeptApi } from '#/api/system/dept';
import { type SystemDeptApi} from '#/api/system/dept';
import { Tree, Input, Spin } from 'ant-design-vue';
import { ref, onMounted } from 'vue';
import { Search } from '@vben/icons';
import { getDeptList } from '#/api/system/dept';
import { getSimpleDeptList } from '#/api/system/dept';
import { handleTree } from '#/utils/tree';
const emit = defineEmits(['select']);
@ -36,7 +36,7 @@ const handleSelect = (_selectedKeys: any[], info: any) => {
onMounted(async () => {
try {
loading.value = true;
const data = await getDeptList();
const data = await getSimpleDeptList();
deptList.value = data;
deptTree.value = handleTree(data);
} catch (error) {

View File

@ -67,4 +67,5 @@ export {
X,
Download,
Upload,
History,
} from 'lucide-vue-next';

View File

@ -1,6 +1,6 @@
import dayjs from 'dayjs';
export function formatDate(time: number | string, format = 'YYYY-MM-DD') {
export function formatDate(time: number | string | Date, format = 'YYYY-MM-DD') {
try {
const date = dayjs(time);
if (!date.isValid()) {
@ -13,7 +13,7 @@ export function formatDate(time: number | string, format = 'YYYY-MM-DD') {
}
}
export function formatDateTime(time: number | string) {
export function formatDateTime(time: number | string | Date) {
return formatDate(time, 'YYYY-MM-DD HH:mm:ss');
}

View File

@ -32,16 +32,16 @@
"@vben/types": "workspace:*",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"crypto-js": "catalog:",
"qrcode": "catalog:",
"tippy.js": "catalog:",
"vue": "catalog:",
"vue-json-viewer": "catalog:",
"vue-router": "catalog:",
"vue-tippy": "catalog:",
"crypto-js": "catalog:"
"vue-tippy": "catalog:"
},
"devDependencies": {
"@types/qrcode": "catalog:",
"@types/crypto-js": "catalog:"
"@types/crypto-js": "catalog:",
"@types/qrcode": "catalog:"
}
}

View File

@ -1,8 +1,9 @@
<script lang="ts" setup>
import type { ComponentInternalInstance } from 'vue';
import type { VerificationProps } from '../types';
import {
type ComponentInternalInstance,
getCurrentInstance,
nextTick,
onMounted,
@ -20,44 +21,6 @@ import { resetSize } from '../utils/util';
* VerifyPoints
* @description 点选
*/
// const props = defineProps({
// barSize: {
// default() {
// return {
// height: '40px',
// width: '310px',
// };
// },
// type: Object,
// },
// captchaType: {
// default() {
// return 'VerifyPoints';
// },
// type: String,
// },
// imgSize: {
// default() {
// return {
// height: '155px',
// width: '310px',
// };
// },
// type: Object,
// },
// // popfixed
// mode: {
// default: 'fixed',
// type: String,
// },
// //
// vSpace: {
// default: 5,
// type: Number,
// },
// });
defineOptions({
name: 'VerifyPoints',
});
@ -127,7 +90,7 @@ onMounted(() => {
const canvas = ref(null);
//
const getMousePos = function (obj: any, e: any) {
const getMousePos = function (_obj: any, e: any) {
const x = e.offsetX;
const y = e.offsetY;
return { x, y };
@ -190,7 +153,7 @@ function canvasClick(e: any) {
if (res.repCode === '0000') {
barAreaColor.value = '#4cae4c';
barAreaBorderColor.value = '#5cb85c';
text.value = $t('ui.captcha.success');
text.value = $t('ui.captcha.sliderSuccessText');
bindingClick.value = false;
if (mode.value === 'pop') {
setTimeout(() => {
@ -227,7 +190,7 @@ async function getPictrue() {
backToken.value = res.data.repData.token;
secretKey.value = res.data.repData.secretKey;
poinTextList.value = res.data.repData.wordList;
text.value = `${$t('ui.captcha.point')}${poinTextList.value.join(',')}`;
text.value = `${$t('ui.captcha.clickInOrder')}${poinTextList.value.join(',')}`;
} else {
text.value = res?.data?.repMsg;
}

View File

@ -105,7 +105,7 @@ defineExpose({
@click="handleSubmit"
>
<slot name="submitButtonText">
{{ submitButtonText || $t('authentication.sendResetLink') }}
{{ submitButtonText || $t('authentication.resetPassword') }}
</slot>
</VbenButton>
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">

View File

@ -2,6 +2,7 @@ import type {
// 系列类型的定义后缀都为 SeriesOption
BarSeriesOption,
LineSeriesOption,
GaugeSeriesOption,
} from 'echarts/charts';
import type {
DatasetComponentOption,
@ -12,7 +13,7 @@ import type {
} from 'echarts/components';
import type { ComposeOption } from 'echarts/core';
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
import { BarChart, LineChart, PieChart, RadarChart, GaugeChart } from 'echarts/charts';
import {
// 数据集组件
DatasetComponent,
@ -34,6 +35,7 @@ export type ECOption = ComposeOption<
| DatasetComponentOption
| GridComponentOption
| LineSeriesOption
| GaugeSeriesOption
| TitleComponentOption
| TooltipComponentOption
>;
@ -49,6 +51,7 @@ echarts.use([
TransformComponent,
BarChart,
LineChart,
GaugeChart,
LabelLayout,
UniversalTransition,
CanvasRenderer,

View File

@ -8,10 +8,12 @@
"selectAccount": "Quick Select Account",
"username": "Username",
"password": "Password",
"nickname": "Nickname",
"tenant": "Tenant",
"usernameTip": "Please enter username",
"passwordErrorTip": "Password is incorrect",
"passwordTip": "Please enter password",
"nicknameTip": "Please enter nickname",
"tenantTip": "Please select tenant",
"verifyRequiredTip": "Please complete the verification first",
"rememberMe": "Remember Me",
@ -31,9 +33,10 @@
"passwordStrength": "Use 8 or more characters with a mix of letters, numbers & symbols",
"forgetPassword": "Forget Password?",
"forgetPasswordSubtitle": "Enter your email and we'll send you instructions to reset your password",
"resetPasswordSuccess": "Reset password success",
"emailTip": "Please enter email",
"emailValidErrorTip": "The email format you entered is incorrect",
"sendResetLink": "Send Reset Link",
"resetPassword": "Reset Password",
"email": "Email",
"qrcodeSubtitle": "Scan the QR code with your phone to login",
"qrcodePrompt": "Click 'Confirm' after scanning to complete login",

View File

@ -8,9 +8,11 @@
"selectAccount": "快速选择账号",
"username": "账号",
"password": "密码",
"nickname": "昵称",
"tenant": "租户",
"usernameTip": "请输入用户名",
"passwordTip": "请输入密码",
"nicknameTip": "请输入昵称",
"tenantTip": "请选择租户",
"verifyRequiredTip": "请先完成验证",
"passwordErrorTip": "密码错误",
@ -31,9 +33,10 @@
"passwordStrength": "使用 8 个或更多字符,混合字母、数字和符号",
"forgetPassword": "忘记密码?",
"forgetPasswordSubtitle": "输入您的电子邮件,我们将向您发送重置密码的连接",
"resetPasswordSuccess": "重置密码成功",
"emailTip": "请输入邮箱",
"emailValidErrorTip": "你输入的邮箱格式不正确",
"sendResetLink": "发送重置链接",
"resetPassword": "重置密码",
"email": "邮箱",
"qrcodeSubtitle": "请用手机扫描二维码登录",
"qrcodePrompt": "扫码后点击 '确认',即可完成登录",