first commit
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
||||
import createRouter from "@/router";
|
||||
import {RouterProvider} from "react-router";
|
||||
import type {DataRouter} from "react-router";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import AntdProvider from "@/components/AntdProvider";
|
||||
import {useEffect, useState} from "react";
|
||||
import useLanguage from '@/hooks/useLanguage';
|
||||
import useAuthStore from "@/stores/user";
|
||||
import useDictStore from "@/stores/dict";
|
||||
|
||||
const App = () => {
|
||||
const { changeLanguage } = useLanguage();
|
||||
const fetchUser = useAuthStore(state => state.info);
|
||||
const initWebInfo = useGlobalStore(state => state.initWebInfo);
|
||||
const initDict = useDictStore(state => state.initDict);
|
||||
const [router, setRoute] = useState<DataRouter>();
|
||||
|
||||
const initData = async () => {
|
||||
// 初始化网站信息
|
||||
initWebInfo();
|
||||
// 初始化字典数据
|
||||
await initDict();
|
||||
// 初始化多语言信息
|
||||
await changeLanguage(localStorage.getItem('i18nextLng') || 'zh');
|
||||
// 初始化用户数据
|
||||
const isLoggedIn = !!localStorage.getItem('token');
|
||||
if (isLoggedIn) {
|
||||
await fetchUser();
|
||||
} else {
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
useEffect(() => {
|
||||
initData();
|
||||
setRoute(createRouter());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AntdProvider>
|
||||
{router && <RouterProvider router={router} />}
|
||||
</AntdProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,24 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type { IAgent } from '@/domain/iAgents';
|
||||
|
||||
export async function getAgentList() {
|
||||
return createAxios<IAgent[]>({
|
||||
url: '/ai/agent',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAgent(id: number) {
|
||||
return createAxios<IAgent>({
|
||||
url: `/ai/agent/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAgent(id: number, data: {enabled: boolean}) {
|
||||
return createAxios({
|
||||
url: `/ai/agent/${id}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type {BubbleItemType, ConversationItemType} from "@ant-design/x";
|
||||
|
||||
/** 获取会话列表 */
|
||||
export async function getConversations() {
|
||||
return createAxios<ConversationItemType[]>({
|
||||
url: '/ai/chat/conversations',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取会话消息 */
|
||||
export async function getMessages(conversationId: string) {
|
||||
return createAxios<BubbleItemType[]>({
|
||||
url: `/ai/chat/messages/${conversationId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除会话 */
|
||||
export async function deleteConversation(conversationId: string) {
|
||||
return createAxios({
|
||||
url: `/ai/chat/conversations/${conversationId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type { IAgentMessage } from '@/domain/iAgents.ts';
|
||||
|
||||
interface PaginatorData<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
pageSize: number;
|
||||
current: number;
|
||||
}
|
||||
|
||||
/** 获取会话消息列表 */
|
||||
export async function getMessages(conversationId: string, params?: Record<string, any>) {
|
||||
return createAxios<PaginatorData<IAgentMessage>>({
|
||||
url: `/ai/conversation/${conversationId}/messages`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Xin Table 公共接口 */
|
||||
import createAxios from '@/utils/request';
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
|
||||
type IListParams = {
|
||||
keyword?: string;
|
||||
current?: number;
|
||||
pageSize?: number;
|
||||
} & { [key: string]: unknown }
|
||||
|
||||
/**
|
||||
* 查询详情接口
|
||||
* @param url
|
||||
* @param id
|
||||
* @param options
|
||||
*/
|
||||
export function Get<T,>(
|
||||
url: string,
|
||||
id: number | string,
|
||||
options?: AxiosRequestConfig | undefined
|
||||
){
|
||||
return createAxios<T>({
|
||||
url: url + '/' + id,
|
||||
method: 'get',
|
||||
...(options || {}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共查询接口
|
||||
* @param url
|
||||
* @param params
|
||||
* @param options
|
||||
*/
|
||||
export function List<T,>(
|
||||
url: string,
|
||||
params?: IListParams,
|
||||
options?: AxiosRequestConfig | undefined
|
||||
){
|
||||
return createAxios<API.ListResponse<T>>({
|
||||
url: url,
|
||||
method: 'get',
|
||||
params,
|
||||
...(options || {}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共新增接口
|
||||
* @param url
|
||||
* @param data
|
||||
* @param options
|
||||
*/
|
||||
export function Create<T = API.ResponseStructure, Values = any>(
|
||||
url: string,
|
||||
data?: Values,
|
||||
options?: AxiosRequestConfig | undefined
|
||||
){
|
||||
return createAxios<T>({
|
||||
url: url,
|
||||
method: 'post',
|
||||
data,
|
||||
...(options || {}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共编辑接口
|
||||
* @param url
|
||||
* @param data
|
||||
* @param options
|
||||
*/
|
||||
export function Update<T = API.ResponseStructure, Values = any>(
|
||||
url: string,
|
||||
data?: Values,
|
||||
options?: AxiosRequestConfig | undefined
|
||||
){
|
||||
return createAxios<T>({
|
||||
url: url,
|
||||
method: 'put',
|
||||
data,
|
||||
...(options || {}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共删除接口
|
||||
* @param url
|
||||
* @param params
|
||||
* @param options
|
||||
*/
|
||||
export function Delete<T,>(
|
||||
url: string,
|
||||
params?: { [key: string]: unknown },
|
||||
options?: AxiosRequestConfig | undefined
|
||||
){
|
||||
return createAxios<T>({
|
||||
url: url,
|
||||
method: 'delete',
|
||||
params,
|
||||
...(options || {}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import createAxios from "@/utils/request";
|
||||
|
||||
interface WebInfo {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
describe: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
export const getWebInfo = () => {
|
||||
return createAxios<WebInfo>({
|
||||
url: "/index",
|
||||
method: "get",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type {AiList} from "@/domain/iAi.ts";
|
||||
|
||||
|
||||
/** 获取 AI 列表 */
|
||||
export async function getAiList() {
|
||||
return createAxios<AiList>({
|
||||
url: '/system/ai/list',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取 AI 配置 */
|
||||
export async function getAiConfig() {
|
||||
return createAxios({
|
||||
url: '/system/ai/config',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存 AI 配置 */
|
||||
export async function saveAiConfig(data: Record<string, any>) {
|
||||
return createAxios({
|
||||
url: '/system/ai/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 测试 AI 供应商连接 */
|
||||
export async function testAiConnection(provider: string) {
|
||||
return createAxios({
|
||||
url: '/system/ai/test',
|
||||
method: 'post',
|
||||
timeout: 60000,
|
||||
data: { provider },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import createAxios from "@/utils/request";
|
||||
import type {IDict} from "@/domain/iDict";
|
||||
import type {IDictItem} from "@/domain/iDictItem";
|
||||
|
||||
/**
|
||||
* 获取所有字典数据(带缓存)
|
||||
*/
|
||||
export function getAllDict() {
|
||||
return createAxios<(IDict & { dict_items: IDictItem[] })[]>({
|
||||
url: '/system/dict/list/all',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import createAxios from "@/utils/request";
|
||||
import type { ISysFileInfo } from "@/domain/iSysFile";
|
||||
|
||||
export type FileListParams = {
|
||||
group_id?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function getFileList(params: FileListParams) {
|
||||
return createAxios<API.ListResponse<ISysFileInfo>>({
|
||||
url: '/system/file/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取回收站文件列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function getTrashedFileList(params: {page?: number; pageSize?: number}) {
|
||||
return createAxios<API.ListResponse<ISysFileInfo>>({
|
||||
url: '/system/file/list/trashed',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param file 文件
|
||||
* @param groupId 文件组ID
|
||||
* @param onProgress 上传进度回调
|
||||
*/
|
||||
export function uploadFile(file: File, groupId: number, onProgress?: (progress: number) => void) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('group_id', groupId.toString());
|
||||
return createAxios({
|
||||
timeout: 0,
|
||||
url: '/system/file/list/upload',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件(软删除)
|
||||
* @param id 文件ID
|
||||
*/
|
||||
export function deleteFile(id: number) {
|
||||
return createAxios({
|
||||
url: `/system/file/list/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除文件(软删除)
|
||||
* @param ids 文件ID数组
|
||||
*/
|
||||
export function batchDeleteFiles(ids: number[]) {
|
||||
return createAxios<{count: number}>({
|
||||
url: '/system/file/list/batch/delete',
|
||||
method: 'delete',
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底删除文件
|
||||
* @param id 文件ID
|
||||
*/
|
||||
export function forceDeleteFile(id: number) {
|
||||
return createAxios({
|
||||
url: `/system/file/list/force-delete/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量彻底删除文件
|
||||
* @param ids 文件ID数组
|
||||
*/
|
||||
export function batchForceDeleteFiles(ids: number[]) {
|
||||
return createAxios<{count: number}>({
|
||||
url: '/system/file/list/batch/force-delete',
|
||||
method: 'delete',
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复文件
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
export function restoreFile(id: number) {
|
||||
return createAxios({
|
||||
url: `/system/file/list/restore/${id}`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量恢复文件
|
||||
* @param ids 文件 ID数组
|
||||
* @returns
|
||||
*/
|
||||
export function batchRestoreFiles(ids: number[]) {
|
||||
return createAxios<{count: number}>({
|
||||
url: '/system/file/list/batch/restore',
|
||||
method: 'post',
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件
|
||||
* @param ids
|
||||
* @param groupId
|
||||
* @returns
|
||||
*/
|
||||
export function copyFile(ids: number | number[], groupId?: number) {
|
||||
return createAxios<ISysFileInfo>({
|
||||
url: `/system/file/list/copy`,
|
||||
method: 'post',
|
||||
data: { group_id: groupId, ids },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动文件
|
||||
* @param ids
|
||||
* @param groupId
|
||||
* @returns
|
||||
*/
|
||||
export function moveFile(ids: number | number[], groupId?: number) {
|
||||
return createAxios<ISysFileInfo>({
|
||||
url: `/system/file/list/move`,
|
||||
method: 'post',
|
||||
data: { group_id: groupId, ids },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件
|
||||
* @param id 文件ID
|
||||
* @param name 新文件名
|
||||
* @returns
|
||||
*/
|
||||
export function renameFile(id: number, name: string) {
|
||||
return createAxios({
|
||||
url: `/system/file/list/rename/${id}`,
|
||||
method: 'put',
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空回收站文件
|
||||
* @returns
|
||||
*/
|
||||
export function cleanTrashed() {
|
||||
return createAxios<{ count: number }>({
|
||||
url: '/system/file/list/clean/trashed',
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ISysFileGroup } from "@/domain/iSysFileGroup";
|
||||
import createAxios from "@/utils/request";
|
||||
|
||||
// 文件夹相关API
|
||||
export function queryFileGroupList(params?: {keywordSearch: string}) {
|
||||
return createAxios<ISysFileGroup[]>({
|
||||
url: '/system/file/group',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function createFileGroup(params: ISysFileGroup) {
|
||||
return createAxios({
|
||||
url: '/system/file/group',
|
||||
method: 'post',
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateFileGroup(params: ISysFileGroup) {
|
||||
return createAxios({
|
||||
url: `/system/file/group/${params.id}`,
|
||||
method: 'put',
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteFileGroup(id: number) {
|
||||
return createAxios<boolean>({
|
||||
url: `/system/file/group/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import createAxios from '@/utils/request';
|
||||
|
||||
/** 获取邮件配置 */
|
||||
export async function getMailConfig() {
|
||||
return createAxios({
|
||||
url: '/system/mail/config',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存邮件配置 */
|
||||
export async function saveMailConfig(data: Record<string, any>) {
|
||||
return createAxios({
|
||||
url: '/system/mail/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 发送测试邮件 */
|
||||
export async function sendTestMail(to: string) {
|
||||
return createAxios({
|
||||
url: '/system/mail/test',
|
||||
method: 'post',
|
||||
data: { to },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import createAxios from '@/utils/request';
|
||||
|
||||
/** 获取存储配置 */
|
||||
export async function getStorageConfig() {
|
||||
return createAxios({
|
||||
url: '/system/storage/config',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存存储配置 */
|
||||
export async function saveStorageConfig(data: Record<string, any>) {
|
||||
return createAxios({
|
||||
url: '/system/storage/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 测试存储连接 */
|
||||
export async function testStorageConnection(disk: string) {
|
||||
return createAxios({
|
||||
url: '/system/storage/test',
|
||||
method: 'post',
|
||||
timeout: 60000,
|
||||
data: { disk },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type { IConfigGroup } from '@/domain/iConfigGroup.ts';
|
||||
import type { IConfigItem } from '@/domain/iConfigItem.ts';
|
||||
|
||||
/** 获取设置组列表 */
|
||||
export async function getConfigGroupList() {
|
||||
return createAxios<IConfigGroup[]>({
|
||||
url: '/system/config/group',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增设置组 */
|
||||
export async function createConfigGroup(data: IConfigGroup) {
|
||||
return createAxios({
|
||||
url: '/system/config/group',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新设置组 */
|
||||
export async function updateConfigGroup(id: number, data: IConfigGroup) {
|
||||
return createAxios({
|
||||
url: `/system/config/group/${id}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除设置组 */
|
||||
export async function deleteConfigGroup(id: number) {
|
||||
return createAxios({
|
||||
url: `/system/config/group/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取设置项列表 */
|
||||
export async function getConfigItemList(group_id?: number) {
|
||||
return createAxios<IConfigItem[]>({
|
||||
url: '/system/config/items',
|
||||
method: 'get',
|
||||
params: { group_id },
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增设置项 */
|
||||
export async function createConfigItem(data: IConfigItem) {
|
||||
return createAxios<IConfigItem>({
|
||||
url: '/system/config/items',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新设置项 */
|
||||
export async function updateConfigItem(id: number, data: IConfigItem) {
|
||||
return createAxios<IConfigItem>({
|
||||
url: `/system/config/items/${id}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除设置项 */
|
||||
export async function deleteConfigItem(id: number) {
|
||||
return createAxios({
|
||||
url: `/system/config/items/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量保存设置项值 */
|
||||
export async function saveConfigItems(configs: { id: number; value: string }[]) {
|
||||
return createAxios({
|
||||
url: '/system/config/items/save',
|
||||
method: 'put',
|
||||
data: { configs },
|
||||
});
|
||||
}
|
||||
|
||||
/** 刷新设置缓存 */
|
||||
export async function refreshCache() {
|
||||
return createAxios({
|
||||
url: '/system/config/items/refreshCache',
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type {ISysDept} from "@/domain/iSysDept.ts";
|
||||
import React from "react";
|
||||
import type ISysUser from "@/domain/iSysUser.ts";
|
||||
|
||||
/** 获取部门列表 */
|
||||
export async function queryDept() {
|
||||
return createAxios<ISysDept[]>({
|
||||
url: '/system/dept',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增部门 */
|
||||
export async function createDept(data: ISysDept) {
|
||||
return createAxios<ISysDept[]>({
|
||||
url: '/system/dept',
|
||||
method: 'post',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
|
||||
/** 编辑部门信息 */
|
||||
export async function updateDept(id: number, data: ISysDept) {
|
||||
return createAxios<ISysDept[]>({
|
||||
url: '/system/dept/' + id,
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量删除部门 */
|
||||
export async function deleteDept(ids: React.Key[]) {
|
||||
return createAxios({
|
||||
url: '/system/dept',
|
||||
method: 'delete',
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量删除部门 */
|
||||
export async function deptUsers(id: number, params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
} = { page: 1, pageSize: 10 }) {
|
||||
return createAxios<API.ListResponse<ISysUser>>({
|
||||
url: '/system/dept/users/' + id,
|
||||
method: 'get',
|
||||
params: params
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type ISysUser from "@/domain/iSysUser.ts";
|
||||
|
||||
export interface RuleFieldsList {
|
||||
key: string;
|
||||
title: string;
|
||||
local: string;
|
||||
children: RuleFieldsList[];
|
||||
}
|
||||
|
||||
/** 获取角色用户列表 */
|
||||
export async function users(id: number, params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
} = { page: 1, pageSize: 10 }) {
|
||||
return createAxios<API.ListResponse<ISysUser>>({
|
||||
url: '/system/role/users/' + id,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/** 设置启用状态 */
|
||||
export async function statusRole(id: number) {
|
||||
return createAxios({
|
||||
url: '/system/role/status/' + id,
|
||||
method: 'put',
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取权限选项 */
|
||||
export async function rulesList() {
|
||||
return createAxios<RuleFieldsList[]>({
|
||||
url: '/system/role/ruleList',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存角色权限 */
|
||||
export async function setRule(role_id: number, rule_ids: number[]) {
|
||||
return createAxios({
|
||||
url: '/system/role/setRule',
|
||||
method: 'post',
|
||||
data: {
|
||||
role_id,
|
||||
rule_ids
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type {ISysRule} from "@/domain/iSysRule.ts";
|
||||
|
||||
/** 获取权限列表 */
|
||||
export async function listRule() {
|
||||
return createAxios<ISysRule[]>({
|
||||
url: '/system/rule',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 设置显示状态 */
|
||||
export async function ruleParent() {
|
||||
return createAxios<ISysRule[]>({
|
||||
url: '/system/rule/parent',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 设置显示状态 */
|
||||
export async function showRule(id: number) {
|
||||
return createAxios({
|
||||
url: '/system/rule/show/' + id,
|
||||
method: 'put',
|
||||
});
|
||||
}
|
||||
|
||||
/** 设置启用状态 */
|
||||
export async function statusRule(id: number) {
|
||||
return createAxios({
|
||||
url: '/system/rule/status/' + id,
|
||||
method: 'put',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type ISysUser from '@/domain/iSysUser.ts';
|
||||
import type {IMenus} from "@/domain/iSysRule.ts";
|
||||
import type ISysLoginRecord from "@/domain/iSysLoginRecord.ts";
|
||||
|
||||
export interface LoginParams {
|
||||
/** 用户名 */
|
||||
username?: string;
|
||||
/** 密码 */
|
||||
password?: string;
|
||||
/** 是否记住我 */
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
/** token */
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface InfoResponse {
|
||||
/** 管理员信息 */
|
||||
info: ISysUser;
|
||||
/** 管理员权限 */
|
||||
access: string[];
|
||||
}
|
||||
|
||||
export interface MenuResponse {
|
||||
/** 管理员菜单 */
|
||||
menus: IMenus[];
|
||||
}
|
||||
|
||||
export interface InfoParams {
|
||||
/** 个人简介 */
|
||||
bio: string;
|
||||
/** 邮箱 */
|
||||
email: string;
|
||||
/** 手机号 */
|
||||
mobile: string;
|
||||
/** 昵称 */
|
||||
nickname: string;
|
||||
/** 性别 */
|
||||
sex: number;
|
||||
}
|
||||
|
||||
export interface PasswordParams {
|
||||
/** 新密码 */
|
||||
newPassword: string;
|
||||
/** 旧密码 */
|
||||
oldPassword: string;
|
||||
/** 重复新密码 */
|
||||
rePassword: string;
|
||||
}
|
||||
|
||||
export interface RoleFieldType {
|
||||
/** 角色ID */
|
||||
role_id: number;
|
||||
/** 角色名称 */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DeptFieldType {
|
||||
/** 部门ID */
|
||||
dept_id: number;
|
||||
/** 部门名称 */
|
||||
name: string;
|
||||
/** 下级部门 */
|
||||
children: DeptFieldType[];
|
||||
}
|
||||
|
||||
export interface ResetPasswordType {
|
||||
/** 用户ID */
|
||||
id: number;
|
||||
/** 密码 */
|
||||
password: string;
|
||||
/** 验证密码 */
|
||||
rePassword: string;
|
||||
}
|
||||
|
||||
/** 获取管理员角色选项栏数据 */
|
||||
export async function roleField() {
|
||||
return createAxios<RoleFieldType[]>({
|
||||
url: '/system/user/role',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取管理员角色选项栏数据 */
|
||||
export async function deptField() {
|
||||
return createAxios<DeptFieldType[]>({
|
||||
url: '/system/user/dept',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 重置密码 */
|
||||
export async function resetPassword(data: ResetPasswordType) {
|
||||
return createAxios({
|
||||
url: '/system/resetPassword',
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** 后台用户登录 */
|
||||
export async function login(data: LoginParams) {
|
||||
return createAxios<LoginResponse>({
|
||||
url: '/system/login',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 后台用户退出登录 */
|
||||
export async function logout() {
|
||||
return createAxios({
|
||||
url: '/system/logout',
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取管理员用户信息 */
|
||||
export async function info() {
|
||||
return createAxios<InfoResponse>({
|
||||
url: '/system/info',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取管理员用户信息 */
|
||||
export async function menu() {
|
||||
return createAxios<MenuResponse>({
|
||||
url: '/system/menu',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 更改管理员信息 */
|
||||
export async function updateInfo(info: InfoParams) {
|
||||
return createAxios({
|
||||
url: '/system/updateInfo',
|
||||
method: 'put',
|
||||
data: info,
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改管理员密码 */
|
||||
export async function updatePassword(data: PasswordParams) {
|
||||
return createAxios({
|
||||
url: '/system/updatePassword',
|
||||
method: 'put',
|
||||
data: data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改管理员头像 */
|
||||
export async function updateAvatar() {
|
||||
return createAxios({
|
||||
url: '/system/uploadAvatar',
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取管理员登录日志 */
|
||||
export async function loginRecord() {
|
||||
return createAxios<ISysLoginRecord[]>({
|
||||
url: '/system/loginRecord',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {App, ConfigProvider, type ThemeConfig} from 'antd';
|
||||
import {type PropsWithChildren, useMemo} from 'react';
|
||||
import algorithm from "@/layout/algorithm.ts";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import useLanguage from '@/hooks/useLanguage';
|
||||
|
||||
function ContextHolder() {
|
||||
const { message, modal, notification } = App.useApp();
|
||||
window.$message = message;
|
||||
window.$modal = modal;
|
||||
window.$notification = notification;
|
||||
return null;
|
||||
}
|
||||
|
||||
const AppProvider = ({ children }: PropsWithChildren) => {
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const { antdLocale } = useLanguage();
|
||||
|
||||
const theme: ThemeConfig = useMemo(() => ({
|
||||
components: {
|
||||
Layout: {
|
||||
headerPadding: "0 " + themeConfig.headerPadding + "px",
|
||||
headerHeight: themeConfig.headerHeight,
|
||||
bodyBg: themeConfig.bodyBg,
|
||||
footerBg: themeConfig.footerBg,
|
||||
headerBg: themeConfig.headerBg,
|
||||
headerColor: themeConfig.headerColor,
|
||||
siderBg: themeConfig.siderBg,
|
||||
footerPadding: 0
|
||||
},
|
||||
Menu: {
|
||||
activeBarBorderWidth: 0,
|
||||
itemBg: 'transparent',
|
||||
subMenuItemBg: 'transparent',
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: themeConfig.colorPrimary,
|
||||
colorBgBase: themeConfig.colorBg,
|
||||
colorTextBase: themeConfig.colorText,
|
||||
colorError: themeConfig.colorError,
|
||||
colorInfo: themeConfig.colorPrimary,
|
||||
colorLink: themeConfig.colorPrimary,
|
||||
colorSuccess: themeConfig.colorSuccess,
|
||||
colorWarning: themeConfig.colorWarning,
|
||||
borderRadius: themeConfig.borderRadius,
|
||||
controlHeight: themeConfig.controlHeight,
|
||||
},
|
||||
algorithm: themeConfig.algorithm ? algorithm[themeConfig.algorithm] : undefined
|
||||
}), [themeConfig])
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={theme} locale={antdLocale}>
|
||||
<App>
|
||||
<ContextHolder />
|
||||
{children}
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppProvider;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import useAuthStore from "@/stores/user";
|
||||
|
||||
interface AuthButtonProps {
|
||||
auth?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const AuthButton = ({ auth, children }: AuthButtonProps) => {
|
||||
const access = useAuthStore(state => state.access);
|
||||
|
||||
const hasPermission = useMemo(() => {
|
||||
// 未指定权限,默认显示
|
||||
if (!auth) return true;
|
||||
|
||||
// 检查权限
|
||||
return access.includes(auth);
|
||||
}, [access, auth]);
|
||||
|
||||
// 无权限时不渲染
|
||||
if (!hasPermission) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default AuthButton;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Badge, Tag } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import useDictStore from '@/stores/dict';
|
||||
|
||||
export interface DictTagProps {
|
||||
/** 字典编码 */
|
||||
code: string;
|
||||
/** 字典值 */
|
||||
value?: string | number;
|
||||
/** 渲染样式 */
|
||||
renderType?: 'text' | 'tag' | 'badge';
|
||||
/** 默认文本 */
|
||||
defaultText?: string;
|
||||
/** 默认颜色 */
|
||||
defaultColor?: string;
|
||||
/** 配置 */
|
||||
}
|
||||
|
||||
const DictTag: React.FC<DictTagProps> = (props) => {
|
||||
const {
|
||||
code,
|
||||
value,
|
||||
renderType = 'text',
|
||||
defaultText = '-',
|
||||
defaultColor = 'default',
|
||||
} = props;
|
||||
|
||||
const getDictItem = useDictStore((state) => state.getDictItem);
|
||||
|
||||
const dictItem = useMemo(() => {
|
||||
return getDictItem(code, value);
|
||||
}, [code, value, getDictItem]);
|
||||
|
||||
const label = dictItem?.label || defaultText;
|
||||
const color = dictItem?.color || defaultColor;
|
||||
|
||||
if (renderType === 'text') {
|
||||
return <span>{label}</span>;
|
||||
}
|
||||
|
||||
if (renderType === 'badge') {
|
||||
return <Badge color={color} text={label} />;
|
||||
}
|
||||
|
||||
return <Tag color={color}>{label}</Tag>;
|
||||
};
|
||||
|
||||
export default DictTag;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createFromIconfontCN, ExclamationOutlined } from '@ant-design/icons';
|
||||
import * as AntdIcons from '@ant-design/icons';
|
||||
import React from 'react';
|
||||
import {categories, oauthScriptUrl} from '@/utils/iconFields.ts'
|
||||
|
||||
const allIcons: any = AntdIcons;
|
||||
|
||||
const OtherIcons = createFromIconfontCN({
|
||||
scriptUrl: oauthScriptUrl,
|
||||
});
|
||||
|
||||
const IconFont = (props: {name?: string, style?: React.CSSProperties}) => {
|
||||
if (!props.name || !categories.allIcons.includes(props.name)) {
|
||||
return <ExclamationOutlined style={props.style} />
|
||||
} else if(allIcons[props.name]) {
|
||||
return React.createElement(allIcons[props.name] , {style: props.style})
|
||||
} else {
|
||||
return <OtherIcons type={props.name} className={props.name} style={props.style} />
|
||||
}
|
||||
}
|
||||
|
||||
export default IconFont;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import {Button, Dropdown, type MenuProps} from 'antd';
|
||||
import type { ButtonProps } from 'antd';
|
||||
import { TranslationOutlined } from '@ant-design/icons';
|
||||
import useLanguage from '@/hooks/useLanguage';
|
||||
|
||||
/**
|
||||
* 语言切换器组件
|
||||
*/
|
||||
const LanguageSwitcher: React.FC<ButtonProps> = (props) => {
|
||||
const { languageOptions, language, changeLanguage } = useLanguage();
|
||||
|
||||
/**
|
||||
* 生成语言下拉菜单项
|
||||
*/
|
||||
const languageMenuItems = useMemo<MenuProps['items']>(() => {
|
||||
return languageOptions.map((option) => ({
|
||||
key: option.value,
|
||||
label: option.label,
|
||||
onClick: () => changeLanguage(option.value),
|
||||
}));
|
||||
}, [languageOptions, changeLanguage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown menu={{ items: languageMenuItems, selectedKeys: [language] }}>
|
||||
<Button icon={<TranslationOutlined />} {...props} />
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
@@ -0,0 +1,140 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#root {
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% auto;
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.loading-sub-title {
|
||||
margin-top: 20px;
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.page-loading-warp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 26px;
|
||||
}
|
||||
.ant-spin {
|
||||
position: absolute;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
font-variant: tabular-nums;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
list-style: none;
|
||||
opacity: 0;
|
||||
transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),-webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.ant-spin-spinning {
|
||||
position: static;
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ant-spin-dot {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
background-color: #1890ff;
|
||||
border-radius: 100%;
|
||||
transform: scale(0.75);
|
||||
transform-origin: 50% 50%;
|
||||
opacity: 0.3;
|
||||
-webkit-animation: antspinmove 1s infinite linear alternate;
|
||||
animation: antSpinMove 1s infinite linear alternate;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(1) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(2) {
|
||||
top: 0;
|
||||
right: 0;
|
||||
-webkit-animation-delay: 0.4s;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(3) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
-webkit-animation-delay: 0.8s;
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(4) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
-webkit-animation-delay: 1.2s;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
|
||||
.ant-spin-dot-spin {
|
||||
transform: rotate(45deg);
|
||||
animation: antRotate 1.2s infinite linear;
|
||||
}
|
||||
|
||||
.ant-spin-lg .ant-spin-dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.ant-spin-lg .ant-spin-dot i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@-webkit-keyframes antSpinMove {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes antSpinMove {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes antRotate {
|
||||
to {
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes antRotate {
|
||||
to {
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import './index.css';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
minHeight: 362
|
||||
}}>
|
||||
<div className="page-loading-warp">
|
||||
<div className="ant-spin ant-spin-lg ant-spin-spinning">
|
||||
<span className="ant-spin-dot ant-spin-dot-spin">
|
||||
<i className="ant-spin-dot-item"></i>
|
||||
<i className="ant-spin-dot-item"></i>
|
||||
<i className="ant-spin-dot-item"></i>
|
||||
<i className="ant-spin-dot-item"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="loading-title">
|
||||
正在加载资源
|
||||
</div>
|
||||
<div className="loading-sub-title">
|
||||
初次加载资源可能需要较多时间 请耐心等待
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useEffect } from "react";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import {useLocation} from "react-router";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext";
|
||||
import {findMenuByPath} from "@/layout/utils.ts";
|
||||
|
||||
const PageTitle: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const location = useLocation();
|
||||
const defaultSiteName = useGlobalStore(state => state.title);
|
||||
const { menus } = useLayoutContext();
|
||||
|
||||
useEffect(() => {
|
||||
const title = defaultSiteName || "Xin Admin";
|
||||
const menu = findMenuByPath(menus, location.pathname);
|
||||
if(!menu) {
|
||||
document.title = title;
|
||||
return;
|
||||
}
|
||||
const pageTitle = menu.local ? t(menu.local) : menu.name;
|
||||
if(pageTitle) {
|
||||
document.title = pageTitle+ ' - ' + title;
|
||||
} else {
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
}, [location.pathname, defaultSiteName, t, menus]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default PageTitle;
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useCallback, useImperativeHandle, useMemo, useState } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Button,
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Modal,
|
||||
Drawer
|
||||
} from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { XinFormProps, XinFormRef, SubmitterButton } from './typings';
|
||||
import type { FormItemProps } from 'antd';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
import FieldRender from '@/components/XinFormField/FieldRender';
|
||||
import {pick} from "lodash";
|
||||
|
||||
/**
|
||||
* XinForm - JSON 配置动态表单组件
|
||||
*/
|
||||
function XinForm<T extends Record<string, any> = any>(props: XinFormProps<T>) {
|
||||
const {
|
||||
columns,
|
||||
layoutType = 'Form',
|
||||
grid = false,
|
||||
rowProps,
|
||||
colProps = { span: 12 },
|
||||
onFinish,
|
||||
formRef,
|
||||
form,
|
||||
modalProps,
|
||||
drawerProps,
|
||||
trigger,
|
||||
submitter,
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [xinForm] = Form.useForm<T>(form);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 暴露表单方法
|
||||
useImperativeHandle(formRef, (): XinFormRef => ({
|
||||
...xinForm,
|
||||
open: handleOpen,
|
||||
close: handleClose,
|
||||
isOpen: () => open,
|
||||
setLoading: (loading: boolean) => setLoading(loading),
|
||||
}));
|
||||
|
||||
// 表单提交处理
|
||||
const handleFinish = useCallback(async (values: T) => {
|
||||
if (!onFinish) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await onFinish(values);
|
||||
if (result !== false && (layoutType === 'ModalForm' || layoutType === 'DrawerForm')) {
|
||||
setOpen(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onFinish, layoutType]);
|
||||
|
||||
// 打开弹窗/抽屉
|
||||
const handleOpen =() => setOpen(true);
|
||||
|
||||
// 关闭弹窗/抽屉
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
// 渲染表单项
|
||||
const renderFormItem = useCallback((column: FormColumn<T>, index: number): React.ReactNode => {
|
||||
const {
|
||||
dataIndex,
|
||||
valueType,
|
||||
title = '',
|
||||
fieldProps = {},
|
||||
dependency,
|
||||
fieldRender,
|
||||
colProps: columnColProps = { span: 12 }
|
||||
} = column;
|
||||
|
||||
// Form.Item 允许的属性列表
|
||||
const formItemPropKeys = [
|
||||
'colon', 'extra', 'getValueFromEvent', 'help', 'hidden', 'htmlFor',
|
||||
'initialValue', 'name', 'normalize',
|
||||
'noStyle', 'preserve', 'tooltip', 'trigger',
|
||||
// 验证相关
|
||||
'required', 'rules', 'validateFirst', 'validateDebounce', 'validateStatus', 'hasFeedback',
|
||||
'validateTrigger', 'valuePropName', 'messageVariables',
|
||||
// 布局
|
||||
'wrapperCol', 'layout', 'label', 'labelAlign', 'labelCol'
|
||||
];
|
||||
|
||||
const formItemProps = pick(column, formItemPropKeys);
|
||||
|
||||
const key = String(dataIndex) || `form-item-${index}`;
|
||||
|
||||
if (dependency) {
|
||||
// 有依赖时使用 Form.Item 的 shouldUpdate
|
||||
return (
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({getFieldsValue}) => {
|
||||
const values = getFieldsValue() as T;
|
||||
// 判断是否隐藏
|
||||
const isHidden = dependency.visible ? !dependency.visible(values) : false;
|
||||
if (isHidden) return null;
|
||||
|
||||
// 判断是否禁用
|
||||
const isDisabled = dependency.disabled ? dependency.disabled(values) : false;
|
||||
// 动态 fieldProps
|
||||
const dynamicFieldProps = dependency.fieldProps ? dependency.fieldProps(values) : {};
|
||||
|
||||
const mergedFieldProps = {
|
||||
...fieldProps,
|
||||
...dynamicFieldProps,
|
||||
disabled: isDisabled || fieldProps?.disabled,
|
||||
}
|
||||
|
||||
const defaultFieldRender = (
|
||||
<FieldRender
|
||||
valueType={valueType}
|
||||
placeholder={title}
|
||||
{...mergedFieldProps}
|
||||
/>
|
||||
);
|
||||
|
||||
return grid ?
|
||||
<Col
|
||||
{...colProps}
|
||||
{...columnColProps}
|
||||
key={key}
|
||||
>
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={dataIndex}
|
||||
label={column.title || column.label}
|
||||
{...formItemProps as FormItemProps}
|
||||
>
|
||||
{fieldRender ? fieldRender(xinForm) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
:
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={dataIndex}
|
||||
label={column.title || column.label}
|
||||
{...formItemProps as FormItemProps}
|
||||
>
|
||||
{fieldRender ? fieldRender(xinForm) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
}}
|
||||
</Form.Item>
|
||||
);
|
||||
} else {
|
||||
const defaultFieldRender = (
|
||||
<FieldRender
|
||||
valueType={valueType}
|
||||
placeholder={title}
|
||||
{...fieldProps}
|
||||
/>
|
||||
);
|
||||
return grid ?
|
||||
<Col
|
||||
{...colProps}
|
||||
{...columnColProps}
|
||||
key={key}
|
||||
>
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={dataIndex}
|
||||
label={column.title || column.label}
|
||||
{...formItemProps as FormItemProps}
|
||||
>
|
||||
{fieldRender ? fieldRender(xinForm) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
:
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={dataIndex}
|
||||
label={column.title || column.label}
|
||||
{...formItemProps as FormItemProps}
|
||||
>
|
||||
{fieldRender ? fieldRender(xinForm) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
}
|
||||
}, [grid, form]);
|
||||
|
||||
// 渲染提交按钮
|
||||
const renderSubmitter = useMemo(() => {
|
||||
if (submitter?.render === false) return null;
|
||||
|
||||
const submitButton = (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => xinForm.submit()}
|
||||
{...submitter?.submitButtonProps}
|
||||
>
|
||||
{submitter?.submitText || t('xin.form.submit')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={handleClose}
|
||||
{...submitter?.closeButtonProps}
|
||||
>
|
||||
{submitter?.closeText || t('xin.form.cancel')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const resetButton = (
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => xinForm.resetFields()}
|
||||
{...submitter?.resetButtonProps}
|
||||
>
|
||||
{submitter?.resetText || t('xin.form.reset')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const buttons: SubmitterButton = {
|
||||
submit: submitButton,
|
||||
close: closeButton,
|
||||
reset: resetButton,
|
||||
};
|
||||
|
||||
if (typeof submitter?.render === 'function') {
|
||||
return submitter.render(buttons, formRef);
|
||||
}
|
||||
|
||||
if(layoutType === 'Form') {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Space>
|
||||
{buttons.reset}
|
||||
{buttons.submit}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
);
|
||||
}else {
|
||||
return (
|
||||
<Space>
|
||||
{buttons.reset}
|
||||
{buttons.submit}
|
||||
{buttons.close}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
}, [loading, form, submitter, t]);
|
||||
|
||||
// 表单内容
|
||||
const formContent = useMemo(() => (
|
||||
<Form
|
||||
{...formProps}
|
||||
form={xinForm}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
{grid ? (
|
||||
<Row {...rowProps}>
|
||||
{columns.map((column, index) => renderFormItem(column, index))}
|
||||
</Row>
|
||||
) : (
|
||||
columns.map((column, index) => renderFormItem(column, index))
|
||||
)}
|
||||
{(layoutType === 'Form') && renderSubmitter}
|
||||
</Form>
|
||||
), [ form, handleFinish, props, grid, rowProps, columns, renderFormItem, layoutType, renderSubmitter ]);
|
||||
|
||||
// 触发器
|
||||
const triggerElement = useMemo(() => {
|
||||
if (!trigger) return null;
|
||||
return React.cloneElement(trigger as React.ReactElement<{ onClick?: () => void }>, {
|
||||
onClick: handleOpen,
|
||||
});
|
||||
}, [trigger, handleOpen]);
|
||||
|
||||
// 根据 layoutType 渲染
|
||||
if (layoutType === 'ModalForm') {
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={renderSubmitter}
|
||||
{...modalProps}
|
||||
>
|
||||
{formContent}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (layoutType === 'DrawerForm') {
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
footer={renderSubmitter}
|
||||
{...drawerProps}
|
||||
>
|
||||
{formContent}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return formContent;
|
||||
}
|
||||
|
||||
export default XinForm;
|
||||
|
||||
export type { XinFormProps, XinFormRef };
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
FormProps,
|
||||
RowProps,
|
||||
ModalProps,
|
||||
DrawerProps,
|
||||
FormInstance,
|
||||
ButtonProps,
|
||||
ColProps,
|
||||
} from 'antd';
|
||||
import {type ReactNode, type RefObject} from 'react';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
|
||||
/**
|
||||
* 表单操作栏按钮
|
||||
*/
|
||||
export type SubmitterButton = {
|
||||
/** 提交按钮 */
|
||||
submit: ReactNode;
|
||||
/** 重置按钮 */
|
||||
reset: ReactNode;
|
||||
/** 关闭按钮 */
|
||||
close: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单操作栏属性
|
||||
*/
|
||||
export interface SubmitterProps {
|
||||
/** 操作栏渲染 */
|
||||
render?: false | ((dom: SubmitterButton, form?: RefObject<XinFormRef | null>) => ReactNode);
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string | ReactNode;
|
||||
/** 重置按钮文本 */
|
||||
resetText?: string | ReactNode;
|
||||
/** 关闭按钮文本 */
|
||||
closeText?: string | ReactNode;
|
||||
/** 提交按钮属性 */
|
||||
submitButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
/** 重置按钮属性 */
|
||||
resetButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
/** 关闭按钮属性 */
|
||||
closeButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinForm 实例方法
|
||||
*/
|
||||
export interface XinFormRef<T = any> extends FormInstance<T> {
|
||||
/** 打开弹窗/抽屉 (仅 ModalForm/DrawerForm 有效) */
|
||||
open: () => void;
|
||||
/** 关闭弹窗/抽屉 (仅 ModalForm/DrawerForm 有效) */
|
||||
close: () => void;
|
||||
/** 获取弹窗/抽屉的打开状态 */
|
||||
isOpen: () => boolean;
|
||||
/** 设置加载状态 */
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinForm 组件属性
|
||||
*/
|
||||
export type XinFormProps<T = any> = Omit<FormProps<T>, 'onFinish'> & {
|
||||
/** 表单列配置 */
|
||||
columns: FormColumn<T>[];
|
||||
/** 表单布局类型 */
|
||||
layoutType?: 'Form' | 'ModalForm' | 'DrawerForm';
|
||||
/** 是否使用 Grid 布局 */
|
||||
grid?: boolean;
|
||||
/** 开启 grid 模式时传递给 Row */
|
||||
rowProps?: RowProps;
|
||||
/** 传递给表单项的 Col */
|
||||
colProps?: ColProps;
|
||||
/** 表单提交 */
|
||||
onFinish?: (values: T) => Promise<boolean | void>;
|
||||
/** 表单实例引用 */
|
||||
formRef?: RefObject<XinFormRef | null>;
|
||||
/** ModalForm 弹窗配置 */
|
||||
modalProps?: Omit<ModalProps, 'open'>;
|
||||
/** DrawerForm 抽屉配置 */
|
||||
drawerProps?: Omit<DrawerProps, 'open'>;
|
||||
/** 触发器 */
|
||||
trigger?: ReactNode;
|
||||
/** 渲染表单操作栏 */
|
||||
submitter?: SubmitterProps;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FormColumn } from "./typings";
|
||||
import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Switch,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
TreeSelect,
|
||||
Cascader,
|
||||
Rate,
|
||||
Slider,
|
||||
ColorPicker
|
||||
} from 'antd';
|
||||
import type {
|
||||
InputProps,
|
||||
InputNumberProps,
|
||||
SelectProps,
|
||||
TreeSelectProps,
|
||||
RadioGroupProps,
|
||||
SwitchProps,
|
||||
RateProps,
|
||||
SliderSingleProps,
|
||||
DatePickerProps,
|
||||
TimePickerProps,
|
||||
ColorPickerProps
|
||||
} from 'antd';
|
||||
import type { PasswordProps, TextAreaProps } from "antd/es/input";
|
||||
import type { RangePickerProps } from "antd/es/date-picker";
|
||||
import type { CheckboxGroupProps } from 'antd/es/checkbox';
|
||||
import IconSelector from '@/components/XinFormField/IconSelector';
|
||||
import ImageUploader from '@/components/XinFormField/ImageUploader';
|
||||
import UserSelector from '@/components/XinFormField/UserSelector';
|
||||
import type { IconSelectProps } from '@/components/XinFormField/IconSelector/typings';
|
||||
import type { ImageUploaderProps } from '@/components/XinFormField/ImageUploader/typings';
|
||||
import type { UserSelectorProps } from '@/components/XinFormField/UserSelector/typings';
|
||||
import type {ReactNode} from "react";
|
||||
|
||||
const { TextArea, Password } = Input;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface FieldsRenderProps<T> extends Record<string, any> {
|
||||
valueType: FormColumn<T>['valueType'];
|
||||
}
|
||||
|
||||
export default function FieldRender<T>(props: FieldsRenderProps<T>) {
|
||||
const { valueType, ...fieldProps } = props;
|
||||
let dom: ReactNode;
|
||||
switch (valueType) {
|
||||
case 'password':
|
||||
dom = <Password {...fieldProps as PasswordProps}/>;
|
||||
break;
|
||||
case 'textarea':
|
||||
dom = <TextArea rows={4} {...fieldProps as TextAreaProps} />;
|
||||
break;
|
||||
case 'digit':
|
||||
dom = <InputNumber style={{ width: '100%' }} {...fieldProps as InputNumberProps} />;
|
||||
break;
|
||||
case 'money':
|
||||
dom = <InputNumber style={{ width: '100%' }} precision={2} prefix="¥" {...fieldProps as InputNumberProps} />;
|
||||
break;
|
||||
case 'select':
|
||||
dom = <Select {...fieldProps as SelectProps} />;
|
||||
break;
|
||||
case 'treeSelect':
|
||||
dom = <TreeSelect {...fieldProps as TreeSelectProps} />;
|
||||
break;
|
||||
case 'cascader':
|
||||
dom = <Cascader {...fieldProps as any} />;
|
||||
break;
|
||||
case 'radio':
|
||||
dom = <Radio.Group options={[]} optionType="default" {...fieldProps as RadioGroupProps} />;
|
||||
break;
|
||||
case 'radioButton':
|
||||
dom = <Radio.Group options={[]} optionType="button" {...fieldProps as RadioGroupProps} />;
|
||||
break;
|
||||
case 'checkbox':
|
||||
dom = <Checkbox.Group options={[]} {...fieldProps as CheckboxGroupProps} />;
|
||||
break;
|
||||
case 'switch':
|
||||
dom = <Switch {...fieldProps as SwitchProps} />;
|
||||
break;
|
||||
case 'rate':
|
||||
dom = <Rate {...fieldProps as RateProps} />;
|
||||
break;
|
||||
case 'slider':
|
||||
dom = <Slider {...fieldProps as SliderSingleProps} />;
|
||||
break;
|
||||
case 'date':
|
||||
dom = <DatePicker style={{ width: '100%' }} {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'dateTime':
|
||||
dom = <DatePicker style={{ width: '100%' }} showTime {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'dateRange':
|
||||
dom = <RangePicker style={{ width: '100%' }} {...fieldProps as RangePickerProps} />;
|
||||
break;
|
||||
case 'time':
|
||||
dom = <TimePicker style={{ width: '100%' }} {...fieldProps as TimePickerProps} />;
|
||||
break;
|
||||
case 'timeRange':
|
||||
dom = <TimePicker.RangePicker style={{ width: '100%' }} {...fieldProps as RangePickerProps} />;
|
||||
break;
|
||||
case 'week':
|
||||
dom = <DatePicker style={{ width: '100%' }} picker="week" {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'month':
|
||||
dom = <DatePicker style={{ width: '100%' }} picker="month" {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'quarter':
|
||||
dom = <DatePicker style={{ width: '100%' }} picker="quarter" {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'year':
|
||||
dom = <DatePicker style={{ width: '100%' }} picker="year" {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'color':
|
||||
dom = <ColorPicker {...fieldProps as ColorPickerProps} />;
|
||||
break;
|
||||
case 'image':
|
||||
dom = <ImageUploader {...fieldProps as ImageUploaderProps} />;
|
||||
break;
|
||||
case 'icon':
|
||||
dom = <IconSelector {...fieldProps as IconSelectProps} />;
|
||||
break;
|
||||
case 'user':
|
||||
dom = <UserSelector {...fieldProps as UserSelectorProps} />;
|
||||
break;
|
||||
case 'text':
|
||||
default:
|
||||
dom = <Input {...fieldProps as InputProps} />;
|
||||
break;
|
||||
}
|
||||
return dom;
|
||||
}
|
||||
|
||||
export type { FieldsRenderProps, FormColumn };
|
||||
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
FormItemProps,
|
||||
ColProps,
|
||||
FormInstance,
|
||||
InputProps,
|
||||
InputNumberProps,
|
||||
SelectProps,
|
||||
TreeSelectProps,
|
||||
CascaderProps,
|
||||
RadioGroupProps,
|
||||
SwitchProps,
|
||||
RateProps,
|
||||
SliderSingleProps,
|
||||
DatePickerProps,
|
||||
TimePickerProps,
|
||||
TimeRangePickerProps,
|
||||
ColorPickerProps
|
||||
} from 'antd';
|
||||
import type { TextAreaProps, PasswordProps } from 'antd/es/input';
|
||||
import type { RangePickerProps } from 'antd/es/date-picker';
|
||||
import type { CheckboxGroupProps } from 'antd/es/checkbox';
|
||||
import {type Key, type ReactNode} from 'react';
|
||||
import type { IconSelectProps } from '@/components/XinFormField/IconSelector/typings';
|
||||
import type { ImageUploaderProps } from '@/components/XinFormField/ImageUploader/typings';
|
||||
import type { UserSelectorProps } from '@/components/XinFormField/UserSelector/typings';
|
||||
|
||||
/**
|
||||
* 表单字段类型
|
||||
*/
|
||||
export type FieldValue =
|
||||
| 'text' // 文本输入
|
||||
| 'password' // 密码输入
|
||||
| 'textarea' // 多行文本
|
||||
| 'digit' // 数字输入
|
||||
| 'money' // 金额输入
|
||||
| 'select' // 下拉选择
|
||||
| 'treeSelect' // 树形选择
|
||||
| 'cascader' // 级联选择
|
||||
| 'radio' // 单选框
|
||||
| 'radioButton' // 单选按钮
|
||||
| 'checkbox' // 多选框
|
||||
| 'switch' // 开关
|
||||
| 'rate' // 评分
|
||||
| 'slider' // 滑动条
|
||||
| 'date' // 日期选择
|
||||
| 'dateTime' // 日期时间选择
|
||||
| 'dateRange' // 日期范围
|
||||
| 'time' // 时间选择
|
||||
| 'timeRange' // 时间范围
|
||||
| 'week' // 周选择
|
||||
| 'month' // 月选择
|
||||
| 'quarter' // 季度选择
|
||||
| 'year' // 年选择
|
||||
| 'image' // 图片上传
|
||||
| 'color' // 颜色选择
|
||||
| 'icon' // 图标选择
|
||||
| 'user' // 用户选择
|
||||
|
||||
/**
|
||||
* valueType 到组件 Props 的类型映射
|
||||
*/
|
||||
export interface FieldPropsMap {
|
||||
text: InputProps;
|
||||
password: PasswordProps;
|
||||
textarea: TextAreaProps;
|
||||
digit: InputNumberProps;
|
||||
money: InputNumberProps;
|
||||
select: SelectProps;
|
||||
treeSelect: TreeSelectProps;
|
||||
cascader: CascaderProps;
|
||||
radio: Omit<RadioGroupProps, 'optionType'>;
|
||||
radioButton: Omit<RadioGroupProps, 'optionType'>;
|
||||
checkbox: CheckboxGroupProps;
|
||||
switch: SwitchProps;
|
||||
rate: RateProps;
|
||||
slider: SliderSingleProps;
|
||||
date: DatePickerProps;
|
||||
dateTime: DatePickerProps;
|
||||
dateRange: RangePickerProps;
|
||||
time: TimePickerProps;
|
||||
timeRange: TimeRangePickerProps;
|
||||
week: DatePickerProps;
|
||||
month: DatePickerProps;
|
||||
quarter: DatePickerProps;
|
||||
year: DatePickerProps;
|
||||
image: ImageUploaderProps;
|
||||
color: ColorPickerProps;
|
||||
icon: IconSelectProps;
|
||||
user: UserSelectorProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段依赖配置
|
||||
*/
|
||||
export interface FieldDependency<T = any> {
|
||||
/** 依赖的字段名 */
|
||||
dependencies: (keyof T)[];
|
||||
/** 根据依赖值判断是否显示 */
|
||||
visible?: (values: Partial<T>) => boolean;
|
||||
/** 根据依赖值判断是否禁用 */
|
||||
disabled?: (values: Partial<T>) => boolean;
|
||||
/** 根据依赖值动态修改 fieldProps */
|
||||
fieldProps?: (values: Partial<T>) => Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 valueType 映射 fieldProps 类型的表单列配置
|
||||
*/
|
||||
type FormColumnMap<T> = {
|
||||
[K in FieldValue]: FormItemProps<T> & {
|
||||
/** 唯一标识 */
|
||||
key?: Key;
|
||||
/** 字段名 */
|
||||
dataIndex?: string | number | (string | number)[];
|
||||
/** 字段标签 */
|
||||
title?: string;
|
||||
/** 字段类型 */
|
||||
valueType?: K;
|
||||
/** 自定义字段渲染 */
|
||||
fieldRender?: (form: FormInstance<T>) => ReactNode;
|
||||
/** Col 属性 表单开启 grid 时生效 */
|
||||
colProps?: ColProps;
|
||||
/** 字段组件的属性,根据 valueType 自动推断类型 */
|
||||
fieldProps?: FieldPropsMap[K];
|
||||
/** 字段依赖配置 */
|
||||
dependency?: FieldDependency<T>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单列配置
|
||||
* fieldProps 类型会根据 valueType 自动推断
|
||||
*/
|
||||
export type FormColumn<T> = FormColumnMap<T>[FieldValue]
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Select, Modal, Tabs } from 'antd';
|
||||
import type { TabsProps } from 'antd';
|
||||
import { categories, type CategoriesKeys } from '@/utils/iconFields';
|
||||
import IconFont from '@/components/IconFont';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 图标选择器组件属性
|
||||
*/
|
||||
export interface IconSelectProps {
|
||||
value?: string;
|
||||
onChange?: (value: string | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图标选择器组件
|
||||
* 基于 Ant Design Select + Modal + Tabs 封装
|
||||
*
|
||||
* @example
|
||||
* <IconSelect
|
||||
* value={icon}
|
||||
* onChange={setIcon}
|
||||
* placeholder="请选择图标"
|
||||
* />
|
||||
*/
|
||||
const IconSelect: React.FC<IconSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedIcon, setSelectedIcon] = useState<string | undefined>(value);
|
||||
|
||||
// 同步外部 value
|
||||
React.useEffect(() => {
|
||||
setSelectedIcon(value);
|
||||
}, [value]);
|
||||
|
||||
// 处理图标选择
|
||||
const handleIconClick = useCallback((icon: string) => {
|
||||
setSelectedIcon(icon);
|
||||
onChange?.(icon);
|
||||
setOpen(false);
|
||||
}, [onChange]);
|
||||
|
||||
// 处理清空
|
||||
const handleClear = useCallback(() => {
|
||||
setSelectedIcon(undefined);
|
||||
onChange?.(null);
|
||||
}, [onChange]);
|
||||
|
||||
// 图标列表组件
|
||||
const IconsList = useCallback(({ type }: { type: CategoriesKeys }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap max-h-[400px] overflow-auto gap-2">
|
||||
{categories[type].map((item) => (
|
||||
<div
|
||||
className="cursor-pointer p-2 border border-gray-300 rounded hover:border-blue-500 hover:bg-blue-50 transition-all flex items-center justify-center"
|
||||
key={item}
|
||||
onClick={() => handleIconClick(item)}
|
||||
title={item}
|
||||
>
|
||||
<IconFont name={item} style={{ fontSize: 20 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [handleIconClick]);
|
||||
|
||||
// Tabs 配置
|
||||
const tabItems: TabsProps['items'] = useMemo(() => [
|
||||
{
|
||||
key: 'use',
|
||||
label: t('xin.form.iconSelector.tabs.use'),
|
||||
children: <IconsList type="useIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'suggestion',
|
||||
label: t('xin.form.iconSelector.tabs.suggestion'),
|
||||
children: <IconsList type="suggestionIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'direction',
|
||||
label: t('xin.form.iconSelector.tabs.direction'),
|
||||
children: <IconsList type="directionIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'editor',
|
||||
label: t('xin.form.iconSelector.tabs.editor'),
|
||||
children: <IconsList type="editorIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'data',
|
||||
label: t('xin.form.iconSelector.tabs.data'),
|
||||
children: <IconsList type="dataIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'logo',
|
||||
label: t('xin.form.iconSelector.tabs.logo'),
|
||||
children: <IconsList type="logoIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: t('xin.form.iconSelector.tabs.other'),
|
||||
children: <IconsList type="otherIcons" />,
|
||||
},
|
||||
], [IconsList, t]);
|
||||
|
||||
// Select 选项
|
||||
const selectOptions = useMemo(() => {
|
||||
if (!selectedIcon) return [];
|
||||
return [{
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconFont name={selectedIcon} />
|
||||
<span>{selectedIcon}</span>
|
||||
</div>
|
||||
),
|
||||
value: selectedIcon,
|
||||
}];
|
||||
}, [selectedIcon]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
value={selectedIcon}
|
||||
placeholder={placeholder || t('xin.form.iconSelector.placeholder')}
|
||||
disabled={disabled}
|
||||
open={false}
|
||||
onClick={() => !disabled && !readonly && setOpen(true)}
|
||||
options={selectOptions}
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
onClear={handleClear}
|
||||
suffixIcon={selectedIcon ? <IconFont name={selectedIcon} /> : undefined}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('xin.form.iconSelector.modal.title')}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Tabs defaultActiveKey="use" items={tabItems} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconSelect;
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 图标选择器组件属性
|
||||
*/
|
||||
export interface IconSelectProps {
|
||||
/**
|
||||
* 选中的图标名称
|
||||
*/
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* 值变化回调
|
||||
* @param value 选中的图标名称或 null(清空时)
|
||||
*/
|
||||
onChange?: (value: string | null) => void;
|
||||
|
||||
/**
|
||||
* 占位符文本
|
||||
* @default '请选择图标'
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* 是否禁用
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* 是否只读(只读模式下不能打开选择弹窗)
|
||||
* @default false
|
||||
*/
|
||||
readonly?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Upload, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import ImgCrop from 'antd-img-crop';
|
||||
import type { UploadFile, UploadProps } from 'antd';
|
||||
import type { RcFile } from 'antd/es/upload';
|
||||
import type { ImageUploaderProps } from './typings';
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 图片上传组件
|
||||
*/
|
||||
const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
action,
|
||||
value,
|
||||
changeType,
|
||||
onChange,
|
||||
mode = 'single',
|
||||
disabled = false,
|
||||
maxCount,
|
||||
maxWidth = 1920,
|
||||
maxHeight = 1080,
|
||||
maxSize = 5,
|
||||
croppable = false,
|
||||
cropperOptions,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
// 计算最大上传数量
|
||||
const uploadMaxCount = useMemo(() => maxCount || (mode === 'single' ? 1 : 9), [mode, maxCount]);
|
||||
|
||||
// 格式化图片列表
|
||||
const fileToList = (file: ISysFileInfo): UploadFile => ({
|
||||
uid: file.id?.toString() || `${file.id}`,
|
||||
name: file.file_name || `image-${file.id}`,
|
||||
status: 'done',
|
||||
url: file.preview_url,
|
||||
response: {
|
||||
success: true,
|
||||
data: file,
|
||||
message: 'Upload success',
|
||||
},
|
||||
});
|
||||
|
||||
// 默认文件列表
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setFileList([]);
|
||||
return;
|
||||
}
|
||||
const valueArray = Array.isArray(value) ? value : [value];
|
||||
const newFileList: UploadFile[] = valueArray.map(fileToList);
|
||||
setFileList(newFileList);
|
||||
}, [value]);
|
||||
|
||||
// 上传前校验
|
||||
const beforeUpload = useCallback((file: RcFile): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 1. 文件类型校验
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error(t('xin.form.imageUploader.error.notImage'));
|
||||
reject(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 文件大小校验
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(t('xin.form.imageUploader.error.sizeExceeded', { maxSize }));
|
||||
reject(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 图片尺寸校验
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (e) => {
|
||||
const img = document.createElement('img');
|
||||
img.src = e.target?.result as string;
|
||||
img.onload = () => {
|
||||
const isValidWidth = img.width <= maxWidth;
|
||||
const isValidHeight = img.height <= maxHeight;
|
||||
|
||||
if (!isValidWidth || !isValidHeight) {
|
||||
message.error(t('xin.form.imageUploader.error.dimensionExceeded', { maxWidth, maxHeight }));
|
||||
reject(false);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
message.error(t('xin.form.imageUploader.error.loadFailed'));
|
||||
reject(false);
|
||||
};
|
||||
};
|
||||
reader.onerror = () => {
|
||||
message.error(t('xin.form.imageUploader.error.readFailed'));
|
||||
reject(false);
|
||||
};
|
||||
});
|
||||
}, [maxSize, maxWidth, maxHeight]);
|
||||
|
||||
// 处理文件列表变化
|
||||
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
|
||||
// 如果所有文件都被删除
|
||||
if (newFileList.length === 0) return;
|
||||
setFileList(newFileList);
|
||||
// 全部上传完成格式化图片列表
|
||||
if (newFileList.every((file) => file.status === 'done' || file.status === 'error')) {
|
||||
// 上传成功的文件
|
||||
const uploadedFiles = newFileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response.data as ISysFileInfo);
|
||||
if( mode === 'single' ) {
|
||||
if (changeType !== 'id') {
|
||||
onChange?.(uploadedFiles[0]);
|
||||
} else {
|
||||
onChange?.(uploadedFiles[0].id);
|
||||
}
|
||||
} else {
|
||||
if (changeType !== 'id') {
|
||||
onChange?.(uploadedFiles);
|
||||
} else {
|
||||
onChange?.(uploadedFiles.map(item => item.id!));
|
||||
}
|
||||
}
|
||||
// 上传失败的文件
|
||||
const errorFiles = newFileList.filter((file) => file.status === 'error');
|
||||
setFileList([...uploadedFiles.map(fileToList), ...errorFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传按钮
|
||||
const uploadButton = useMemo(() => (
|
||||
<button style={{ border: 0, background: 'none' }} type="button">
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>{t('xin.form.imageUploader.uploadText')}</div>
|
||||
</button>
|
||||
), [t]);
|
||||
|
||||
// Upload 组件
|
||||
const uploadComponent = (
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
name="file"
|
||||
fileList={fileList}
|
||||
beforeUpload={beforeUpload}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
maxCount={uploadMaxCount}
|
||||
multiple={mode === 'multiple'}
|
||||
headers={{
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
}}
|
||||
onRemove={(file) => {
|
||||
setFileList(fileList.filter((item) => {
|
||||
return item.uid != file.uid
|
||||
}))
|
||||
}}
|
||||
action={import.meta.env.VITE_BASE_URL + action}
|
||||
accept="image/*"
|
||||
>
|
||||
{fileList.length >= uploadMaxCount ? null : uploadButton}
|
||||
</Upload>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{croppable && mode === 'single' ? (
|
||||
<ImgCrop {...cropperOptions}>
|
||||
{uploadComponent}
|
||||
</ImgCrop>
|
||||
) : (
|
||||
uploadComponent
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import type { ImgCropProps } from 'antd-img-crop';
|
||||
|
||||
/**
|
||||
* 图片上传器组件属性
|
||||
*/
|
||||
export interface ImageUploaderProps {
|
||||
/**
|
||||
* 上传地址
|
||||
*/
|
||||
action: string;
|
||||
|
||||
/**
|
||||
* 当前值
|
||||
* - 单选模式:ISysFileInfo | null
|
||||
* - 多选模式:ISysFileInfo[]
|
||||
*/
|
||||
value?: ISysFileInfo | ISysFileInfo[] | null;
|
||||
|
||||
/**
|
||||
* 赋值类型
|
||||
* 上传完成后赋值的类型。
|
||||
*/
|
||||
changeType?: 'id' | 'object';
|
||||
|
||||
/**
|
||||
* 上传完成回调
|
||||
* @param value ISysFileInfo | ISysFileInfo[] | null
|
||||
*/
|
||||
onChange?: (value?: ISysFileInfo | ISysFileInfo[] | number | number[] | null) => void;
|
||||
|
||||
/**
|
||||
* 上传模式
|
||||
* @default 'single'
|
||||
*/
|
||||
mode?: 'single' | 'multiple';
|
||||
|
||||
/**
|
||||
* 是否禁用
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* 最大上传数量
|
||||
* @default 1 (单选模式) | 9 (多选模式)
|
||||
*/
|
||||
maxCount?: number;
|
||||
|
||||
/**
|
||||
* 图片宽度限制 (px)
|
||||
* @default 1920
|
||||
*/
|
||||
maxWidth?: number;
|
||||
|
||||
/**
|
||||
* 图片高度限制 (px)
|
||||
* @default 1080
|
||||
*/
|
||||
maxHeight?: number;
|
||||
|
||||
/**
|
||||
* 图片大小限制 (MB)
|
||||
* @default 5
|
||||
*/
|
||||
maxSize?: number;
|
||||
|
||||
/**
|
||||
* 是否支持裁剪
|
||||
* @default false
|
||||
*/
|
||||
croppable?: boolean;
|
||||
|
||||
/**
|
||||
* 剪裁参数
|
||||
*/
|
||||
cropperOptions?: Omit<ImgCropProps, 'children'>;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Modal, Tag, Select, Table, Input, type TableProps, type TableColumnType } from 'antd';
|
||||
import type ISysUser from '@/domain/iSysUser';
|
||||
import { List } from '@/api/common/table';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 用户选择器组件属性
|
||||
*/
|
||||
export interface UserSelectorProps {
|
||||
value?: number | number[] | ISysUser | ISysUser[] | null;
|
||||
onChange?: (value: number | number[] | null) => void;
|
||||
mode?: 'single' | 'multiple';
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
showDept?: boolean;
|
||||
maxTagCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户选择器组件
|
||||
*/
|
||||
const UserSelector: React.FC<UserSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
mode = 'single',
|
||||
placeholder,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
showDept = true,
|
||||
maxTagCount = 2,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<ISysUser[]>([]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
// 表格加载状态
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
// 表格数据
|
||||
const [dataSource, setDataSource] = useState<ISysUser[]>([]);
|
||||
// 分页参数
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
||||
/** 表格查询参数 */
|
||||
const [tableParams, setParams] = useState<{ keywordSearch: string }>();
|
||||
|
||||
// 从 value 加载用户信息
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedUsers([]);
|
||||
setSelectedRowKeys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const valueArray = Array.isArray(value) ? value : [value];
|
||||
|
||||
// 如果是用户对象,直接使用
|
||||
if (typeof valueArray[0] === 'object') {
|
||||
setSelectedUsers(valueArray as ISysUser[]);
|
||||
setSelectedRowKeys(valueArray.map((u: any) => u.id));
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 API 加载用户信息
|
||||
setLoading(true);
|
||||
Promise.all(valueArray.map(id => List<ISysUser>('/sys-user/list', { id: id as number })))
|
||||
.then(results => {
|
||||
const users = results.map(res => res.data.data?.data?.[0]).filter(Boolean) as ISysUser[];
|
||||
setSelectedUsers(users);
|
||||
setSelectedRowKeys(users.map(u => u.id!));
|
||||
})
|
||||
.catch(error => console.error('Failed to load users:', error))
|
||||
.finally(() => setLoading(false));
|
||||
}, [value]);
|
||||
|
||||
// 表格列配置
|
||||
const columns: TableColumnType<ISysUser>[] = useMemo(() => [
|
||||
{
|
||||
title: t('system.user.id'),
|
||||
dataIndex: 'id',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.user.username'),
|
||||
dataIndex: 'username',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.user.nickname'),
|
||||
dataIndex: 'nickname',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.user.mobile'),
|
||||
dataIndex: 'mobile',
|
||||
width: 130,
|
||||
align: 'center',
|
||||
},
|
||||
...(showDept ? [{
|
||||
title: t('system.user.dept'),
|
||||
dataIndex: 'dept_name',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
render: (text: any) => text ? <Tag color="volcano">{text}</Tag> : '-',
|
||||
}] : []),
|
||||
{
|
||||
title: t('system.user.status'),
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (value: number) => {
|
||||
return value === 1
|
||||
? <Tag color="success">{t('system.user.status.1')}</Tag>
|
||||
: <Tag color="error">{t('system.user.status.0')}</Tag>;
|
||||
},
|
||||
},
|
||||
], [t, showDept]);
|
||||
|
||||
// 处理选择确认
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange?.(mode === 'single' ? (selectedUsers[0]?.id || null) : selectedUsers.map(u => u.id!));
|
||||
setOpen(false);
|
||||
}, [mode, selectedUsers, onChange]);
|
||||
|
||||
// 处理行选择
|
||||
const handleRowSelectionChange = useCallback((keys: React.Key[], rows: ISysUser[]) => {
|
||||
if (mode === 'single' && keys.length > 1) {
|
||||
const lastKey = keys[keys.length - 1];
|
||||
const lastRow = rows.find(r => r.id === lastKey)!;
|
||||
setSelectedRowKeys([lastKey]);
|
||||
setSelectedUsers([lastRow]);
|
||||
} else {
|
||||
setSelectedRowKeys(keys);
|
||||
setSelectedUsers(rows);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
// 获取表格数据
|
||||
const fetchTableData = useCallback(async (page = 1, pageSize = 10, keywordSearch?: string) => {
|
||||
setTableLoading(true);
|
||||
try {
|
||||
const api = '/system-user/list';
|
||||
const response = await List<ISysUser>(api, { page, pageSize, keywordSearch });
|
||||
setDataSource(response.data.data?.data || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize,
|
||||
total: response.data.data?.total || 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
setDataSource([]);
|
||||
} finally {
|
||||
setTableLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 打开弹窗时加载数据
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchTableData(1, pagination.pageSize, tableParams?.keywordSearch);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 表格配置
|
||||
const tableProps: TableProps<ISysUser> = useMemo(() => ({
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
dataSource,
|
||||
loading: tableLoading,
|
||||
rowSelection: {
|
||||
type: mode === 'single' ? 'radio' : 'checkbox',
|
||||
selectedRowKeys,
|
||||
onChange: handleRowSelectionChange,
|
||||
preserveSelectedRowKeys: true,
|
||||
},
|
||||
pagination: {
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
onChange: (page, pageSize) => {
|
||||
fetchTableData(page, pageSize, tableParams?.keywordSearch);
|
||||
},
|
||||
},
|
||||
bordered: true,
|
||||
size: 'small',
|
||||
scroll: { x: 800 },
|
||||
}), [columns, dataSource, tableLoading, mode, selectedRowKeys, handleRowSelectionChange, pagination, tableParams, fetchTableData]);
|
||||
|
||||
// 处理 Select 的 onChange
|
||||
const handleSelectChange = useCallback((selectedValue: number | number[]) => {
|
||||
if (mode === 'single') {
|
||||
const user = selectedUsers.find(u => u.id === selectedValue);
|
||||
if (!user && selectedValue) {
|
||||
// 如果选中的用户不在列表中,需要从 API 加载
|
||||
List<ISysUser>('/sys-user/list', { id: selectedValue as number })
|
||||
.then(res => {
|
||||
const loadedUser = res.data.data?.data?.[0];
|
||||
if (loadedUser) {
|
||||
setSelectedUsers([loadedUser]);
|
||||
setSelectedRowKeys([loadedUser.id!]);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Failed to load user:', error));
|
||||
}
|
||||
onChange?.(selectedValue as number || null);
|
||||
} else {
|
||||
onChange?.(selectedValue as number[]);
|
||||
}
|
||||
}, [mode, selectedUsers, onChange]);
|
||||
|
||||
// 生成 Select 的 options
|
||||
const selectOptions = useMemo(() => {
|
||||
return selectedUsers.map(user => ({
|
||||
label: user.nickname || user.username,
|
||||
value: user.id!,
|
||||
}));
|
||||
}, [selectedUsers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
mode={mode === 'multiple' ? 'multiple' : undefined}
|
||||
value={mode === 'single'
|
||||
? (selectedUsers[0]?.id || undefined)
|
||||
: selectedUsers.map(u => u.id!)
|
||||
}
|
||||
onChange={handleSelectChange}
|
||||
placeholder={placeholder || t('xin.form.userSelector.placeholder')}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
maxTagCount={maxTagCount}
|
||||
open={false}
|
||||
onClick={() => !disabled && !readonly && setOpen(true)}
|
||||
options={selectOptions}
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
onClear={() => {
|
||||
setSelectedUsers([]);
|
||||
setSelectedRowKeys([]);
|
||||
onChange?.(mode === 'single' ? null : []);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('xin.form.userSelector.modal.title')}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={handleSelect}
|
||||
width={1000}
|
||||
okText={t('xin.form.userSelector.modal.okText')}
|
||||
cancelText={t('xin.form.userSelector.modal.cancelText')}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Input.Search
|
||||
placeholder={t("system.user.searchPlaceholder")}
|
||||
style={{ width: 304 }}
|
||||
onSearch={(value: string) => {
|
||||
setParams({ keywordSearch: value });
|
||||
fetchTableData(1, pagination.pageSize, value);
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<Table {...tableProps} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSelector;
|
||||
@@ -0,0 +1,68 @@
|
||||
import type ISysUser from '@/domain/iSysUser';
|
||||
|
||||
/**
|
||||
* 用户选择器的值类型
|
||||
* 支持单个ID、ID数组、或完整的用户对象
|
||||
*/
|
||||
export type UserSelectorValue =
|
||||
| number
|
||||
| number[]
|
||||
| ISysUser
|
||||
| ISysUser[]
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* 用户选择器组件属性
|
||||
*/
|
||||
export interface UserSelectorProps {
|
||||
/**
|
||||
* 选中的值
|
||||
* - 单选模式:number | ISysUser | null
|
||||
* - 多选模式:number[] | ISysUser[]
|
||||
*/
|
||||
value?: UserSelectorValue;
|
||||
|
||||
/**
|
||||
* 值变化回调
|
||||
* - 单选模式:返回 number | null
|
||||
* - 多选模式:返回 number[]
|
||||
*/
|
||||
onChange?: (value: number | number[] | null) => void;
|
||||
|
||||
/**
|
||||
* 选择模式
|
||||
* @default 'single'
|
||||
*/
|
||||
mode?: 'single' | 'multiple';
|
||||
|
||||
/**
|
||||
* 占位符文本
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* 是否禁用
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* 是否只读(只读模式下不能打开选择弹窗)
|
||||
* @default false
|
||||
*/
|
||||
readonly?: boolean;
|
||||
|
||||
/**
|
||||
* 是否显示部门信息
|
||||
* @default true
|
||||
*/
|
||||
showDept?: boolean;
|
||||
|
||||
/**
|
||||
* 多选模式下最多显示的标签数量
|
||||
* 超出部分会显示 +N
|
||||
* @default 2
|
||||
*/
|
||||
maxTagCount?: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import IconSelector from './IconSelector';
|
||||
import ImageUploader from './ImageUploader';
|
||||
import UserSelector from './UserSelector';
|
||||
|
||||
export default {
|
||||
IconSelector,
|
||||
ImageUploader,
|
||||
UserSelector
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {
|
||||
Form,
|
||||
Button,
|
||||
Space, Row, Col,
|
||||
} from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SearchFormProps, SubmitterButton } from './typings';
|
||||
import type { FormItemProps } from 'antd';
|
||||
import type { FormColumn } from "@/components/XinFormField/FieldRender";
|
||||
import FieldRender from '@/components/XinFormField/FieldRender';
|
||||
import { pick } from 'lodash';
|
||||
|
||||
/**
|
||||
* SearchForm - JSON 配置动态搜索表单组件
|
||||
*/
|
||||
function SearchForm<T extends Record<string, any> = any>(props: SearchFormProps<T>) {
|
||||
const {
|
||||
columns,
|
||||
handleSearch,
|
||||
submitter,
|
||||
form: formRef,
|
||||
grid = true,
|
||||
rowProps = {
|
||||
gutter: [20, 20],
|
||||
wrap: true
|
||||
},
|
||||
colProps = {
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
md: 12,
|
||||
lg: 8,
|
||||
xl: 6,
|
||||
xxl: 4
|
||||
},
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm<T>(formRef);
|
||||
|
||||
// 渲染表单项
|
||||
const renderFormItem = useCallback((column: FormColumn<T>, index: number): React.ReactNode => {
|
||||
const {
|
||||
dataIndex,
|
||||
valueType,
|
||||
title = '',
|
||||
fieldProps = {},
|
||||
fieldRender
|
||||
} = column;
|
||||
|
||||
// Form.Item 允许的属性列表
|
||||
const formItemPropKeys = [
|
||||
'colon', 'extra', 'getValueFromEvent', 'help', 'htmlFor',
|
||||
'initialValue', 'labelAlign', 'labelCol', 'name', 'normalize',
|
||||
'noStyle', 'tooltip', 'wrapperCol', 'layout'
|
||||
];
|
||||
const formItemProps = pick(column, formItemPropKeys);
|
||||
|
||||
const key = String(dataIndex) || `form-item-${index}`;
|
||||
|
||||
const defaultFieldRender = (
|
||||
<FieldRender
|
||||
valueType={valueType}
|
||||
placeholder={title}
|
||||
{...fieldProps}
|
||||
/>
|
||||
);
|
||||
|
||||
const formItemContent = (
|
||||
<Form.Item
|
||||
style={{marginBottom: 0}}
|
||||
key={key}
|
||||
name={key}
|
||||
label={column.title}
|
||||
{...formItemProps as FormItemProps}
|
||||
required={false}
|
||||
>
|
||||
{fieldRender ? fieldRender(form) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
return grid ? <Col {...colProps} {...column.colProps} key={key}>{formItemContent}</Col> : formItemContent;
|
||||
}, [form, grid, colProps]);
|
||||
|
||||
// 渲染提交按钮
|
||||
const renderSubmitter = useMemo(() => {
|
||||
if (submitter?.render === false) return null;
|
||||
|
||||
const submitButton = (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => form.submit()}
|
||||
{...submitter?.submitButtonProps}
|
||||
>
|
||||
{submitter?.submitText || t('xin.table.search.search')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const resetButton = (
|
||||
<Button
|
||||
onClick={() => form.resetFields()}
|
||||
{...submitter?.resetButtonProps}
|
||||
>
|
||||
{submitter?.resetText || t('xin.table.search.reset')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const buttons: SubmitterButton = {
|
||||
search: submitButton,
|
||||
reset: resetButton
|
||||
};
|
||||
|
||||
if (typeof submitter?.render === 'function') {
|
||||
return submitter.render(buttons);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item style={{marginBottom: 0}}>
|
||||
<Space size={16}>
|
||||
{buttons.reset}
|
||||
{buttons.search}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
);
|
||||
}, [form, submitter, t]);
|
||||
|
||||
// 表单内容
|
||||
return (
|
||||
<Form
|
||||
{...formProps}
|
||||
form={form}
|
||||
onFinish={handleSearch}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{grid ? (
|
||||
<Row {...rowProps}>
|
||||
{columns.map((column, index) => renderFormItem(column, index))}
|
||||
<Col {...colProps}>{renderSubmitter}</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<>
|
||||
{columns.map((column, index) => renderFormItem(column, index))}
|
||||
{renderSubmitter}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchForm;
|
||||
export type { SearchFormProps };
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ButtonProps, ColProps, FormInstance, FormProps, RowProps } from "antd";
|
||||
import type { ReactNode } from "react";
|
||||
import type { FormColumn } from "@/components/XinFormField/FieldRender";
|
||||
|
||||
/**
|
||||
* 操作栏按钮
|
||||
*/
|
||||
export type SubmitterButton = {
|
||||
/** 查询按钮 */
|
||||
search: ReactNode;
|
||||
/** 重置按钮 */
|
||||
reset: ReactNode;
|
||||
/** 折叠按钮 */
|
||||
collapse?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单操作栏属性
|
||||
*/
|
||||
export interface SubmitterProps {
|
||||
/** 操作栏渲染 */
|
||||
render?: false | ((dom: SubmitterButton) => ReactNode);
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string | ReactNode;
|
||||
/** 重置按钮文本 */
|
||||
resetText?: string | ReactNode;
|
||||
/** 提交按钮属性 */
|
||||
submitButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
/** 重置按钮属性 */
|
||||
resetButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
/** 折叠按钮渲染 */
|
||||
collapseRender?: (collapse: boolean) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinSearch 组件属性
|
||||
*/
|
||||
export type SearchFormProps<T = any> = Omit<FormProps<T>, 'onFinish' | 'form'> & {
|
||||
/** 表单列配置 */
|
||||
columns: FormColumn<T>[];
|
||||
/** 表单实例引用 */
|
||||
form?: FormInstance<T>;
|
||||
/** 表单提交 */
|
||||
handleSearch?: (values: T) => Promise<boolean | void>;
|
||||
/** 渲染表单操作栏 */
|
||||
submitter?: SubmitterProps;
|
||||
/** 是否使用 Grid 布局 */
|
||||
grid?: boolean;
|
||||
/** 开启 grid 模式时传递给 Row */
|
||||
rowProps?: RowProps;
|
||||
/** 传递给表单项的 Col */
|
||||
colProps?: ColProps;
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import {
|
||||
Button,
|
||||
Tree,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
Table,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Dropdown,
|
||||
type TableProps,
|
||||
type TableColumnType,
|
||||
type TreeProps, ConfigProvider, Flex, Divider,
|
||||
} from "antd";
|
||||
import type {XinTableProps, XinTableInstance, RequestParams, FormMode} from "./typings";
|
||||
import SearchForm from "./SearchForm";
|
||||
import XinForm, {type XinFormRef} from "@/components/XinForm";
|
||||
import {type ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from "react";
|
||||
import type {FormColumn} from "@/components/XinFormField/FieldRender/typings";
|
||||
import {Delete, List, Create, Update} from "@/api/common/table.ts";
|
||||
import {isArray, isEmpty, omit} from "lodash";
|
||||
import type {SearchProps} from "antd/es/input";
|
||||
import {
|
||||
BorderlessTableOutlined,
|
||||
BorderOutlined,
|
||||
ColumnHeightOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined
|
||||
} from "@ant-design/icons";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type {DataNode} from "antd/es/tree";
|
||||
import AuthButton from "@/components/AuthButton";
|
||||
|
||||
// 默认分页大小
|
||||
const DEFAULT_PAGE_SIZE: number = 10;
|
||||
// 默认页数
|
||||
const DEFAULT_PAGE: number = 1;
|
||||
|
||||
export default function XinTable<T extends Record<string, any> = any>(props: XinTableProps<T>) {
|
||||
const {
|
||||
api,
|
||||
accessName,
|
||||
rowKey,
|
||||
columns,
|
||||
tableRef,
|
||||
|
||||
formProps,
|
||||
modalProps,
|
||||
cardProps,
|
||||
searchProps,
|
||||
operateProps = {},
|
||||
pagination: customPagination = {},
|
||||
|
||||
addShow = true,
|
||||
editShow = true,
|
||||
deleteShow = true,
|
||||
searchShow = true,
|
||||
operateShow = true,
|
||||
paginationShow = true,
|
||||
keywordSearchShow = true,
|
||||
|
||||
toolBarRender: customToolBalRender,
|
||||
actionBarRender: customActionBarRender,
|
||||
operateRender: customOperateRender,
|
||||
|
||||
handleRequest: customHandleRequest,
|
||||
requestParams: customRequestParams,
|
||||
handleFinish: customHandleFinish,
|
||||
} = props;
|
||||
|
||||
const {t} = useTranslation();
|
||||
const formRef = useRef<XinFormRef<T>>(null);
|
||||
|
||||
// 内部状态
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [dataSource, setDataSource] = useState<T[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [requestParams, setRequestParams] = useState<RequestParams>({
|
||||
page: DEFAULT_PAGE,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [density, setDensity] = useState<TableProps['size']>();
|
||||
const [bordered, setBordered] = useState<boolean>();
|
||||
|
||||
const [searchRef] = Form.useForm<T>();
|
||||
const [columnsChecked, setColumnsChecked] = useState<any[]>([]);
|
||||
const [searchRender, setSearchRender] = useState<boolean>(false);
|
||||
|
||||
// 表单模式状态
|
||||
const [formMode, setFormMode] = useState<FormMode>('create');
|
||||
const [formDefaultValues, setFormDefaultValues] = useState<T | undefined>(undefined);
|
||||
|
||||
/** 表格请求 */
|
||||
const handleRequest = useCallback(async (params?: RequestParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const defaultParams: RequestParams = Object.assign({
|
||||
page: DEFAULT_PAGE,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
}, params);
|
||||
// 自定义参数处理
|
||||
const finalParams: RequestParams = customRequestParams ? customRequestParams(defaultParams) : defaultParams;
|
||||
// 自定义请求
|
||||
let listData: { data: T[]; total: number };
|
||||
if (customHandleRequest) {
|
||||
listData = await customHandleRequest(finalParams);
|
||||
} else {
|
||||
const { data } = await List<T>(api, finalParams);
|
||||
listData = data.data!;
|
||||
}
|
||||
setDataSource(listData.data);
|
||||
setTotal(listData.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api, customHandleRequest, customRequestParams]);
|
||||
|
||||
/** 新增按钮点击 */
|
||||
const handleCreate = useCallback(() => {
|
||||
setFormMode('create');
|
||||
setFormDefaultValues(undefined);
|
||||
formRef.current?.resetFields();
|
||||
formRef.current?.open();
|
||||
}, []);
|
||||
|
||||
/** 修改按钮点击 */
|
||||
const handleUpdate = useCallback((record: T) => {
|
||||
setFormMode('update');
|
||||
setFormDefaultValues(record);
|
||||
formRef.current?.setFieldsValue(record);
|
||||
formRef.current?.open();
|
||||
}, []);
|
||||
|
||||
// 暴露方法到 tableRef
|
||||
useImperativeHandle(tableRef, (): XinTableInstance<T> => ({
|
||||
reload: async () => { await handleRequest(); },
|
||||
reset: async () => {
|
||||
searchRef.resetFields();
|
||||
setRequestParams({ page: 1, pageSize: 10 });
|
||||
await handleRequest({ page: 1, pageSize: 10 });
|
||||
},
|
||||
getDataSource: () => dataSource,
|
||||
setDataSource,
|
||||
getTotal: () => total,
|
||||
getLoading: () => loading,
|
||||
setLoading,
|
||||
setPageInfo: (page?, pageSize?) => {
|
||||
setRequestParams(prev => ({
|
||||
...prev,
|
||||
...(page !== undefined && { page }),
|
||||
...(pageSize !== undefined && { pageSize }),
|
||||
}));
|
||||
},
|
||||
getForm: () => formRef.current,
|
||||
getSearchForm: () => searchRef,
|
||||
}));
|
||||
|
||||
/** 初始化 */
|
||||
useEffect(() => { void handleRequest(); }, []);
|
||||
|
||||
/** 处理表格变化 */
|
||||
const handleTableChange: TableProps<T>['onChange'] = async (newPagination, newFilters, newSorter) => {
|
||||
const params: RequestParams = {
|
||||
...requestParams,
|
||||
page: newPagination.current ?? requestParams.page,
|
||||
pageSize: newPagination.pageSize ?? requestParams.pageSize,
|
||||
};
|
||||
// 处理筛选
|
||||
if (!isEmpty(newFilters)) {
|
||||
params.filterValues = newFilters;
|
||||
}
|
||||
// 处理排序
|
||||
if (newSorter && !isArray(newSorter) && !isEmpty(newSorter) && newSorter.field) {
|
||||
params.sorterValue = {
|
||||
field: String(newSorter.field),
|
||||
order: newSorter.order === 'ascend' ? 'asc' : 'desc',
|
||||
};
|
||||
} else {
|
||||
delete params.sorterValue;
|
||||
}
|
||||
setRequestParams(params);
|
||||
await handleRequest(params);
|
||||
};
|
||||
|
||||
/** 快速搜索 */
|
||||
const handleKeywordSearch: SearchProps['onSearch'] = async (value: string) => {
|
||||
if( !value ) {
|
||||
window.$message?.warning(t('xin.table.keywordEmpty'));
|
||||
return;
|
||||
}
|
||||
const params: RequestParams = {
|
||||
...requestParams,
|
||||
page: 1,
|
||||
keywordSearch: value,
|
||||
}
|
||||
setRequestParams(params);
|
||||
await handleRequest(params);
|
||||
};
|
||||
|
||||
/** 快速搜索 Change */
|
||||
const keywordSearchChange: SearchProps['onChange'] = (e) => {
|
||||
if( !e.target.value ) {
|
||||
const params = { ...requestParams };
|
||||
delete params.keywordSearch;
|
||||
setRequestParams(params);
|
||||
} else {
|
||||
setRequestParams({
|
||||
...requestParams,
|
||||
keywordSearch: e.target.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** 搜索表单提交 */
|
||||
const handleSearch = async () => {
|
||||
const searchValues: T = searchRef.getFieldsValue();
|
||||
// 移除 空值
|
||||
Object.keys(searchValues).forEach((key) => {
|
||||
if (searchValues[key] === '' || searchValues[key] === undefined) {
|
||||
delete searchValues[key];
|
||||
}
|
||||
});
|
||||
await handleRequest({
|
||||
page: 1,
|
||||
...requestParams,
|
||||
...searchValues,
|
||||
});
|
||||
};
|
||||
|
||||
/** 搜索列 */
|
||||
const searchColumn: FormColumn<T>[] = useMemo(() => {
|
||||
if(!searchShow) return [];
|
||||
return columns.filter((column) => column.hideInSearch !== true);
|
||||
}, [columns, searchShow]);
|
||||
|
||||
/** 表单列 */
|
||||
const formColumn: FormColumn<T>[] = useMemo(() => {
|
||||
return columns.filter((column) => {
|
||||
if(formMode === 'update') {
|
||||
return column.hideInForm !== true && column.hideInUpdate !== true;
|
||||
} else {
|
||||
return column.hideInForm !== true && column.hideInCreate !== true;
|
||||
}
|
||||
});
|
||||
}, [columns, formMode]);
|
||||
|
||||
/** 默认表格列 */
|
||||
const defaultTableColumns = useMemo(() => {
|
||||
return columns
|
||||
.filter((column) => column.hideInTable !== true && column.dataIndex)
|
||||
.map(column => omit(column, ['hideInTable', 'hideInForm', 'hideInSearch', 'search']));
|
||||
}, [columns]);
|
||||
|
||||
/** 初始化列设置树数据 */
|
||||
useEffect(() => {
|
||||
const dataIndexList = defaultTableColumns.map(item => item.dataIndex!);
|
||||
setColumnsChecked(dataIndexList);
|
||||
}, [defaultTableColumns]);
|
||||
|
||||
/** 表格操作列渲染 */
|
||||
const operateRender = (record: T): ReactNode[] => {
|
||||
// 编辑按钮渲染
|
||||
const editRender = (): ReactNode => {
|
||||
const show = typeof editShow === 'function' ? editShow(record) : editShow;
|
||||
return show ? (
|
||||
<AuthButton auth={props.accessName + '.update'} key={'update'}>
|
||||
<Tooltip title={t('xin.table.edit')}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
size={'small'}
|
||||
onClick={() => handleUpdate(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</AuthButton>
|
||||
) : null;
|
||||
}
|
||||
// 删除按钮渲染
|
||||
const deleteRender = (): ReactNode => {
|
||||
const show = typeof deleteShow === 'function' ? deleteShow(record) : deleteShow;
|
||||
return show ? (
|
||||
<AuthButton auth={props.accessName + '.delete'} key={'delete'}>
|
||||
<Tooltip title={t('xin.table.delete')}>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
icon={<DeleteOutlined />}
|
||||
size={'small'}
|
||||
onClick={() => handleDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</AuthButton>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (customOperateRender) {
|
||||
return customOperateRender(record, {
|
||||
del: deleteRender(),
|
||||
edit: editRender()
|
||||
});
|
||||
}
|
||||
return [deleteRender(), editRender()];
|
||||
};
|
||||
|
||||
/** 删除记录 */
|
||||
const handleDelete = async (record: T) => {
|
||||
window.$modal?.confirm({
|
||||
title: t('xin.table.deleteConfirm', { id: record[rowKey] }),
|
||||
okText: t('xin.table.deleteOk'),
|
||||
cancelText: t('xin.table.deleteCancel'),
|
||||
onOk: async () => {
|
||||
await Delete(api + `/${record[rowKey]}`);
|
||||
window.$message?.success(t('xin.table.deleteSuccess'));
|
||||
await handleRequest(requestParams);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
/** 表单提交处理 */
|
||||
const handleFinish = async (values: T) => {
|
||||
try {
|
||||
formRef.current?.setLoading(true);
|
||||
// 如果有自定义 onFinish,优先调用
|
||||
if (customHandleFinish) {
|
||||
const result = await customHandleFinish(values, formMode, formRef, formDefaultValues);
|
||||
if (result) {
|
||||
await handleRequest(requestParams);
|
||||
formRef.current?.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (formMode === 'create') {
|
||||
await Create(api, values);
|
||||
window.$message?.success(t('xin.table.form.createSuccess'));
|
||||
} else {
|
||||
if (formDefaultValues && rowKey) {
|
||||
await Update(api + `/${formDefaultValues[rowKey]}`, values);
|
||||
window.$message?.success(t('xin.table.form.updateSuccess'));
|
||||
} else {
|
||||
window.$message?.error(t('xin.table.form.updateKeyUndefined'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await handleRequest(requestParams);
|
||||
formRef.current?.close();
|
||||
} finally {
|
||||
formRef.current?.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 密度设置菜单 */
|
||||
const densityMenu = {
|
||||
items: [
|
||||
{
|
||||
key: 'large',
|
||||
label: t('xin.table.density.default'),
|
||||
onClick: () => setDensity('large'),
|
||||
},
|
||||
{
|
||||
key: 'middle',
|
||||
label: t('xin.table.density.middle'),
|
||||
onClick: () => setDensity('middle'),
|
||||
},
|
||||
{
|
||||
key: 'small',
|
||||
label: t('xin.table.density.compact'),
|
||||
onClick: () => setDensity('small'),
|
||||
},
|
||||
],
|
||||
selectedKeys: [density || 'large'],
|
||||
};
|
||||
|
||||
/** 列设置树数据 */
|
||||
const columnTreeData: DataNode[] = useMemo(() => {
|
||||
const filteredColumns = columns.filter(item => item.hideInTable !== true && item.dataIndex);
|
||||
return filteredColumns
|
||||
.map((item) => ({
|
||||
key: String(item.dataIndex),
|
||||
title: item.title,
|
||||
}));
|
||||
}, [columns]);
|
||||
|
||||
/** 列设置选中改变 */
|
||||
const columnSettingCheck: TreeProps['onCheck'] = (keys) => {
|
||||
if(isArray(keys)) {
|
||||
setColumnsChecked(keys);
|
||||
}
|
||||
}
|
||||
|
||||
/** 最终表格列,计算排序以及显示状态 */
|
||||
const tableColumns: TableColumnType<T>[] = useMemo(() => {
|
||||
// 过滤出选中的列
|
||||
const filteredColumns = defaultTableColumns.filter(column =>
|
||||
columnsChecked.includes(column.dataIndex as any)
|
||||
);
|
||||
if(! operateShow) {
|
||||
return filteredColumns;
|
||||
}
|
||||
return [
|
||||
...filteredColumns,
|
||||
{
|
||||
title: t('xin.table.operate'),
|
||||
key: 'operate',
|
||||
align: 'center',
|
||||
...operateProps,
|
||||
render: (_, record: T) => (
|
||||
<Space>{...operateRender(record)}</Space>
|
||||
),
|
||||
}
|
||||
];
|
||||
}, [defaultTableColumns, columnsChecked, operateShow]);
|
||||
|
||||
/** 分页配置 */
|
||||
const paginationProps: TableProps['pagination'] = {
|
||||
showQuickJumper: true,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => t('xin.table.total', { total }),
|
||||
pageSize: requestParams.pageSize,
|
||||
...customPagination,
|
||||
current: requestParams.page,
|
||||
total,
|
||||
}
|
||||
|
||||
/** 顶部操作栏渲染 */
|
||||
const actionBarRender = (): ReactNode[] => {
|
||||
// 新增按钮渲染
|
||||
const addButtonRender = addShow ? (
|
||||
<AuthButton auth={accessName + '.create'}>
|
||||
<Button type="primary" onClick={handleCreate} icon={<PlusOutlined />}>{t('xin.table.add')}</Button>
|
||||
</AuthButton>
|
||||
) : null;
|
||||
// 搜索按钮渲染
|
||||
const searchButtonRender = searchShow ? (
|
||||
<Button type="primary" icon={<SearchOutlined/>} onClick={() => setSearchRender(!searchRender)}>
|
||||
搜索
|
||||
</Button>
|
||||
) : null;
|
||||
// 关键字搜索按钮渲染
|
||||
const keywordSearchRender = keywordSearchShow ? (
|
||||
<Input.Search
|
||||
onChange={keywordSearchChange}
|
||||
placeholder={t('xin.table.keywordPlaceholder')}
|
||||
style={{ width: 200 }}
|
||||
value={requestParams.keywordSearch}
|
||||
onSearch={handleKeywordSearch}
|
||||
/>
|
||||
) : null;
|
||||
// 自定义顶部操作栏渲染
|
||||
if (customActionBarRender) {
|
||||
return customActionBarRender({
|
||||
add: addButtonRender,
|
||||
search: searchButtonRender,
|
||||
keywordSearch: keywordSearchRender
|
||||
}).filter(Boolean);
|
||||
} else {
|
||||
return [addButtonRender, searchButtonRender, keywordSearchRender].filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶部工具栏渲染 */
|
||||
const toolBarRender = (): ReactNode[] => {
|
||||
// 刷新
|
||||
const reload = (
|
||||
<Tooltip title={'刷新表格'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => handleRequest()}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
// 密度设置
|
||||
const columnHeight = (
|
||||
<Dropdown menu={densityMenu} trigger={['click']}>
|
||||
<Button type="text" icon={<ColumnHeightOutlined />} />
|
||||
</Dropdown>
|
||||
);
|
||||
// 边框设置
|
||||
const hideBorder = (
|
||||
<Tooltip title={bordered ? t('xin.table.hideBorder') : t('xin.table.showBorder')}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={bordered ? <BorderOutlined /> : <BorderlessTableOutlined />}
|
||||
onClick={() => setBordered(!bordered)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
// 列设置
|
||||
const columnSetting = (
|
||||
<Popover
|
||||
content={(
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: { Tree: { switcherSize: 12 } },
|
||||
}}
|
||||
>
|
||||
<Tree
|
||||
icon={false}
|
||||
blockNode
|
||||
checkable={true}
|
||||
treeData={columnTreeData}
|
||||
selectable={false}
|
||||
checkedKeys={columnsChecked}
|
||||
onCheck={columnSettingCheck}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
|
||||
)}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={t('xin.table.columnSettings')}
|
||||
>
|
||||
<Tooltip title={t('xin.table.columnSettings')}>
|
||||
<Button type="text" icon={<SettingOutlined />} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
// 自定义顶部操作栏渲染
|
||||
if (customToolBalRender) {
|
||||
return customToolBalRender({reload, columnHeight, hideBorder, columnSetting}).filter(Boolean);
|
||||
} else {
|
||||
return [reload, columnHeight, hideBorder, columnSetting];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card {...cardProps}>
|
||||
{/* 搜索表单 */}
|
||||
{ searchRender && (
|
||||
<>
|
||||
<SearchForm<T>
|
||||
form={searchRef}
|
||||
columns={searchColumn}
|
||||
handleSearch={handleSearch}
|
||||
{...searchProps}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{/* 操作栏 */}
|
||||
<Flex justify={'space-between'} align={'center'} style={{ marginBottom: 20 }}>
|
||||
<Space>{ ...actionBarRender() }</Space>
|
||||
<Space size={1}>{...toolBarRender()}</Space>
|
||||
</Flex>
|
||||
{/* 表格 */}
|
||||
<Table
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
size={density}
|
||||
bordered={bordered}
|
||||
{...props}
|
||||
columns={tableColumns}
|
||||
rowKey={rowKey}
|
||||
onChange={handleTableChange}
|
||||
pagination={paginationShow ? paginationProps : false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新增编辑表单 */}
|
||||
<XinForm
|
||||
{...formProps}
|
||||
columns={formColumn}
|
||||
formRef={formRef}
|
||||
layoutType="ModalForm"
|
||||
modalProps={{
|
||||
title: formMode === 'update' ? t('xin.table.form.editTitle') : t('xin.table.form.createTitle'),
|
||||
styles: { header: { marginBottom: 16 } },
|
||||
...modalProps,
|
||||
}}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type {
|
||||
TableProps,
|
||||
TableColumnType,
|
||||
CardProps,
|
||||
FormInstance,
|
||||
PaginationProps,
|
||||
} from 'antd';
|
||||
import type {ReactNode, RefObject, Dispatch, SetStateAction} from 'react';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
import type { SearchFormProps } from './SearchForm';
|
||||
import type {XinFormProps, XinFormRef} from '@/components/XinForm/typings';
|
||||
|
||||
/**
|
||||
* 表单模式
|
||||
*/
|
||||
export type FormMode = 'create' | 'update';
|
||||
|
||||
/**
|
||||
* 表格列配置
|
||||
*/
|
||||
export type XinTableColumn<T = any> = Omit<TableColumnType<T>, 'dataIndex'> & {
|
||||
hideInSearch?: boolean;
|
||||
hideInForm?: boolean;
|
||||
hideInTable?: boolean;
|
||||
hideInUpdate?: boolean;
|
||||
hideInCreate?: boolean;
|
||||
search?: FormColumn<T>;
|
||||
} & FormColumn<T>;
|
||||
|
||||
/**
|
||||
* 表格操作栏按钮
|
||||
*/
|
||||
export type OperateNode = {
|
||||
/** 删除按钮 */
|
||||
del: ReactNode;
|
||||
/** 编辑按钮 */
|
||||
edit: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部操作按钮渲染
|
||||
*/
|
||||
export type ActionNode = {
|
||||
/** 新增按钮 */
|
||||
add: ReactNode;
|
||||
/** 搜索按钮 */
|
||||
search: ReactNode;
|
||||
/** 关键字搜索框 */
|
||||
keywordSearch: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部右侧工具栏渲染
|
||||
*/
|
||||
export type ToolBarNode = {
|
||||
/** 刷新按钮 */
|
||||
reload: ReactNode;
|
||||
/** 密度设置按钮 */
|
||||
columnHeight: ReactNode;
|
||||
/** 边框设置按钮 */
|
||||
hideBorder: ReactNode;
|
||||
/** 列设置按钮 */
|
||||
columnSetting: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinTable 实例
|
||||
*/
|
||||
export interface XinTableInstance<T = any> {
|
||||
/** 刷新表格(保持当前页) */
|
||||
reload: (resetPage?: boolean) => Promise<void>;
|
||||
/** 重置表格到第一页并刷新 */
|
||||
reset: () => Promise<void>;
|
||||
/** 获取当前数据源 */
|
||||
getDataSource: () => T[];
|
||||
/** 设置数据源 */
|
||||
setDataSource: Dispatch<SetStateAction<T[]>>;
|
||||
/** 获取数据总数 */
|
||||
getTotal: () => number;
|
||||
/** 获取加载状态 */
|
||||
getLoading: () => boolean;
|
||||
/** 设置加载状态 */
|
||||
setLoading: Dispatch<SetStateAction<boolean>>;
|
||||
/** 设置分页参数 */
|
||||
setPageInfo: (page?: number, pageSize?: number) => void;
|
||||
/** 获取表单实例 */
|
||||
getForm: () => XinFormRef<T> | null | undefined;
|
||||
/** 获取搜索表单实例 */
|
||||
getSearchForm: () => FormInstance<T> | undefined;
|
||||
}
|
||||
|
||||
export interface SorterParams {
|
||||
field: string;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** 请求参数类型 */
|
||||
export interface RequestParams extends Record<string, any> {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
filter?: Record<string, any>;
|
||||
sorter?: SorterParams;
|
||||
keywordSearch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinTable 组件属性
|
||||
*/
|
||||
export interface XinTableProps<T = any> extends Omit<TableProps<T>, 'columns' | 'rowKey' | 'onChange' | 'pagination'> {
|
||||
/** API 地址 */
|
||||
api: string;
|
||||
/** 权限名称前缀 */
|
||||
accessName: string;
|
||||
/** 主键 */
|
||||
rowKey: string;
|
||||
/** 列配置 */
|
||||
columns: XinTableColumn<T>[];
|
||||
|
||||
/** 表格 ref */
|
||||
tableRef?: RefObject<XinTableInstance<T> | null>;
|
||||
|
||||
/** 新增按钮显示 */
|
||||
addShow?: boolean;
|
||||
/** 编辑按钮显示 */
|
||||
editShow?: boolean | ((record: T) => boolean);
|
||||
/** 删除按钮显示 */
|
||||
deleteShow?: boolean | ((record: T) => boolean);
|
||||
/** 搜索栏显示 */
|
||||
searchShow?: boolean;
|
||||
/** 表格操作列显示 */
|
||||
operateShow?: boolean;
|
||||
/** 分页显示 */
|
||||
paginationShow?: boolean;
|
||||
/** 快速搜索显示 */
|
||||
keywordSearchShow?: boolean;
|
||||
|
||||
/** 表单属性 */
|
||||
formProps?: Omit<XinFormProps<T>, 'onFinish' | 'modalProps' | 'columns' | 'formRef' | 'layoutType'> | false;
|
||||
/** 表单属性 */
|
||||
modalProps?: XinFormProps<T>['modalProps'];
|
||||
/** 搜索栏属性 */
|
||||
searchProps?: Omit<SearchFormProps<T>, 'form'> | false;
|
||||
/** 操作栏属性 */
|
||||
operateProps?: TableColumnType<T>;
|
||||
/** 卡片属性 */
|
||||
cardProps?: Pick<CardProps, 'variant' | 'hoverable' | 'size' | 'classNames' | 'styles'>;
|
||||
/** 分页配置 */
|
||||
pagination?: Omit<PaginationProps, 'total' | 'onChange' | 'current'>;
|
||||
|
||||
/** 顶部操作栏渲染 */
|
||||
actionBarRender?: ((dom: ActionNode) => ReactNode[]);
|
||||
/** 工具栏渲染 */
|
||||
toolBarRender?: ((dom: ToolBarNode) => ReactNode[]);
|
||||
/** 表格操作栏渲染 */
|
||||
operateRender?: ((record: T, dom: OperateNode) => ReactNode[]);
|
||||
|
||||
/** 自定义请求 */
|
||||
handleRequest?: (params: RequestParams) => Promise<{ data: T[]; total: number }>;
|
||||
/** 请求参数处理 */
|
||||
requestParams?: (params: RequestParams) => RequestParams;
|
||||
/** 自定义表单请求 */
|
||||
handleFinish?: (values: T, mode: FormMode, formRef: RefObject<XinFormRef<T> | null>, defaultValue?: T) => Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface IAgent {
|
||||
id?: number;
|
||||
namespace: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description?: string | null;
|
||||
tags?: string[];
|
||||
enabled: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface IAgentConversation {
|
||||
id?: string;
|
||||
user_id?: number | null;
|
||||
username?: string;
|
||||
title?: string;
|
||||
message_count?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface IAgentMessage {
|
||||
id?: string;
|
||||
conversation_id?: string;
|
||||
user_id?: number | null;
|
||||
agent?: string;
|
||||
role?: string;
|
||||
content?: string;
|
||||
attachments?: Record<string, any> | null;
|
||||
tool_calls?: Record<string, any> | null;
|
||||
tool_results?: Record<string, any> | null;
|
||||
usage?: Record<string, any> | null;
|
||||
meta?: Record<string, any> | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* AI Feature
|
||||
*/
|
||||
export type AiFeature = 'default';
|
||||
|
||||
/**
|
||||
* AI lab
|
||||
*/
|
||||
export type AiLab = 'anthropic'| 'azure'| 'bedrock'| 'cohere'| 'deepseek'| 'eleven'| 'gemini'| 'groq'| 'jina'| 'mistral'| 'ollama'| 'openai'| 'openrouter'| 'voyageai'| 'xai';
|
||||
|
||||
/**
|
||||
* Ai List
|
||||
*/
|
||||
export type AiList = Record<AiFeature, string[]>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
|
||||
/** 首页轮播图 */
|
||||
export interface ICarousel {
|
||||
id?: number;
|
||||
/** 轮播图标题 */
|
||||
title: string;
|
||||
/** 轮播图图片信息(ISysFileInfo 对象) */
|
||||
image: ISysFileInfo | string;
|
||||
/** 跳转链接 */
|
||||
link?: string;
|
||||
/** 状态:0=启用,1=禁用 */
|
||||
status: number;
|
||||
/** 排序 */
|
||||
sort: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface IConfigGroup {
|
||||
/** ID */
|
||||
id?: number;
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
/** 键 */
|
||||
key?: string;
|
||||
/** 描述 */
|
||||
remark?: string;
|
||||
/** 创建时间 */
|
||||
created_at?: Date;
|
||||
/** 更新时间 */
|
||||
updated_at?: Date;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type ConfigType = 'Input' | 'TextArea' | 'InputNumber' | 'Switch' | 'Radio' | 'Checkbox';
|
||||
|
||||
export interface IConfigItem {
|
||||
/** ID */
|
||||
id?: number;
|
||||
/** 键 */
|
||||
key?: string;
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
/** 描述 */
|
||||
describe?: string;
|
||||
/** 值 */
|
||||
values?: string;
|
||||
/** 类型 */
|
||||
type?: ConfigType;
|
||||
/** 选项 */
|
||||
options?: string;
|
||||
/** 选项JSON */
|
||||
options_json?: string;
|
||||
/** 属性 */
|
||||
props?: string;
|
||||
/** 属性JSON */
|
||||
props_json?: string;
|
||||
/** 组ID */
|
||||
group_id?: number;
|
||||
/** 排序 */
|
||||
sort?: number;
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface IDict {
|
||||
/** ID */
|
||||
id?: number;
|
||||
/** 字典名称 */
|
||||
name?: string;
|
||||
/** 字典编码 */
|
||||
code?: string;
|
||||
/** 字典描述 */
|
||||
describe?: string;
|
||||
/** 状态:0正常 1停用 */
|
||||
status?: number;
|
||||
/** 排序 */
|
||||
sort?: number;
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface IDictItem {
|
||||
/** ID */
|
||||
id?: number;
|
||||
/** 字典ID */
|
||||
dict_id?: number;
|
||||
/** 字典标签 */
|
||||
label?: string;
|
||||
/** 字典键值 */
|
||||
value?: string;
|
||||
/** 颜色 */
|
||||
color?: string;
|
||||
/** 状态:0正常 1停用 */
|
||||
status?: number;
|
||||
/** 排序 */
|
||||
sort?: number;
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** Ant Design 颜色选项 */
|
||||
export const COLORS = [
|
||||
{ label: '默认', value: 'default' },
|
||||
{ label: '蓝色', value: 'blue' },
|
||||
{ label: '绿色', value: 'green' },
|
||||
{ label: '红色', value: 'red' },
|
||||
{ label: '橙色', value: 'orange' },
|
||||
{ label: '紫色', value: 'purple' },
|
||||
{ label: '青色', value: 'cyan' },
|
||||
{ label: '金色', value: 'gold' },
|
||||
{ label: '绿黄色', value: 'lime' },
|
||||
{ label: '极客蓝', value: 'geekblue' },
|
||||
{ label: '品红', value: 'magenta' },
|
||||
{ label: '火山红', value: 'volcano' },
|
||||
] as const;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
|
||||
/** 首页宫格导航 */
|
||||
export interface IGridNav {
|
||||
id?: number;
|
||||
/** 导航标题 */
|
||||
title: string;
|
||||
/** 导航图标信息(ISysFileInfo 对象) */
|
||||
image: ISysFileInfo | string;
|
||||
/** 跳转链接 */
|
||||
link?: string;
|
||||
/** 状态:0=启用,1=禁用 */
|
||||
status: number;
|
||||
/** 排序 */
|
||||
sort: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** 部门类型 */
|
||||
export interface ISysDept {
|
||||
/** 部门ID */
|
||||
id?: number;
|
||||
/** 部门编码 */
|
||||
code?: string;
|
||||
/** 部门名称 */
|
||||
name?: string;
|
||||
/** 部门负责人 */
|
||||
leader?: string;
|
||||
/** 部门类型 0:公司 1:部门 2:岗位 */
|
||||
type?: number;
|
||||
/** 父级ID */
|
||||
parent_id?: number;
|
||||
/** 部门邮箱 */
|
||||
email?: string;
|
||||
/** 部门地址 */
|
||||
address?: string;
|
||||
/** 部门电话 */
|
||||
phone?: string;
|
||||
/** 部门备注 */
|
||||
remark?: string;
|
||||
/** 部门排序 */
|
||||
sort?: number;
|
||||
/** 部门状态 0:正常 1:停用 */
|
||||
status?: number;
|
||||
/** 子部门 */
|
||||
children?: ISysDept[];
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface ISysFileInfo {
|
||||
/** 文件ID */
|
||||
id?: number;
|
||||
/** 文件夹ID */
|
||||
group_id?: number;
|
||||
/** 上传渠道 10:系统用户,20:APP用户 */
|
||||
channel?: number;
|
||||
/** 存储磁盘 local/s3 */
|
||||
disk?: string;
|
||||
/** 文件类型 10:图片,20:音频,30:视频,40:压缩包,50:文档,99:其它 */
|
||||
file_type?: number;
|
||||
/** 文件名 */
|
||||
file_name?: string;
|
||||
/** 文件路径 */
|
||||
file_path?: string;
|
||||
/** 预览URL */
|
||||
preview_url?: string;
|
||||
/** 文件访问URL */
|
||||
file_url?: string;
|
||||
/** 文件大小 */
|
||||
file_size?: number;
|
||||
/** 文件扩展名 */
|
||||
file_ext?: string;
|
||||
/** 上传用户ID */
|
||||
uploader_id?: number;
|
||||
/** 软删除时间 */
|
||||
deleted_at?: string;
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 文件夹相关类型定义
|
||||
export interface ISysFileGroup {
|
||||
/** 文件夹ID */
|
||||
id: number;
|
||||
/** 文件夹名称 */
|
||||
name: string;
|
||||
/** 排序 */
|
||||
sort?: number;
|
||||
/** 父文件夹ID */
|
||||
parent_id?: string;
|
||||
/** 描述 */
|
||||
describe?: string;
|
||||
/** 文件总数 */
|
||||
countFiles?: number;
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
/** 子文件夹 */
|
||||
children?: ISysFileGroup[];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export default interface ISysLoginRecord {
|
||||
/** 记录ID */
|
||||
id?: number;
|
||||
/** 浏览器 */
|
||||
browser?: string;
|
||||
/** IP 地址 */
|
||||
ipaddr?: string;
|
||||
/** 登录地址 */
|
||||
login_location?: string;
|
||||
/** 登录时间 */
|
||||
login_time?: string;
|
||||
/** 登录提示 */
|
||||
msg?: string;
|
||||
/** 登录设备 */
|
||||
os?: string;
|
||||
/** 登陆状态 */
|
||||
status?: string;
|
||||
/** 用户ID */
|
||||
user_id?: number;
|
||||
/** 用户名 */
|
||||
username?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface ISysRole {
|
||||
/** 角色ID */
|
||||
id?: number;
|
||||
/** 角色名称 */
|
||||
name?: string;
|
||||
/** 排序 */
|
||||
sort?: number;
|
||||
/** 拥有的权限ID */
|
||||
ruleIds?: number[];
|
||||
/** 角色描述 */
|
||||
description?: string;
|
||||
/** 角色用户数量 */
|
||||
countUser?: number;
|
||||
/** 启用状态 */
|
||||
status?: number;
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** 规则类型 */
|
||||
export type IRuleType = 'menu' | 'route' | 'rule';
|
||||
|
||||
/** 菜单路由规则数据类型 */
|
||||
export interface ISysRule {
|
||||
/** 权限ID */
|
||||
id?: number;
|
||||
/** 上级ID,顶级菜单的 pid 为 0 */
|
||||
pid?: number;
|
||||
/** 权限类型,分为菜单、路由、权限 */
|
||||
type?: IRuleType;
|
||||
/** 权限的唯一标识 */
|
||||
key?: string;
|
||||
/** 排序 */
|
||||
order?: number;
|
||||
/** 名称 */
|
||||
name?: string;
|
||||
/** 菜单的路径,menu 的路径会被当作前缀路由 */
|
||||
path?: string;
|
||||
/** 路由的图标 */
|
||||
icon?: string;
|
||||
/** 多语言 */
|
||||
local?: string;
|
||||
/** 是否隐藏 */
|
||||
hidden?: number;
|
||||
/** 是否启用 */
|
||||
status?: number;
|
||||
/** 是否外链 */
|
||||
link?: number;
|
||||
}
|
||||
|
||||
/** 菜单类型 */
|
||||
export interface IMenus extends ISysRule {
|
||||
/** 子菜单 */
|
||||
children?: IMenus[];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export default interface ISysUser {
|
||||
/** 用户ID */
|
||||
id?: number;
|
||||
/** 用户名 */
|
||||
username?: string;
|
||||
/** 头像ID */
|
||||
avatar_id?: number;
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
/** 部门ID */
|
||||
dept_id?: number;
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
/** 邮箱验证时间 */
|
||||
email_verified_at?: string;
|
||||
/** 最后登录IP */
|
||||
login_ip?: string;
|
||||
/** 最后登录时间 */
|
||||
login_time?: string;
|
||||
/** 手机号 */
|
||||
mobile?: string;
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
/** 性别 */
|
||||
sex?: number;
|
||||
/** 个人简介 */
|
||||
bio: string;
|
||||
/** 用户状态 */
|
||||
status?: number;
|
||||
/** 创建时间 */
|
||||
updated_at?: string;
|
||||
// 下面是附加数据
|
||||
/** 头像Url */
|
||||
avatar_url?: string;
|
||||
/** 角色 */
|
||||
roles_field?: {
|
||||
/** 角色ID */
|
||||
role_id: number;
|
||||
/** 角色名称 */
|
||||
name: string;
|
||||
}[];
|
||||
/** 部门名称 */
|
||||
dept_name?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import useAuthStore from "@/stores/user";
|
||||
|
||||
function useAuth() {
|
||||
const access = useAuthStore(state => state.access);
|
||||
|
||||
const auth = (key: string) => access.includes(key);
|
||||
|
||||
return {auth};
|
||||
}
|
||||
|
||||
export default useAuth;
|
||||
@@ -0,0 +1,48 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useMemo} from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import options from '@/locales/options';
|
||||
|
||||
/**
|
||||
* 语言切换 Hook
|
||||
*/
|
||||
const useLanguage = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
/**
|
||||
* 切换语言
|
||||
* @param lng 语言代码
|
||||
*/
|
||||
const changeLanguage = async (lng: string) => {
|
||||
const config = getLanguageConfig(lng);
|
||||
dayjs.locale(config.dayjsLocale);
|
||||
await i18n.changeLanguage(lng);
|
||||
localStorage.setItem('i18nextLng', lng);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前语言配置对象
|
||||
*/
|
||||
const getLanguageConfig = (lng: string) => {
|
||||
return options.find(opt => opt.value === lng) || options[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Antd Locale Hook
|
||||
*/
|
||||
const antdLocale = useMemo(() => {
|
||||
const currentLang = i18n.language;
|
||||
const config = options.find(opt => opt.value === currentLang);
|
||||
return config ? config.antdLocale : options[0].antdLocale;
|
||||
}, [i18n.language]);
|
||||
|
||||
return {
|
||||
antdLocale,
|
||||
language: i18n.language,
|
||||
changeLanguage,
|
||||
getLanguageConfig,
|
||||
languageOptions: options,
|
||||
};
|
||||
};
|
||||
|
||||
export default useLanguage;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 移动端检测Hook
|
||||
* 根据屏幕宽度检测设备类型,默认768px以下为移动端
|
||||
*/
|
||||
const useMobile = (breakpoint: number = 768): boolean => {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < breakpoint);
|
||||
};
|
||||
|
||||
// 初始检测
|
||||
checkMobile();
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', checkMobile);
|
||||
window.addEventListener('orientationchange', checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
window.removeEventListener('orientationchange', checkMobile);
|
||||
};
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
};
|
||||
|
||||
export default useMobile;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { AxiosResponse, AxiosError } from 'axios';
|
||||
|
||||
interface UseRequestOptions<T> {
|
||||
manual?: boolean;
|
||||
onSuccess?: (data: T | undefined, response: AxiosResponse<API.ResponseStructure<T>>) => void;
|
||||
onError?: (error: AxiosError) => void;
|
||||
onFinally?: () => void;
|
||||
}
|
||||
|
||||
interface UseRequestResult<T> {
|
||||
data: T | undefined;
|
||||
loading: boolean;
|
||||
success: boolean | null;
|
||||
error: AxiosError | null;
|
||||
run: (...args: any[]) => Promise<AxiosResponse<API.ResponseStructure<T>> | undefined>;
|
||||
}
|
||||
|
||||
function useRequest<T = any>(
|
||||
apiFunction: (...args: any[]) => Promise<AxiosResponse<API.ResponseStructure<T>>>,
|
||||
options: UseRequestOptions<T> = {}
|
||||
): UseRequestResult<T> {
|
||||
const { manual = false, onSuccess, onError, onFinally } = options;
|
||||
const [data, setData] = useState<T>();
|
||||
const [loading, setLoading] = useState(!manual);
|
||||
const [success, setSuccess] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<AxiosError | null>(null);
|
||||
|
||||
const run = useCallback(
|
||||
async (...args: any[]) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSuccess(null);
|
||||
setError(null);
|
||||
|
||||
const response = await apiFunction(...args);
|
||||
|
||||
setData(response.data.data);
|
||||
setSuccess(true);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(response.data.data, response);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
const axiosError = err as AxiosError;
|
||||
setError(axiosError);
|
||||
setSuccess(false);
|
||||
|
||||
if (onError) {
|
||||
onError(axiosError);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (onFinally) {
|
||||
onFinally();
|
||||
}
|
||||
}
|
||||
},
|
||||
[apiFunction, onSuccess, onError, onFinally]
|
||||
);
|
||||
|
||||
// 自动执行
|
||||
useState(() => {
|
||||
if (!manual) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
|
||||
return { data, loading, success, error, run };
|
||||
}
|
||||
|
||||
export default useRequest;
|
||||
@@ -0,0 +1,3 @@
|
||||
@layer theme, base, antd, components, utilities;
|
||||
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,55 @@
|
||||
import {Breadcrumb, type BreadcrumbProps} from "antd";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {HomeOutlined} from "@ant-design/icons";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useLocation} from "react-router";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext";
|
||||
import {buildBreadcrumbMap, findMenuByPath} from "@/layout/utils.ts";
|
||||
|
||||
const defaultBreadcrumb: BreadcrumbProps['items'] = [
|
||||
{
|
||||
title: <HomeOutlined />,
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const BreadcrumbRender = () => {
|
||||
const {t} = useTranslation();
|
||||
const location = useLocation();
|
||||
const { menus } = useLayoutContext();
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<BreadcrumbProps['items']>(defaultBreadcrumb);
|
||||
|
||||
const breadcrumbMap = useMemo(() => buildBreadcrumbMap(menus), [menus]);
|
||||
|
||||
useEffect(() => {
|
||||
const path = location.pathname;
|
||||
const menu = findMenuByPath(menus, path);
|
||||
if(menu && menu.key) {
|
||||
const breadcrumbs = breadcrumbMap[menu.key];
|
||||
if(breadcrumbs) {
|
||||
setBreadcrumbItems([
|
||||
{
|
||||
title: <HomeOutlined />,
|
||||
},
|
||||
...breadcrumbs.map(item => ({
|
||||
title: (
|
||||
<>
|
||||
{item.icon && <IconFont name={item.icon} />}
|
||||
<span>{item.local ? t(item.local) : item.title}</span>
|
||||
</>
|
||||
),
|
||||
}))
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBreadcrumbItems(defaultBreadcrumb)
|
||||
}, [t, location.pathname, breadcrumbMap]);
|
||||
|
||||
return (
|
||||
<Breadcrumb items={breadcrumbItems}/>
|
||||
)
|
||||
}
|
||||
|
||||
export default BreadcrumbRender;
|
||||
@@ -0,0 +1,72 @@
|
||||
import IconFont from "@/components/IconFont";
|
||||
import MenuRender from "@/layout/MenuRender.tsx";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {theme} from "antd";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import React from "react";
|
||||
import type {IMenus} from "@/domain/iSysRule.ts";
|
||||
import {useNavigate} from "react-router";
|
||||
|
||||
const {useToken} = theme;
|
||||
|
||||
const ColumnsMenu: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const {token} = useToken();
|
||||
const navigate = useNavigate();
|
||||
const {menus, parentKey, collapsed, setParentKey, setSelectKey} = useLayoutContext();
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
|
||||
const onMenuChange = (menu: IMenus) => {
|
||||
if(!menu.key) return;
|
||||
setParentKey(menu.key);
|
||||
if(menu.type === 'menu') {
|
||||
return;
|
||||
}
|
||||
setSelectKey([menu.key]);
|
||||
// 外链路由
|
||||
if(menu.type === 'route' && menu.link) {
|
||||
window.open(menu.path, '_blank');
|
||||
return;
|
||||
}
|
||||
if(menu.type === 'route' && menu.path) {
|
||||
navigate(menu.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex h-full'}>
|
||||
<div
|
||||
className={'box-border shrink-0 h-full overflow-auto'}
|
||||
style={{
|
||||
borderRight: !collapsed ? "1px solid " + themeConfig.colorBorder : 'none',
|
||||
width: 79
|
||||
}}
|
||||
>
|
||||
{/* 侧栏一级菜单 */}
|
||||
{menus.filter(item => item.hidden).map(rule => (
|
||||
<div
|
||||
key={rule.key}
|
||||
style={{
|
||||
backgroundColor: parentKey === rule.key ? token.colorPrimaryBg : 'transparent',
|
||||
color: parentKey === rule.key ? token.colorPrimary : themeConfig.siderColor,
|
||||
}}
|
||||
className={"flex items-center justify-center flex-col p-2 mb-2 pt-3 pb-3 cursor-pointer"}
|
||||
onClick={() => onMenuChange(rule)}
|
||||
>
|
||||
<IconFont name={rule.icon} style={{ fontSize: 18 }}/>
|
||||
<span className={"mt-1 truncate w-full text-center"}>{rule.local ? t(rule.local) : rule.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ !collapsed && (
|
||||
<div className={'flex-1 h-full overflow-y-auto'}>
|
||||
<MenuRender />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColumnsMenu;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import {Layout} from "antd";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
|
||||
const {Footer} = Layout;
|
||||
|
||||
const FooterRender: React.FC = () => {
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
|
||||
return (
|
||||
<>
|
||||
{themeConfig.fixedFooter &&
|
||||
<div className={"h-10"}></div>
|
||||
}
|
||||
<Footer
|
||||
className={
|
||||
(themeConfig.fixedFooter ? 'sticky' : 'relative') +
|
||||
" z-10 w-full bottom-0 pt-2.5 pb-2.5"
|
||||
}
|
||||
style={{
|
||||
borderTop: '1px solid ' + themeConfig.colorBorder,
|
||||
}}
|
||||
>
|
||||
<div className={"flex items-center justify-center w-full"}>
|
||||
Xin Admin ©{currentYear} Created by xiaoliu
|
||||
</div>
|
||||
</Footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterRender;
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Button, ConfigProvider, Layout, Menu, type MenuProps, type ThemeConfig} from "antd";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import HeaderRightRender from "@/layout/HeaderRightRender";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import MenuRender from "@/layout/MenuRender.tsx";
|
||||
import {MenuFoldOutlined, MenuUnfoldOutlined} from "@ant-design/icons";
|
||||
import BreadcrumbRender from "@/layout/BreadcrumbRender.tsx";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext";
|
||||
import useMobile from "@/hooks/useMobile";
|
||||
import {useNavigate} from "react-router";
|
||||
import {findMenuByKey} from "@/layout/utils.ts";
|
||||
|
||||
const {Header} = Layout;
|
||||
|
||||
const HeaderRender: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const navigate = useNavigate();
|
||||
const logo = useGlobalStore(state => state.logo);
|
||||
const title = useGlobalStore(state => state.title);
|
||||
const layout = useGlobalStore(state => state.layout);
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const { menus, parentKey, collapsed, setCollapsed, setParentKey, setSelectKey } = useLayoutContext();
|
||||
|
||||
const theme: ThemeConfig = {
|
||||
token: { colorTextBase: themeConfig.headerColor },
|
||||
components: {
|
||||
Menu: {
|
||||
activeBarBorderWidth: 0,
|
||||
itemBg: 'transparent',
|
||||
}
|
||||
}
|
||||
}
|
||||
const [mixMenu, setMixMenu] = useState<MenuProps['items']>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setMixMenu(menus.filter(item => item.hidden).map(item => ({
|
||||
label: item.local ? t(item.local) : item.name,
|
||||
icon: item.icon ? <IconFont name={item.icon}/> : false,
|
||||
key: item.key!,
|
||||
})));
|
||||
}, [menus, t]);
|
||||
|
||||
const onMenuChange = (key: string) => {
|
||||
const menu = findMenuByKey(menus, key);
|
||||
if(!menu || !menu.key) return;
|
||||
setParentKey(menu.key);
|
||||
if(menu.type === 'menu') {
|
||||
return;
|
||||
}
|
||||
setSelectKey([menu.key]);
|
||||
if(menu.type === 'route' && menu.link) {
|
||||
window.open(menu.path, '_blank');
|
||||
return;
|
||||
}
|
||||
if(menu.type === 'route' && menu.path) {
|
||||
navigate(menu.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={theme}>
|
||||
{ isMobile ? (
|
||||
<Header className={"flex sticky z-1 top-0 backdrop-blur-xs justify-between items-center"}>
|
||||
<div className={"flex items-center"}>
|
||||
<img className={"w-9 mr-5"} src={logo} alt="logo"/>
|
||||
<span className={"font-semibold text-[20px] mr-2"}>{title}</span>
|
||||
</div>
|
||||
<HeaderRightRender/>
|
||||
</Header>
|
||||
) : (
|
||||
<Header
|
||||
className={"flex sticky z-1 top-0 backdrop-blur-xs"}
|
||||
style={{borderBottom: "1px solid " + themeConfig.colorBorder}}
|
||||
>
|
||||
<div className={"flex items-center relative"}>
|
||||
<img className={"h-8"} src={logo} alt="logo"/>
|
||||
<span className={"font-semibold text-[20px] ml-5 mr-5"}>{title}</span>
|
||||
{/* 侧边栏开关 */}
|
||||
{layout !== 'top' && (
|
||||
<Button
|
||||
type={'text'}
|
||||
className={'text-[16px] mr-2.5'}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{ collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/> }
|
||||
</Button>
|
||||
)}
|
||||
{/* 面包屑 */}
|
||||
{['columns', 'side'].includes(layout) && <BreadcrumbRender/> }
|
||||
</div>
|
||||
<div className={"overflow-hidden flex-1"}>
|
||||
{/* 顶部菜单 */}
|
||||
{ layout == 'top' && <MenuRender /> }
|
||||
{/* 混合布局模式下的顶部菜单 */}
|
||||
{ layout == 'mix' && (
|
||||
<Menu
|
||||
className={"border-b-0 w-full"}
|
||||
mode="horizontal"
|
||||
items={mixMenu}
|
||||
onSelect={(info) => onMenuChange(info.key)}
|
||||
selectedKeys={[parentKey!]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<HeaderRightRender/>
|
||||
</Header>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderRender;
|
||||
@@ -0,0 +1,131 @@
|
||||
import {Avatar, Button, Dropdown, Empty, Input, type MenuProps, message, Modal, Space, theme} from "antd";
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
EnterOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
GithubOutlined,
|
||||
HomeOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
VerticalLeftOutlined
|
||||
} from "@ant-design/icons";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import useAuthStore from "@/stores/user";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useState} from "react";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import useMobile from "@/hooks/useMobile";
|
||||
|
||||
const {useToken} = theme;
|
||||
|
||||
const HeaderLeftRender = () => {
|
||||
const {token} = useToken();
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate()
|
||||
const themeDrawer = useGlobalStore(state => state.themeDrawer);
|
||||
const setThemeDrawer = useGlobalStore(state => state.setThemeDrawer);
|
||||
const userInfo = useAuthStore(state => state.userinfo);
|
||||
const logout = useAuthStore(state => state.logout);
|
||||
const isMobile = useMobile();
|
||||
const [fullscreen, setFullscreen] = useState<boolean>(false);
|
||||
const [searchOpen, setSearch] = useState<boolean>(false);
|
||||
|
||||
/** 全屏按钮操作事件 */
|
||||
const onFullscreenClick = () => {
|
||||
/* 获取 documentElement (<html>) 以全屏显示页面 */
|
||||
const elem = document.documentElement;
|
||||
/* 全屏查看 */
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().then(() => {
|
||||
setFullscreen(false);
|
||||
});
|
||||
} else {
|
||||
elem.requestFullscreen().then(() => {
|
||||
setFullscreen(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const userItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: t('layout.profile'),
|
||||
icon: <UserOutlined/> ,
|
||||
onClick: () => navigate('/user/profile')
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('layout.logout'),
|
||||
icon: <VerticalLeftOutlined />,
|
||||
onClick: async () => {
|
||||
logout().then(() => {
|
||||
message.success(t('layout.logoutSuccess'))
|
||||
window.location.href = '/login';
|
||||
}).catch(() => {
|
||||
message.error(t('layout.logoutFailed'))
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
closable={false}
|
||||
open={searchOpen}
|
||||
footer={null}
|
||||
style={{top: 40, width: 600}}
|
||||
styles={{body: {maxHeight: '80vh'}, container: {padding: 0}}}
|
||||
onCancel={() => setSearch(false)}
|
||||
>
|
||||
<div className={'p-5'}>
|
||||
<Input size="large" placeholder={t('layout.searchPlaceholder')} prefix={<SearchOutlined/>}/>
|
||||
<div className={'mt-5'}>
|
||||
<Empty/>
|
||||
{/* TODO 可以帮忙实现菜单栏搜索 */}
|
||||
</div>
|
||||
</div>
|
||||
<Space className={'mt-5 flex align-center pl-5 pr-5 pt-2.5 pb-2.5'}
|
||||
style={{borderTop: '1px solid ' + token.colorBorder}}>
|
||||
<EnterOutlined/> <span className={'mr-4'}>{t('layout.searchConfirm')}</span>
|
||||
<ArrowUpOutlined/><ArrowDownOutlined/> <span className={'mr-4'}>{t('layout.searchSwitch')}</span>
|
||||
Esc <span className={'mr-2'}>{t('layout.searchClose')}</span>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Space>
|
||||
{ !isMobile && (
|
||||
<>
|
||||
<Button icon={<HomeOutlined/>} size={'large'} type={'text'}
|
||||
onClick={() => window.open('https://xin-admin.com')}/>
|
||||
<Button icon={<GithubOutlined/>} size={'large'} type={'text'}
|
||||
onClick={() => window.open('https://github.com/xin-admin/xin-admin-ui')}/>
|
||||
<Button icon={<SearchOutlined/>} size={'large'} type={'text'} onClick={() => setSearch(true)}/>
|
||||
<Button
|
||||
onClick={onFullscreenClick}
|
||||
icon={fullscreen ? <FullscreenExitOutlined/> : <FullscreenOutlined/>}
|
||||
size={"large"}
|
||||
type={'text'}
|
||||
/>
|
||||
<LanguageSwitcher size={"large"} type={'text'} />
|
||||
<Button onClick={() => setThemeDrawer(!themeDrawer)} icon={<SettingOutlined/>} size={"large"} type={'text'}/>
|
||||
</>
|
||||
)}
|
||||
{userInfo ?
|
||||
<Dropdown menu={{items: userItems}}>
|
||||
<Button size={"large"} type={'text'}>
|
||||
<div>{userInfo.nickname || userInfo.username}</div>
|
||||
<Avatar src={userInfo.avatar_url ? userInfo.avatar_url : null} size={'small'}/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
: null
|
||||
}
|
||||
</Space>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderLeftRender
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, {createContext, useContext, useEffect, useState} from "react";
|
||||
import {useLocation} from "react-router";
|
||||
import {menu} from "@/api/system/sys_user";
|
||||
import type {IMenus} from "@/domain/iSysRule.ts";
|
||||
import {getMenuParentKeys} from "@/layout/utils.ts";
|
||||
|
||||
interface LayoutContextType {
|
||||
menus: IMenus[];
|
||||
parentKey: string | undefined;
|
||||
selectKey: string[];
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
setParentKey: (key: string) => void;
|
||||
setSelectKey: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContextType | null>(null);
|
||||
|
||||
export const LayoutProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const [menus, setMenus] = useState<IMenus[]>([]);
|
||||
const [parentKey, setParentKey] = useState<string>();
|
||||
const [selectKey, setSelectKey] = useState<string[]>([]);
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// 获取菜单数据
|
||||
useEffect(() => {
|
||||
menu().then(({ data }) => {
|
||||
const items = data.data!.menus;
|
||||
setMenus(items);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 根据 URL 恢复菜单状态
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
const ancestors = getMenuParentKeys(menus, pathname);
|
||||
if(ancestors && ancestors.length) {
|
||||
setSelectKey(ancestors);
|
||||
setParentKey(ancestors[0]);
|
||||
}
|
||||
}, [menus]);
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={{
|
||||
menus,
|
||||
parentKey,
|
||||
selectKey,
|
||||
collapsed,
|
||||
setParentKey,
|
||||
setCollapsed,
|
||||
setSelectKey,
|
||||
}}>
|
||||
{children}
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLayoutContext = () => {
|
||||
const ctx = useContext(LayoutContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useLayoutContext must be used within LayoutProvider');
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {IMenus} from "@/domain/iSysRule.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {Menu, type MenuProps} from "antd";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext";
|
||||
import useMobile from "@/hooks/useMobile.ts";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import {findMenuByKey} from "@/layout/utils.ts";
|
||||
import {useNavigate} from "react-router";
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
/**
|
||||
* 构建菜单Item
|
||||
* @param nodes
|
||||
* @param t
|
||||
*/
|
||||
const transformMenus = (nodes: IMenus[], t: any): MenuItem[] => {
|
||||
return nodes.reduce<MenuItem[]>((acc, node) => {
|
||||
if (!['route', 'menu'].includes(node.type!) || !node.hidden) {
|
||||
return acc;
|
||||
}
|
||||
const menuItem: MenuItem = {
|
||||
label: node.local ? t(node.local) : node.name,
|
||||
icon: node.icon ? <IconFont name={node.icon} /> : undefined,
|
||||
key: node.key!,
|
||||
};
|
||||
if (node.type === 'menu' && node.children?.length) {
|
||||
(menuItem as any).children = transformMenus(node.children, t);
|
||||
}
|
||||
acc.push(menuItem);
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const MenuRender: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const navigate = useNavigate();
|
||||
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
|
||||
const layout = useGlobalStore(state => state.layout);
|
||||
const { menus, parentKey, selectKey, setSelectKey, setParentKey } = useLayoutContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setMenuItems(transformMenus(menus, t));
|
||||
return;
|
||||
}
|
||||
if (['columns', 'mix'].includes(layout) && parentKey) {
|
||||
const rule = menus.find(item => item.key === parentKey);
|
||||
setMenuItems(transformMenus(rule?.children || [], t));
|
||||
} else {
|
||||
setMenuItems(transformMenus(menus, t));
|
||||
}
|
||||
}, [menus, layout, parentKey, t, isMobile]);
|
||||
|
||||
const onMenuChange = (keyPath: string[]) => {
|
||||
setSelectKey(keyPath);
|
||||
if (['top', 'side'].includes(layout)) {
|
||||
setParentKey(keyPath[keyPath.length -1 ]);
|
||||
}
|
||||
const menu = findMenuByKey(menus, keyPath[0]);
|
||||
if (!menu || !menu.key) return;
|
||||
if(menu.type === 'route' && menu.link) {
|
||||
window.open(menu.path, '_blank');
|
||||
return;
|
||||
}
|
||||
if(menu.type === 'route' && menu.path) {
|
||||
navigate(menu.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ menuItems.length > 0 && (
|
||||
<Menu
|
||||
className={"border-b-0 flex-1"}
|
||||
mode={ layout === 'top' && !isMobile ? 'horizontal' : 'inline' }
|
||||
items={menuItems}
|
||||
onSelect={info => onMenuChange(info.keyPath)}
|
||||
selectedKeys={selectKey}
|
||||
/>
|
||||
) }
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MenuRender;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, {useState} from 'react';
|
||||
import { Button, Drawer, Space } from 'antd';
|
||||
import useGlobalStore from '@/stores/global';
|
||||
import MenuRender from '@/layout/MenuRender';
|
||||
import {GithubOutlined, HomeOutlined, MenuFoldOutlined, MenuUnfoldOutlined, SettingOutlined} from '@ant-design/icons';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
|
||||
/**
|
||||
* 移动端抽屉菜单组件
|
||||
*/
|
||||
const MobileDrawerMenu: React.FC = () => {
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const themeDrawer = useGlobalStore(state => state.themeDrawer);
|
||||
const setThemeDrawer = useGlobalStore(state => state.setThemeDrawer);
|
||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="fixed bottom-8 left-8 z-999">
|
||||
<Button
|
||||
type={'primary'}
|
||||
shape="circle"
|
||||
size='large'
|
||||
icon={collapsed ? <MenuFoldOutlined/> : <MenuUnfoldOutlined/>}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
</div>
|
||||
<Drawer
|
||||
placement="left"
|
||||
closable={true}
|
||||
onClose={() => setCollapsed(false)}
|
||||
open={collapsed}
|
||||
styles={{
|
||||
section: {width: 280},
|
||||
header: {
|
||||
borderBottom: '1px solid ' + themeConfig.colorBorder,
|
||||
background: themeConfig.siderBg,
|
||||
color: themeConfig.siderColor,
|
||||
},
|
||||
body: {
|
||||
padding: 0,
|
||||
background: themeConfig.siderBg,
|
||||
},
|
||||
}}
|
||||
footer={(
|
||||
<Space>
|
||||
<Button icon={<HomeOutlined/>} size={'large'} type={'text'}
|
||||
onClick={() => window.open('https://xin-admin.com')}/>
|
||||
<Button icon={<GithubOutlined/>} size={'large'} type={'text'}
|
||||
onClick={() => window.open('https://github.com/xin-admin/xin-admin-ui')}/>
|
||||
<LanguageSwitcher size={"large"} type={'text'} />
|
||||
<Button onClick={() => setThemeDrawer(!themeDrawer)} icon={<SettingOutlined/>} size={"large"} type={'text'}/>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
background: themeConfig.siderBg,
|
||||
}}
|
||||
>
|
||||
<MenuRender />
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileDrawerMenu;
|
||||
@@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
import {debounce} from 'lodash';
|
||||
import {Button, Col, ColorPicker, Divider, Drawer, InputNumber, Row, Select, Switch, theme, Tooltip} from 'antd';
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import {configTheme, darkColorTheme, defaultColorTheme} from "@/layout/theme.ts";
|
||||
import {algorithmOptions} from "@/layout/algorithm.ts";
|
||||
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
const {useToken} = theme;
|
||||
|
||||
|
||||
// 主题配置项
|
||||
const THEME_CONFIGS = [
|
||||
{key: 'colorPrimary', label: 'layout.colorPrimary'},
|
||||
{key: 'colorText', label: 'layout.colorText'},
|
||||
{key: 'colorBg', label: 'layout.colorBg'},
|
||||
{key: 'colorSuccess', label: 'layout.colorSuccess'},
|
||||
{key: 'colorWarning', label: 'layout.colorWarning'},
|
||||
{key: 'colorError', label: 'layout.colorError'},
|
||||
{key: 'bodyBg', label: 'layout.bodyBg'},
|
||||
{key: 'footerBg', label: 'layout.footerBg'},
|
||||
{key: 'headerBg', label: 'layout.headerBg'},
|
||||
{key: 'headerColor', label: 'layout.headerColor'},
|
||||
{key: 'siderBg', label: 'layout.siderBg'},
|
||||
{key: 'siderColor', label: 'layout.siderColor'},
|
||||
{key: 'colorBorder', label: 'layout.colorBorder'},
|
||||
];
|
||||
|
||||
// 风格配置项
|
||||
const STYLE_CONFIGS = [
|
||||
{key: 'fixedFooter', label: 'layout.fixedFooter', type: 'switch'},
|
||||
{key: 'borderRadius', label: 'layout.borderRadius', type: 'number', min: 0, max: 30},
|
||||
{key: 'controlHeight', label: 'layout.controlHeight', type: 'number', min: 0},
|
||||
{key: 'headerPadding', label: 'layout.headerPadding', type: 'number', min: 0},
|
||||
{key: 'headerHeight', label: 'layout.headerHeight', type: 'number', min: 0},
|
||||
{key: 'siderWeight', label: 'layout.siderWeight', type: 'number', min: 0},
|
||||
{key: 'bodyPadding', label: 'layout.bodyPadding', type: 'number', min: 0},
|
||||
];
|
||||
|
||||
// 预设主题列表
|
||||
const THEME_LIST = [
|
||||
{background: '/static/theme/default.svg', name: 'light', title: 'layout.themeLight'},
|
||||
{background: '/static/theme/dark.svg', name: 'dark', title: 'layout.themeDark'},
|
||||
];
|
||||
|
||||
// 布局配置
|
||||
const LAYOUT_CONFIGS = [
|
||||
{
|
||||
key: 'side', title: 'layout.layoutSide', render: (token: any) => (
|
||||
<>
|
||||
<div className="rounded-sm w-full h-6 mb-1.5" style={{background: token.colorPrimaryBorder}}/>
|
||||
<div className="flex">
|
||||
<div className="rounded-sm h-16 w-6" style={{background: token.colorPrimary}}/>
|
||||
<div className="rounded-sm flex-1 ml-1.5" style={{background: token.colorPrimaryBg}}/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'top', title: 'layout.layoutTop', render: (token: any) => (
|
||||
<>
|
||||
<div className="rounded-sm h-6 w-full mb-1.5" style={{background: token.colorPrimary}}/>
|
||||
<div className="rounded-sm h-16" style={{background: token.colorPrimaryBg}}/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'mix', title: 'layout.layoutMix', render: (token: any) => (
|
||||
<>
|
||||
<div className="rounded-sm w-full h-6 mb-1.5" style={{background: token.colorPrimary}}/>
|
||||
<div className="flex">
|
||||
<div className="rounded-sm h-16 w-6" style={{background: token.colorPrimary}}/>
|
||||
<div className="rounded-sm flex-1 ml-1.5" style={{background: token.colorPrimaryBg}}/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'columns', title: 'layout.layoutColumns', render: (token: any) => (
|
||||
<div className="flex">
|
||||
<div className="rounded-sm mr-1.5 w-3 h-24" style={{background: token.colorPrimary}}/>
|
||||
<div className="rounded-sm mr-1.5 w-6 h-24" style={{background: token.colorPrimaryHover}}/>
|
||||
<div className="rounded-sm flex-auto h-24" style={{background: token.colorPrimaryBg}}/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
const SettingDrawer: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const {token} = useToken();
|
||||
const themeDrawer = useGlobalStore(state => state.themeDrawer);
|
||||
const setThemeDrawer = useGlobalStore(state => state.setThemeDrawer);
|
||||
const setThemeConfig = useGlobalStore(state => state.setThemeConfig);
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const layout = useGlobalStore(state => state.layout);
|
||||
const setLayout = useGlobalStore(state => state.setLayout);
|
||||
|
||||
// 翻译后的算法选项
|
||||
const translatedAlgorithmOptions = algorithmOptions?.map(option => ({
|
||||
...option,
|
||||
label: t(option.label as string)
|
||||
})) || [];
|
||||
|
||||
// 防抖更新主题配置(带过渡动画)
|
||||
const changeSetting = debounce((key: string, value: any) => {
|
||||
setThemeConfig({...themeConfig, [key]: value});
|
||||
}, 400, {leading: true, trailing: false});
|
||||
|
||||
// 处理主题切换(带圆形扩散动画)
|
||||
const handleThemeChange = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-theme]');
|
||||
if (!target) return;
|
||||
|
||||
const themeName = (target as HTMLElement).dataset.theme;
|
||||
const themeMap = {
|
||||
dark: darkColorTheme,
|
||||
light: defaultColorTheme
|
||||
};
|
||||
|
||||
if (themeMap[themeName as keyof typeof themeMap]) {
|
||||
setThemeConfig({...themeConfig, ...themeMap[themeName as keyof typeof themeMap]});
|
||||
}
|
||||
};
|
||||
|
||||
// 重置主题(带过渡动画)
|
||||
const resetTheme = () => {
|
||||
setThemeConfig({...configTheme, ...defaultColorTheme});
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
styles={{body: {paddingTop: 10}}}
|
||||
placement="right"
|
||||
closable={false}
|
||||
onClose={() => setThemeDrawer(false)}
|
||||
open={themeDrawer}
|
||||
footer={(
|
||||
<div className="flex w-full justify-between">
|
||||
<Button onClick={resetTheme}>
|
||||
{t('layout.resetTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{/* 布局样式 */}
|
||||
<Divider>{t('layout.layoutStyle')}</Divider>
|
||||
<Row gutter={[20, 20]}>
|
||||
{LAYOUT_CONFIGS.map(({key, title, render}) => (
|
||||
<Col span={12} key={key}>
|
||||
<Tooltip title={t(title)}>
|
||||
<div
|
||||
className="p-2 rounded-lg cursor-pointer"
|
||||
style={{
|
||||
boxShadow: token.boxShadow,
|
||||
borderRadius: token.borderRadius,
|
||||
border: layout === key ? `2px solid ${token.colorPrimary}` : '2px solid transparent',
|
||||
}}
|
||||
onClick={() => setLayout(key as any)}
|
||||
>
|
||||
{render(token)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 预设主题 */}
|
||||
<Divider>{t('layout.presetTheme')}</Divider>
|
||||
<Row gutter={20} onClick={handleThemeChange}>
|
||||
{THEME_LIST.map((item) => (
|
||||
<Col span={8} key={item.name} className="mb-2.5">
|
||||
<div
|
||||
data-theme={item.name}
|
||||
className="cursor-pointer overflow-hidden border-solid"
|
||||
style={{
|
||||
borderRadius: token.borderRadius,
|
||||
borderWidth: themeConfig.themeScheme === item.name ? '2px' : '0px',
|
||||
borderColor: themeConfig.themeScheme === item.name ? token.colorPrimary : 'transparent'
|
||||
}}
|
||||
>
|
||||
<img src={item.background} alt={item.name}/>
|
||||
</div>
|
||||
<div className="text-center mt-1.5">{t(item.title)}</div>
|
||||
</Col>
|
||||
))}
|
||||
<Col span={24} className="mb-2.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>{t('layout.themeAlgorithm')}</div>
|
||||
<Select
|
||||
value={themeConfig.algorithm}
|
||||
style={{ width: 160 }}
|
||||
onChange={(value) => changeSetting('algorithm', value)}
|
||||
options={translatedAlgorithmOptions}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 主题颜色 */}
|
||||
<Divider>{t('layout.themeColor')}</Divider>
|
||||
{THEME_CONFIGS.map(({key, label}) => (
|
||||
<div key={key} className="flex justify-between items-center mb-2.5">
|
||||
<div>{t(label)}</div>
|
||||
<ColorPicker
|
||||
showText
|
||||
value={themeConfig[key as keyof typeof themeConfig] as string}
|
||||
onChange={(value) => changeSetting(key, value.toCssString())}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 风格配置 */}
|
||||
<Divider>{t('layout.styleConfig')}</Divider>
|
||||
{STYLE_CONFIGS.map(({key, label, type, ...rest}) => (
|
||||
<div key={key} className="flex justify-between items-center mb-2.5">
|
||||
<div>{t(label)}</div>
|
||||
{type === 'switch' ? (
|
||||
<Switch
|
||||
value={themeConfig[key as keyof typeof themeConfig] as boolean}
|
||||
onChange={(value) => changeSetting(key, value)}
|
||||
/>
|
||||
) : (
|
||||
<InputNumber
|
||||
value={themeConfig[key as keyof typeof themeConfig] as number}
|
||||
onChange={(value) => changeSetting(key, value)}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingDrawer;
|
||||
@@ -0,0 +1,37 @@
|
||||
import {type SelectProps, theme as antTheme} from "antd";
|
||||
|
||||
/** AntDesign 算法,你可以在此自定义你的算法 */
|
||||
const algorithm = {
|
||||
/** 默认算法 */
|
||||
defaultAlgorithm: antTheme.defaultAlgorithm,
|
||||
/** 暗黑模式算法 */
|
||||
darkAlgorithm: antTheme.darkAlgorithm,
|
||||
/** 默认 + 紧凑算法 */
|
||||
defaultCompactAlgorithm: [antTheme.defaultAlgorithm, antTheme.compactAlgorithm],
|
||||
/** 暗黑 + 紧凑算法 */
|
||||
darkCompactAlgorithm: [antTheme.darkAlgorithm, antTheme.compactAlgorithm],
|
||||
};
|
||||
|
||||
/** 算法切换选择器的 Options (使用 i18n key) */
|
||||
export const algorithmOptions: SelectProps['options'] = [
|
||||
{
|
||||
label: 'layout.algorithmDefault',
|
||||
value: 'defaultAlgorithm',
|
||||
},
|
||||
{
|
||||
label: 'layout.algorithmDark',
|
||||
value: 'darkAlgorithm',
|
||||
},
|
||||
{
|
||||
label: 'layout.algorithmDefaultCompact',
|
||||
value: 'defaultCompactAlgorithm',
|
||||
},
|
||||
{
|
||||
label: 'layout.algorithmDarkCompact',
|
||||
value: 'darkCompactAlgorithm',
|
||||
},
|
||||
]
|
||||
|
||||
export type algorithmType = keyof typeof algorithm;
|
||||
|
||||
export default algorithm;
|
||||
@@ -0,0 +1,84 @@
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import {Layout} from "antd";
|
||||
import HeaderRender from "@/layout/HeaderRender";
|
||||
import FooterRender from "@/layout/FooterRender";
|
||||
import MenuRender from "@/layout/MenuRender";
|
||||
import MobileDrawerMenu from "@/layout/MobileDrawerMenu";
|
||||
import SettingDrawer from "@/layout/SettingDrawer";
|
||||
import PageTitle from "@/components/PageTitle";
|
||||
import {LayoutProvider, useLayoutContext} from "@/layout/LayoutContext";
|
||||
import {Outlet} from "react-router";
|
||||
import useMobile from "@/hooks/useMobile.ts";
|
||||
import ColumnsMenu from "@/layout/ColumnsMenu.tsx";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
const {Content, Sider} = Layout;
|
||||
|
||||
const LayoutContent: React.FC = () => {
|
||||
const isMobile = useMobile();
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const layout = useGlobalStore(state => state.layout);
|
||||
const [siderWidth, setSiderWidth] = useState<number>(226);
|
||||
const {menus, collapsed, parentKey} = useLayoutContext();
|
||||
|
||||
useEffect(() => {
|
||||
const menuWidth = themeConfig.siderWeight ? themeConfig.siderWeight : 226;
|
||||
const columnWidth = layout === 'columns' ? 79 : 0;
|
||||
const rule = menus.find(item => item.key === parentKey);
|
||||
if(['mix', 'columns'].includes(layout)) {
|
||||
if(rule?.children && rule?.children.length) {
|
||||
setSiderWidth(columnWidth + menuWidth)
|
||||
} else {
|
||||
setSiderWidth(columnWidth);
|
||||
}
|
||||
} else {
|
||||
setSiderWidth(menuWidth);
|
||||
}
|
||||
}, [themeConfig, layout, menus, parentKey]);
|
||||
|
||||
return (
|
||||
<Layout className="min-h-screen" style={{ background: themeConfig.background }}>
|
||||
{/* 页面标题 */}
|
||||
<PageTitle />
|
||||
{/* 主题设置抽屉 */}
|
||||
<SettingDrawer />
|
||||
{/* 顶栏 */}
|
||||
<HeaderRender />
|
||||
{/* 移动端抽屉菜单 */}
|
||||
{ isMobile && <MobileDrawerMenu /> }
|
||||
{/* 内容区域 */}
|
||||
<Layout hasSider className={"relative"}>
|
||||
{ layout !== "top" && !isMobile && (
|
||||
<Sider
|
||||
collapsed={collapsed}
|
||||
width={siderWidth}
|
||||
style={{
|
||||
borderRight: "1px solid " + themeConfig.colorBorder,
|
||||
height: `calc(100vh - ${themeConfig.headerHeight}px)`,
|
||||
top: themeConfig.headerHeight,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
position: 'sticky'
|
||||
}}
|
||||
>
|
||||
{ layout === "columns" ? <ColumnsMenu/> : <MenuRender />}
|
||||
</Sider>
|
||||
)}
|
||||
<Layout>
|
||||
<Content style={{padding: themeConfig.bodyPadding}}>
|
||||
<Outlet/>
|
||||
</Content>
|
||||
<FooterRender/>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutRender = () => (
|
||||
<LayoutProvider>
|
||||
<LayoutContent />
|
||||
</LayoutProvider>
|
||||
);
|
||||
|
||||
export default LayoutRender;
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {ThemeProps} from "@/layout/typing.ts";
|
||||
|
||||
// 默认主题
|
||||
export const defaultColorTheme: ThemeProps = {
|
||||
themeScheme: "light",
|
||||
background: "transparent",
|
||||
colorPrimary: "#1677ff",
|
||||
borderRadius: 8,
|
||||
colorText: "#000",
|
||||
colorBg: "#fff",
|
||||
bodyBg: "#f9f9f9",
|
||||
footerBg: "#fff",
|
||||
headerBg: "#fff",
|
||||
headerColor: "#000",
|
||||
siderBg: "#fff",
|
||||
siderColor: "#000",
|
||||
colorBorder: "#f0f0f0",
|
||||
algorithm: "defaultAlgorithm",
|
||||
}
|
||||
|
||||
// 暗黑模式主题
|
||||
export const darkColorTheme: ThemeProps = {
|
||||
themeScheme: "dark",
|
||||
background: "transparent",
|
||||
borderRadius: 8,
|
||||
colorPrimary: "#1677ff",
|
||||
colorText: "#fff",
|
||||
colorBg: "#000",
|
||||
bodyBg: "#000",
|
||||
footerBg: "#141414",
|
||||
headerBg: "#141414",
|
||||
headerColor: "#fff",
|
||||
siderBg: "#141414",
|
||||
siderColor: "#fff",
|
||||
colorBorder: "#282828",
|
||||
algorithm: "darkAlgorithm",
|
||||
}
|
||||
|
||||
export const configTheme: ThemeProps = {
|
||||
// 主题
|
||||
themeScheme: "light",
|
||||
// 品牌色
|
||||
colorPrimary: "#1677ff",
|
||||
// 错误色
|
||||
colorError: "#ff4d4f",
|
||||
// 成功色
|
||||
colorSuccess: "#52c41a",
|
||||
// 警告色
|
||||
colorWarning: "#faad14",
|
||||
// 基础组件的圆角大小
|
||||
borderRadius: 10,
|
||||
// 按钮和输入框等基础控件的高度
|
||||
controlHeight: 32,
|
||||
// 头部两侧内边距
|
||||
headerPadding: 20,
|
||||
// 头部高度
|
||||
headerHeight: 56,
|
||||
// 侧边栏宽度
|
||||
siderWeight: 226,
|
||||
// 内容区域内边距
|
||||
bodyPadding: 20,
|
||||
// 固定页脚
|
||||
fixedFooter: false,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { algorithmType } from "@/layout/algorithm";
|
||||
|
||||
export type ThemeScheme = "light" | "dark" | "pink" | "green";
|
||||
|
||||
export type LayoutType = "side" | "top" | "mix" | "columns";
|
||||
|
||||
export interface ThemeProps {
|
||||
// 主题
|
||||
themeScheme?: ThemeScheme;
|
||||
// 背景
|
||||
background?: string;
|
||||
// 品牌色
|
||||
colorPrimary?: string;
|
||||
// 错误色
|
||||
colorError?: string;
|
||||
// 成功色
|
||||
colorSuccess?: string;
|
||||
// 警告色
|
||||
colorWarning?: string;
|
||||
// 基础组件的圆角大小
|
||||
borderRadius?: number;
|
||||
// 按钮和输入框等基础控件的高度
|
||||
controlHeight?: number;
|
||||
// 头部两侧内边距
|
||||
headerPadding?: number;
|
||||
// 头部高度
|
||||
headerHeight?: number;
|
||||
// 侧边栏宽度
|
||||
siderWeight?: number;
|
||||
// 内容区域内边距
|
||||
bodyPadding?: number;
|
||||
// 固定页脚
|
||||
fixedFooter?: boolean;
|
||||
// 基础文字颜色
|
||||
colorText?: string;
|
||||
// 基础背景颜色
|
||||
colorBg?: string;
|
||||
// 内容区域背景色
|
||||
bodyBg?: string;
|
||||
// 页脚背景色
|
||||
footerBg?: string;
|
||||
// 头部背景色
|
||||
headerBg?: string;
|
||||
// 头部文字颜色
|
||||
headerColor?: string;
|
||||
// 侧边栏背景色
|
||||
siderBg?: string;
|
||||
// 侧边栏文字颜色
|
||||
siderColor?: string;
|
||||
// 布局分割线边框颜色
|
||||
colorBorder?: string;
|
||||
// 算法
|
||||
algorithm?: algorithmType;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {IMenus} from "../domain/iSysRule";
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
href?: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
local?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建面包屑Map
|
||||
* @param menus
|
||||
*/
|
||||
export function buildBreadcrumbMap(menus: IMenus[]) {
|
||||
const breadcrumbMap: Record<string, BreadcrumbItem[]> = {};
|
||||
const processMenu = (item: IMenus, breadcrumbs: BreadcrumbItem[] = []) => {
|
||||
if (!item.key) return;
|
||||
const currentBreadcrumb: BreadcrumbItem = {
|
||||
href: item.path,
|
||||
title: item.name,
|
||||
icon: item.icon,
|
||||
local: item.local,
|
||||
};
|
||||
const newBreadcrumbs = [...breadcrumbs, currentBreadcrumb];
|
||||
breadcrumbMap[item.key] = newBreadcrumbs;
|
||||
if (item.children && item.children.length) {
|
||||
item.children.forEach(child => processMenu(child, newBreadcrumbs));
|
||||
}
|
||||
};
|
||||
menus.forEach(item => processMenu(item));
|
||||
return breadcrumbMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径查找菜单项
|
||||
* @param menus
|
||||
* @param path
|
||||
*/
|
||||
export function findMenuByPath(menus: IMenus[], path: string): IMenus | null {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === path) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const result = findMenuByPath(menu.children, path);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径构建父菜单 keys
|
||||
* @param menus
|
||||
* @param path
|
||||
* @param parentKeys
|
||||
*/
|
||||
export function getMenuParentKeys(menus: IMenus[], path: string, parentKeys: string[] = []): string[] | null {
|
||||
for (const menu of menus) {
|
||||
if (!menu.key) continue;
|
||||
const keys = [...parentKeys, menu.key];
|
||||
if (menu.path === path) {
|
||||
return keys;
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const result = getMenuParentKeys(menu.children, path, keys);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径查找菜单项
|
||||
* @param menus
|
||||
* @param key
|
||||
*/
|
||||
export function findMenuByKey(menus: IMenus[], key: string): IMenus | null {
|
||||
for (const menu of menus) {
|
||||
if (menu.key === key) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const result = findMenuByKey(menu.children, key);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
'ai.agent.page.title': 'Agent Management',
|
||||
'ai.agent.page.description': 'Manage AI Agent registration and status. Agents are registered via database seeder.',
|
||||
'ai.agent.update.success': 'Updated successfully',
|
||||
'ai.agent.update.failed': 'Failed to update',
|
||||
'ai.agent.empty': 'No agents registered.',
|
||||
'ai.agent.goChat': 'Chat',
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
'ai.chat.title': 'AI Chat',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.placeholder': 'Type your question, press Enter to send...',
|
||||
'ai.chat.welcome.title': 'How can I help you?',
|
||||
'ai.chat.welcome.description': 'I am your AI assistant. I can answer questions, write code, analyze data, and more.',
|
||||
'ai.chat.loading': 'Thinking...',
|
||||
'ai.chat.error.send': 'Failed to send, please retry',
|
||||
'ai.chat.delete.confirm': 'Are you sure you want to delete this conversation?',
|
||||
'ai.chat.delete.success': 'Conversation deleted',
|
||||
'ai.chat.empty': 'No conversations yet, start a new chat',
|
||||
'ai.chat.copy': 'Copy',
|
||||
'ai.chat.copy.success': 'Copied to clipboard',
|
||||
'ai.chat.retry': 'Retry',
|
||||
'ai.chat.thinking': 'Deep thinking...',
|
||||
'ai.chat.thinking.done': 'Thinking completed',
|
||||
'ai.chat.attachment.upload': 'Upload file',
|
||||
'ai.chat.attachment.maxCount': 'Maximum {count} files',
|
||||
'ai.chat.attachment.error': 'File upload failed',
|
||||
'ai.chat.switchAgent': 'Switch Agent',
|
||||
'ai.chat.defaultTitle': 'AI Chat',
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
export default {
|
||||
'ai.conversation.page.title': 'AI Conversation Management',
|
||||
'ai.conversation.page.description': 'Manage AI chat conversations and message records',
|
||||
'ai.conversation.id': 'Conversation ID',
|
||||
'ai.conversation.username': 'User',
|
||||
'ai.conversation.title': 'Title',
|
||||
'ai.conversation.messageCount': 'Messages',
|
||||
'ai.conversation.createdAt': 'Created At',
|
||||
'ai.conversation.updatedAt': 'Updated At',
|
||||
'ai.conversation.deleteConfirm': 'Are you sure you want to delete this conversation? All messages will be removed.',
|
||||
'ai.conversation.deleteSuccess': 'Conversation deleted',
|
||||
'ai.conversation.viewMessages': 'View Messages',
|
||||
'ai.conversation.messageTitle': 'Conversation Messages',
|
||||
'ai.conversation.noUser': 'Anonymous',
|
||||
|
||||
'ai.conversation.message.id': 'Message ID',
|
||||
'ai.conversation.message.role': 'Role',
|
||||
'ai.conversation.message.role.user': 'User',
|
||||
'ai.conversation.message.role.assistant': 'Assistant',
|
||||
'ai.conversation.message.role.system': 'System',
|
||||
'ai.conversation.message.agent': 'AI Model',
|
||||
'ai.conversation.message.content': 'Content',
|
||||
'ai.conversation.message.createdAt': 'Sent At',
|
||||
'ai.conversation.message.toolCalls': 'Tool Calls',
|
||||
'ai.conversation.message.usage': 'Usage',
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
'xin.crud.operate': 'Actions',
|
||||
'xin.crud.edit': 'Edit',
|
||||
'xin.crud.delete': 'Delete',
|
||||
'xin.crud.add': 'Add',
|
||||
'xin.crud.batchDelete': 'Batch Delete',
|
||||
'xin.crud.confirm': 'Confirm',
|
||||
'xin.crud.cancel': 'Cancel',
|
||||
'xin.crud.deleteConfirm': 'Confirm Delete',
|
||||
'xin.crud.deleteConfirmDesc': 'Data cannot be recovered after deletion. Are you sure to delete?',
|
||||
'xin.crud.batchDeleteConfirm': 'Confirm Batch Delete',
|
||||
'xin.crud.batchDeleteConfirmDesc': 'Are you sure to delete {{count}} selected items?',
|
||||
'xin.crud.selectAtLeastOne': 'Please select at least one item',
|
||||
'xin.crud.createSuccess': 'Created successfully',
|
||||
'xin.crud.updateSuccess': 'Updated successfully',
|
||||
'xin.crud.deleteSuccess': 'Deleted successfully',
|
||||
'xin.crud.batchDeleteSuccess': 'Batch deleted successfully',
|
||||
'xin.crud.createTitle': 'Create',
|
||||
'xin.crud.editTitle': 'Edit',
|
||||
'xin.crud.total': 'Total {{total}} items',
|
||||
'xin.crud.yes': 'Yes',
|
||||
'xin.crud.no': 'No',
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
export default {
|
||||
// XinForm Component
|
||||
"xin.form.submit": "Submit",
|
||||
"xin.form.reset": "Reset",
|
||||
"xin.form.cancel": "Cancel",
|
||||
"xin.form.formList.add": "Add Item",
|
||||
"xin.form.upload.button": "Click to Upload",
|
||||
|
||||
// UserSelector Component
|
||||
"xin.form.userSelector.placeholder": "Please select user",
|
||||
"xin.form.userSelector.modal.title": "Select User",
|
||||
"xin.form.userSelector.modal.okText": "OK",
|
||||
"xin.form.userSelector.modal.cancelText": "Cancel",
|
||||
|
||||
// IconSelector Component
|
||||
"xin.form.iconSelector.placeholder": "Please select icon",
|
||||
"xin.form.iconSelector.modal.title": "Select Icon",
|
||||
"xin.form.iconSelector.tabs.use": "Custom Icons",
|
||||
"xin.form.iconSelector.tabs.suggestion": "Website General Icons",
|
||||
"xin.form.iconSelector.tabs.direction": "Directional Icons",
|
||||
"xin.form.iconSelector.tabs.editor": "Editor Icons",
|
||||
"xin.form.iconSelector.tabs.data": "Data Icons",
|
||||
"xin.form.iconSelector.tabs.logo": "Brands and Logos",
|
||||
"xin.form.iconSelector.tabs.other": "Other Icons",
|
||||
|
||||
// ImageUploader Component
|
||||
"xin.form.imageUploader.uploadText": "Upload Image",
|
||||
"xin.form.imageUploader.error.notImage": "Only image files can be uploaded!",
|
||||
"xin.form.imageUploader.error.sizeExceeded": "Image size cannot exceed {{maxSize}}MB!",
|
||||
"xin.form.imageUploader.error.dimensionExceeded": "Image dimensions cannot exceed {{maxWidth}}x{{maxHeight}}px!",
|
||||
"xin.form.imageUploader.error.loadFailed": "Failed to load image!",
|
||||
"xin.form.imageUploader.error.readFailed": "Failed to read image!",
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export default {
|
||||
// XinTable Component
|
||||
'xin.table.queryTable': 'Query Table',
|
||||
'xin.table.keywordPlaceholder': 'Enter keyword',
|
||||
'xin.table.keywordEmpty': 'Please enter search content',
|
||||
'xin.table.add': 'Add',
|
||||
'xin.table.refresh': 'Refresh',
|
||||
'xin.table.columnSettings': 'Column Settings',
|
||||
'xin.table.showBorder': 'Show Border',
|
||||
'xin.table.hideBorder': 'Hide Border',
|
||||
'xin.table.density.default': 'Default',
|
||||
'xin.table.density.middle': 'Middle',
|
||||
'xin.table.density.compact': 'Compact',
|
||||
'xin.table.total': 'Total {{total}} items',
|
||||
// Operate Column
|
||||
'xin.table.operate': 'Actions',
|
||||
'xin.table.edit': 'Edit',
|
||||
'xin.table.delete': 'Delete',
|
||||
'xin.table.deleteConfirm': 'Are you sure to delete record {{id}}?',
|
||||
'xin.table.deleteOk': 'Confirm Delete',
|
||||
'xin.table.deleteCancel': 'Cancel',
|
||||
'xin.table.deleteSuccess': 'Deleted successfully',
|
||||
// FormModal
|
||||
'xin.table.form.createTitle': 'Create',
|
||||
'xin.table.form.editTitle': 'Edit',
|
||||
'xin.table.form.close': 'Close',
|
||||
'xin.table.form.createSuccess': 'Created successfully!',
|
||||
'xin.table.form.updateSuccess': 'Updated successfully!',
|
||||
'xin.table.form.updateKeyUndefined': 'Primary key for update is undefined!',
|
||||
// SearchForm
|
||||
'xin.table.search.search': 'Search',
|
||||
'xin.table.search.reset': 'Reset',
|
||||
'xin.table.search.expand': 'Expand',
|
||||
'xin.table.search.collapse': 'Collapse',
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
export default {
|
||||
// Common
|
||||
"dashboard.since.lastWeek": "Since last week",
|
||||
"dashboard.vs.lastWeek": "vs last week",
|
||||
|
||||
// Analysis Page
|
||||
"dashboard.analysis.totalRevenue": "Total Revenue",
|
||||
"dashboard.analysis.totalExpenses": "Total Expenses",
|
||||
"dashboard.analysis.visitors": "Visitors",
|
||||
"dashboard.analysis.likes": "Likes",
|
||||
"dashboard.analysis.annualSales": "Annual Sales",
|
||||
"dashboard.analysis.grossProfit": "Gross Profit",
|
||||
"dashboard.analysis.netProfit": "Net Profit",
|
||||
"dashboard.analysis.totalExpense": "Total Expense",
|
||||
"dashboard.analysis.accessFrom": "Access From",
|
||||
"dashboard.analysis.searchEngine": "Search Engine",
|
||||
"dashboard.analysis.direct": "Direct",
|
||||
"dashboard.analysis.email": "Email",
|
||||
"dashboard.analysis.unionAds": "Union Ads",
|
||||
"dashboard.analysis.videoAds": "Video Ads",
|
||||
"dashboard.analysis.salesRanking": "Sales Ranking",
|
||||
"dashboard.analysis.month": "Month",
|
||||
"dashboard.analysis.year": "Year",
|
||||
"dashboard.analysis.day": "Day",
|
||||
"dashboard.analysis.article": "Article",
|
||||
"dashboard.analysis.age": "Age",
|
||||
"dashboard.analysis.address": "Address",
|
||||
"dashboard.analysis.tags": "Tags",
|
||||
"dashboard.analysis.action": "Action",
|
||||
"dashboard.analysis.invite": "Invite",
|
||||
"dashboard.analysis.delete": "Delete",
|
||||
"dashboard.analysis.userReviews": "User Reviews",
|
||||
"dashboard.analysis.reviewDescription": "Ant Design, a design language for background applications, is refined by Ant UED Team",
|
||||
|
||||
// Months
|
||||
"dashboard.analysis.january": "January",
|
||||
"dashboard.analysis.february": "February",
|
||||
"dashboard.analysis.march": "March",
|
||||
"dashboard.analysis.april": "April",
|
||||
"dashboard.analysis.may": "May",
|
||||
"dashboard.analysis.june": "June",
|
||||
"dashboard.analysis.july": "July",
|
||||
"dashboard.analysis.august": "August",
|
||||
"dashboard.analysis.september": "September",
|
||||
"dashboard.analysis.october": "October",
|
||||
"dashboard.analysis.november": "November",
|
||||
"dashboard.analysis.december": "December",
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
"dashboard.completion": "Completion",
|
||||
"dashboard.vs.lastWeek": "vs last week",
|
||||
|
||||
// Monitor Page
|
||||
"dashboard.monitor.helpService": "Help Service",
|
||||
"dashboard.monitor.propertyFee": "Property Fee",
|
||||
"dashboard.monitor.alarmEvents": "Alarm Events",
|
||||
"dashboard.monitor.otherEvents": "Other Events",
|
||||
"dashboard.monitor.todoList": "To-Do List",
|
||||
"dashboard.monitor.myFocus": "My Focus",
|
||||
"dashboard.monitor.all": "All",
|
||||
"dashboard.monitor.propertyOrder": "Property Orders",
|
||||
"dashboard.monitor.repairOrder": "Repair Orders",
|
||||
"dashboard.monitor.fireSafety": "Fire Safety",
|
||||
"dashboard.monitor.paidService": "Paid Services",
|
||||
"dashboard.monitor.publicService": "Public Services",
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
export default {
|
||||
// Workplace Page
|
||||
"dashboard.workplace.greeting.morning": "Good Morning",
|
||||
"dashboard.workplace.greeting.afternoon": "Good Afternoon",
|
||||
"dashboard.workplace.greeting.evening": "Good Evening",
|
||||
"dashboard.workplace.welcome": "Welcome back!",
|
||||
"dashboard.workplace.today": "Today is",
|
||||
"dashboard.workplace.myProjects": "My Projects",
|
||||
"dashboard.workplace.myTeams": "My Teams",
|
||||
"dashboard.workplace.latestActivities": "Latest Activities",
|
||||
"dashboard.workplace.notifications": "Notifications",
|
||||
"dashboard.workplace.appStore": "App Store",
|
||||
"dashboard.workplace.appStore.desc": "Application distribution and management platform",
|
||||
"dashboard.workplace.mallSystem": "Mall System",
|
||||
"dashboard.workplace.mallSystem.desc": "Enterprise e-commerce solution",
|
||||
"dashboard.workplace.collaboration": "Collaboration",
|
||||
"dashboard.workplace.collaboration.desc": "Team collaboration and communication tools",
|
||||
"dashboard.workplace.docCenter": "Document Center",
|
||||
"dashboard.workplace.docCenter.desc": "Enterprise knowledge management and document collaboration",
|
||||
"dashboard.workplace.badge.new": "New",
|
||||
"dashboard.workplace.status.inProgress": "In Progress",
|
||||
"dashboard.workplace.status.completed": "Completed",
|
||||
"dashboard.workplace.status.planning": "Planning",
|
||||
"dashboard.workplace.progress": "Progress",
|
||||
"dashboard.workplace.members": "Members",
|
||||
"dashboard.workplace.people": "people",
|
||||
"dashboard.workplace.recentVisits": "Recent Visits",
|
||||
"dashboard.workplace.location": "Local",
|
||||
|
||||
// Project Data
|
||||
"dashboard.workplace.project1.name": "Enterprise Management System",
|
||||
"dashboard.workplace.project1.desc": "Enterprise backend management system based on React and AntDesign",
|
||||
"dashboard.workplace.project2.name": "Mobile H5 Application",
|
||||
"dashboard.workplace.project2.desc": "Mobile e-commerce application developed with Vue3",
|
||||
"dashboard.workplace.project3.name": "Data Visualization Platform",
|
||||
"dashboard.workplace.project3.desc": "Big data visualization and analysis platform based on ECharts",
|
||||
"dashboard.workplace.project4.name": "System Upgrade Notice",
|
||||
"dashboard.workplace.project4.desc": "System will be upgraded and maintained from 2:00-4:00 AM this Friday",
|
||||
"dashboard.workplace.project5.name": "Team Invitation",
|
||||
"dashboard.workplace.project5.desc": "You have been invited to join the \"New Product R&D\" project team",
|
||||
"dashboard.workplace.project6.name": "Task Reminder",
|
||||
"dashboard.workplace.project6.desc": "You have 3 tasks that are about to expire, please handle them in time",
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import menu from "./menu";
|
||||
import login from "./login";
|
||||
|
||||
import dashboardAnalysis from "./dashboard/analysis";
|
||||
import dashboardMonitor from "./dashboard/monitor";
|
||||
import dashboardWorkplace from "./dashboard/workplace";
|
||||
|
||||
import systemInfo from "./system/info";
|
||||
import systemConfig from "./system/config";
|
||||
import systemDept from "./system/dept";
|
||||
import systemDict from "./system/dict";
|
||||
import systemFile from "./system/file";
|
||||
import systemMail from "./system/mail";
|
||||
import systemStorage from "./system/storage";
|
||||
import systemUser from "./system/user";
|
||||
import systemRole from "./system/role";
|
||||
import systemRule from "./system/rule";
|
||||
import systemAi from "./system/ai";
|
||||
import systemCarousel from "./system/carousel";
|
||||
import systemGridNav from "./system/gridNav";
|
||||
|
||||
import aiChat from "./ai/chat";
|
||||
import aiConversation from "./ai/conversation";
|
||||
import aiAgent from "./ai/agent";
|
||||
|
||||
import userProfile from "./user/profile";
|
||||
|
||||
import xinForm from "./components/xin-form";
|
||||
import xinTable from "./components/xin-table";
|
||||
import xinCrud from "./components/xin-crud";
|
||||
|
||||
import layout from "./layout/layout";
|
||||
|
||||
export default {
|
||||
...menu,
|
||||
...login,
|
||||
...dashboardAnalysis,
|
||||
...dashboardMonitor,
|
||||
...dashboardWorkplace,
|
||||
...systemInfo,
|
||||
...systemConfig,
|
||||
...systemDept,
|
||||
...systemDict,
|
||||
...systemFile,
|
||||
...systemMail,
|
||||
...systemStorage,
|
||||
...systemUser,
|
||||
...systemRole,
|
||||
...systemRule,
|
||||
...systemAi,
|
||||
...systemCarousel,
|
||||
...systemGridNav,
|
||||
...aiChat,
|
||||
...aiConversation,
|
||||
...aiAgent,
|
||||
...userProfile,
|
||||
...xinForm,
|
||||
...xinTable,
|
||||
...xinCrud,
|
||||
...layout,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
export default {
|
||||
// Setting Drawer
|
||||
"layout.resetTheme": "Reset Theme",
|
||||
"layout.saveTheme": "Save Theme",
|
||||
"layout.layoutStyle": "Layout Style",
|
||||
"layout.layoutSide": "Side Navigation",
|
||||
"layout.layoutTop": "Top Navigation",
|
||||
"layout.layoutMix": "Mix Navigation",
|
||||
"layout.layoutColumns": "Columns Navigation",
|
||||
"layout.presetTheme": "Preset Theme",
|
||||
"layout.themeLight": "Light",
|
||||
"layout.themeDark": "Dark",
|
||||
"layout.themePink": "Pink",
|
||||
"layout.themeGreen": "Green",
|
||||
"layout.themeAlgorithm": "Theme Algorithm",
|
||||
"layout.algorithmDefault": "Default",
|
||||
"layout.algorithmDark": "Dark",
|
||||
"layout.algorithmDefaultCompact": "Default + Compact",
|
||||
"layout.algorithmDarkCompact": "Dark + Compact",
|
||||
"layout.themeColor": "Theme Color",
|
||||
"layout.colorPrimary": "Primary Color",
|
||||
"layout.colorText": "Text Color",
|
||||
"layout.colorBg": "Background Color",
|
||||
"layout.colorSuccess": "Success Color",
|
||||
"layout.colorWarning": "Warning Color",
|
||||
"layout.colorError": "Error Color",
|
||||
"layout.bodyBg": "Body Background",
|
||||
"layout.footerBg": "Footer Background",
|
||||
"layout.headerBg": "Header Background",
|
||||
"layout.headerColor": "Header Text Color",
|
||||
"layout.siderBg": "Sider Background",
|
||||
"layout.siderColor": "Sider Text Color",
|
||||
"layout.colorBorder": "Border Color",
|
||||
"layout.styleConfig": "Style Configuration",
|
||||
"layout.fixedFooter": "Fixed Footer",
|
||||
"layout.borderRadius": "Border Radius",
|
||||
"layout.controlHeight": "Control Height",
|
||||
"layout.headerPadding": "Header Padding",
|
||||
"layout.headerHeight": "Header Height",
|
||||
"layout.siderWeight": "Sider Width",
|
||||
"layout.bodyPadding": "Body Padding",
|
||||
"layout.systemConfig": "System Configuration",
|
||||
"layout.localRoute": "Local Route",
|
||||
|
||||
// Header Right
|
||||
"layout.profile": "User Settings",
|
||||
"layout.logout": "Logout",
|
||||
"layout.logoutSuccess": "Logout successful, redirecting",
|
||||
"layout.logoutFailed": "Logout failed",
|
||||
"layout.searchPlaceholder": "Please enter search content",
|
||||
"layout.searchConfirm": "Confirm",
|
||||
"layout.searchSwitch": "Switch",
|
||||
"layout.searchClose": "Close",
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
"login.username": "Username",
|
||||
"login.password": "Password",
|
||||
"login.usernamePlaceholder": "admin",
|
||||
"login.passwordPlaceholder": "123456",
|
||||
"login.usernameRequired": "Please enter your username!",
|
||||
"login.passwordRequired": "Please enter your password!",
|
||||
"login.remember": "Remember me",
|
||||
"login.forgotPassword": "Forgot password",
|
||||
"login.otherLogin": "Other login methods",
|
||||
"login.success": "Login successful!",
|
||||
"login.alreadyLoggedIn": "Already logged in, redirecting!",
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export default {
|
||||
"menu.dashboard": "Dashboard",
|
||||
"menu.analysis": "Analysis",
|
||||
"menu.monitor": "Monitor",
|
||||
"menu.workplace": "Workplace",
|
||||
"menu.result": "Results",
|
||||
"menu.result.success": "Success",
|
||||
"menu.result.fail": "Failure",
|
||||
"menu.result.warning": "Warning",
|
||||
"menu.result.info": "Information",
|
||||
"menu.exception": "Exceptions",
|
||||
"menu.exception.403": "403",
|
||||
"menu.exception.404": "404",
|
||||
"menu.exception.500": "500",
|
||||
"menu.multi-menu": "Multi-level Menu",
|
||||
"menu.multi-menu.two": "Level 2",
|
||||
"menu.multi-menu.first": "Level 2 Page",
|
||||
"menu.multi-menu.two.three": "Level 3",
|
||||
"menu.multi-menu.two.second": "Level 3 Page",
|
||||
"menu.multi-menu.two.three.third": "Level 4",
|
||||
"menu.page-layout": "Layouts",
|
||||
"menu.page-layout.base-layout": "Basic Layout",
|
||||
"menu.page-layout.fix-header": "Fixed Header",
|
||||
"menu.page-layout.descriptions": "Descriptions",
|
||||
"menu.example": "Examples",
|
||||
"menu.example.user-selector": "User Selector",
|
||||
"menu.example.icon-selector": "Icon Selector",
|
||||
"menu.example.image-uploader": "Image Upload",
|
||||
"menu.example.xin-form": "Xin Form",
|
||||
"menu.example.xin-table": "Xin Table",
|
||||
"menu.ai": "AI",
|
||||
"menu.ai.chat": "Chat",
|
||||
"menu.ai.agent": "Agents",
|
||||
"menu.ai.conversation": "Conversations",
|
||||
"menu.system": "System",
|
||||
"menu.system.user": "Users",
|
||||
"menu.system.dept": "Departments",
|
||||
"menu.system.role": "Roles",
|
||||
"menu.system.rule": "Menus",
|
||||
"menu.system.info": "System Information",
|
||||
"menu.system.file": "Files",
|
||||
"menu.system.dict": "Dictionaries",
|
||||
"menu.system.mail": "Mail",
|
||||
"menu.system.storage": "Storage",
|
||||
"menu.system.config": "Site Config",
|
||||
"menu.system.ai": "AI",
|
||||
"menu.system.carousel": "Carousel",
|
||||
"menu.system.grid-nav": "Grid Navigation",
|
||||
"menu.xin-admin": "XinAdmin",
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export default {
|
||||
// Page title
|
||||
'system.ai.page.title': 'AI Settings',
|
||||
'system.ai.page.description': 'Manage AI provider and model configurations, supports multiple providers and default model assignment',
|
||||
|
||||
// Default model selection
|
||||
'system.ai.default': 'Default Model',
|
||||
'system.ai.default.tooltip': 'Default model for general AI chat, analysis and other tasks',
|
||||
|
||||
// Provider config
|
||||
'system.ai.provider.api_key': '{{lab}} API Key',
|
||||
'system.ai.provider.api_key.placeholder': 'Please enter API Key',
|
||||
'system.ai.provider.url': '{{lab}} API URL',
|
||||
'system.ai.provider.url.placeholder': 'Example: {{url}}',
|
||||
|
||||
// Azure specific
|
||||
'system.ai.azure.api_version': 'API Version',
|
||||
'system.ai.azure.deployment': 'Deployment Name',
|
||||
'system.ai.azure.embedding_deployment': 'Embedding Deployment Name',
|
||||
'system.ai.azure.image_deployment': 'Image Deployment Name',
|
||||
|
||||
// Bedrock specific
|
||||
'system.ai.bedrock.region': 'AWS Region',
|
||||
'system.ai.bedrock.access_key_id': 'Access Key ID',
|
||||
'system.ai.bedrock.secret_access_key': 'Secret Access Key',
|
||||
'system.ai.bedrock.session_token': 'Session Token (optional)',
|
||||
|
||||
// Connection test
|
||||
'system.ai.test.title': 'Connection Test',
|
||||
'system.ai.test.hint': 'Click the button below to test the current AI provider connection',
|
||||
'system.ai.test.button': 'Test Connection',
|
||||
'system.ai.test.testing': 'Testing connection...',
|
||||
'system.ai.test.success': 'Connection test successful!',
|
||||
'system.ai.test.error': 'Connection test failed',
|
||||
|
||||
// Save
|
||||
'system.ai.save.success': 'AI settings saved successfully',
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
// Page Title
|
||||
"system.carousel.page.title": "Homepage Carousel",
|
||||
"system.carousel.page.description": "Manage homepage carousel images and links",
|
||||
|
||||
// Fields
|
||||
"system.carousel.id": "ID",
|
||||
"system.carousel.title": "Carousel Title",
|
||||
"system.carousel.title.required": "Carousel title is required",
|
||||
"system.carousel.image": "Carousel Image",
|
||||
"system.carousel.image.required": "Carousel image is required",
|
||||
"system.carousel.link": "Redirect Link",
|
||||
"system.carousel.link.placeholder": "Enter redirect link",
|
||||
"system.carousel.status": "Status",
|
||||
"system.carousel.status.required": "Status is required",
|
||||
"system.carousel.status.normal": "Active",
|
||||
"system.carousel.status.disabled": "Disabled",
|
||||
"system.carousel.sort": "Sort",
|
||||
"system.carousel.createdAt": "Created At",
|
||||
"system.carousel.updatedAt": "Updated At",
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
export default {
|
||||
// Page title
|
||||
'system.config.page.title': 'Site Configs',
|
||||
'system.config.page.description': 'Manage site configuration items, support dynamic configuration of site parameters',
|
||||
|
||||
// Config group
|
||||
'system.config.group.title': 'Config Groups',
|
||||
'system.config.group.add': 'Add',
|
||||
'system.config.group.create': 'Create Config Group',
|
||||
'system.config.group.edit': 'Edit Config Group',
|
||||
'system.config.group.delete': 'Delete',
|
||||
'system.config.group.deleteConfirm': 'Confirm deletion?',
|
||||
'system.config.group.deleteWarning': 'Deleting a setting group will also delete all settings in that group',
|
||||
'system.config.group.field.title': 'Title',
|
||||
'system.config.group.field.title.required': 'Please enter title',
|
||||
'system.config.group.field.key': 'Key',
|
||||
'system.config.group.field.key.required': 'Please enter key',
|
||||
'system.config.group.field.remark': 'Description',
|
||||
'system.config.group.createSuccess': 'Config group created successfully',
|
||||
'system.config.group.updateSuccess': 'Config group updated successfully',
|
||||
'system.config.group.deleteSuccess': 'Config group deleted successfully',
|
||||
|
||||
// Config item
|
||||
'system.config.item.title': 'Config Items',
|
||||
'system.config.item.add': 'Add Config Item',
|
||||
'system.config.item.create': 'Create Config Item',
|
||||
'system.config.item.edit': 'Edit Config Item',
|
||||
'system.config.item.delete': 'Delete',
|
||||
'system.config.item.deleteConfirm': 'Confirm deletion?',
|
||||
'system.config.item.selectGroup': 'Please select a setting group first',
|
||||
'system.config.item.empty': 'No setting items, please click "Add Config Item" button to add',
|
||||
'system.config.item.field.key': 'Key',
|
||||
'system.config.item.field.key.required': 'Please enter key',
|
||||
'system.config.item.field.title': 'Title',
|
||||
'system.config.item.field.title.required': 'Please enter title',
|
||||
'system.config.item.field.type': 'Component Type',
|
||||
'system.config.item.field.type.required': 'Please select component type',
|
||||
'system.config.item.field.describe': 'Description',
|
||||
"system.config.item.field.options": "Options",
|
||||
"system.config.item.field.options.tooltip": "Options for components such as radio buttons and checkboxes, e.g.: 1=Option 1 /n 2=Option 2",
|
||||
"system.config.item.field.props": "Properties",
|
||||
"system.config.item.field.props.tooltip": "Component configuration, supporting simple settings, e.g.: placeholder=Please enter /n maxLength=100",
|
||||
'system.config.item.field.values': 'Default Value',
|
||||
'system.config.item.field.sort': 'Sort',
|
||||
'system.config.item.createSuccess': 'Config item created successfully',
|
||||
'system.config.item.updateSuccess': 'Config item updated successfully',
|
||||
'system.config.item.deleteSuccess': 'Config item deleted successfully',
|
||||
'system.config.item.refreshCacheSuccess': 'refresh cache successfully',
|
||||
|
||||
// Form component types
|
||||
'system.config.component.Input': 'Input',
|
||||
'system.config.component.TextArea': 'Text Area',
|
||||
'system.config.component.InputNumber': 'Number Input',
|
||||
'system.config.component.Switch': 'Switch',
|
||||
'system.config.component.Radio': 'Radio',
|
||||
'system.config.component.Checkbox': 'Checkbox',
|
||||
|
||||
// Form placeholders
|
||||
'system.config.form.placeholder.input': 'Please input',
|
||||
'system.config.form.placeholder.select': 'Please select',
|
||||
'system.config.form.placeholder.date': 'Please select date',
|
||||
|
||||
// Operation prompts
|
||||
'system.config.save.button': 'Save',
|
||||
'system.config.refresh.button': 'Refresh Cache',
|
||||
'system.config.confirm.ok': 'Confirm',
|
||||
'system.config.confirm.cancel': 'Cancel',
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
export default {
|
||||
// Page title
|
||||
"system.dept.page.title": "Department Management",
|
||||
"system.dept.page.description": "Manage system departments, configure department hierarchy, manage department users",
|
||||
|
||||
"system.dept.tab.info": "Basic Information",
|
||||
"system.dept.tab.users": "User List",
|
||||
"system.dept.unnamed": "Unnamed Department",
|
||||
"system.dept.createSuccess": "Department added successfully!",
|
||||
"system.dept.updateSuccess": "Department updated successfully!",
|
||||
"system.dept.deleteSuccess": "Batch department deletion successful!",
|
||||
"system.dept.column.name": "Department Name",
|
||||
"system.dept.column.name.required": "Department name is required!",
|
||||
"system.dept.column.code": "Department Code",
|
||||
"system.dept.column.code.required": "Department code is required!",
|
||||
"system.dept.column.type": "Department Type",
|
||||
"system.dept.column.type.0": "Company",
|
||||
"system.dept.column.type.1": "Department",
|
||||
"system.dept.column.type.2": "Position",
|
||||
"system.dept.column.type.required": "Department type is required!",
|
||||
"system.dept.column.parent": "Parent Department",
|
||||
"system.dept.column.parent.0": "Top Level",
|
||||
"system.dept.column.parent.required": "Parent department is required!",
|
||||
"system.dept.column.email": "Email",
|
||||
"system.dept.column.address": "Address",
|
||||
"system.dept.column.phone": "Phone",
|
||||
"system.dept.column.sort": "Sort Order",
|
||||
"system.dept.column.sort.required": "Sort order is required!",
|
||||
"system.dept.column.status": "Status",
|
||||
"system.dept.column.status.0": "Normal",
|
||||
"system.dept.column.status.1": "Disabled",
|
||||
"system.dept.column.status.required": "Status is required!",
|
||||
"system.dept.column.remark": "Remarks",
|
||||
"system.dept.users.column.id": "User ID",
|
||||
"system.dept.users.column.username": "Username",
|
||||
"system.dept.users.column.nickname": "Nickname",
|
||||
"system.dept.users.column.email": "Email",
|
||||
"system.dept.users.column.mobile": "Mobile Number",
|
||||
"system.dept.users.column.status": "Status",
|
||||
"system.dept.users.column.status.0": "Enabled",
|
||||
"system.dept.users.column.status.1": "Disabled",
|
||||
"system.dept.createButton": "Add Department",
|
||||
"system.dept.createModalTitle": "Add Department Form",
|
||||
"system.dept.createChildrenButton": "Add Subordinate",
|
||||
"system.dept.checkedMessage": "{{checked}} records selected",
|
||||
"system.dept.unselect": "Deselect",
|
||||
"system.dept.delete.ok": "Delete",
|
||||
"system.dept.delete.cancel": "Cancel Delete",
|
||||
"system.dept.delete.title": "Delete Department",
|
||||
"system.dept.delete.description": "Are you sure you want to delete these departments?",
|
||||
"system.dept.saveInfo": "Save Information"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export default {
|
||||
// Page title
|
||||
'system.dict.page.title': 'Dictionary Management',
|
||||
'system.dict.page.description': 'Manage system dictionary types, configure data dictionary items',
|
||||
|
||||
// Dictionary Type Table
|
||||
'system.dict.id': 'ID',
|
||||
'system.dict.name': 'Dict Name',
|
||||
'dict.name.required': 'Dict name is required',
|
||||
'system.dict.code': 'Dict Code',
|
||||
'dict.code.required': 'Dict code is required',
|
||||
'system.dict.renderType': 'Render Type',
|
||||
'system.dict.renderType.required': 'Render type is required',
|
||||
'system.dict.renderType.text': 'Text',
|
||||
'system.dict.renderType.tag': 'Tag',
|
||||
'system.dict.renderType.badge': 'Badge',
|
||||
'system.dict.renderType.switch': 'Switch',
|
||||
'system.dict.status': 'Status',
|
||||
'system.dict.status.required': 'Status is required',
|
||||
'system.dict.status.normal': 'Normal',
|
||||
'system.dict.status.disabled': 'Disabled',
|
||||
'system.dict.sort': 'Sort',
|
||||
'system.dict.describe': 'Description',
|
||||
'system.dict.createdAt': 'Created At',
|
||||
'system.dict.updatedAt': 'Updated At',
|
||||
|
||||
// Dictionary Data Table
|
||||
'system.dict.item.id': 'ID',
|
||||
'system.dict.item.label': 'Label',
|
||||
'system.dict.item.label.required': 'Label is required',
|
||||
'system.dict.item.value': 'Value',
|
||||
'system.dict.item.value.required': 'Value is required',
|
||||
'system.dict.item.color': 'Color',
|
||||
'system.dict.item.isDefault': 'Default',
|
||||
'system.dict.item.isDefault.required': 'Default is required',
|
||||
'system.dict.item.isDefault.yes': 'Yes',
|
||||
'system.dict.item.isDefault.no': 'No',
|
||||
'system.dict.item.status': 'Status',
|
||||
'system.dict.item.status.required': 'Status is required',
|
||||
'system.dict.item.status.normal': 'Normal',
|
||||
'system.dict.item.status.disabled': 'Disabled',
|
||||
'system.dict.item.sort': 'Sort',
|
||||
'system.dict.item.createTime': 'Created At',
|
||||
'system.dict.item.updateTime': 'Updated At',
|
||||
|
||||
// Operations
|
||||
'system.dict.refreshCache': 'Refresh Cache',
|
||||
'system.dict.refreshSuccess': 'Dictionary cache refreshed successfully',
|
||||
'system.dict.manageItems': 'Dict Data',
|
||||
'system.dict.itemManagement': 'Dictionary Data Management',
|
||||
'system.dict.item.createSuccess': 'Dictionary data created successfully',
|
||||
'system.dict.item.updateSuccess': 'Dictionary data updated successfully',
|
||||
'system.dict.backToList': 'Back to List',
|
||||
'system.dict.selectDictFirst': 'Please select a dictionary from the list to manage',
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
export default {
|
||||
// Page title
|
||||
"system.file.page.title": "File Management",
|
||||
"system.file.page.description": "Manage system files, support upload, download, delete, rename, etc.",
|
||||
|
||||
"system.file.fileFolder": "Folders",
|
||||
"system.file.fileName": "File Name",
|
||||
"system.file.fileType": "Type",
|
||||
"system.file.fileSize": "Size",
|
||||
"system.file.preview": "Preview",
|
||||
"system.file.createdAt": "Created At",
|
||||
"system.file.updatedAt": "Updated At",
|
||||
"system.file.deletedAt": "Deleted At",
|
||||
"system.file.action": "Action",
|
||||
"system.file.download": "Download",
|
||||
"system.file.delete": "Delete",
|
||||
"system.file.forceDelete": "Force Delete",
|
||||
"system.file.restore": "Restore",
|
||||
"system.file.rename": "Rename",
|
||||
"system.file.move": "Move",
|
||||
"system.file.copy": "Copy",
|
||||
"system.file.confirmDelete": "Are you sure to delete this file?",
|
||||
"system.file.confirmForceDelete": "Are you sure to permanently delete this file? This action cannot be undone!",
|
||||
"system.file.confirmDeleteFolder": "Are you sure to delete this folder?",
|
||||
"system.file.confirmRestore": "Are you sure to restore this file?",
|
||||
"system.file.confirmBatchDelete": "Are you sure to delete {{count}} selected files?",
|
||||
"system.file.confirmBatchForceDelete": "Are you sure to permanently delete {{count}} selected files? This action cannot be undone!",
|
||||
"system.file.confirmBatchRestore": "Are you sure to restore {{count}} selected files?",
|
||||
"system.file.ok": "OK",
|
||||
"system.file.cancel": "Cancel",
|
||||
"system.file.upload": "Upload File",
|
||||
"system.file.uploadTitle": "Upload File",
|
||||
"system.file.uploadFileType": "File Type",
|
||||
"system.file.selectFile": "Select File",
|
||||
"system.file.uploadProgress": "Upload Progress",
|
||||
"system.file.uploadDragText": "Click or drag files to this area to upload",
|
||||
"system.file.uploadHint": "Support for single or batch upload",
|
||||
"system.file.selectFileWarning": "Please select files to upload",
|
||||
"system.file.uploadSuccess": "Upload successful",
|
||||
"system.file.deleteSuccess": "File deleted successfully",
|
||||
"system.file.forceDeleteSuccess": "File permanently deleted",
|
||||
"system.file.restoreSuccess": "File restored successfully",
|
||||
"system.file.renameSuccess": "File renamed successfully",
|
||||
"system.file.moveSuccess": "File moved successfully",
|
||||
"system.file.copySuccess": "File copied successfully",
|
||||
"system.file.batchDeleteSuccess": "Files deleted successfully",
|
||||
"system.file.batchForceDeleteSuccess": "Files permanently deleted",
|
||||
"system.file.batchRestoreSuccess": "Files restored successfully",
|
||||
"system.file.deleteFolderSuccess": "Folder deleted successfully",
|
||||
"system.file.deleteFolderHint": "Please delete all files in the folder before deleting the folder",
|
||||
"system.file.addFolder": "Add Subfolder",
|
||||
"system.file.editFolder": "Edit Folder",
|
||||
"system.file.deleteFolder": "Delete Folder",
|
||||
"system.file.addFolderTitle": "Add Folder",
|
||||
"system.file.editFolderTitle": "Edit Folder",
|
||||
"system.file.saveFolderSuccess": "{{action}} folder successfully",
|
||||
"system.file.actionAdd": "Add",
|
||||
"system.file.actionEdit": "Edit",
|
||||
"system.file.folderName": "Folder Name",
|
||||
"system.file.folderNameRequired": "Please enter folder name",
|
||||
"system.file.folderSearchPlaceholder": "Search folder by name",
|
||||
"system.file.parentFolder": "Parent Folder",
|
||||
"system.file.sort": "Sort",
|
||||
"system.file.sortRequired": "Sort is required",
|
||||
"system.file.describe": "Description",
|
||||
"system.file.root": "Root",
|
||||
"system.file.type.image": "Image",
|
||||
"system.file.type.audio": "Audio",
|
||||
"system.file.type.video": "Video",
|
||||
"system.file.type.archive": "Archive",
|
||||
"system.file.type.other": "Other",
|
||||
"system.file.type.document": "Document",
|
||||
"system.file.trash": "Trash",
|
||||
"system.file.emptyTrash": "Empty Trash",
|
||||
"system.file.confirmEmptyTrash": "Are you sure to empty the trash? This action cannot be undone!",
|
||||
"system.file.emptyTrashSuccess": "Trash emptied, {{count}} files deleted",
|
||||
"system.file.batchDelete": "Batch Delete",
|
||||
"system.file.batchForceDelete": "Batch Force Delete",
|
||||
"system.file.batchRestore": "Batch Restore",
|
||||
"system.file.batchMove": "Batch Move",
|
||||
"system.file.batchCopy": "Batch Copy",
|
||||
"system.file.noSelected": "Please select files first",
|
||||
"system.file.renameTitle": "Rename File",
|
||||
"system.file.renameDescription": "You are renaming the file, please enter a new file name!",
|
||||
"system.file.moveTitle": "Move File",
|
||||
"system.file.moveDescription": "You are moving the file, please select the target folder",
|
||||
"system.file.copyTitle": "Copy File",
|
||||
"system.file.copyDescription": "You are copying the file, please select the target folder!",
|
||||
"system.file.newFileNameRequired": "Please enter new file name",
|
||||
"system.file.disk": "Storage Disk",
|
||||
"system.file.loading": "Loading...",
|
||||
"system.file.refresh": "Refresh",
|
||||
"system.file.totalFiles": "Total {{total}} files",
|
||||
"system.file.fileDetail": "File Details",
|
||||
"system.file.basicInfo": "Basic Information",
|
||||
"system.file.fileExt": "Extension",
|
||||
"system.file.filePath": "File Path",
|
||||
"system.file.fileGroup": "File Group",
|
||||
"system.file.ungrouped": "Ungrouped",
|
||||
"system.file.accessUrl": "Access URL",
|
||||
"system.file.storageMethod": "Storage Method"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
// Page Title
|
||||
"system.gridNav.page.title": "Homepage Grid Navigation",
|
||||
"system.gridNav.page.description": "Manage homepage grid navigation icons and links",
|
||||
|
||||
// Fields
|
||||
"system.gridNav.id": "ID",
|
||||
"system.gridNav.title": "Navigation Title",
|
||||
"system.gridNav.title.required": "Navigation title is required",
|
||||
"system.gridNav.image": "Navigation Icon",
|
||||
"system.gridNav.image.required": "Navigation icon is required",
|
||||
"system.gridNav.link": "Redirect Link",
|
||||
"system.gridNav.link.placeholder": "Enter redirect link",
|
||||
"system.gridNav.status": "Status",
|
||||
"system.gridNav.status.required": "Status is required",
|
||||
"system.gridNav.status.normal": "Active",
|
||||
"system.gridNav.status.disabled": "Disabled",
|
||||
"system.gridNav.sort": "Sort",
|
||||
"system.gridNav.createdAt": "Created At",
|
||||
"system.gridNav.updatedAt": "Updated At",
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
export default {
|
||||
'system.info.title': 'System Info',
|
||||
'system.info.basic.title': 'System Basic Info',
|
||||
'system.info.project.title': 'Project Address',
|
||||
'system.info.changelog.title': 'Changelog',
|
||||
'system.info.author.title': 'Author Introduction',
|
||||
'system.info.desc.title': 'System Description',
|
||||
'system.info.join': 'Join the community for updates and support',
|
||||
|
||||
'system.info.label.name': 'System Name',
|
||||
'system.info.label.version': 'Version',
|
||||
'system.info.label.build': 'Build Tool',
|
||||
'system.info.label.frontend': 'Frontend Framework',
|
||||
'system.info.label.ui': 'UI Framework',
|
||||
'system.info.label.css': 'CSS Framework',
|
||||
'system.info.label.router': 'Router Management',
|
||||
'system.info.label.state': 'State Management',
|
||||
'system.info.label.ts': 'TypeScript',
|
||||
|
||||
'system.info.link.github': 'GitHub Repository',
|
||||
'system.info.link.docs': 'Online Documentation',
|
||||
'system.info.link.demo': 'Demo Address',
|
||||
'system.info.link.issues': 'Issues & Feedback',
|
||||
|
||||
'system.info.log.content': 'Refactored project modules based on React 19, Vite 7, ReactRouter 7, zustand 5, and TypeScript',
|
||||
|
||||
'system.info.author.name': 'XinAdmin Team',
|
||||
'system.info.author.role': 'Enterprise-level Admin Solution Provider',
|
||||
'system.info.contact.github': 'GitHub',
|
||||
'system.info.contact.group': 'QQ Group',
|
||||
'system.info.contact.discuss': 'Discussions',
|
||||
|
||||
'system.info.desc.content': `XinAdmin is an enterprise-level admin template based on Ant Design design specifications, adopting the latest frontend tech stack including React 19, Vite 7, ReactRouter 7, zustand 5, and TypeScript.
|
||||
|
||||
Core Features:
|
||||
✨ Cutting-edge Stack - React 19 + Vite 7 + TypeScript 5.8
|
||||
👑 Ant Design Specs - Modular solution, reducing redundant development
|
||||
🎢 Clear Structure - Semantic directory naming, independent namespaces
|
||||
🎡 ReactRouter v7 - Supports backend dynamic routing, auto menu generation
|
||||
🧩 TailwindCSS - Atomic CSS, perfect match with Ant Design
|
||||
🎡 Built-in i18n - Supports 5 languages including English, Chinese, etc.
|
||||
⛳ Complete Page Components - Includes error pages, layout components, etc.`,
|
||||
|
||||
'system.info.tag.enterprise': 'Enterprise',
|
||||
'system.info.tag.ready': 'Out of Box',
|
||||
'system.info.tag.scalable': 'Scalable',
|
||||
'system.info.tag.modern': 'Modern',
|
||||
'system.info.tag.i18n': 'Internationalization',
|
||||
'system.info.tag.auth': 'Access Control',
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
export default {
|
||||
// Page title and description
|
||||
'system.mail.page.title': 'Mail Settings',
|
||||
'system.mail.page.description': 'Mail configuration for sending emails, supports failover and round-robin switching',
|
||||
|
||||
// Mode selection
|
||||
'system.mail.mode': 'Mode',
|
||||
'system.mail.mode.single': 'Single',
|
||||
'system.mail.mode.failover': 'Failover',
|
||||
'system.mail.mode.roundrobin': 'Round Robin',
|
||||
|
||||
// Driver
|
||||
'system.mail.driver': 'Mail Driver',
|
||||
'system.mail.driver.smtp': 'SMTP',
|
||||
'system.mail.driver.ses': 'Amazon SES',
|
||||
'system.mail.driver.mailgun': 'Mailgun',
|
||||
'system.mail.driver.postmark': 'Postmark',
|
||||
'system.mail.driver.resend': 'Resend',
|
||||
'system.mail.driver.log': 'Log',
|
||||
'system.mail.driver.array': 'Array (Debug)',
|
||||
|
||||
// SMTP Configuration
|
||||
'system.mail.smtp.title': 'SMTP Configuration',
|
||||
'system.mail.host': 'Host',
|
||||
'system.mail.host.placeholder': 'smtp.example.com',
|
||||
'system.mail.port': 'Port',
|
||||
'system.mail.port.placeholder': '587',
|
||||
'system.mail.username': 'Username',
|
||||
'system.mail.username.placeholder': 'your-email@example.com',
|
||||
'system.mail.password': 'Password',
|
||||
'system.mail.password.placeholder': 'Your email password or app password',
|
||||
|
||||
// Log Configuration
|
||||
'system.mail.log.title': 'Log Configuration',
|
||||
'system.mail.log.channel': 'Log Channel',
|
||||
'system.mail.log.channel.placeholder': 'mail',
|
||||
|
||||
// Postmark Configuration
|
||||
'system.mail.postmark.title': 'Postmark Configuration',
|
||||
'system.mail.postmark.token': 'Postmark Token',
|
||||
'system.mail.postmark.token.placeholder': 'Your Postmark API token',
|
||||
|
||||
// Resend Configuration
|
||||
'system.mail.resend.title': 'Resend Configuration',
|
||||
'system.mail.resend.key': 'Resend API Key',
|
||||
'system.mail.resend.key.placeholder': 'Your Resend API key',
|
||||
|
||||
// Mailgun Configuration
|
||||
'system.mail.mailgun.title': 'Mailgun Configuration',
|
||||
'system.mail.mailgun.domain': 'Mailgun Domain',
|
||||
'system.mail.mailgun.domain.placeholder': 'mg.yourdomain.com',
|
||||
'system.mail.mailgun.secret': 'Mailgun Secret',
|
||||
'system.mail.mailgun.secret.placeholder': 'Your Mailgun API key',
|
||||
'system.mail.mailgun.endpoint': 'Mailgun Endpoint',
|
||||
'system.mail.mailgun.endpoint.placeholder': 'api.mailgun.net',
|
||||
|
||||
// SES Configuration
|
||||
'system.mail.ses.title': 'SES (Amazon) Configuration',
|
||||
'system.mail.ses.key': 'AWS Access Key',
|
||||
'system.mail.ses.key.placeholder': 'AKIA...',
|
||||
'system.mail.ses.secret': 'AWS Secret Key',
|
||||
'system.mail.ses.secret.placeholder': 'Your AWS secret key',
|
||||
'system.mail.ses.region': 'AWS Region',
|
||||
'system.mail.ses.region.placeholder': 'us-east-1',
|
||||
'system.mail.ses.token': 'AWS Session Token',
|
||||
'system.mail.ses.token.placeholder': 'Optional session token',
|
||||
|
||||
// From settings
|
||||
'system.mail.from_address': 'From Address',
|
||||
'system.mail.from_address.placeholder': 'noreply@example.com',
|
||||
'system.mail.from_name': 'From Name',
|
||||
'system.mail.from_name.placeholder': 'Your Application Name',
|
||||
|
||||
// Test mail
|
||||
'system.mail.test.title': 'Send Test Email',
|
||||
'system.mail.test.to': 'Recipient Email',
|
||||
'system.mail.test.to.placeholder': 'receiver@example.com',
|
||||
'system.mail.test.send': 'Send Test',
|
||||
'system.mail.test.success': 'Test email sent successfully',
|
||||
'system.mail.test.failed': 'Failed to send test email',
|
||||
|
||||
// Save
|
||||
'system.mail.save.success': 'Configuration saved successfully',
|
||||
'system.mail.save.failed': 'Failed to save configuration',
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
export default {
|
||||
// Page title
|
||||
"system.role.page.title": "Role Management",
|
||||
"system.role.page.description": "Manage system roles, configure role permissions, assign roles to users",
|
||||
|
||||
"system.role.title": "Role Management",
|
||||
"system.role.tab.users": "User List",
|
||||
"system.role.tab.rules": "Permission Settings",
|
||||
"system.role.table.roleName": "Role Name",
|
||||
"system.role.table.roleName.required": "Please enter role name",
|
||||
"system.role.table.sort": "Sort Order",
|
||||
"system.role.table.sort.required": "Please enter sort value",
|
||||
"system.role.table.userCount": "User Count",
|
||||
"system.role.table.status": "Status",
|
||||
"system.role.table.status.required": "Please select status",
|
||||
"system.role.table.status.enable": "Enable",
|
||||
"system.role.table.status.disable": "Disable",
|
||||
"system.role.table.status.enabled": "Enabled",
|
||||
"system.role.table.status.disabled": "Disabled",
|
||||
"system.role.table.description": "Role Description",
|
||||
"system.role.table.createdAt": "Created At",
|
||||
"system.role.table.updatedAt": "Updated At",
|
||||
"system.role.table.headerTitle": "Role List",
|
||||
"system.role.userTable.userId": "User ID",
|
||||
"system.role.userTable.username": "Username",
|
||||
"system.role.userTable.nickname": "Nickname",
|
||||
"system.role.userTable.email": "Email",
|
||||
"system.role.userTable.mobile": "Mobile Number",
|
||||
"system.role.userTable.status": "Status",
|
||||
"system.role.userTable.status.normal": "Normal",
|
||||
"system.role.userTable.status.banned": "Banned",
|
||||
"system.role.userTable.person": "users",
|
||||
"system.role.button.expandAll": "Expand All",
|
||||
"system.role.button.collapseAll": "Collapse All",
|
||||
"system.role.button.selectAll": "Select All",
|
||||
"system.role.button.clearAll": "Clear Selection",
|
||||
"system.role.button.invertSelection": "Invert Selection",
|
||||
"system.role.button.saveRules": "Save Permissions",
|
||||
"system.role.message.selectRoleFirst": "Please select a role first",
|
||||
"system.role.message.statusUpdateSuccess": "Status updated successfully",
|
||||
"system.role.message.statusUpdateFailed": "Status update failed",
|
||||
"system.role.message.rulesSaveSuccess": "Permissions saved successfully",
|
||||
"system.role.permission.selectedCount": "Selected {{count}} permissions",
|
||||
"system.role.placeholder.selectRole": "Please select a role first",
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export default {
|
||||
// Page description
|
||||
"system.rule.description": "Manage system menus and permissions, configure routes and permission items",
|
||||
|
||||
"system.rule.routePath": "Route Path",
|
||||
"system.rule.routePath.required": "Route path is required",
|
||||
"system.rule.routePath.tooltip": "Access path for the route page. For external links, please provide the complete URL",
|
||||
"system.rule.routePath.showTooltip": "Route Path: {{path}}",
|
||||
"system.rule.icon": "Menu Icon",
|
||||
"system.rule.local": "i18n Key",
|
||||
"system.rule.local.show": "Display Name",
|
||||
"system.rule.link": "External Link",
|
||||
"system.rule.link.1": "Yes",
|
||||
"system.rule.link.0": "No",
|
||||
"system.rule.type": "Menu Type",
|
||||
"system.rule.type.menu": "Menu Item",
|
||||
"system.rule.type.route": "Route Page",
|
||||
"system.rule.type.rule": "Permission Item",
|
||||
"system.rule.type.required": "Menu type is required",
|
||||
"system.rule.parent": "Parent Menu",
|
||||
"system.rule.parent.0": "Root Menu",
|
||||
"system.rule.parent.required": "Parent menu is required",
|
||||
"system.rule.order": "Sort Order",
|
||||
"system.rule.order.required": "Sort order is required",
|
||||
"system.rule.name": "Rule Name",
|
||||
"system.rule.name.required": "Rule name is required",
|
||||
"system.rule.key": "Permission Key",
|
||||
"system.rule.key.required": "Permission key is required",
|
||||
"system.rule.hidden": "Visibility",
|
||||
"system.rule.hidden.tooltip": "Controls menu display in navigation (route and permission functions remain active)",
|
||||
"system.rule.hidden.0": "Hidden",
|
||||
"system.rule.hidden.1": "Visible",
|
||||
"system.rule.hidden.updateSuccess": "Visibility updated successfully",
|
||||
"system.rule.status": "Status",
|
||||
"system.rule.status.tooltip": "Permission status (disabled permissions will not participate in system permission verification)",
|
||||
"system.rule.status.0": "Disabled",
|
||||
"system.rule.status.1": "Enabled",
|
||||
"system.rule.status.updateSuccess": "Status updated successfully",
|
||||
"system.rule.created_at": "Created At",
|
||||
"system.rule.updated_at": "Updated At",
|
||||
"system.rule.title": "Permission Management",
|
||||
"system.rule.addChildButton": "Add Child Item",
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export default {
|
||||
// Page title and description
|
||||
'system.storage.page.title': 'Storage Configuration',
|
||||
'system.storage.page.description': 'Configure system file storage, supports local storage, S3 object storage, FTP and SFTP',
|
||||
|
||||
// Driver selection
|
||||
'system.storage.driver': 'Default Storage Driver',
|
||||
'system.storage.driver.local': 'Local Storage',
|
||||
'system.storage.driver.s3': 'Amazon S3 / Object Storage',
|
||||
'system.storage.driver.ftp': 'FTP Server',
|
||||
'system.storage.driver.sftp': 'SFTP Server',
|
||||
|
||||
// Local storage configuration
|
||||
'system.storage.local.title': 'Local Storage Configuration',
|
||||
'system.storage.local.url': 'Access URL',
|
||||
'system.storage.local.url.placeholder': 'e.g., https://example.com/storage',
|
||||
'system.storage.local.url.tooltip': 'Public URL prefix for file access',
|
||||
|
||||
// S3 configuration
|
||||
'system.storage.s3.title': 'S3 / Object Storage Configuration',
|
||||
'system.storage.s3.key': 'Access Key ID',
|
||||
'system.storage.s3.key.placeholder': 'Enter Access Key ID',
|
||||
'system.storage.s3.secret': 'Secret Access Key',
|
||||
'system.storage.s3.secret.placeholder': 'Enter Secret Access Key',
|
||||
'system.storage.s3.region': 'Region',
|
||||
'system.storage.s3.region.placeholder': 'e.g., us-east-1 or cn-hangzhou',
|
||||
'system.storage.s3.bucket': 'Bucket',
|
||||
'system.storage.s3.bucket.placeholder': 'Enter bucket name',
|
||||
'system.storage.s3.endpoint': 'Endpoint',
|
||||
'system.storage.s3.endpoint.placeholder': 'Custom endpoint, e.g., Aliyun OSS: oss-cn-hangzhou.aliyuncs.com',
|
||||
'system.storage.s3.endpoint.tooltip': 'Leave empty for AWS S3, required for Aliyun OSS, Tencent COS, etc.',
|
||||
'system.storage.s3.url': 'Custom URL',
|
||||
'system.storage.s3.url.placeholder': 'Custom file access URL prefix (optional)',
|
||||
'system.storage.s3.url.tooltip': 'Configure CDN domain if using CDN',
|
||||
'system.storage.s3.path_style': 'Path Style Endpoint',
|
||||
'system.storage.s3.path_style.tooltip': 'Some S3-compatible services (like MinIO) require this option',
|
||||
|
||||
// FTP configuration
|
||||
'system.storage.ftp.title': 'FTP Server Configuration',
|
||||
'system.storage.ftp.host': 'Host',
|
||||
'system.storage.ftp.host.placeholder': 'e.g., ftp.example.com',
|
||||
'system.storage.ftp.port': 'Port',
|
||||
'system.storage.ftp.port.placeholder': '21',
|
||||
'system.storage.ftp.username': 'Username',
|
||||
'system.storage.ftp.username.placeholder': 'Enter FTP username',
|
||||
'system.storage.ftp.password': 'Password',
|
||||
'system.storage.ftp.password.placeholder': 'Enter FTP password',
|
||||
'system.storage.ftp.root': 'Root Directory',
|
||||
'system.storage.ftp.root.placeholder': 'e.g., /public_html/uploads',
|
||||
'system.storage.ftp.root.tooltip': 'Storage root directory on FTP server',
|
||||
'system.storage.ftp.timeout': 'Timeout',
|
||||
'system.storage.ftp.timeout.placeholder': '30',
|
||||
'system.storage.ftp.timeout.suffix': 'seconds',
|
||||
'system.storage.ftp.passive': 'Passive Mode',
|
||||
'system.storage.ftp.passive.tooltip': 'Recommended for most network environments',
|
||||
'system.storage.ftp.ssl': 'SSL/TLS',
|
||||
'system.storage.ftp.ssl.tooltip': 'Enable FTPS secure connection',
|
||||
|
||||
// SFTP configuration
|
||||
'system.storage.sftp.title': 'SFTP Server Configuration',
|
||||
'system.storage.sftp.host': 'Host',
|
||||
'system.storage.sftp.host.placeholder': 'e.g., sftp.example.com',
|
||||
'system.storage.sftp.port': 'Port',
|
||||
'system.storage.sftp.port.placeholder': '22',
|
||||
'system.storage.sftp.username': 'Username',
|
||||
'system.storage.sftp.username.placeholder': 'Enter SFTP username',
|
||||
'system.storage.sftp.password': 'Password',
|
||||
'system.storage.sftp.password.placeholder': 'Enter SFTP password',
|
||||
'system.storage.sftp.password.tooltip': 'Leave empty if using key authentication',
|
||||
'system.storage.sftp.root': 'Root Directory',
|
||||
'system.storage.sftp.root.placeholder': 'e.g., /var/www/uploads',
|
||||
'system.storage.sftp.root.tooltip': 'Storage root directory on SFTP server',
|
||||
'system.storage.sftp.timeout': 'Timeout',
|
||||
'system.storage.sftp.timeout.placeholder': '30',
|
||||
'system.storage.sftp.timeout.suffix': 'seconds',
|
||||
'system.storage.sftp.private_key': 'Private Key',
|
||||
'system.storage.sftp.private_key.placeholder': 'Paste SSH private key content (optional)',
|
||||
'system.storage.sftp.private_key.tooltip': 'Paste private key content for key authentication',
|
||||
'system.storage.sftp.passphrase': 'Passphrase',
|
||||
'system.storage.sftp.passphrase.placeholder': 'Enter if private key is password protected',
|
||||
'system.storage.sftp.passphrase.tooltip': 'Private key passphrase (if any)',
|
||||
|
||||
// Test connection
|
||||
'system.storage.test.title': 'Connection Test',
|
||||
'system.storage.test.current_driver': 'Current Storage Driver',
|
||||
'system.storage.test.button': 'Test Connection',
|
||||
'system.storage.test.success': 'Connection test successful',
|
||||
'system.storage.test.failed': 'Connection test failed',
|
||||
|
||||
// Help
|
||||
'system.storage.help.title': 'Storage Driver Guide',
|
||||
'system.storage.help.local': 'Local Storage:',
|
||||
'system.storage.help.local.desc': 'Files stored on server local disk, suitable for small projects.',
|
||||
'system.storage.help.s3': 'S3 / Object Storage:',
|
||||
'system.storage.help.s3.desc': 'Compatible with AWS S3, Aliyun OSS, Tencent COS, Qiniu, suitable for large-scale storage.',
|
||||
'system.storage.help.ftp': 'FTP:',
|
||||
'system.storage.help.ftp.desc': 'Connect to remote server via FTP protocol.',
|
||||
'system.storage.help.sftp': 'SFTP:',
|
||||
'system.storage.help.sftp.desc': 'Secure connection via SSH, more secure and reliable.',
|
||||
|
||||
// Save
|
||||
'system.storage.save.success': 'Configuration saved successfully',
|
||||
'system.storage.save.failed': 'Failed to save configuration',
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
export default {
|
||||
"system.user.page.title": "User List",
|
||||
"system.user.page.description": "Manage system users through the admin list, assign departments and roles to users.",
|
||||
"system.user.id": "User ID",
|
||||
"system.user.username": "Username",
|
||||
"system.user.username.required": "Username is required",
|
||||
"system.user.nickname": "Nickname",
|
||||
"system.user.nickname.required": "Nickname is required",
|
||||
"system.user.sex": "Gender",
|
||||
"system.user.sex.0": "Male",
|
||||
"system.user.sex.1": "Female",
|
||||
"system.user.email": "Email",
|
||||
"system.user.email.required": "Email is required",
|
||||
"system.user.role": "User Role",
|
||||
"system.user.role.required": "User role is required",
|
||||
"system.user.dept": "User Department",
|
||||
"system.user.dept.required": "User department is required",
|
||||
"system.user.status": "Status",
|
||||
"system.user.status.0": "Disabled",
|
||||
"system.user.status.1": "Enabled",
|
||||
"system.user.status.required": "Status is required",
|
||||
"system.user.mobile": "Mobile Number",
|
||||
"system.user.mobile.required": "Mobile number is required",
|
||||
"system.user.avatar": "Avatar",
|
||||
"system.user.password": "Password",
|
||||
"system.user.password.required": "Password is required",
|
||||
"system.user.rePassword": "Confirm Password",
|
||||
"system.user.rePassword.required": "Confirm password is required",
|
||||
"system.user.created_at": "Created At",
|
||||
"system.user.updated_at": "Updated At",
|
||||
"system.user.searchPlaceholder": "Please enter nickname, username, or mobile number to search",
|
||||
"system.user.resetSuccess": "Reset successful!",
|
||||
"system.user.resetPassword": "Reset Password",
|
||||
"system.user.resetButton": "Submit Reset"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user