first commit

This commit is contained in:
liu
2026-07-13 15:23:29 +08:00
commit 50885a98c8
473 changed files with 33772 additions and 0 deletions
+49
View File
@@ -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;
+24
View File
@@ -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,
});
}
+26
View File
@@ -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',
});
}
+18
View File
@@ -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,
});
}
+103
View File
@@ -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 || {}),
})
}
+15
View File
@@ -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",
});
};
+38
View File
@@ -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 },
});
}
+13
View File
@@ -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',
});
}
+183
View File
@@ -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',
});
}
+34
View File
@@ -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',
});
}
+27
View File
@@ -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 },
});
}
+28
View File
@@ -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 },
});
}
+89
View File
@@ -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'
});
}
+51
View File
@@ -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
});
}
+49
View File
@@ -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
}
});
}
+34
View File
@@ -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',
});
}
+169
View File
@@ -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',
})
}
+62
View File
@@ -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;
+26
View File
@@ -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;
+48
View File
@@ -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;
+22
View File
@@ -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;
+33
View File
@@ -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;
+140
View File
@@ -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);
}
}
+32
View File
@@ -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>
)
}
+33
View File
@@ -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;
+320
View File
@@ -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 };
+85
View File
@@ -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;
}
+9
View File
@@ -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;
}
+580
View File
@@ -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}
/>
</>
);
}
+163
View File
@@ -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>;
}
+38
View File
@@ -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;
}
+14
View File
@@ -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[]>;
+18
View File
@@ -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;
}
+14
View File
@@ -0,0 +1,14 @@
export interface IConfigGroup {
/** ID */
id?: number;
/** 标题 */
title?: string;
/** 键 */
key?: string;
/** 描述 */
remark?: string;
/** 创建时间 */
created_at?: Date;
/** 更新时间 */
updated_at?: Date;
}
+32
View File
@@ -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;
}
+18
View File
@@ -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;
}
+36
View File
@@ -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;
+18
View File
@@ -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;
}
+33
View File
@@ -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;
}
+32
View File
@@ -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;
}
+21
View File
@@ -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[];
}
+22
View File
@@ -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;
}
+20
View File
@@ -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;
}
+36
View File
@@ -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[];
}
+46
View File
@@ -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;
}
+11
View File
@@ -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;
+48
View File
@@ -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;
+31
View File
@@ -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;
+76
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
@layer theme, base, antd, components, utilities;
@import "tailwindcss";
+55
View File
@@ -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;
+72
View File
@@ -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;
+34
View File
@@ -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;
+116
View File
@@ -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;
+131
View File
@@ -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
+66
View File
@@ -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;
};
+90
View File
@@ -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;
+71
View File
@@ -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;
+237
View File
@@ -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;
+37
View File
@@ -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;
+84
View File
@@ -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;
+64
View File
@@ -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,
}
+54
View File
@@ -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;
}
+95
View File
@@ -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;
}
+8
View File
@@ -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',
};
+22
View File
@@ -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',
};
+26
View File
@@ -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',
};
+23
View File
@@ -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',
};
+33
View File
@@ -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!",
}
+35
View File
@@ -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',
};
+48
View File
@@ -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",
};
+18
View File
@@ -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",
};
+43
View File
@@ -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",
};
+61
View File
@@ -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,
};
+54
View File
@@ -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",
}
+13
View File
@@ -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!",
}
+50
View File
@@ -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",
}
+38
View File
@@ -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',
};
+21
View File
@@ -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",
};
+68
View File
@@ -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',
};
+52
View File
@@ -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"
}
+55
View File
@@ -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',
};
+102
View File
@@ -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"
}
+21
View File
@@ -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",
};
+50
View File
@@ -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',
};
+85
View File
@@ -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',
};
+45
View File
@@ -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",
}
+43
View File
@@ -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",
}
+104
View File
@@ -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',
};
+35
View File
@@ -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