mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 05:02:57 +00:00
chore: 清理冗余代码与配置,优化项目结构
1. 删除无用文件与废弃代码:移除locale枚举、element-plus插件、sse路由、api token模块等 2. 简化类型导入与依赖:移除大量未使用的类型导入,统一echarts导入方式 3. 优化配置与样式:调整gitignore、样式引入顺序,新增列表动画样式 4. 修复接口与模型:修正接口返回类型、查询参数配置,更新部门模型字段 5. 优化性能与体验:添加图片懒加载,优化加载逻辑与表格渲染 6. 调整环境配置:新增并更新开发/生产环境配置文件
This commit is contained in:
+77
-47
@@ -22,32 +22,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, onMounted, onUnmounted, watch } from "vue";
|
||||
import { computed, onBeforeMount, onErrorCaptured, onMounted, onUnmounted } from "vue";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useAppStore, useUserStore } from "./store";
|
||||
import { useSettingsStore } from "./store/modules/setting.store";
|
||||
import { defaultSettings } from "./config/setting";
|
||||
import { ComponentSize } from "./enums/settings/layout.enum";
|
||||
import { MOBILE_BREAKPOINT } from "./utils/constants/definitions";
|
||||
import AiAssistant from "./components/others/fa-ai-assistant/index.vue";
|
||||
import { hexToRgba, toggleTransition } from "./utils/ui";
|
||||
import { initializeTheme } from "./hooks/core/useTheme";
|
||||
import { useAppBootstrap } from "@/hooks/core/useAppBootstrap";
|
||||
import { useEventBus } from "@/hooks/core/useEventBus";
|
||||
import { ThemeMode } from "./enums";
|
||||
import en from "element-plus/es/locale/lang/en";
|
||||
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||
import { router } from "@/router";
|
||||
import { ElNotification } from "element-plus";
|
||||
import { ElNotification } from "@/utils/message";
|
||||
import { initIconifyAsync } from "./plugins/iconify";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
// SSE 事件总线
|
||||
const { connect, disconnect, subscribe } = useEventBus();
|
||||
|
||||
// H5 用小尺寸,桌面用用户设置的大小
|
||||
const size = computed(() => {
|
||||
if (width.value < MOBILE_BREAKPOINT) return "small" as ComponentSize;
|
||||
@@ -102,6 +99,57 @@ const handleStorageInvalidated = () => {
|
||||
|
||||
const { bootstrap } = useAppBootstrap();
|
||||
|
||||
// ── 全局键盘快捷键 ──
|
||||
const handleGlobalKeydown = (e: KeyboardEvent) => {
|
||||
const isMac = navigator.userAgent.includes("Mac");
|
||||
const modKey = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Ctrl+S / Cmd+S:提交弹窗表单
|
||||
if (modKey && e.key === "s") {
|
||||
// 优先触发 useCrudForm 中已注册的键盘监听(由其自行 e.preventDefault)
|
||||
// 兜底:查找可见 dialog/drawer 中的确认按钮
|
||||
const confirmBtn = document.querySelector<HTMLElement>(
|
||||
[
|
||||
".el-dialog:not(.is-hidden) .el-dialog__footer .el-button--primary",
|
||||
".el-overlay-dialog:not(.is-hidden) .el-dialog__footer .el-button--primary",
|
||||
".el-drawer:not(.is-hidden) .el-drawer__footer .el-button--primary",
|
||||
].join(", ")
|
||||
);
|
||||
if (confirmBtn && document.querySelector(".el-overlay-dialog")) {
|
||||
confirmBtn.click();
|
||||
}
|
||||
// useCrudForm 中的 useFormKeyboardSubmit 已自行 e.preventDefault,此处也调用防止默认
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+F / Cmd+F:聚焦搜索输入框
|
||||
if (modKey && e.key === "f") {
|
||||
const searchInput = document.querySelector<HTMLElement>(".fa-search-bar input");
|
||||
if (searchInput) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC:由 ElDialog / ElDrawer 内置逻辑处理,此处不做额外干预
|
||||
};
|
||||
window.addEventListener("keydown", handleGlobalKeydown);
|
||||
|
||||
const handleOffline = () => {
|
||||
ElNotification({
|
||||
title: "网络已断开",
|
||||
message: "请检查您的网络连接",
|
||||
type: "error",
|
||||
duration: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnline = () => {
|
||||
ElMessage.success("网络已恢复");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
bootstrap();
|
||||
|
||||
@@ -109,50 +157,32 @@ onMounted(() => {
|
||||
window.addEventListener("app:storage-invalidated", handleStorageInvalidated);
|
||||
|
||||
// 全局网络状态监听
|
||||
window.addEventListener("offline", () => {
|
||||
ElNotification({
|
||||
title: "网络已断开",
|
||||
message: "请检查您的网络连接",
|
||||
type: "error",
|
||||
duration: 0,
|
||||
});
|
||||
});
|
||||
window.addEventListener("online", () => {
|
||||
ElMessage.success("网络已恢复");
|
||||
});
|
||||
window.addEventListener("offline", handleOffline);
|
||||
window.addEventListener("online", handleOnline);
|
||||
|
||||
// 用户登录后连接 SSE
|
||||
watch(
|
||||
() => userStore.basicInfo,
|
||||
(info) => {
|
||||
if (info && Object.keys(info).length > 0) {
|
||||
connect();
|
||||
subscribe("payment_success", (data) => {
|
||||
ElNotification({
|
||||
title: "支付成功",
|
||||
message: `订单 ${data.order_no} 已支付成功`,
|
||||
type: "success",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
subscribe("ticket_reply", (data) => {
|
||||
ElNotification({
|
||||
title: "工单回复",
|
||||
message: `"${data.title}" 有了新回复`,
|
||||
type: "info",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
disconnect();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
// 异步加载图标集,避免阻塞首屏
|
||||
initIconifyAsync();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("app:storage-invalidated", handleStorageInvalidated);
|
||||
disconnect();
|
||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
window.removeEventListener("online", handleOnline);
|
||||
});
|
||||
|
||||
// ─── 全局错误边界 ───
|
||||
onErrorCaptured((err, _instance, info) => {
|
||||
console.error(`[ErrorBoundary] ${info}:`, err);
|
||||
// 开发环境弹窗提示,生产环境静默上报
|
||||
if (import.meta.env.DEV) {
|
||||
ElNotification({
|
||||
title: "组件渲染异常",
|
||||
message: `${info}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
type: "error",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
return false; // 阻止异常向上冒泡
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -44,7 +44,7 @@ const DemoAPI = {
|
||||
|
||||
batchDemo(body: BatchType) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/available/setting`,
|
||||
url: `${API_PATH}/status/batch`,
|
||||
method: "patch",
|
||||
data: body,
|
||||
});
|
||||
@@ -60,7 +60,7 @@ const DemoAPI = {
|
||||
},
|
||||
|
||||
downloadTemplateDemo() {
|
||||
return request<ApiResponse>({
|
||||
return request<Blob>({
|
||||
url: `${API_PATH}/download/template`,
|
||||
method: "post",
|
||||
responseType: "blob",
|
||||
|
||||
@@ -13,11 +13,9 @@ export interface RecentLoginItem {
|
||||
export interface DashboardStats {
|
||||
online_users: number;
|
||||
total_users: number;
|
||||
total_orders: number;
|
||||
today_login_count: number;
|
||||
today_unique_users: number;
|
||||
week_user_created: number;
|
||||
paid_orders: number;
|
||||
recent_logins: RecentLoginItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@ const OnlineAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
// 获取当前用户自己的在线会话
|
||||
listCurrentOnline() {
|
||||
return request<ApiResponse<OnlineUserTable[]>>({
|
||||
url: `${API_PATH}/current`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
// 强退用户
|
||||
clearOnline() {
|
||||
return request<ApiResponse>({
|
||||
|
||||
@@ -109,7 +109,7 @@ export const ResourceAPI = {
|
||||
return request<Blob>({
|
||||
url: `${API_PATH}/export`,
|
||||
method: "post",
|
||||
data: body,
|
||||
params: body,
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
@@ -169,14 +169,14 @@ export interface ResourcePageQuery extends PageQuery {
|
||||
* 资源上传响应模型
|
||||
*/
|
||||
export interface ResourceUploadSchema {
|
||||
/** 文件路径 */
|
||||
file_path?: string;
|
||||
/** 文件名 */
|
||||
filename: string;
|
||||
/** 访问URL */
|
||||
file_url: string;
|
||||
/** 文件大小 */
|
||||
file_size: number;
|
||||
/** 上传时间 */
|
||||
upload_time: string;
|
||||
file_name?: string;
|
||||
/** 原始文件名 */
|
||||
origin_name?: string;
|
||||
/** 文件URL */
|
||||
file_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,11 +188,11 @@ export interface ResourceItem {
|
||||
/** 文件URL路径 */
|
||||
file_url: string;
|
||||
/** 相对路径 */
|
||||
relative_path?: string;
|
||||
relative_path: string;
|
||||
/** 是否为文件 */
|
||||
is_file?: boolean;
|
||||
is_file: boolean;
|
||||
/** 是否为目录 */
|
||||
is_dir?: boolean;
|
||||
is_dir: boolean;
|
||||
/** 文件大小(字节) */
|
||||
size?: number | null;
|
||||
/** 创建时间 */
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
const API_PATH = "/system/api_token";
|
||||
|
||||
const ApiTokenAPI = {
|
||||
/** 创建 API Token */
|
||||
createToken(body: ApiTokenCreateForm) {
|
||||
return request<ApiResponse<ApiTokenCreatedSchema>>({
|
||||
url: `${API_PATH}/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 分页列表 */
|
||||
listToken(query: ApiTokenPageQuery) {
|
||||
return request<ApiResponse<PageResult<ApiTokenTable>>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
/** 详情 */
|
||||
detailToken(id: number) {
|
||||
return request<ApiResponse<ApiTokenTable>>({
|
||||
url: `${API_PATH}/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 重置 token */
|
||||
resetToken(id: number, body: { name?: string; description?: string }) {
|
||||
return request<ApiResponse<ApiTokenCreatedSchema>>({
|
||||
url: `${API_PATH}/${id}/reset`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 启用/禁用 */
|
||||
setTokenStatus(id: number, body: { status: number }) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/${id}/status`,
|
||||
method: "patch",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除 */
|
||||
deleteToken(id: number) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
|
||||
/** 查看明文(二次验证) */
|
||||
revealToken(id: number, body: { password: string }) {
|
||||
return request<ApiResponse<ApiTokenRevealSchema>>({
|
||||
url: `${API_PATH}/${id}/reveal`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default ApiTokenAPI;
|
||||
|
||||
export interface ApiTokenPageQuery extends PageQuery {
|
||||
name?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ApiTokenTable extends BaseType {
|
||||
name?: string;
|
||||
token_prefix?: string;
|
||||
token_mask?: string;
|
||||
owner_user_id?: number;
|
||||
scopes?: string;
|
||||
expires_at?: string;
|
||||
rate_limit?: number;
|
||||
status?: number;
|
||||
last_used_at?: string;
|
||||
last_used_ip?: string;
|
||||
used_count?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ApiTokenCreateForm extends BaseFormType {
|
||||
name: string;
|
||||
scopes?: string[];
|
||||
expires_at?: string;
|
||||
rate_limit?: number;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ApiTokenCreatedSchema {
|
||||
id: number;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
/** 创建时完整返回,请立即保存 */
|
||||
token_plain: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface ApiTokenRevealSchema {
|
||||
id: number;
|
||||
token_plain: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const AuthAPI = {
|
||||
return request<ApiResponse<JWTOut>>({
|
||||
url: `${API_PATH}/token/refresh`,
|
||||
method: "post",
|
||||
data: refreshToken,
|
||||
data: { refresh_token: refreshToken },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ const AuthAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
logout(body: LogoutBody) {
|
||||
logout(body: string) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/logout`,
|
||||
method: "post",
|
||||
@@ -64,6 +64,7 @@ export interface LoginFormData {
|
||||
username: string;
|
||||
password: string;
|
||||
captcha_key?: string;
|
||||
captcha?: string;
|
||||
remember?: boolean;
|
||||
login_type?: string;
|
||||
}
|
||||
@@ -79,11 +80,6 @@ export interface JWTOut {
|
||||
/** 登录成功返回 */
|
||||
export type LoginResult = JWTOut;
|
||||
|
||||
/** 退出登录请求体 */
|
||||
export interface LogoutBody {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** 验证码信息 */
|
||||
export interface CaptchaInfo {
|
||||
enable: boolean;
|
||||
|
||||
@@ -62,9 +62,6 @@ export interface DeptTable extends BaseType {
|
||||
name?: string;
|
||||
order?: number;
|
||||
code: string;
|
||||
leader?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
parent_id?: number;
|
||||
parent_name?: string;
|
||||
children?: DeptTable[];
|
||||
@@ -75,9 +72,6 @@ export interface DeptTable extends BaseType {
|
||||
export interface DeptForm extends BaseFormType {
|
||||
name?: string;
|
||||
code: string;
|
||||
leader?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
parent_id?: number;
|
||||
order?: number;
|
||||
status?: number;
|
||||
|
||||
@@ -12,7 +12,7 @@ const DictAPI = {
|
||||
},
|
||||
|
||||
optionDictType() {
|
||||
return request<ApiResponse>({
|
||||
return request<ApiResponse<DictTable[]>>({
|
||||
url: `${API_PATH}/type/optionselect`,
|
||||
method: "get",
|
||||
});
|
||||
@@ -132,13 +132,13 @@ const DictAPI = {
|
||||
|
||||
export default DictAPI;
|
||||
|
||||
export interface DictPageQuery extends PageQuery, UserByQueryParams {
|
||||
export interface DictPageQuery extends PageQuery {
|
||||
dict_name?: string;
|
||||
dict_type?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface DictDataPageQuery extends PageQuery, UserByQueryParams {
|
||||
export interface DictDataPageQuery extends PageQuery {
|
||||
dict_label?: string;
|
||||
dict_type?: string;
|
||||
dict_type_id?: number;
|
||||
|
||||
@@ -42,6 +42,7 @@ export default OperationLogAPI;
|
||||
|
||||
export interface OperationLogPageQuery extends PageQuery, UserByQueryParams {
|
||||
request_path?: string;
|
||||
request_method?: string;
|
||||
username?: string;
|
||||
status?: number;
|
||||
request_ip?: string;
|
||||
@@ -49,10 +50,13 @@ export interface OperationLogPageQuery extends PageQuery, UserByQueryParams {
|
||||
|
||||
export interface OperationLogTable {
|
||||
id: number;
|
||||
request_path?: string;
|
||||
request_method?: string;
|
||||
username: string;
|
||||
status?: number;
|
||||
description?: string;
|
||||
request_path: string;
|
||||
request_method: string;
|
||||
request_payload?: Record<string, unknown> | string;
|
||||
response_code?: number;
|
||||
response_code: number;
|
||||
response_json?: Record<string, unknown> | string;
|
||||
process_time?: string;
|
||||
created_time?: string;
|
||||
|
||||
@@ -12,7 +12,7 @@ const NoticeAPI = {
|
||||
},
|
||||
|
||||
listNoticeAvailable() {
|
||||
return request<ApiResponse<PageResult<NoticeTable>>>({
|
||||
return request<ApiResponse<NoticeTable[]>>({
|
||||
url: `${API_PATH}/available`,
|
||||
method: "get",
|
||||
});
|
||||
|
||||
@@ -23,29 +23,6 @@ const ParamsAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
listParams(query: ConfigPageQuery) {
|
||||
return request<ApiResponse<PageResult<ConfigTable>>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
detailParams(query: number) {
|
||||
return request<ApiResponse<ConfigTable>>({
|
||||
url: `${API_PATH}/detail/${query}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
createParams(body: ConfigForm) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
updateParams(id: number, body: ConfigForm) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/update/${id}`,
|
||||
@@ -53,42 +30,10 @@ const ParamsAPI = {
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
deleteParams(body: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
batchParams(body: BatchType) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/status/batch`,
|
||||
method: "patch",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
exportParams(query: ConfigPageQuery) {
|
||||
return request<Blob>({
|
||||
url: `${API_PATH}/export`,
|
||||
method: "post",
|
||||
data: query,
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default ParamsAPI;
|
||||
|
||||
export interface ConfigPageQuery extends PageQuery, UserByQueryParams {
|
||||
config_name?: string;
|
||||
config_key?: string;
|
||||
config_type?: boolean;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ConfigTable extends BaseType {
|
||||
config_name?: string;
|
||||
config_key?: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ export const UserAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
uploadCurrentUserAvatar(body: any) {
|
||||
uploadCurrentUserAvatar(body: FormData) {
|
||||
return request<ApiResponse<UploadFilePath>>({
|
||||
url: `/common/file/upload?upload_type=avatar`,
|
||||
method: "post",
|
||||
@@ -117,14 +117,14 @@ export const UserAPI = {
|
||||
},
|
||||
|
||||
downloadTemplateUser() {
|
||||
return request<ApiResponse>({
|
||||
return request<Blob>({
|
||||
url: `${API_PATH}/import/template`,
|
||||
method: "get",
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
|
||||
importUser(body: any) {
|
||||
importUser(body: FormData) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/import/data`,
|
||||
method: "post",
|
||||
@@ -143,6 +143,8 @@ export interface ForgetPasswordForm {
|
||||
new_password: string;
|
||||
mobile?: string;
|
||||
confirmPassword: string;
|
||||
captcha_key?: string;
|
||||
captcha?: string;
|
||||
}
|
||||
|
||||
export interface RegisterForm {
|
||||
@@ -150,6 +152,9 @@ export interface RegisterForm {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
captcha_key?: string;
|
||||
captcha?: string;
|
||||
}
|
||||
|
||||
export interface UserPageQuery extends PageQuery, UserByQueryParams {
|
||||
@@ -185,7 +190,7 @@ export interface UserInfo extends BaseType {
|
||||
position_names?: positionSelectorType["name"][];
|
||||
position_ids?: positionSelectorType["id"][];
|
||||
is_superuser?: boolean;
|
||||
is_impersonate?: boolean;
|
||||
|
||||
last_login?: string;
|
||||
created_by?: CommonType;
|
||||
updated_by?: CommonType;
|
||||
@@ -224,7 +229,7 @@ export interface positionSelectorType {
|
||||
export interface InfoFormState {
|
||||
id?: number;
|
||||
name?: string;
|
||||
gender?: number;
|
||||
gender?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
@@ -263,7 +268,7 @@ export interface UserForm extends BaseFormType {
|
||||
position_ids?: number[];
|
||||
position_names?: string[];
|
||||
password?: string;
|
||||
gender?: number;
|
||||
gender?: string;
|
||||
email?: string;
|
||||
mobile?: string;
|
||||
is_superuser?: boolean;
|
||||
@@ -274,7 +279,7 @@ export interface UserForm extends BaseFormType {
|
||||
|
||||
export interface CurrentUserFormState {
|
||||
name?: string;
|
||||
gender?: number;
|
||||
gender?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
|
||||
@@ -94,6 +94,14 @@ const JobAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
modifyJob(jobId: string, body: Record<string, any>) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/task/modify/${jobId}`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
getJobLogList(query: JobLogPageQuery) {
|
||||
return request<ApiResponse<PageResult<JobLogTable>>>({
|
||||
url: `${API_PATH}/log/list`,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="fa-card p-5 flex items-center flex-col pb-6 h-full" :style="{ height: height }">
|
||||
<div class="flex items-center flex-col gap-4 text-center">
|
||||
<div class="w-45">
|
||||
<img :src="image" :alt="title" class="w-full h-full object-contain" loading="eager" />
|
||||
<img :src="image" :alt="title" class="w-full h-full object-contain" loading="lazy" />
|
||||
</div>
|
||||
<div class="box-border px-4">
|
||||
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
|
||||
interface Comment {
|
||||
id: number;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
||||
</div>
|
||||
<ElScrollbar :style="{ height: maxHeight }">
|
||||
<div v-for="(item, index) in list" :key="index" class="flex items-center py-2">
|
||||
<div v-for="item in list" :key="item.title" class="flex items-center py-2">
|
||||
<div
|
||||
v-if="item.icon"
|
||||
class="flex items-center justify-center mr-3 size-10 rounded-lg"
|
||||
@@ -21,12 +21,7 @@
|
||||
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
<ElButton
|
||||
class="mt-[25px] w-full text-center"
|
||||
v-if="showMoreButton"
|
||||
v-ripple
|
||||
@click="handleMore"
|
||||
>
|
||||
<ElButton class="mt-6.25 w-full text-center" v-if="showMoreButton" v-ripple @click="handleMore">
|
||||
查看更多
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<ElImage
|
||||
:src="props.imageUrl"
|
||||
fit="cover"
|
||||
lazy
|
||||
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
|
||||
>
|
||||
<template #placeholder>
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
|
||||
import { getCssVar } from "@utils";
|
||||
import { graphic, type EChartsOption } from "@/plugins/echarts";
|
||||
import type { BarChartProps, BarDataItem } from "@/types/component/chart";
|
||||
|
||||
defineOptions({ name: "FaBarChart" });
|
||||
|
||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
|
||||
import type { EChartsOption, BarSeriesOption } from "@/plugins/echarts";
|
||||
import type { BidirectionalBarChartProps } from "@/types/component/chart";
|
||||
|
||||
defineOptions({ name: "FaDualBarCompareChart" });
|
||||
|
||||
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
|
||||
import { getCssVar } from "@utils";
|
||||
import { graphic, type EChartsOption } from "@/plugins/echarts";
|
||||
import type { BarChartProps, BarDataItem } from "@/types/component/chart";
|
||||
|
||||
defineOptions({ name: "FaHBarChart" });
|
||||
|
||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from "@/plugins/echarts";
|
||||
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
|
||||
import type { KLineChartProps } from "@/types/component/chart";
|
||||
|
||||
defineOptions({ name: "FaKLineChart" });
|
||||
|
||||
const props = withDefaults(defineProps<KLineChartProps>(), {
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
import { graphic, type EChartsOption } from "@/plugins/echarts";
|
||||
import { getCssVar, hexToRgba } from "@utils";
|
||||
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
|
||||
import type { LineChartProps, LineDataItem } from "@/types/component/chart";
|
||||
|
||||
defineOptions({ name: "FaLineChart" });
|
||||
|
||||
const props = withDefaults(defineProps<LineChartProps>(), {
|
||||
@@ -357,7 +355,7 @@ const renderChart = () => {
|
||||
};
|
||||
|
||||
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
|
||||
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, { deep: true });
|
||||
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart);
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
import { echarts } from "@/plugins/echarts";
|
||||
import { useSettingsStore } from "@stores";
|
||||
import chinaMapJson from "@/mock/json/chinaMap.json";
|
||||
import type { MapChartProps } from "@/types/component/chart";
|
||||
|
||||
defineOptions({ name: "FaMapChart" });
|
||||
|
||||
const chinaMapRef = ref<HTMLElement | null>(null);
|
||||
@@ -206,7 +204,10 @@ const initMap = async (): Promise<void> => {
|
||||
|
||||
chartInstance.value = echarts.init(chinaMapRef.value);
|
||||
|
||||
echarts.registerMap("china", chinaMapJson as any);
|
||||
echarts.registerMap(
|
||||
"china",
|
||||
chinaMapJson as unknown as Parameters<typeof echarts.registerMap>[1]
|
||||
);
|
||||
const mapData = props.mapData.length > 0 ? props.mapData : prepareMapData(chinaMapJson);
|
||||
const option = createChartOption(mapData);
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from "@/plugins/echarts";
|
||||
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
|
||||
import type { RadarChartProps } from "@/types/component/chart";
|
||||
|
||||
defineOptions({ name: "FaRadarChart" });
|
||||
|
||||
const props = withDefaults(defineProps<RadarChartProps>(), {
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from "@/plugins/echarts";
|
||||
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
|
||||
import type { RingChartProps } from "@/types/component/chart";
|
||||
|
||||
defineOptions({ name: "FaRingChart" });
|
||||
|
||||
const props = withDefaults(defineProps<RingChartProps>(), {
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
import type { EChartsOption } from "@/plugins/echarts";
|
||||
import { getCssVar } from "@utils";
|
||||
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
|
||||
import type { ScatterChartProps } from "@/types/component/chart";
|
||||
|
||||
defineOptions({ name: "FaScatterChart" });
|
||||
|
||||
const props = withDefaults(defineProps<ScatterChartProps>(), {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 表单组件公共组合式函数 —— 提取 FaForm 与 FaSearchBar 的共享逻辑。
|
||||
*
|
||||
* 共享函数:cloneModelValue / isRichTextEmpty / sanitizeOutputValue / getProps / getSlots / getColSpan
|
||||
*/
|
||||
import { computed, toRaw, type Component } from "vue";
|
||||
import { type VNode } from "vue";
|
||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from "@utils";
|
||||
|
||||
// ── 类型定义 ──
|
||||
|
||||
export interface FormItemBase {
|
||||
key: string;
|
||||
label?: string | (() => VNode) | Component;
|
||||
labelWidth?: string | number;
|
||||
type?: string;
|
||||
hidden?: boolean;
|
||||
span?: number;
|
||||
slots?: Record<string, (() => any) | undefined>;
|
||||
props?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SanitizeOutputOptions {
|
||||
removeEmptyString: boolean;
|
||||
removeEmptyArray: boolean;
|
||||
removeEmptyObject: boolean;
|
||||
removeEmptyRichText: boolean;
|
||||
keepZero: boolean;
|
||||
keepFalse: boolean;
|
||||
}
|
||||
|
||||
/** 传递给组件时需排除的表单配置属性 */
|
||||
const ROOT_PROPS = ["label", "labelWidth", "key", "type", "hidden", "span", "slots"];
|
||||
|
||||
/** 日期选择器类型列表(getProps 中用于传递 type 到 FaDatePicker) */
|
||||
const DATE_PICKER_TYPES = ["date", "daterange", "datetime", "datetimerange", "monthrange"];
|
||||
|
||||
// ── 公共函数 ──
|
||||
|
||||
/**
|
||||
* 深拷贝表单数据(toRaw + 递归,避免 getSanitizedOutput 残留响应式代理)
|
||||
*/
|
||||
export const cloneModelValue = (value: Record<string, any> | undefined): Record<string, any> => {
|
||||
if (!value) return {};
|
||||
|
||||
const deepClone = (source: unknown): unknown => {
|
||||
if (Array.isArray(source)) {
|
||||
return source.map((item) => deepClone(item));
|
||||
}
|
||||
|
||||
if (source && typeof source === "object") {
|
||||
const rawSource = toRaw(source);
|
||||
return Object.keys(rawSource).reduce<Record<string, unknown>>((accumulator, key) => {
|
||||
accumulator[key] = deepClone((rawSource as Record<string, unknown>)[key]);
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return source;
|
||||
};
|
||||
|
||||
return deepClone(toRaw(value)) as Record<string, any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断富文本内容是否仅包含占位标签(空内容)
|
||||
*/
|
||||
export const isRichTextEmpty = (value: string): boolean => {
|
||||
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
value
|
||||
.replace(/ /gi, "")
|
||||
.replace(/<br\s*\/?>/gi, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.trim() === ""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清洗输出值 —— 按配置移除空字符串/空数组/空对象/空富文本
|
||||
*/
|
||||
export const sanitizeOutputValue = (value: unknown, options: SanitizeOutputOptions): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
const sanitizedArray = value
|
||||
.map((item) => sanitizeOutputValue(item, options))
|
||||
.filter((item) => item !== undefined);
|
||||
return sanitizedArray.length === 0 && options.removeEmptyArray ? undefined : sanitizedArray;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const rawValue = toRaw(value);
|
||||
const sanitizedObject = Object.entries(rawValue).reduce<Record<string, unknown>>(
|
||||
(accumulator, [key, item]) => {
|
||||
const sanitizedItem = sanitizeOutputValue(item, options);
|
||||
if (sanitizedItem !== undefined) {
|
||||
accumulator[key] = sanitizedItem;
|
||||
}
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
|
||||
? undefined
|
||||
: sanitizedObject;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (options.removeEmptyString && value.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return options.keepZero ? value : undefined;
|
||||
}
|
||||
|
||||
if (value === false) {
|
||||
return options.keepFalse ? value : undefined;
|
||||
}
|
||||
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建清洗配置 computed,与组件 props.sanitizeOutput 合并默认值
|
||||
*/
|
||||
export const useSanitizeOutputOptions = (sanitizeOutput: Partial<SanitizeOutputOptions>) => {
|
||||
return computed<SanitizeOutputOptions>(() => ({
|
||||
removeEmptyString: true,
|
||||
removeEmptyArray: true,
|
||||
removeEmptyObject: true,
|
||||
removeEmptyRichText: true,
|
||||
keepZero: true,
|
||||
keepFalse: true,
|
||||
...sanitizeOutput,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取组件 props —— 从 FormItem 中分离表单配置属性,保留组件所需属性
|
||||
*/
|
||||
export const getProps = (item: FormItemBase): Record<string, any> => {
|
||||
if (item.props) {
|
||||
const props = { ...item.props };
|
||||
if (item.type && DATE_PICKER_TYPES.includes(item.type) && !props.type) {
|
||||
props.type = item.type;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
const props = { ...item };
|
||||
ROOT_PROPS.forEach((key) => delete (props as Record<string, any>)[key]);
|
||||
|
||||
// 日期选择器需要传递 type 到 FaDatePicker
|
||||
if (item.type && DATE_PICKER_TYPES.includes(item.type) && !props.type) {
|
||||
props.type = item.type;
|
||||
}
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插槽 —— 过滤掉未定义的插槽
|
||||
*/
|
||||
export const getSlots = (item: FormItemBase): Record<string, () => any> => {
|
||||
if (!item.slots) return {};
|
||||
const validSlots: Record<string, () => any> = {};
|
||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||
if (slotFn) {
|
||||
validSlots[key] = slotFn;
|
||||
}
|
||||
});
|
||||
return validSlots;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取列宽 span 值 —— 根据屏幕尺寸智能降级
|
||||
*/
|
||||
export const getColSpan = (
|
||||
itemSpan: number | undefined,
|
||||
span: number,
|
||||
breakpoint: ResponsiveBreakpoint
|
||||
): number => {
|
||||
return calculateResponsiveSpan(itemSpan, span, breakpoint);
|
||||
};
|
||||
@@ -24,7 +24,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuth } from "@/hooks/core/useAuth";
|
||||
import type { ButtonMoreItem } from "./types";
|
||||
|
||||
defineOptions({ name: "FaButtonMore" });
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
||||
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
||||
<template>
|
||||
<ElScrollbar v-if="scrollbar" :max-height="maxHeight" :view-style="{ overflowX: 'hidden' }">
|
||||
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||
<ElScrollbar v-if="scrollbar" :max-height="maxHeight" :view-style="{ overflowX: 'hidden' }">
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
@@ -99,11 +99,9 @@
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</section>
|
||||
</ElScrollbar>
|
||||
<!-- 不使用滚动条时直接渲染 -->
|
||||
<section v-else class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||
</ElScrollbar>
|
||||
<ElForm
|
||||
v-else
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:label-position="labelPosition"
|
||||
@@ -201,7 +199,7 @@
|
||||
*/
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toRaw, type Component } from "vue";
|
||||
import { type Component } from "vue";
|
||||
import FaDatePicker from "@/components/forms/fa-search-bar/FaDatePicker.vue";
|
||||
import {
|
||||
ElCascader,
|
||||
@@ -220,7 +218,15 @@ import {
|
||||
ElTreeSelect,
|
||||
type FormInstance,
|
||||
} from "element-plus";
|
||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from "@utils";
|
||||
import {
|
||||
cloneModelValue as cloneModelValueShared,
|
||||
sanitizeOutputValue as sanitizeOutputValueShared,
|
||||
getProps as getPropsShared,
|
||||
getSlots as getSlotsShared,
|
||||
getColSpan as getColSpanShared,
|
||||
useSanitizeOutputOptions,
|
||||
type SanitizeOutputOptions,
|
||||
} from "../composables/useFormBase";
|
||||
|
||||
defineOptions({ name: "FaForm" });
|
||||
|
||||
@@ -308,21 +314,6 @@ interface Props {
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
interface SanitizeOutputOptions {
|
||||
/** 移除空字符串 */
|
||||
removeEmptyString: boolean;
|
||||
/** 移除空数组 */
|
||||
removeEmptyArray: boolean;
|
||||
/** 移除清洗后为空的对象 */
|
||||
removeEmptyObject: boolean;
|
||||
/** 移除空富文本占位内容,如 <p><br></p> */
|
||||
removeEmptyRichText: boolean;
|
||||
/** 保留数字 0 这类有效值 */
|
||||
keepZero: boolean;
|
||||
/** 保留 false 这类有效值 */
|
||||
keepFalse: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
items: () => [],
|
||||
span: 6,
|
||||
@@ -350,41 +341,16 @@ const modelValue = defineModel<Record<string, any>>({ default: {} });
|
||||
const initialModelValue = ref<Record<string, any>>({});
|
||||
|
||||
// 保存组件初始化时的表单快照,用于 reset 时恢复默认值。
|
||||
const cloneModelValue = (value: Record<string, any> | undefined) => {
|
||||
if (!value) return {};
|
||||
initialModelValue.value = cloneModelValueShared(modelValue.value);
|
||||
|
||||
const deepClone = (source: unknown): unknown => {
|
||||
if (Array.isArray(source)) {
|
||||
return source.map((item) => deepClone(item));
|
||||
}
|
||||
const sanitizeOutputOptions = useSanitizeOutputOptions(props.sanitizeOutput);
|
||||
|
||||
if (source && typeof source === "object") {
|
||||
const rawSource = toRaw(source);
|
||||
return Object.keys(rawSource).reduce<Record<string, unknown>>((accumulator, key) => {
|
||||
accumulator[key] = deepClone((rawSource as Record<string, unknown>)[key]);
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return source;
|
||||
};
|
||||
|
||||
return deepClone(toRaw(value)) as Record<string, any>;
|
||||
};
|
||||
|
||||
initialModelValue.value = cloneModelValue(modelValue.value);
|
||||
|
||||
const rootProps = ["label", "labelWidth", "key", "type", "hidden", "span", "slots"];
|
||||
// 输出时的清洗策略默认偏“接口友好”,但允许按业务覆盖。
|
||||
const sanitizeOutputOptions = computed<SanitizeOutputOptions>(() => ({
|
||||
removeEmptyString: true,
|
||||
removeEmptyArray: true,
|
||||
removeEmptyObject: true,
|
||||
removeEmptyRichText: true,
|
||||
keepZero: true,
|
||||
keepFalse: true,
|
||||
...props.sanitizeOutput,
|
||||
}));
|
||||
// 模板引用的函数(从公共 composable 重新导出,使模板可访问)
|
||||
// 注:getColSpan 保持 2 参数签名(span 从组件内部读取)
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: any) =>
|
||||
getColSpanShared(itemSpan, span.value, breakpoint);
|
||||
const getProps = getPropsShared;
|
||||
const getSlots = getSlotsShared;
|
||||
|
||||
const PATH_NUMBER_RE = /^\d+$/;
|
||||
|
||||
@@ -456,102 +422,11 @@ const setFieldValue = (path: string, value: unknown) => {
|
||||
});
|
||||
};
|
||||
|
||||
const isRichTextEmpty = (value: string) => {
|
||||
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 去掉编辑器常见占位标签后再判断是否还有实际内容。
|
||||
return (
|
||||
value
|
||||
.replace(/ /gi, "")
|
||||
.replace(/<br\s*\/?>/gi, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.trim() === ""
|
||||
);
|
||||
};
|
||||
|
||||
// 提交时按配置清洗空值,但保留 0 和 false 这类有效值。
|
||||
const sanitizeOutputValue = (value: unknown): unknown => {
|
||||
const options = sanitizeOutputOptions.value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const sanitizedArray = value
|
||||
.map((item) => sanitizeOutputValue(item))
|
||||
.filter((item) => item !== undefined);
|
||||
return sanitizedArray.length === 0 && options.removeEmptyArray ? undefined : sanitizedArray;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const rawValue = toRaw(value);
|
||||
const sanitizedObject = Object.entries(rawValue).reduce<Record<string, unknown>>(
|
||||
(accumulator, [key, item]) => {
|
||||
const sanitizedItem = sanitizeOutputValue(item);
|
||||
if (sanitizedItem !== undefined) {
|
||||
accumulator[key] = sanitizedItem;
|
||||
}
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
|
||||
? undefined
|
||||
: sanitizedObject;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (options.removeEmptyString && value.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return options.keepZero ? value : undefined;
|
||||
}
|
||||
|
||||
if (value === false) {
|
||||
return options.keepFalse ? value : undefined;
|
||||
}
|
||||
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
const getSanitizedOutput = () => {
|
||||
return (sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}) as Record<string, any>;
|
||||
};
|
||||
|
||||
const getProps = (item: FormItem) => {
|
||||
let props: Record<string, any>;
|
||||
if (item.props) {
|
||||
props = { ...item.props };
|
||||
} else {
|
||||
props = { ...item };
|
||||
rootProps.forEach((key) => delete props[key]);
|
||||
}
|
||||
|
||||
// 对于日期选择器组件,确保 type 被传递给 FaDatePicker
|
||||
const datePickerTypes = ["date", "daterange", "datetime", "datetimerange", "monthrange"];
|
||||
if (item.type && datePickerTypes.includes(item.type) && !props.type) {
|
||||
props.type = item.type;
|
||||
}
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
// 获取插槽
|
||||
const getSlots = (item: FormItem) => {
|
||||
if (!item.slots) return {};
|
||||
const validSlots: Record<string, () => any> = {};
|
||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||
if (slotFn) {
|
||||
validSlots[key] = slotFn;
|
||||
}
|
||||
});
|
||||
return validSlots;
|
||||
return (sanitizeOutputValueShared(
|
||||
cloneModelValueShared(modelValue.value),
|
||||
sanitizeOutputOptions.value
|
||||
) || {}) as Record<string, any>;
|
||||
};
|
||||
|
||||
// 组件
|
||||
@@ -562,15 +437,12 @@ const getComponent = (item: FormItem) => {
|
||||
}
|
||||
// 使用 type 获取预定义组件
|
||||
const { type } = item;
|
||||
return componentMap[type as keyof typeof componentMap] || componentMap["input"];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取列宽 span 值
|
||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||
*/
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint);
|
||||
const comp = componentMap[type as keyof typeof componentMap];
|
||||
if (!comp) {
|
||||
console.warn(`[FaForm] 未知表单类型 "${type}",回退到 input`, item);
|
||||
return componentMap["input"];
|
||||
}
|
||||
return comp;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -602,7 +474,7 @@ const handleReset = () => {
|
||||
Object.keys(modelValue.value).forEach((key) => {
|
||||
delete modelValue.value[key];
|
||||
});
|
||||
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value));
|
||||
Object.assign(modelValue.value, cloneModelValueShared(initialModelValue.value));
|
||||
|
||||
// 触发 reset 事件
|
||||
emit("reset");
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
@clear-click="handleClearSelection"
|
||||
>
|
||||
<template #status="scope">
|
||||
<ElTag :type="scope.row[scope.prop] === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row[scope.prop] === "0" ? "启用" : "停用" }}
|
||||
</ElTag>
|
||||
<template v-if="scope.row[scope.prop] === 0">
|
||||
<ElTag type="success">启用</ElTag>
|
||||
</template>
|
||||
<template v-else-if="scope.row[scope.prop] === 1">
|
||||
<ElTag type="danger">停用</ElTag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElTag type="info">未知</ElTag>
|
||||
</template>
|
||||
</template>
|
||||
</FaTableSelect>
|
||||
</template>
|
||||
@@ -46,7 +52,7 @@ const selectConfig: ISelectConfig = {
|
||||
type: "select",
|
||||
label: "状态",
|
||||
prop: "status",
|
||||
initialValue: "0",
|
||||
initialValue: 0,
|
||||
attrs: {
|
||||
placeholder: "全部",
|
||||
clearable: true,
|
||||
@@ -55,8 +61,8 @@ const selectConfig: ISelectConfig = {
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{ label: "启用", value: "0" },
|
||||
{ label: "停用", value: "1" },
|
||||
{ label: "启用", value: 0 },
|
||||
{ label: "停用", value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -70,11 +76,6 @@ const selectConfig: ISelectConfig = {
|
||||
delete query[k];
|
||||
}
|
||||
});
|
||||
// 规范化状态为布尔值
|
||||
if (typeof query.status === "string") {
|
||||
if (query.status === "true") query.status = true;
|
||||
else if (query.status === "false") query.status = false;
|
||||
}
|
||||
// 请求用户分页列表并适配 TableSelect 需要的结构
|
||||
const res = await UserAPI.listUser(query);
|
||||
return {
|
||||
|
||||
@@ -101,25 +101,36 @@
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
|
||||
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
|
||||
<div class="form-buttons">
|
||||
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
||||
<template #icon>
|
||||
<Refresh />
|
||||
</template>
|
||||
{{ t("table.searchBar.reset") }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="showSearch"
|
||||
type="primary"
|
||||
class="search-button"
|
||||
@click="handleSearch"
|
||||
v-ripple
|
||||
:disabled="disabledSearch"
|
||||
<ElTooltip
|
||||
v-if="showReset"
|
||||
:content="t('table.searchBar.resetTooltip')"
|
||||
placement="top"
|
||||
>
|
||||
<template #icon>
|
||||
<Search />
|
||||
</template>
|
||||
{{ t("table.searchBar.search") }}
|
||||
</ElButton>
|
||||
<ElButton class="reset-button" @click="handleReset" v-ripple>
|
||||
<template #icon>
|
||||
<Refresh />
|
||||
</template>
|
||||
{{ t("table.searchBar.reset") }}
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
v-if="showSearch"
|
||||
:content="t('table.searchBar.searchTooltip')"
|
||||
placement="top"
|
||||
>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="search-button"
|
||||
@click="handleSearch"
|
||||
v-ripple
|
||||
:disabled="disabledSearch"
|
||||
>
|
||||
<template #icon>
|
||||
<Search />
|
||||
</template>
|
||||
{{ t("table.searchBar.search") }}
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand">
|
||||
<span>{{ expandToggleText }}</span>
|
||||
@@ -139,9 +150,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpBold, ArrowDownBold, Refresh, Search } from "@element-plus/icons-vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useWindowSize, onKeyStroke } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toRaw, type Component } from "vue";
|
||||
import { type Component } from "vue";
|
||||
import FaDatePicker from "@/components/forms/fa-search-bar/FaDatePicker.vue";
|
||||
import FaUserTableSelect from "./FaUserTableSelect.vue";
|
||||
import {
|
||||
@@ -165,10 +176,26 @@ import {
|
||||
ElTreeSelect,
|
||||
type FormInstance,
|
||||
} from "element-plus";
|
||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from "@utils/form";
|
||||
import {
|
||||
cloneModelValue as cloneModelValueShared,
|
||||
sanitizeOutputValue as sanitizeOutputValueShared,
|
||||
getProps as getPropsShared,
|
||||
getSlots as getSlotsShared,
|
||||
getColSpan as getColSpanShared,
|
||||
useSanitizeOutputOptions,
|
||||
type SanitizeOutputOptions,
|
||||
} from "../composables/useFormBase";
|
||||
|
||||
defineOptions({ name: "FaSearchBar" });
|
||||
|
||||
// Ctrl+Enter 快捷键触发搜索
|
||||
onKeyStroke("Enter", (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
});
|
||||
|
||||
const componentMap = {
|
||||
input: ElInput, // 输入框
|
||||
inputTag: ElInputTag, // 标签输入框
|
||||
@@ -262,21 +289,6 @@ interface Props {
|
||||
auditItemOptions?: GetAuditSearchFormItemsOptions;
|
||||
}
|
||||
|
||||
interface SanitizeOutputOptions {
|
||||
/** 移除空字符串 */
|
||||
removeEmptyString: boolean;
|
||||
/** 移除空数组 */
|
||||
removeEmptyArray: boolean;
|
||||
/** 移除清洗后为空的对象 */
|
||||
removeEmptyObject: boolean;
|
||||
/** 移除空富文本占位内容,如 <p><br></p> */
|
||||
removeEmptyRichText: boolean;
|
||||
/** 保留数字 0 这类有效筛选值 */
|
||||
keepZero: boolean;
|
||||
/** 保留 false 这类有效筛选值 */
|
||||
keepFalse: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
items: () => [],
|
||||
span: 6,
|
||||
@@ -314,74 +326,21 @@ const mergedItems = computed(() => {
|
||||
});
|
||||
|
||||
// 保存组件初始化时的表单快照,用于 reset 时恢复默认筛选条件。
|
||||
const cloneModelValue = (value: Record<string, any> | undefined) => {
|
||||
if (!value) return {};
|
||||
initialModelValue.value = cloneModelValueShared(modelValue.value);
|
||||
|
||||
const deepClone = (source: unknown): unknown => {
|
||||
if (Array.isArray(source)) {
|
||||
return source.map((item) => deepClone(item));
|
||||
}
|
||||
const sanitizeOutputOptions = useSanitizeOutputOptions(props.sanitizeOutput);
|
||||
|
||||
if (source && typeof source === "object") {
|
||||
const rawSource = toRaw(source);
|
||||
return Object.keys(rawSource).reduce<Record<string, unknown>>((accumulator, key) => {
|
||||
accumulator[key] = deepClone((rawSource as Record<string, unknown>)[key]);
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return source;
|
||||
};
|
||||
|
||||
return deepClone(toRaw(value)) as Record<string, any>;
|
||||
};
|
||||
|
||||
initialModelValue.value = cloneModelValue(modelValue.value);
|
||||
// 模板引用的函数(从公共 composable 重新导出,使模板可访问)
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: any) =>
|
||||
getColSpanShared(itemSpan, span.value, breakpoint);
|
||||
const getProps = getPropsShared;
|
||||
const getSlots = getSlotsShared;
|
||||
|
||||
/**
|
||||
* 是否展开状态
|
||||
*/
|
||||
const isExpanded = ref(props.defaultExpanded);
|
||||
|
||||
const rootProps = ["label", "labelWidth", "key", "type", "hidden", "span", "slots"];
|
||||
// 搜索参数默认更激进地去掉空值,减少无效 query 参数。
|
||||
const sanitizeOutputOptions = computed<SanitizeOutputOptions>(() => ({
|
||||
removeEmptyString: true,
|
||||
removeEmptyArray: true,
|
||||
removeEmptyObject: true,
|
||||
removeEmptyRichText: true,
|
||||
keepZero: true,
|
||||
keepFalse: true,
|
||||
...props.sanitizeOutput,
|
||||
}));
|
||||
|
||||
const getProps = (item: SearchFormItem) => {
|
||||
if (item.props) return item.props;
|
||||
const props = { ...item };
|
||||
rootProps.forEach((key) => delete (props as Record<string, any>)[key]);
|
||||
return props;
|
||||
};
|
||||
|
||||
// 获取插槽
|
||||
const getSlots = (item: SearchFormItem) => {
|
||||
if (!item.slots) return {};
|
||||
const validSlots: Record<string, () => any> = {};
|
||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||
if (slotFn) {
|
||||
validSlots[key] = slotFn;
|
||||
}
|
||||
});
|
||||
return validSlots;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取列宽 span 值
|
||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||
*/
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint);
|
||||
};
|
||||
|
||||
// 搜索表单清空输入时不保留空字符串,避免后续请求携带空字段。
|
||||
const normalizeFieldValue = (value: unknown) => {
|
||||
return value === "" ? undefined : value;
|
||||
@@ -400,72 +359,11 @@ const setFieldValue = (key: string, value: unknown) => {
|
||||
modelValue.value[key] = normalizedValue;
|
||||
};
|
||||
|
||||
const isRichTextEmpty = (value: string) => {
|
||||
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 去掉编辑器常见占位标签后再判断是否还有实际内容。
|
||||
return (
|
||||
value
|
||||
.replace(/ /gi, "")
|
||||
.replace(/<br\s*\/?>/gi, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.trim() === ""
|
||||
);
|
||||
};
|
||||
|
||||
// 搜索时按配置清洗空值,但保留 0 和 false 这类有效筛选条件。
|
||||
const sanitizeOutputValue = (value: unknown): unknown => {
|
||||
const options = sanitizeOutputOptions.value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const sanitizedArray = value
|
||||
.map((item) => sanitizeOutputValue(item))
|
||||
.filter((item) => item !== undefined);
|
||||
return sanitizedArray.length === 0 && options.removeEmptyArray ? undefined : sanitizedArray;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const rawValue = toRaw(value);
|
||||
const sanitizedObject = Object.entries(rawValue).reduce<Record<string, unknown>>(
|
||||
(accumulator, [key, item]) => {
|
||||
const sanitizedItem = sanitizeOutputValue(item);
|
||||
if (sanitizedItem !== undefined) {
|
||||
accumulator[key] = sanitizedItem;
|
||||
}
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
|
||||
? undefined
|
||||
: sanitizedObject;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (options.removeEmptyString && value.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return options.keepZero ? value : undefined;
|
||||
}
|
||||
|
||||
if (value === false) {
|
||||
return options.keepFalse ? value : undefined;
|
||||
}
|
||||
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
const getSanitizedOutput = () => {
|
||||
return (sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}) as Record<string, any>;
|
||||
return (sanitizeOutputValueShared(
|
||||
cloneModelValueShared(modelValue.value),
|
||||
sanitizeOutputOptions.value
|
||||
) || {}) as Record<string, any>;
|
||||
};
|
||||
|
||||
// 组件
|
||||
@@ -483,7 +381,7 @@ const getComponent = (item: SearchFormItem) => {
|
||||
* 更新审计字段并立即触发搜索
|
||||
*/
|
||||
const patchAuditField = (key: "created_id" | "updated_id", val: number | undefined) => {
|
||||
modelValue.value = { ...modelValue.value, [key]: val };
|
||||
modelValue.value[key] = val;
|
||||
};
|
||||
|
||||
const emitImmediateSearch = () => {
|
||||
@@ -554,7 +452,7 @@ const handleReset = () => {
|
||||
Object.keys(modelValue.value).forEach((key) => {
|
||||
delete modelValue.value[key];
|
||||
});
|
||||
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value));
|
||||
Object.assign(modelValue.value, cloneModelValueShared(initialModelValue.value));
|
||||
|
||||
// 触发 reset 事件
|
||||
emit("reset");
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useUserStore } from "@stores";
|
||||
import { request, EmojiText } from "@utils";
|
||||
import { IDomEditor, IToolbarConfig, IEditorConfig } from "@wangeditor-next/editor";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
|
||||
defineOptions({ name: "FaWangEditor" });
|
||||
|
||||
@@ -363,7 +363,7 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
.w-e-bar-divider {
|
||||
height: 20px;
|
||||
margin-top: 10px;
|
||||
background-color: #ccc;
|
||||
background-color: var(--fa-gray-400);
|
||||
}
|
||||
|
||||
/* 工具栏菜单 */
|
||||
@@ -512,14 +512,14 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
transition: border 0.3s;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid #318ef4 !important;
|
||||
border: 1px solid var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.w-e-image-dragger {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #318ef4;
|
||||
background-color: var(--el-color-primary);
|
||||
border: 2px solid #fff;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@
|
||||
z-index: 50;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--fa-gray-200);
|
||||
// 毛玻璃效果移到伪元素,避免创建 containing block 影响内部 fixed 定位元素
|
||||
&::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
content: "";
|
||||
background-color: color-mix(in srgb, var(--default-box-color) 85%, transparent);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
}
|
||||
|
||||
#app-content {
|
||||
@@ -58,6 +69,10 @@
|
||||
.app-layout {
|
||||
#app-main {
|
||||
height: 100dvh;
|
||||
|
||||
#app-header {
|
||||
backdrop-filter: none; // 移动端无毛玻璃
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
ref="messageContainer"
|
||||
class="flex-1 border-t border-(--default-border) px-4 py-7.5"
|
||||
>
|
||||
<template v-for="(message, index) in messages" :key="index">
|
||||
<template v-for="message in messages" :key="message.id">
|
||||
<div
|
||||
:class="[
|
||||
'mb-7.5 flex w-full items-start gap-2',
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Picture, Paperclip, Close } from "@element-plus/icons-vue";
|
||||
import { ElScrollbar } from "element-plus";
|
||||
|
||||
import { mittBus } from "@utils";
|
||||
import meAvatar from "@imgs/avatar/avatar5.webp";
|
||||
import aiAvatar from "@imgs/avatar/avatar10.webp";
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFastEnter } from "@/hooks/core/useFastEnter";
|
||||
import type { FastEnterApplication, FastEnterQuickLink } from "@/types/config";
|
||||
|
||||
defineOptions({ name: "FaFastEnter" });
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div
|
||||
class="box mt-0! cursor-pointer text-base leading-none"
|
||||
v-for="(item, index) in searchResult"
|
||||
:key="index"
|
||||
:key="item.path"
|
||||
>
|
||||
<div
|
||||
class="mt-2 h-12 flex items-center justify-between rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700"
|
||||
@@ -51,7 +51,7 @@
|
||||
<div
|
||||
class="box mt-2 h-12 cursor-pointer flex items-center justify-between rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
|
||||
v-for="(item, index) in historyResult"
|
||||
:key="index"
|
||||
:key="item.path"
|
||||
:class="
|
||||
historyHIndex === index
|
||||
? 'highlighted bg-theme/70! text-white! [&_.selected-icon]:text-white!'
|
||||
@@ -108,11 +108,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { AppRouteRecord } from "@/types/router";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import { mittBus, formatMenuTitle, handleMenuJump } from "@utils";
|
||||
import { useUserStore, useMenuStore } from "@stores";
|
||||
import { type ScrollbarInstance } from "element-plus";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
defineOptions({ name: "FaGlobalSearch" });
|
||||
|
||||
const userStore = useUserStore();
|
||||
@@ -177,14 +177,14 @@ const focusInput = () => {
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 搜索逻辑
|
||||
const search = (val: string) => {
|
||||
// 搜索逻辑(防抖优化)
|
||||
const search = useDebounceFn((val: string) => {
|
||||
if (val) {
|
||||
searchResult.value = flattenAndFilterMenuItems(menuList.value, val);
|
||||
} else {
|
||||
searchResult.value = [];
|
||||
}
|
||||
};
|
||||
}, 150);
|
||||
|
||||
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
|
||||
const lowerVal = val.toLowerCase();
|
||||
|
||||
@@ -79,9 +79,7 @@
|
||||
<FaSvgIcon icon="ri:search-line" class="text-sm text-g-500" />
|
||||
<span class="ml-1 text-xs font-normal text-g-500">{{ $t("topBar.search.title") }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center h-5 px-1.5 text-g-500/80 border border-(--el-color-primary) rounded"
|
||||
>
|
||||
<div class="flex items-center h-5 px-1.5 text-g-500/80 border rounded">
|
||||
<FaSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
|
||||
<FaSvgIcon v-else icon="ri:command-fill" class="text-xs" />
|
||||
<span class="ml-0.5 text-xs">k</span>
|
||||
@@ -195,10 +193,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LanguageEnum } from "@/enums/appEnum";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useFullscreen, useWindowSize } from "@vueuse/core";
|
||||
import { LanguageEnum, MenuTypeEnum } from "@/enums/appEnum";
|
||||
|
||||
import {
|
||||
useSettingsStore,
|
||||
useMenuStore,
|
||||
@@ -235,7 +234,7 @@ const headerLogoSrc = computed(() => {
|
||||
});
|
||||
|
||||
const headerSystemName = computed(() => {
|
||||
const raw = configStore.configData.name?.config_value;
|
||||
const raw = configStore.configData.sys_name?.config_value;
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
return AppConfig.systemInfo.name;
|
||||
});
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
<!-- 参数配置 -->
|
||||
<template>
|
||||
<FaDrawer
|
||||
v-model="drawerVisible"
|
||||
title="配置中心"
|
||||
:size="drawerSize"
|
||||
destroy-on-close
|
||||
@close="onDrawerClosed"
|
||||
>
|
||||
<ElTabs v-model="activeTabRef" type="border-card">
|
||||
<ElTabPane label="AI 模型" name="aiModel">
|
||||
<FaAiModelConfigPanel />
|
||||
</ElTabPane>
|
||||
<ElTabPane label="IP黑名单" name="ipBlacklist">
|
||||
<ElForm :model="configState" label-suffix=":" label-width="100px" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<ElDivider>IP黑名单</ElDivider>
|
||||
<div v-for="(item, key) in ipBlacklistConfigs" :key="key">
|
||||
<ElFormItem :label="item?.config_name">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="listItem in ipBlacklistItems"
|
||||
:key="listItem.id"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ElInput
|
||||
v-model="listItem.value"
|
||||
:placeholder="'192.168.1.1'"
|
||||
clearable
|
||||
:style="'flex: 1'"
|
||||
@input="markModified(key)"
|
||||
@blur="
|
||||
{
|
||||
if (!isValidIp(listItem.value) && listItem.value.trim()) {
|
||||
ElMessage.warning('请输入有效的IP地址格式');
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ElButton
|
||||
type="danger"
|
||||
icon="minus"
|
||||
circle
|
||||
size="small"
|
||||
@click="removeIpBlacklistItem(listItem.id)"
|
||||
/>
|
||||
</div>
|
||||
<ElButton
|
||||
type="primary"
|
||||
icon="plus"
|
||||
size="small"
|
||||
:style="'margin-top: 10px'"
|
||||
@click="addIpBlacklistItem"
|
||||
>
|
||||
添加IP地址
|
||||
</ElButton>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
配置说明:添加到黑名单的IP地址将无法访问系统,支持单个IP配置。
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="演示环境配置" name="demo">
|
||||
<ElForm :model="configState" label-suffix=":" label-width="100px" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<ElDivider>演示环境配置</ElDivider>
|
||||
<div v-for="(item, key) in demoConfigs" :key="key">
|
||||
<ElFormItem :label="item?.config_name">
|
||||
<!-- 演示模式开关 -->
|
||||
<template v-if="key === 'demo_enable'">
|
||||
<ElSwitch
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
:model-value="item?.config_value === 'on'"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
item!.config_value = value ? 'on' : 'off';
|
||||
markModified(key);
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
配置说明:启用后系统将进入演示模式,部分功能可能受限。
|
||||
</div>
|
||||
</template>
|
||||
<!-- IP白名单 -->
|
||||
<template v-else-if="key === 'ip_white_list'">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="listItem in demoIpWhitelistItems"
|
||||
:key="listItem.id"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ElInput
|
||||
v-model="listItem.value"
|
||||
:placeholder="'192.168.1.1'"
|
||||
clearable
|
||||
:style="'flex: 1'"
|
||||
@input="markModified(key)"
|
||||
@blur="
|
||||
{
|
||||
if (!isValidIp(listItem.value) && listItem.value.trim()) {
|
||||
ElMessage.warning('请输入有效的IP地址格式');
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ElButton
|
||||
type="danger"
|
||||
icon="minus"
|
||||
circle
|
||||
size="small"
|
||||
@click="removeDemoIpWhitelistItem(listItem.id)"
|
||||
/>
|
||||
</div>
|
||||
<ElButton
|
||||
type="primary"
|
||||
icon="plus"
|
||||
size="small"
|
||||
:style="'margin-top: 10px'"
|
||||
@click="addDemoIpWhitelistItem"
|
||||
>
|
||||
添加IP地址
|
||||
</ElButton>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
配置说明:演示模式下,只有白名单中的IP地址可以访问系统,支持单个IP配置。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 其他配置项 -->
|
||||
<template v-else>
|
||||
<ElInput
|
||||
v-model="item!.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
:style="'width: 100%'"
|
||||
@input="markModified(key)"
|
||||
/>
|
||||
</template>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
<template #footer>
|
||||
<ElButton @click="handleCloseDialog">取消</ElButton>
|
||||
<ElButton
|
||||
v-if="activeTabRef !== 'aiModel'"
|
||||
v-hasPerm="['module_system:param:update']"
|
||||
type="primary"
|
||||
:disabled="!hasChanges"
|
||||
@click="submitChanges"
|
||||
>
|
||||
保存
|
||||
</ElButton>
|
||||
</template>
|
||||
</FaDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted, computed } from "vue";
|
||||
import ParamsAPI, { type ConfigTable } from "@/api/module_system/params";
|
||||
import { useConfigStore } from "@stores";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import FaAiModelConfigPanel from "@/views/module_ai/chat/components/FaAiModelConfigPanel.vue";
|
||||
|
||||
defineOptions({ name: "FaConfigInfoDrawer" });
|
||||
|
||||
// 定义列表项类型
|
||||
interface ListItem {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return Math.random().toString(36).substring(2, 11);
|
||||
};
|
||||
|
||||
// IP地址验证函数
|
||||
const isValidIp = (ip: string): boolean => {
|
||||
const ipRegex =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipRegex.test(ip);
|
||||
};
|
||||
|
||||
const drawerSize = ref("60%");
|
||||
|
||||
const t = useI18n().t;
|
||||
const configStore = useConfigStore();
|
||||
|
||||
const activeTabRef = ref("ipBlacklist");
|
||||
|
||||
// 配置状态管理与父组件的 v-model 同步
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
interface Emits {
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
const drawerVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: boolean) => emit("update:modelValue", val),
|
||||
});
|
||||
|
||||
// 配置状态管理
|
||||
const configState = reactive<ConfigTable>({
|
||||
id: undefined,
|
||||
config_name: "",
|
||||
config_key: "",
|
||||
config_value: "",
|
||||
config_type: undefined,
|
||||
description: "",
|
||||
});
|
||||
|
||||
// 记录修改过的字段
|
||||
const modifiedFields = reactive<Record<string, boolean>>({});
|
||||
|
||||
// 标记字段为已修改
|
||||
const markModified = (key: string) => {
|
||||
modifiedFields[key] = true;
|
||||
};
|
||||
|
||||
// 判断是否有修改
|
||||
const hasChanges = computed(() => Object.keys(modifiedFields).length > 0);
|
||||
|
||||
// 提交修改
|
||||
const submitChanges = async () => {
|
||||
const keysToSubmit = Object.keys(modifiedFields);
|
||||
if (keysToSubmit.length === 0) return;
|
||||
|
||||
try {
|
||||
// 1. 处理IP黑名单
|
||||
if ("ip_black_list" in modifiedFields && ipBlacklistConfigs.value.ip_black_list?.id) {
|
||||
const ipBlacklistArray = ipBlacklistItems.value
|
||||
.map((item) => item.value.trim())
|
||||
.filter(Boolean);
|
||||
// 转换为JSON字符串格式保存
|
||||
const ipBlacklistJson = JSON.stringify(ipBlacklistArray);
|
||||
await ParamsAPI.updateParams(ipBlacklistConfigs.value.ip_black_list.id, {
|
||||
...ipBlacklistConfigs.value.ip_black_list,
|
||||
config_value: ipBlacklistJson,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 处理演示环境IP白名单
|
||||
if ("ip_white_list" in modifiedFields && demoConfigs.value.ip_white_list?.id) {
|
||||
const demoIpWhitelistArray = demoIpWhitelistItems.value
|
||||
.map((item) => item.value.trim())
|
||||
.filter(Boolean);
|
||||
// 转换为JSON字符串格式保存
|
||||
const demoIpWhitelistJson = JSON.stringify(demoIpWhitelistArray);
|
||||
await ParamsAPI.updateParams(demoConfigs.value.ip_white_list.id, {
|
||||
...demoConfigs.value.ip_white_list,
|
||||
config_value: demoIpWhitelistJson,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 处理其他配置项(已迁移到租户管理的配置不再处理)
|
||||
const otherKeys = keysToSubmit.filter(
|
||||
(key) => !["ip_black_list", "ip_white_list"].includes(key)
|
||||
);
|
||||
const otherUpdatePromises = otherKeys.map((key) => {
|
||||
const item = demoConfigs.value[key as keyof typeof demoConfigs.value];
|
||||
return item && item.id ? ParamsAPI.updateParams(item.id, { ...item }) : Promise.resolve();
|
||||
});
|
||||
await Promise.all(otherUpdatePromises);
|
||||
|
||||
// 清除已提交的修改标记
|
||||
keysToSubmit.forEach((key) => {
|
||||
delete modifiedFields[key];
|
||||
});
|
||||
|
||||
// 重新加载配置数据(强制重新加载以同步到浏览器内存)
|
||||
configStore.isConfigLoaded = false;
|
||||
await configStore.getConfig();
|
||||
initializeLists();
|
||||
} catch (error) {
|
||||
console.error("保存失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消修改:重置所有修改字段的状态并恢复初始值
|
||||
const resetForm = async () => {
|
||||
// 强制重新加载配置数据(从服务器获取最新数据)
|
||||
await configStore.getConfig(true);
|
||||
|
||||
// 重置动态列表
|
||||
initializeLists();
|
||||
|
||||
// 重置其他配置项
|
||||
const keysToReset = Object.keys(modifiedFields);
|
||||
for (const key of keysToReset) {
|
||||
const config = configStore.configData[key as keyof typeof configStore.configData];
|
||||
if (key !== "ip_white_list" && config) {
|
||||
(configStore.configData as Record<string, ConfigTable>)[key as string]!.config_value =
|
||||
config.config_value || "";
|
||||
}
|
||||
delete modifiedFields[key];
|
||||
}
|
||||
ElMessageBox.close();
|
||||
};
|
||||
|
||||
async function handleCloseDialog() {
|
||||
// 仅关闭抽屉,等待关闭动画结束后再重置
|
||||
drawerVisible.value = false;
|
||||
}
|
||||
|
||||
async function onDrawerClosed() {
|
||||
// 抽屉关闭动画结束后再执行重置,避免打断动画
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
// IP黑名单配置 - 动态管理
|
||||
const ipBlacklistItems = ref<ListItem[]>([]);
|
||||
// IP白名单配置 - 动态管理
|
||||
const demoIpWhitelistItems = ref<ListItem[]>([]);
|
||||
|
||||
// 从配置数据初始化列表
|
||||
const initializeLists = () => {
|
||||
// 初始化IP黑名单
|
||||
const ipBlacklistStr = configStore.configData.ip_black_list?.config_value || "";
|
||||
try {
|
||||
// 尝试解析为JSON数组
|
||||
const ipBlacklistArray = JSON.parse(ipBlacklistStr);
|
||||
if (Array.isArray(ipBlacklistArray)) {
|
||||
ipBlacklistItems.value = ipBlacklistArray
|
||||
.filter((item) => typeof item === "string" && item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }));
|
||||
} else {
|
||||
// 如果不是数组,回退到按换行符分割
|
||||
ipBlacklistItems.value = ipBlacklistStr
|
||||
? ipBlacklistStr
|
||||
.split("\n")
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: "" }];
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,回退到按换行符分割
|
||||
ipBlacklistItems.value = ipBlacklistStr
|
||||
? ipBlacklistStr
|
||||
.split("\n")
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: "" }];
|
||||
}
|
||||
|
||||
// 初始化演示环境IP白名单
|
||||
const demoIpWhitelistStr = configStore.configData.ip_white_list?.config_value || "";
|
||||
try {
|
||||
// 尝试解析为JSON数组
|
||||
const demoIpWhitelistArray = JSON.parse(demoIpWhitelistStr);
|
||||
if (Array.isArray(demoIpWhitelistArray)) {
|
||||
demoIpWhitelistItems.value = demoIpWhitelistArray
|
||||
.filter((item) => typeof item === "string" && item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }));
|
||||
} else {
|
||||
// 如果不是数组,回退到按换行符分割
|
||||
demoIpWhitelistItems.value = demoIpWhitelistStr
|
||||
? demoIpWhitelistStr
|
||||
.split("\n")
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: "" }];
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,回退到按换行符分割
|
||||
demoIpWhitelistItems.value = demoIpWhitelistStr
|
||||
? demoIpWhitelistStr
|
||||
.split("\n")
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: "" }];
|
||||
}
|
||||
};
|
||||
|
||||
// 添加IP黑名单项
|
||||
const addIpBlacklistItem = () => {
|
||||
ipBlacklistItems.value.push({ id: generateId(), value: "" });
|
||||
markModified("ip_black_list");
|
||||
};
|
||||
|
||||
// 移除IP黑名单项
|
||||
const removeIpBlacklistItem = (id: string) => {
|
||||
if (ipBlacklistItems.value.length <= 1) {
|
||||
ElMessage.warning("至少需要保留一个IP黑名单配置");
|
||||
return;
|
||||
}
|
||||
ipBlacklistItems.value = ipBlacklistItems.value.filter((item) => item.id !== id);
|
||||
markModified("ip_black_list");
|
||||
};
|
||||
|
||||
// 添加演示环境IP白名单项
|
||||
const addDemoIpWhitelistItem = () => {
|
||||
demoIpWhitelistItems.value.push({ id: generateId(), value: "" });
|
||||
markModified("ip_white_list");
|
||||
};
|
||||
|
||||
// 移除演示环境IP白名单项
|
||||
const removeDemoIpWhitelistItem = (id: string) => {
|
||||
if (demoIpWhitelistItems.value.length <= 1) {
|
||||
ElMessage.warning("至少需要保留一个IP白名单配置");
|
||||
return;
|
||||
}
|
||||
demoIpWhitelistItems.value = demoIpWhitelistItems.value.filter((item) => item.id !== id);
|
||||
markModified("ip_white_list");
|
||||
};
|
||||
|
||||
// IP黑名单配置项
|
||||
const ipBlacklistConfigs = computed(() => ({
|
||||
ip_black_list: configStore.configData.ip_black_list as ConfigTable | undefined,
|
||||
}));
|
||||
|
||||
// 演示环境配置项
|
||||
const demoConfigs = computed(() => ({
|
||||
demo_enable: configStore.configData.demo_enable as ConfigTable | undefined,
|
||||
ip_white_list: configStore.configData.ip_white_list as ConfigTable | undefined,
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
initializeLists();
|
||||
configStore.getConfig(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -63,13 +63,6 @@
|
||||
<FaSvgIcon icon="ri:user-3-line" class="mr-2 text-base" />
|
||||
<span class="text-sm">{{ $t("topBar.user.userCenter") }}</span>
|
||||
</li>
|
||||
<li
|
||||
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
|
||||
@click="openParamConfig"
|
||||
>
|
||||
<FaSvgIcon icon="ri:settings-3-line" class="mr-2 text-base" />
|
||||
<span class="text-sm">{{ $t("topBar.user.paramConfig") }}</span>
|
||||
</li>
|
||||
<li
|
||||
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
|
||||
@click="toGithub()"
|
||||
@@ -102,15 +95,13 @@
|
||||
</div>
|
||||
</template>
|
||||
</ElPopover>
|
||||
|
||||
<FaConfigInfoDrawer v-model="paramDrawerVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import { ElMessageBox } from "@/utils/message";
|
||||
import { useUserStore } from "@stores";
|
||||
import { WEB_LINKS, mittBus } from "@utils";
|
||||
|
||||
@@ -122,7 +113,6 @@ const userStore = useUserStore();
|
||||
|
||||
const { info: userInfo } = storeToRefs(userStore);
|
||||
const userMenuPopover = ref();
|
||||
const paramDrawerVisible = ref(false);
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
const a = (userInfo.value as { avatar?: string })?.avatar?.trim();
|
||||
@@ -138,11 +128,6 @@ const displayName = computed(
|
||||
|
||||
const displayEmail = computed(() => (userInfo.value as { email?: string })?.email || "");
|
||||
|
||||
function openParamConfig(): void {
|
||||
closeUserMenu();
|
||||
paramDrawerVisible.value = true;
|
||||
}
|
||||
|
||||
function goPage(path: string): void {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import { useSettingsStore } from "@stores";
|
||||
|
||||
defineOptions({ name: "FaHorizontalMenu" });
|
||||
|
||||
-1
@@ -45,7 +45,6 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { AppRouteRecord } from "@/types/router";
|
||||
import { handleMenuJump, formatMenuTitle } from "@utils";
|
||||
|
||||
defineOptions({ name: "FaHorizontalSubmenu" });
|
||||
|
||||
@@ -57,8 +57,6 @@ import { ref, computed, onMounted, nextTick } from "vue";
|
||||
import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue";
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
import { formatMenuTitle, handleMenuJump } from "@utils";
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
|
||||
defineOptions({ name: "FaMixedMenu" });
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppConfig from "@/config";
|
||||
import { useConfigStore, useSettingsStore, useMenuStore } from "@stores";
|
||||
import { MenuTypeEnum, MenuWidth } from "@/enums/appEnum";
|
||||
import { MenuWidth } from "@/enums/appEnum";
|
||||
import { isIframe, handleMenuJump } from "@utils";
|
||||
import SidebarSubmenu from "./widgets/FaSidebarSubmenu.vue";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
@@ -150,7 +150,7 @@ const sidebarLogoSrc = computed(() => {
|
||||
});
|
||||
|
||||
const sidebarTitle = computed(() => {
|
||||
const raw = configStore.configData.name?.config_value;
|
||||
const raw = configStore.configData.sys_name?.config_value;
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
return AppConfig.systemInfo.name;
|
||||
});
|
||||
@@ -253,9 +253,9 @@ const { start: delayHideMobileModal } = useTimeoutFn(
|
||||
/**
|
||||
* 查找 iframe 对应的二级菜单列表
|
||||
*/
|
||||
const findIframeMenuList = (currentPath: string, menuList: any[]) => {
|
||||
const findIframeMenuList = (currentPath: string, menuList: AppRouteRecord[]) => {
|
||||
// 递归查找包含当前路径的菜单项
|
||||
const hasPath = (items: any[]): boolean => {
|
||||
const hasPath = (items: AppRouteRecord[]): boolean => {
|
||||
for (const item of items) {
|
||||
if (item.path === currentPath) {
|
||||
return true;
|
||||
|
||||
-1
@@ -57,7 +57,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import { formatMenuTitle, handleMenuJump } from "@utils";
|
||||
import { useSettingsStore } from "@stores";
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<ul>
|
||||
<li
|
||||
v-for="(item, index) in noticeList"
|
||||
:key="index"
|
||||
:key="item.title + item.time"
|
||||
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
||||
@click="handleMarkAsRead(index)"
|
||||
>
|
||||
@@ -54,7 +54,7 @@
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import NoticeAPI, { type NoticeTable } from "@/api/module_system/notice";
|
||||
import NoticeAPI from "@/api/module_system/notice";
|
||||
|
||||
defineOptions({ name: "FaNotification" });
|
||||
|
||||
@@ -85,11 +85,10 @@ const fetchNotices = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await NoticeAPI.listNoticeAvailable();
|
||||
const data = (res.data as any)?.data ?? res.data ?? {};
|
||||
const items: any[] = data.items || data.list || [];
|
||||
noticeList.value = items.map((n: NoticeTable) => ({
|
||||
title: n.notice_title || "",
|
||||
time: n.created_time || "",
|
||||
const items = res.data?.data ?? [];
|
||||
noticeList.value = items.map((n) => ({
|
||||
title: n.notice_title ?? "",
|
||||
time: n.created_time ?? "",
|
||||
read: false,
|
||||
}));
|
||||
} catch {
|
||||
@@ -154,7 +153,7 @@ watch(
|
||||
duration-300
|
||||
origin-top
|
||||
will-change-[top,left]
|
||||
max-[640px]:top-[65px]
|
||||
max-[640px]:top-16.25
|
||||
max-[640px]:right-0
|
||||
max-[640px]:w-full
|
||||
max-[640px]:h-[80vh];
|
||||
|
||||
@@ -150,7 +150,7 @@ import { Lock } from "@element-plus/icons-vue";
|
||||
|
||||
defineOptions({ name: "FaScreenLock" });
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElInput } from "element-plus";
|
||||
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import CryptoJS from "crypto-js";
|
||||
@@ -158,7 +158,7 @@ import { useUserStore, useSettingsStore } from "@stores";
|
||||
import { mittBus, useNow } from "@utils";
|
||||
import bgDark from "@imgs/lock/bg_dark.webp";
|
||||
import bgLight from "@imgs/lock/bg_light.webp";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -184,15 +184,10 @@ const lockPageBgStyle = computed(() => {
|
||||
|
||||
const { hour, month, minute, meridiem, year, day, week } = useNow(true);
|
||||
|
||||
const displayName = computed(
|
||||
() =>
|
||||
(userInfo.value as { name?: string; username?: string })?.name ||
|
||||
(userInfo.value as { username?: string })?.username ||
|
||||
"—"
|
||||
);
|
||||
const displayName = computed(() => userInfo.value?.name || userInfo.value?.username || "—");
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
const a = (userInfo.value as { avatar?: string })?.avatar?.trim();
|
||||
const a = userInfo.value?.avatar?.trim();
|
||||
return a || "";
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ContainerWidthEnum } from "@/enums/appEnum";
|
||||
import AppConfig from "@/config";
|
||||
import { headerBarConfig } from "@/config/modules/headerBar";
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { ContainerWidthEnum } from "@/enums/appEnum";
|
||||
import { useSettingsStore } from "@stores";
|
||||
import { storeToRefs } from "pinia";
|
||||
import type { ContainerWidthEnum } from "@/enums/appEnum";
|
||||
|
||||
/**
|
||||
* 设置项通用处理逻辑
|
||||
|
||||
+2
-1
@@ -1,10 +1,11 @@
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useSettingsStore } from "@stores";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { MenuTypeEnum } from "@/enums/appEnum";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { MOBILE_BREAKPOINT } from "@utils/constants";
|
||||
import AppConfig from "@/config";
|
||||
import { SystemThemeEnum, MenuTypeEnum } from "@/enums/appEnum";
|
||||
|
||||
import { mittBus, StorageConfig } from "@utils";
|
||||
import { useTheme } from "@/hooks/core/useTheme";
|
||||
import { useCeremony } from "@/hooks/core/useCeremony";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { MenuTypeEnum } from "@/enums/appEnum";
|
||||
import { useSettingsStore } from "@stores";
|
||||
import { MenuThemeEnum, MenuTypeEnum } from "@/enums/appEnum";
|
||||
|
||||
/**
|
||||
* 设置状态管理
|
||||
|
||||
+2
-1
@@ -21,10 +21,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MenuThemeEnum } from "@/enums/appEnum";
|
||||
import AppConfig from "@/config";
|
||||
|
||||
defineOptions({ name: "FaMenuStyleSettings" });
|
||||
import { MenuTypeEnum, type MenuThemeEnum } from "@/enums/appEnum";
|
||||
|
||||
import { useSettingsStore } from "@stores";
|
||||
|
||||
const menuThemeList = AppConfig.themeList;
|
||||
|
||||
@@ -16,9 +16,9 @@ import { useSettingsStore } from "@stores";
|
||||
import { SETTING_DEFAULT_CONFIG } from "@/config/setting";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { MenuThemeEnum } from "@/enums/appEnum";
|
||||
|
||||
import { useTheme } from "@/hooks/core/useTheme";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
|
||||
defineOptions({ name: "SettingActions" });
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}"
|
||||
>
|
||||
<li
|
||||
class="worktab-tab fa-card-xs inline-flex flex items-center justify-center h-8 mr-1.5 text-xs cursor-pointer hover:text-theme group"
|
||||
class="worktab-tab fa-card-xs inline-flex items-center justify-center h-8 mr-1.5 text-xs cursor-pointer hover:text-theme group"
|
||||
:class="[
|
||||
item.path === activeTab
|
||||
? chromeTabStrip
|
||||
@@ -99,7 +99,7 @@
|
||||
/>
|
||||
<span
|
||||
v-if="list.length > 1 && !item.fixedTab"
|
||||
class="worktab-close inline-flex flex items-center justify-center relative ml-0.5 rounded-full p-1 transition duration-200"
|
||||
class="worktab-close inline-flex items-center justify-center relative ml-0.5 rounded-full p-1 transition duration-200"
|
||||
@click.stop="closeWorktab('current', item.path)"
|
||||
>
|
||||
<FaSvgIcon icon="ri:close-large-fill" class="text-[10px]" />
|
||||
@@ -168,15 +168,15 @@
|
||||
* 关闭/切换/Pin 操作全部通过 worktabStore 管理。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch, nextTick, onUnmounted } from "vue";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { LocationQueryRaw, useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
import { refreshAppCaches, useWorktabStore, useUserStore, useSettingsStore } from "@stores";
|
||||
import { MenuItemType } from "@/components/others/fa-menu-right/index.vue";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { formatMenuTitle, quickStartManager } from "@utils";
|
||||
import { WorkTab } from "@/types";
|
||||
|
||||
defineOptions({ name: "FaWorkTab" });
|
||||
|
||||
@@ -667,6 +667,9 @@ async function handleRefreshCache(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖的 tab 溢出测量
|
||||
const debouncedMeasureTabOverflow = useDebounceFn(measureTabOverflow, 150);
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
setupEventListeners();
|
||||
@@ -676,14 +679,14 @@ onMounted(() => {
|
||||
measureTabOverflow();
|
||||
setupTabOverflowObserver();
|
||||
});
|
||||
window.addEventListener("resize", measureTabOverflow);
|
||||
window.addEventListener("resize", debouncedMeasureTabOverflow);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupEventListeners();
|
||||
quickStartManager.removeListener(onQuickLinksChanged);
|
||||
teardownTabOverflowObserver();
|
||||
window.removeEventListener("resize", measureTabOverflow);
|
||||
window.removeEventListener("resize", debouncedMeasureTabOverflow);
|
||||
});
|
||||
|
||||
// 监听器
|
||||
@@ -693,7 +696,7 @@ watch(tabOverflow, (overflow) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(list, () => nextTick(measureTabOverflow), { deep: true });
|
||||
watch(list, () => nextTick(measureTabOverflow));
|
||||
|
||||
watch(
|
||||
() => currentRoute.value,
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<!-- 左侧菜单导航 -->
|
||||
<aside id="app-sidebar">
|
||||
<aside id="app-sidebar" aria-label="主菜单导航">
|
||||
<FaSidebarMenu />
|
||||
</aside>
|
||||
|
||||
<!-- 右侧主区域 -->
|
||||
<main id="app-main">
|
||||
<main id="app-main" aria-label="主要内容区域">
|
||||
<div id="app-header">
|
||||
<FaHeaderBar />
|
||||
</div>
|
||||
|
||||
@@ -387,7 +387,7 @@ function triggerCrop() {
|
||||
<style lang="scss" scoped>
|
||||
.cutter-container {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
flex-flow: row nowrap;
|
||||
|
||||
.title {
|
||||
padding-bottom: 10px;
|
||||
@@ -400,6 +400,8 @@ function triggerCrop() {
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
flex-shrink: 0;
|
||||
|
||||
.preview-box {
|
||||
background-color: var(--art-active-color) !important;
|
||||
|
||||
|
||||
@@ -37,12 +37,27 @@
|
||||
</template>
|
||||
<template v-else-if="formMode" #footer>
|
||||
<div class="fa-dialog-footer" :style="'padding-right: var(--el-dialog-padding-primary)'">
|
||||
<ElButton v-if="formMode !== 'detail'" type="primary" plain @click="emit('cancel')">
|
||||
{{ cancelText }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="confirmLoading" @click="emit('confirm')">
|
||||
{{ confirmText }}
|
||||
<!-- detail 模式仅显示关闭按钮 -->
|
||||
<ElButton v-if="formMode === 'detail'" type="primary" @click="emit('confirm')">
|
||||
{{ confirmText || "关闭" }}
|
||||
</ElButton>
|
||||
<template v-else>
|
||||
<ElButton type="primary" plain @click="emit('cancel')">
|
||||
{{ cancelText }}
|
||||
</ElButton>
|
||||
<!-- 创建模式支持"提交并继续添加" -->
|
||||
<ElButton
|
||||
v-if="showSubmitAndContinue && formMode === 'create'"
|
||||
type="primary"
|
||||
:loading="confirmLoading"
|
||||
@click="emit('submitAndContinue')"
|
||||
>
|
||||
提交并继续添加
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="confirmLoading" @click="emit('confirm')">
|
||||
{{ confirmText }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
@@ -50,7 +65,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DialogProps } from "element-plus";
|
||||
import { computed, ref, useAttrs, watch } from "vue";
|
||||
import { computed, ref, useAttrs, watch, onMounted, onUnmounted } from "vue";
|
||||
import FaIconButton from "@/components/widget/fa-icon-button/index.vue";
|
||||
|
||||
defineOptions({ name: "FaDialog", inheritAttrs: false });
|
||||
@@ -65,7 +80,7 @@ interface Props {
|
||||
dialogClass?: string;
|
||||
/** 遮罩层自定义 class */
|
||||
modalClass?: string;
|
||||
/** 表单模式:detail 仅显示确定;create/update 显示取消+确定 */
|
||||
/** 表单模式:detail 仅显示关闭;create/update 显示取消+确定 */
|
||||
formMode?: "detail" | "create" | "update";
|
||||
/** 确定按钮 loading 状态 */
|
||||
confirmLoading?: boolean;
|
||||
@@ -73,12 +88,15 @@ interface Props {
|
||||
confirmText?: string;
|
||||
/** 取消按钮文本 */
|
||||
cancelText?: string;
|
||||
/** 是否显示"提交并继续添加"按钮(仅 create 模式有效) */
|
||||
showSubmitAndContinue?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
draggable: true,
|
||||
confirmText: "确定",
|
||||
cancelText: "取消",
|
||||
showSubmitAndContinue: false,
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
@@ -91,6 +109,8 @@ interface Emits {
|
||||
cancel: [];
|
||||
/** 点击确定按钮 */
|
||||
confirm: [];
|
||||
/** 点击提交并继续添加按钮 */
|
||||
submitAndContinue: [];
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
@@ -102,6 +122,19 @@ watch(fullscreen, (newVal) => {
|
||||
emit("fullscreen-change", newVal);
|
||||
});
|
||||
|
||||
// Ctrl+Enter / Cmd+Enter 快捷键触发确认提交(非 detail 模式)
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
if (props.modelValue && props.formMode && props.formMode !== "detail") {
|
||||
e.preventDefault();
|
||||
emit("confirm");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
const dialogClass = computed(() => {
|
||||
const a = attrs.class;
|
||||
return [props.dialogClass, a].filter(Boolean);
|
||||
|
||||
@@ -30,12 +30,27 @@
|
||||
</template>
|
||||
<template v-else-if="formMode" #footer>
|
||||
<div class="fa-drawer-footer" :style="'padding-right: var(--el-drawer-padding-primary)'">
|
||||
<ElButton v-if="formMode !== 'detail'" @click="emit('cancel')">
|
||||
{{ cancelText }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="confirmLoading" @click="emit('confirm')">
|
||||
{{ confirmText }}
|
||||
<!-- detail 模式仅显示关闭按钮 -->
|
||||
<ElButton v-if="formMode === 'detail'" type="primary" @click="emit('confirm')">
|
||||
{{ confirmText || "关闭" }}
|
||||
</ElButton>
|
||||
<template v-else>
|
||||
<ElButton @click="emit('cancel')">
|
||||
{{ cancelText }}
|
||||
</ElButton>
|
||||
<!-- 创建模式支持"提交并继续添加" -->
|
||||
<ElButton
|
||||
v-if="showSubmitAndContinue && formMode === 'create'"
|
||||
type="primary"
|
||||
:loading="confirmLoading"
|
||||
@click="emit('submitAndContinue')"
|
||||
>
|
||||
提交并继续添加
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="confirmLoading" @click="emit('confirm')">
|
||||
{{ confirmText }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
@@ -43,7 +58,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DrawerProps } from "element-plus";
|
||||
import { computed, useAttrs } from "vue";
|
||||
import { computed, useAttrs, onMounted, onUnmounted } from "vue";
|
||||
import FaIconButton from "@/components/widget/fa-icon-button/index.vue";
|
||||
|
||||
defineOptions({ name: "FaDrawer", inheritAttrs: false });
|
||||
@@ -55,7 +70,7 @@ interface Props {
|
||||
direction?: "rtl" | "ltr" | "ttb" | "btt";
|
||||
/** 透传到 el-drawer 的 class */
|
||||
drawerClass?: string;
|
||||
/** 表单模式:detail 仅显示确定;create/update 显示取消+确定 */
|
||||
/** 表单模式:detail 仅显示关闭;create/update 显示取消+确定 */
|
||||
formMode?: "detail" | "create" | "update";
|
||||
/** 确定按钮 loading 状态 */
|
||||
confirmLoading?: boolean;
|
||||
@@ -63,12 +78,15 @@ interface Props {
|
||||
confirmText?: string;
|
||||
/** 取消按钮文本 */
|
||||
cancelText?: string;
|
||||
/** 是否显示"提交并继续添加"按钮(仅 create 模式有效) */
|
||||
showSubmitAndContinue?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
direction: "rtl",
|
||||
confirmText: "确定",
|
||||
cancelText: "取消",
|
||||
showSubmitAndContinue: false,
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
@@ -79,6 +97,8 @@ interface Emits {
|
||||
cancel: [];
|
||||
/** 点击确定按钮 */
|
||||
confirm: [];
|
||||
/** 点击提交并继续添加按钮 */
|
||||
submitAndContinue: [];
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
@@ -90,6 +110,19 @@ const visible = computed({
|
||||
set: (v: boolean) => emit("update:modelValue", v),
|
||||
});
|
||||
|
||||
// Ctrl+Enter / Cmd+Enter 快捷键触发确认提交(非 detail 模式)
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
if (props.modelValue && props.formMode && props.formMode !== "detail") {
|
||||
e.preventDefault();
|
||||
emit("confirm");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
const drawerClassMerged = computed(() => {
|
||||
const a = attrs.class;
|
||||
return [props.drawerClass, a].filter(Boolean);
|
||||
|
||||
@@ -43,6 +43,12 @@
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="导出格式" prop="format">
|
||||
<ElRadioGroup v-model="exportsFormData.format">
|
||||
<ElRadio value="xlsx">Excel (.xlsx)</ElRadio>
|
||||
<ElRadio value="csv">CSV (.csv)</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="字段" prop="fields">
|
||||
<ElCheckboxGroup v-model="exportsFormData.fields">
|
||||
<template v-for="col in cols" :key="col.prop">
|
||||
@@ -65,17 +71,21 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import ExcelJS from "exceljs";
|
||||
import type { IContentConfig, IObject } from "@/components/modal/types";
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
import { type FormInstance, type FormRules, ElMessage } from "element-plus";
|
||||
import { nextTick, ref, reactive, computed } from "vue";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { nextTick, reactive, computed } from "vue";
|
||||
|
||||
defineOptions({ name: "FaExportDialog", inheritAttrs: false });
|
||||
|
||||
function saveBlobDownload(blob: Blob, rawName: string) {
|
||||
const name = /\.xlsx?$/i.test(rawName) ? rawName : `${rawName}.xlsx`;
|
||||
function saveBlobDownload(blob: Blob, rawName: string, format: string = "xlsx") {
|
||||
const ext = format === "csv" ? ".csv" : ".xlsx";
|
||||
const name = new RegExp(`\\.${ext.replace(".", "")}$`, "i").test(rawName)
|
||||
? rawName
|
||||
: `${rawName}${ext}`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -132,33 +142,39 @@ const exportsFormData = reactive({
|
||||
sheetname: "",
|
||||
fields: [] as string[],
|
||||
origin: ExportsOriginEnum.CURRENT,
|
||||
format: "xlsx" as "xlsx" | "csv",
|
||||
});
|
||||
const exportsFormRules: FormRules = {
|
||||
fields: [{ required: true, message: "请选择字段" }],
|
||||
origin: [{ required: true, message: "请选择数据源" }],
|
||||
};
|
||||
|
||||
// 表格列
|
||||
// 表格列(浅克隆避免在 computed 中修改原始 props 对象)
|
||||
const cols = computed(() =>
|
||||
props.contentConfig.cols.map((col) => {
|
||||
if (col.initFn) {
|
||||
col.initFn(col);
|
||||
const cloned = { ...col };
|
||||
if (cloned.initFn) {
|
||||
cloned.initFn(cloned);
|
||||
}
|
||||
if (col.show === undefined) {
|
||||
col.show = true;
|
||||
}
|
||||
if (col.prop !== undefined && col.columnKey === undefined && col["column-key"] === undefined) {
|
||||
col.columnKey = col.prop;
|
||||
if (cloned.show === undefined) {
|
||||
cloned.show = true;
|
||||
}
|
||||
if (
|
||||
col.type === "selection" &&
|
||||
col.reserveSelection === undefined &&
|
||||
col["reserve-selection"] === undefined
|
||||
cloned.prop !== undefined &&
|
||||
cloned.columnKey === undefined &&
|
||||
cloned["column-key"] === undefined
|
||||
) {
|
||||
cloned.columnKey = cloned.prop;
|
||||
}
|
||||
if (
|
||||
cloned.type === "selection" &&
|
||||
cloned.reserveSelection === undefined &&
|
||||
cloned["reserve-selection"] === undefined
|
||||
) {
|
||||
// 配合表格row-key实现跨页多选
|
||||
col.reserveSelection = true;
|
||||
cloned.reserveSelection = true;
|
||||
}
|
||||
return col;
|
||||
return cloned;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -185,54 +201,101 @@ function handleCloseExportsModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// 导出
|
||||
async function handleExports() {
|
||||
try {
|
||||
const filename = exportsFormData.filename
|
||||
? exportsFormData.filename
|
||||
: props.contentConfig.permPrefix || "export";
|
||||
const sheetname = exportsFormData.sheetname ? exportsFormData.sheetname : "sheet";
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet(sheetname);
|
||||
const columns: Partial<ExcelJS.Column>[] = [];
|
||||
cols.value.forEach((col) => {
|
||||
if (col.label && col.prop && exportsFormData.fields.includes(col.prop)) {
|
||||
columns.push({ header: col.label, key: col.prop });
|
||||
}
|
||||
});
|
||||
worksheet.columns = columns;
|
||||
/**
|
||||
* 将工作表数据导出为 CSV 格式
|
||||
*/
|
||||
async function workbookToCsv(
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
columns: Partial<ExcelJS.Column>[]
|
||||
): Promise<Blob> {
|
||||
const headerRow = columns.map((col) => col.header ?? "").join(",");
|
||||
const rows: string[] = [headerRow];
|
||||
|
||||
if (exportsFormData.origin === ExportsOriginEnum.REMOTE) {
|
||||
const lastFormData = props.queryParams ?? {};
|
||||
if (props.contentConfig.exportsBlobAction) {
|
||||
const blob = await props.contentConfig.exportsBlobAction(lastFormData);
|
||||
saveBlobDownload(blob, filename as string);
|
||||
ElMessage.success("导出成功");
|
||||
return;
|
||||
}
|
||||
if (props.contentConfig.exportsAction) {
|
||||
const res = await props.contentConfig.exportsAction(lastFormData);
|
||||
worksheet.addRows(res);
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
const values = columns.map((col) => {
|
||||
const cellValue = row.getCell(col.key as string).value;
|
||||
if (cellValue === null || cellValue === undefined) return "";
|
||||
const str = String(cellValue);
|
||||
return /[,"\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
|
||||
});
|
||||
rows.push(values.join(","));
|
||||
});
|
||||
|
||||
const bom = "\uFEFF";
|
||||
const content = bom + rows.join("\n");
|
||||
return new Blob([content], { type: "text/csv;charset=utf-8" });
|
||||
}
|
||||
|
||||
// 导出
|
||||
async function handleExports(): Promise<{ count: number }> {
|
||||
ElMessage.info("正在导出数据,请稍候...");
|
||||
const filename = exportsFormData.filename
|
||||
? exportsFormData.filename
|
||||
: props.contentConfig.permPrefix || "export";
|
||||
const sheetname = exportsFormData.sheetname ? exportsFormData.sheetname : "sheet";
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet(sheetname);
|
||||
const columns: Partial<ExcelJS.Column>[] = [];
|
||||
cols.value.forEach((col) => {
|
||||
if (col.label && col.prop && exportsFormData.fields.includes(col.prop)) {
|
||||
columns.push({ header: col.label, key: col.prop });
|
||||
}
|
||||
});
|
||||
worksheet.columns = columns;
|
||||
|
||||
const isCsv = exportsFormData.format === "csv";
|
||||
let exportCount: number;
|
||||
|
||||
if (exportsFormData.origin === ExportsOriginEnum.REMOTE) {
|
||||
const lastFormData = props.queryParams ?? {};
|
||||
if (props.contentConfig.exportsBlobAction) {
|
||||
const blob = await props.contentConfig.exportsBlobAction(lastFormData);
|
||||
saveBlobDownload(blob, filename as string, exportsFormData.format);
|
||||
ElMessage.success("导出成功!");
|
||||
return { count: 0 };
|
||||
}
|
||||
if (props.contentConfig.exportsAction) {
|
||||
const res = await props.contentConfig.exportsAction(lastFormData);
|
||||
const rows = Array.isArray(res) ? res : [];
|
||||
exportCount = rows.length;
|
||||
worksheet.addRows(rows);
|
||||
if (isCsv) {
|
||||
const blob = await workbookToCsv(worksheet, columns);
|
||||
saveBlobDownload(blob, filename as string, "csv");
|
||||
} else {
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
saveXlsx(buffer, filename as string);
|
||||
} else {
|
||||
ElMessage.error("未配置 exportsAction 或 exportsBlobAction");
|
||||
}
|
||||
} else if (exportsFormData.origin === ExportsOriginEnum.SELECTED) {
|
||||
const rows = props.selectionData ?? [];
|
||||
worksheet.addRows(rows);
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
saveXlsx(buffer, filename as string);
|
||||
} else {
|
||||
const rows = props.pageData ?? [];
|
||||
worksheet.addRows(rows);
|
||||
throw new Error("未配置导出接口操作");
|
||||
}
|
||||
} else if (exportsFormData.origin === ExportsOriginEnum.SELECTED) {
|
||||
const rows = props.selectionData ?? [];
|
||||
exportCount = rows.length;
|
||||
worksheet.addRows(rows);
|
||||
if (isCsv) {
|
||||
const blob = await workbookToCsv(worksheet, columns);
|
||||
saveBlobDownload(blob, filename as string, "csv");
|
||||
} else {
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
saveXlsx(buffer, filename as string);
|
||||
}
|
||||
} else {
|
||||
const rows = props.pageData ?? [];
|
||||
exportCount = rows.length;
|
||||
worksheet.addRows(rows);
|
||||
if (isCsv) {
|
||||
const blob = await workbookToCsv(worksheet, columns);
|
||||
saveBlobDownload(blob, filename as string, "csv");
|
||||
} else {
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
saveXlsx(buffer, filename as string);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("导出失败:", error);
|
||||
ElMessage.error("导出失败");
|
||||
}
|
||||
|
||||
ElMessage.success(`导出成功!共导出 ${exportCount} 条数据`);
|
||||
return { count: exportCount };
|
||||
}
|
||||
|
||||
// 导出确认
|
||||
@@ -243,15 +306,18 @@ const handleExportsSubmit = useThrottleFn(async () => {
|
||||
loadingRef.value = true;
|
||||
await handleExports();
|
||||
handleCloseExportsModal();
|
||||
} catch {
|
||||
// 校验失败
|
||||
} catch (error: unknown) {
|
||||
// 校验失败或导出过程异常
|
||||
if (error instanceof Error) {
|
||||
ElMessage.error(error.message || "导出失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
loadingRef.value = false;
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// 浏览器保存文件
|
||||
function saveXlsx(fileData: any, fileName: string) {
|
||||
function saveXlsx(fileData: ArrayBuffer, fileName: string) {
|
||||
try {
|
||||
const fileType =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
|
||||
@@ -270,12 +336,10 @@ function saveXlsx(fileData: any, fileName: string) {
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
} catch (error) {
|
||||
console.error("保存文件失败:", error);
|
||||
ElMessage.error("保存文件失败");
|
||||
ElMessage.error("导出文件保存失败,请重试");
|
||||
}
|
||||
}
|
||||
|
||||
// 提供给父组件的方法
|
||||
defineExpose({
|
||||
handleCloseExportsModal,
|
||||
});
|
||||
// 经审查 handleCloseExportsModal 仅在组件内部使用,defineExpose 已清理
|
||||
</script>
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Download, UploadFilled } from "@element-plus/icons-vue";
|
||||
import { ElMessage, type UploadUserFile } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
import type { UploadUserFile } from "element-plus";
|
||||
import { ref, reactive } from "vue";
|
||||
import type { IContentConfig, IObject } from "@/components/modal/types";
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ defineOptions({ name: "FaAiAssistant" });
|
||||
import { resolveIconForFaSvgIcon } from "@utils";
|
||||
import { nextTick, onBeforeUnmount, onMounted, watch, ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
import { useSettingsStore } from "@stores";
|
||||
import { AiChatAPI, ChatSession, ChatSessionDetail } from "@/api/module_ai/chat";
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { dayjs } from "element-plus";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
defineOptions({ name: "FaCalendar" });
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
defineOptions({ name: "FaDescriptions" });
|
||||
|
||||
import { computed, useAttrs } from "vue";
|
||||
import { useNamespace } from "element-plus";
|
||||
import { useNamespace } from "element-plus/es/hooks/use-namespace/index";
|
||||
|
||||
export type TagType = "primary" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: "FaGuide" });
|
||||
import { MenuTypeEnum } from "@/enums/appEnum";
|
||||
import { computed } from "vue";
|
||||
import { useSettingsStore } from "@stores";
|
||||
import { MenuTypeEnum } from "@/enums/appEnum";
|
||||
|
||||
const settingStore = useSettingsStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<ElIcon>
|
||||
<component :is="icon" />
|
||||
<component :is="elementPlusIconsVue[icon]" />
|
||||
</ElIcon>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -81,7 +81,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: "FaIconSelect" });
|
||||
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
|
||||
import {
|
||||
listLocalIconBasenames,
|
||||
isIconifyStoredIcon,
|
||||
@@ -111,7 +110,16 @@ const popoverVisible = ref(false);
|
||||
const activeTab = ref("svg");
|
||||
|
||||
const svgIcons = ref<string[]>([]);
|
||||
const elementIcons = ref<string[]>(Object.keys(ElementPlusIconsVue));
|
||||
const elementIcons = ref<string[]>([]);
|
||||
const elementPlusIconsVue = ref<Record<string, any>>({});
|
||||
|
||||
// 异步加载 Element Plus 图标,避免影响首屏
|
||||
async function loadElementIcons() {
|
||||
if (elementIcons.value.length > 0) return;
|
||||
const icons = await import("@element-plus/icons-vue");
|
||||
elementPlusIconsVue.value = icons;
|
||||
elementIcons.value = Object.keys(icons);
|
||||
}
|
||||
const selectedIcon = defineModel<string | undefined>("modelValue", {
|
||||
default: "",
|
||||
});
|
||||
@@ -127,8 +135,11 @@ function loadIcons() {
|
||||
filteredSvgIcons.value = svgIcons.value;
|
||||
}
|
||||
|
||||
function handleTabClick(tabPane: any) {
|
||||
async function handleTabClick(tabPane: any) {
|
||||
activeTab.value = tabPane.props.name;
|
||||
if (tabPane.props.name === "element") {
|
||||
await loadElementIcons();
|
||||
}
|
||||
filterIcons();
|
||||
}
|
||||
|
||||
@@ -167,13 +178,18 @@ function clearSelectedIcon() {
|
||||
selectedIcon.value = "";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadIcons();
|
||||
if (selectedIcon.value) {
|
||||
const raw = selectedIcon.value.trim();
|
||||
const epKey = raw.replace(/^el-icon-/i, "");
|
||||
if (elementIcons.value.includes(epKey)) {
|
||||
activeTab.value = "element";
|
||||
if (raw.startsWith("el-icon-")) {
|
||||
await loadElementIcons();
|
||||
if (elementIcons.value.includes(epKey)) {
|
||||
activeTab.value = "element";
|
||||
} else {
|
||||
activeTab.value = "svg";
|
||||
}
|
||||
} else if (isIconifyStoredIcon(raw)) {
|
||||
activeTab.value = "svg";
|
||||
} else {
|
||||
|
||||
@@ -5,11 +5,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import markdownItHighlightjs from "markdown-it-highlightjs";
|
||||
import hljs from "highlight.js";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import html from "highlight.js/lib/languages/xml";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import scss from "highlight.js/lib/languages/scss";
|
||||
import sql from "highlight.js/lib/languages/sql";
|
||||
import bash from "highlight.js/lib/languages/bash";
|
||||
import yaml from "highlight.js/lib/languages/yaml";
|
||||
import markdown from "highlight.js/lib/languages/markdown";
|
||||
import DOMPurify from "dompurify";
|
||||
import "highlight.js/styles/atom-one-light.css";
|
||||
|
||||
// 注册语言(按需导入,减少打包体积)
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("html", html);
|
||||
hljs.registerLanguage("xml", html);
|
||||
hljs.registerLanguage("vue", html);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("scss", scss);
|
||||
hljs.registerLanguage("sql", sql);
|
||||
hljs.registerLanguage("bash", bash);
|
||||
hljs.registerLanguage("yaml", yaml);
|
||||
hljs.registerLanguage("markdown", markdown);
|
||||
|
||||
defineOptions({ name: "FaMarkdownRenderer" });
|
||||
|
||||
interface Props {
|
||||
@@ -41,7 +66,7 @@ const md: MarkdownIt = new MarkdownIt({
|
||||
}
|
||||
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
|
||||
},
|
||||
}).use(markdownItHighlightjs);
|
||||
});
|
||||
|
||||
const defaultRender =
|
||||
md.renderer.rules.link_open ||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 搜索栏 + 操作按钮 -->
|
||||
<div class="flex flex-col h-full" v-loading="loading">
|
||||
<div class="mb-3 flex items-center gap-3 shrink-0">
|
||||
<ElInput
|
||||
v-model="filterText"
|
||||
@@ -17,8 +16,7 @@
|
||||
<ElCheckbox v-model="parentChildLinked">父子联动</ElCheckbox>
|
||||
</div>
|
||||
|
||||
<!-- 菜单树 -->
|
||||
<div class="flex-1 overflow-auto" v-loading="loading">
|
||||
<ElScrollbar class="flex-1" :native="false">
|
||||
<ElTree
|
||||
ref="treeRef"
|
||||
node-key="id"
|
||||
@@ -28,6 +26,7 @@
|
||||
:default-expand-all="isExpanded"
|
||||
:filter-node-method="filterNode"
|
||||
:props="{ children: 'children', label: 'name' }"
|
||||
@expand-change="handleExpandChange"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="menu-node flex items-center gap-2">
|
||||
@@ -41,18 +40,17 @@
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
import { ref, watch, nextTick, shallowRef } from "vue";
|
||||
import { Search, Switch as SwitchIcon } from "@element-plus/icons-vue";
|
||||
import FaMenuRouteIcon from "@/components/others/fa-menu-route-icon/index.vue";
|
||||
|
||||
defineOptions({ name: "FaMenuTreeTable" });
|
||||
|
||||
// ==================== 类型 ====================
|
||||
interface MenuNode {
|
||||
id?: number;
|
||||
type?: number; // 1=目录 2=菜单 3=按钮 4=链接
|
||||
@@ -62,7 +60,6 @@ interface MenuNode {
|
||||
children?: MenuNode[];
|
||||
}
|
||||
|
||||
// el-tree 内部节点结构(仅用到部分字段)
|
||||
interface TreeNode {
|
||||
data: MenuNode;
|
||||
checked: boolean;
|
||||
@@ -83,10 +80,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// 节点类型常量(1=目录 2=菜单 3=按钮 4=链接)
|
||||
const isLeaf = (t?: number) => t === 3 || t === 4; // 按钮 / 链接
|
||||
const isLeaf = (t?: number) => t === 3 || t === 4;
|
||||
|
||||
// 节点类型 → 标签样式 / 文案
|
||||
type TagType = "primary" | "success" | "warning" | "danger" | "info";
|
||||
const NODE_META: Record<number, { type: TagType; label: string }> = {
|
||||
1: { type: "warning", label: "目录" },
|
||||
@@ -96,16 +91,14 @@ const NODE_META: Record<number, { type: TagType; label: string }> = {
|
||||
};
|
||||
const nodeMeta = (n: MenuNode) => NODE_META[n.type ?? 2] ?? NODE_META[2];
|
||||
|
||||
// ==================== 状态 ====================
|
||||
const treeRef = ref<any>(null);
|
||||
const filterText = ref("");
|
||||
const isExpanded = ref(true);
|
||||
const isExpanded = ref(false);
|
||||
const parentChildLinked = ref(true);
|
||||
const expandedKeys = shallowRef<Set<number>>(new Set());
|
||||
|
||||
// 工具:安全获取 el-tree 内部 nodesMap
|
||||
const getNodesMap = () => treeRef.value?.store?.nodesMap as Record<number, TreeNode> | undefined;
|
||||
|
||||
// ==================== 搜索 / 展开 ====================
|
||||
function filterNode(value: string, data: any) {
|
||||
if (!value) return true;
|
||||
return (data.name ?? "").toLowerCase().includes(value.toLowerCase());
|
||||
@@ -126,8 +119,35 @@ function toggleExpandAll() {
|
||||
setAllExpanded(isExpanded.value);
|
||||
}
|
||||
|
||||
// ==================== 父级状态计算 ====================
|
||||
// 单次遍历子节点:统计 fully checked + 是否存在 indeterminate
|
||||
function handleExpandChange(data: MenuNode, expanded: boolean) {
|
||||
if (data.id != null) {
|
||||
if (expanded) {
|
||||
expandedKeys.value.add(data.id);
|
||||
} else {
|
||||
expandedKeys.value.delete(data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function expandMatchingNodes(value: string) {
|
||||
nextTick(() => {
|
||||
const tree = treeRef.value;
|
||||
if (!tree) return;
|
||||
const nodesMap = getNodesMap();
|
||||
if (!nodesMap) return;
|
||||
|
||||
for (const node of Object.values(nodesMap)) {
|
||||
if ((node.data.name ?? "").toLowerCase().includes(value.toLowerCase())) {
|
||||
let p: TreeNode | null = node;
|
||||
while (p) {
|
||||
p.expanded = true;
|
||||
p = p.parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function recomputeNode(p: TreeNode) {
|
||||
let fully = 0;
|
||||
let hasIndeterminate = false;
|
||||
@@ -140,21 +160,18 @@ function recomputeNode(p: TreeNode) {
|
||||
p.indeterminate = (fully > 0 || hasIndeterminate) && !p.checked;
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
// 回显策略:只勾叶子(按钮/链接),父级状态自动向上传播半选
|
||||
function initFromProps() {
|
||||
nextTick(() => {
|
||||
const tree = treeRef.value;
|
||||
const nodesMap = getNodesMap();
|
||||
if (!tree || !nodesMap) return;
|
||||
|
||||
// 1. 清空
|
||||
for (const node of Object.values(nodesMap)) {
|
||||
node.checked = false;
|
||||
node.indeterminate = false;
|
||||
node.expanded = expandedKeys.value.has(node.data.id ?? -1);
|
||||
}
|
||||
|
||||
// 2. 勾叶子 + 收集受影响父级(去重,每个父级只重算一次)
|
||||
const affected = new Set<TreeNode>();
|
||||
for (const id of props.checkedIds ?? []) {
|
||||
const node = tree.getNode(id) as TreeNode | null;
|
||||
@@ -163,21 +180,17 @@ function initFromProps() {
|
||||
for (let p = node.parent; p; p = p.parent) affected.add(p);
|
||||
}
|
||||
|
||||
// 3. 统一重算
|
||||
for (const p of affected) recomputeNode(p);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 对外 API ====================
|
||||
function getCheckedIds(): number[] {
|
||||
const tree = treeRef.value;
|
||||
if (!tree) return [];
|
||||
const ids = new Set<number>();
|
||||
// 完全选中的菜单 / 按钮 / 链接
|
||||
for (const n of (tree.getCheckedNodes() ?? []) as MenuNode[]) {
|
||||
if (n.id != null && n.type !== 1) ids.add(n.id);
|
||||
}
|
||||
// 半选父级(菜单 + 目录)—— 作为父级路径传后端
|
||||
for (const n of (tree.getHalfCheckedNodes() ?? []) as MenuNode[]) {
|
||||
if (n.id != null) ids.add(n.id);
|
||||
}
|
||||
@@ -186,19 +199,20 @@ function getCheckedIds(): number[] {
|
||||
|
||||
defineExpose({ getCheckedIds, refresh: initFromProps });
|
||||
|
||||
// ==================== 监听 ====================
|
||||
watch(
|
||||
() => [props.menuTree, props.checkedIds] as const,
|
||||
() => props.menuTree,
|
||||
() => initFromProps(),
|
||||
{ immediate: true, deep: true }
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
() => props.checkedIds,
|
||||
() => initFromProps()
|
||||
);
|
||||
// 切换父子联动时重新初始化:check-strictly 改变需要重置半选状态
|
||||
watch(parentChildLinked, () => initFromProps());
|
||||
watch(filterText, (val) => {
|
||||
treeRef.value?.filter(val);
|
||||
if (val) {
|
||||
isExpanded.value = true;
|
||||
setAllExpanded(true);
|
||||
expandMatchingNodes(val);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,7 @@ import { ComponentSize } from "@/enums/settings/layout.enum";
|
||||
import { useAppStore } from "@stores";
|
||||
import { resolveIconForFaSvgIcon } from "@utils";
|
||||
import { computed } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
|
||||
const { t } = useI18n();
|
||||
const sizeOptions = computed(() => {
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElTag } from "element-plus";
|
||||
|
||||
defineOptions({ name: "FaStatusTag" });
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -154,7 +154,7 @@ defineSlots<{
|
||||
import { ref, reactive, computed } from "vue";
|
||||
import { useResizeObserver } from "@vueuse/core";
|
||||
import type { FormInstance, PopoverProps, TableInstance } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
|
||||
// 对象类型
|
||||
export type IObject = Record<string, any>;
|
||||
@@ -302,10 +302,14 @@ function handleSelect(selection: any[]) {
|
||||
selectedItems.value = selection;
|
||||
} else {
|
||||
// 单选
|
||||
selectedItems.value = [selection[selection.length - 1]];
|
||||
const lastItem = selection[selection.length - 1];
|
||||
selectedItems.value = [lastItem];
|
||||
tableRef.value?.clearSelection();
|
||||
tableRef.value?.toggleRowSelection(selectedItems.value[0], true);
|
||||
tableRef.value?.setCurrentRow(selectedItems.value[0]);
|
||||
tableRef.value?.toggleRowSelection(
|
||||
lastItem as Parameters<TableInstance["toggleRowSelection"]>[0],
|
||||
true
|
||||
);
|
||||
tableRef.value?.setCurrentRow(lastItem as Parameters<TableInstance["setCurrentRow"]>[0]);
|
||||
}
|
||||
}
|
||||
function handleSelectAll(selection: any[]) {
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
defineOptions({ name: "FaUpload" });
|
||||
|
||||
import { ref, watch } from "vue";
|
||||
import { UploadRawFile, UploadRequestOptions, ElMessage, type UploadUserFile } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
import type { UploadRawFile, UploadRequestOptions, UploadUserFile } from "element-plus";
|
||||
import { CircleCloseFilled } from "@element-plus/icons-vue";
|
||||
import ParamsAPI from "@/api/module_system/params";
|
||||
import { dataURLToFile } from "@utils";
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<template>
|
||||
<div class="data-table__toolbar--left inline-flex flex-wrap items-center gap-2">
|
||||
<template v-if="configButtons && configButtons.length">
|
||||
<template v-for="(btn, index) in configButtons" :key="index">
|
||||
<template v-for="btn in configButtons" :key="btn.name">
|
||||
<ElButton
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
:disabled="btn.name === 'delete' && removeIds.length === 0"
|
||||
@click="$emit('toolbar', btn.name)"
|
||||
>
|
||||
{{ btn.text }}
|
||||
{{ btn.name === "delete" ? batchDeleteText(btn.text ?? "") : btn.text }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</template>
|
||||
@@ -61,7 +61,7 @@
|
||||
@click="$emit('delete')"
|
||||
plain
|
||||
>
|
||||
批量删除
|
||||
{{ batchDeleteText("批量删除") }}
|
||||
</ElButton>
|
||||
<ElDropdown
|
||||
v-if="permPatch"
|
||||
@@ -123,6 +123,8 @@ interface Props {
|
||||
createLoading?: boolean;
|
||||
/** 「更多」下拉项(启用/停用)loading */
|
||||
moreLoading?: boolean;
|
||||
/** 是否全选状态,用于显示 "已选择全部 X 项" */
|
||||
isAllSelected?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -132,6 +134,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
exportLoading: false,
|
||||
createLoading: false,
|
||||
moreLoading: false,
|
||||
isAllSelected: false,
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
@@ -149,4 +152,10 @@ defineEmits<Emits>();
|
||||
const moreDisabled = computed(
|
||||
() => props.removeIds.length === 0 || props.deleteLoading || props.moreLoading
|
||||
);
|
||||
|
||||
/** 生成带选中计数的批量删除按钮文本 */
|
||||
function batchDeleteText(baseText: string): string {
|
||||
const count = props.removeIds.length;
|
||||
return count > 0 ? `${baseText} (${count})` : baseText;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
>
|
||||
<div
|
||||
class="button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="search"
|
||||
@keydown.enter.prevent="search"
|
||||
@keydown.space.prevent="search"
|
||||
:class="!showSearchBar ? 'active bg-theme! hover:bg-theme/80!' : ''"
|
||||
>
|
||||
<FaSvgIcon icon="ri:search-line" :class="!showSearchBar ? 'text-white' : 'text-g-700'" />
|
||||
@@ -25,7 +29,11 @@
|
||||
<div
|
||||
v-if="shouldShow('refresh')"
|
||||
class="button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="refresh"
|
||||
@keydown.enter.prevent="refresh"
|
||||
@keydown.space.prevent="refresh"
|
||||
:class="{ loading: loading && isManualRefresh }"
|
||||
>
|
||||
<FaSvgIcon
|
||||
@@ -59,7 +67,15 @@
|
||||
</ElDropdown>
|
||||
|
||||
<!-- 全屏 -->
|
||||
<div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen">
|
||||
<div
|
||||
v-if="shouldShow('fullscreen')"
|
||||
class="button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="toggleFullScreen"
|
||||
@keydown.enter.prevent="toggleFullScreen"
|
||||
@keydown.space.prevent="toggleFullScreen"
|
||||
>
|
||||
<FaSvgIcon :icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'" />
|
||||
</div>
|
||||
|
||||
@@ -71,7 +87,11 @@
|
||||
>
|
||||
<div
|
||||
class="button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="toggleRowDrag"
|
||||
@keydown.enter.prevent="toggleRowDrag"
|
||||
@keydown.space.prevent="toggleRowDrag"
|
||||
:class="isRowDrag ? 'active bg-theme! hover:bg-theme/80!' : ''"
|
||||
>
|
||||
<FaSvgIcon icon="ri:drag-move-line" :class="isRowDrag ? 'text-white' : 'text-g-700'" />
|
||||
@@ -156,8 +176,6 @@ import { TableSizeEnum } from "@/enums/formEnum";
|
||||
import { useTableStore } from "@stores";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
|
||||
defineOptions({ name: "FaTableHeader" });
|
||||
|
||||
// 显式声明插槽类型
|
||||
|
||||
@@ -39,7 +39,18 @@
|
||||
:disabled="rowDragDisabled"
|
||||
@end="onRowDragEnd"
|
||||
>
|
||||
<ElTable ref="elTableRef" :key="tableKey" v-loading="!!loading" v-bind="mergedTableProps">
|
||||
<ElTable
|
||||
ref="elTableRef"
|
||||
:key="tableKey"
|
||||
v-loading="!!loading"
|
||||
:expand-row-keys="
|
||||
props.rowKey && !hasExplicitTableProp('treeProps')
|
||||
? expandRowKeys.map(String)
|
||||
: undefined
|
||||
"
|
||||
@expand-change="!hasExplicitTableProp('treeProps') ? onExpandChange : undefined"
|
||||
v-bind="mergedTableProps"
|
||||
>
|
||||
<template v-for="col in columns" :key="col.prop || col.type">
|
||||
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
|
||||
<template #default="{ $index }">
|
||||
@@ -110,7 +121,7 @@ import {
|
||||
ref,
|
||||
computed,
|
||||
nextTick,
|
||||
watchEffect,
|
||||
watch,
|
||||
getCurrentInstance,
|
||||
useAttrs,
|
||||
useSlots,
|
||||
@@ -119,9 +130,10 @@ import {
|
||||
defineComponent,
|
||||
type PropType,
|
||||
} from "vue";
|
||||
import type { ElTable, TableProps } from "element-plus";
|
||||
import type { ElTable, TableInstance, TableProps } from "element-plus";
|
||||
import { useRoute } from "vue-router";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { ColumnOption } from "@/types";
|
||||
|
||||
import { useTableStore } from "@stores";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { useTableHeight } from "@/hooks/core/useTableHeight";
|
||||
@@ -135,7 +147,7 @@ const { width } = useWindowSize();
|
||||
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT);
|
||||
// H5 ↔ 桌面切换时强制重建 ElTable,使列宽 / formatter 重新计算
|
||||
const tableKey = computed(() => (isMobile.value ? "mobile" : "desktop"));
|
||||
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null);
|
||||
const elTableRef = ref<TableInstance | null>(null);
|
||||
const paginationRef = ref<HTMLElement>();
|
||||
const tableHeaderRef = ref<HTMLElement>();
|
||||
const tableStore = useTableStore();
|
||||
@@ -212,6 +224,51 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
const instance = getCurrentInstance();
|
||||
const attrs = useAttrs();
|
||||
const route = useRoute();
|
||||
|
||||
// ── 树形表格展开状态记忆 ──
|
||||
/** localStorage 存储 key */
|
||||
const expandStorageKey = computed(() => `table-expand-${route.path}`);
|
||||
|
||||
/** 当前展开的行 key 集合 */
|
||||
const expandRowKeys = ref<(string | number)[]>([]);
|
||||
|
||||
/** 保存展开状态到 localStorage */
|
||||
function saveExpandState(keys: (string | number)[]) {
|
||||
try {
|
||||
localStorage.setItem(expandStorageKey.value, JSON.stringify(keys));
|
||||
} catch {
|
||||
// 静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 localStorage 恢复展开状态 */
|
||||
function restoreExpandState(): (string | number)[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(expandStorageKey.value);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 数据刷新后尝试恢复展开状态
|
||||
watch(
|
||||
() => props.data,
|
||||
(newData) => {
|
||||
if (!newData?.length) {
|
||||
expandRowKeys.value = [];
|
||||
return;
|
||||
}
|
||||
const savedKeys = restoreExpandState();
|
||||
if (savedKeys.length > 0) {
|
||||
expandRowKeys.value = savedKeys;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/** 仅当调用方显式传入对应 prop 时视为「固定」,否则交由表格 store */
|
||||
const hasExplicitTableProp = (propName: string): boolean => {
|
||||
@@ -329,20 +386,24 @@ const headerCellStyle = computed(() => ({
|
||||
...(props.headerCellStyle || {}), // 合并用户传入的样式
|
||||
}));
|
||||
|
||||
const mergedTableProps = computed(() => ({
|
||||
...attrs,
|
||||
...props,
|
||||
height: height.value,
|
||||
stripe: stripe.value,
|
||||
border: border.value,
|
||||
size: hasExplicitTableProp("size") ? size.value : undefined,
|
||||
headerCellStyle: headerCellStyle.value,
|
||||
highlightCurrentRow: highlightCurrentRow.value,
|
||||
// Element Plus 默认值为 true,未显式传入时不应被 FaTable 覆盖成 false。
|
||||
selectOnIndeterminate: hasExplicitTableProp("selectOnIndeterminate")
|
||||
? props.selectOnIndeterminate
|
||||
: undefined,
|
||||
}));
|
||||
const mergedTableProps = computed(() => {
|
||||
const { expandRowKeys: _ignored, ...restProps } = props;
|
||||
void _ignored;
|
||||
return {
|
||||
...attrs,
|
||||
...restProps,
|
||||
height: height.value,
|
||||
stripe: stripe.value,
|
||||
border: border.value,
|
||||
size: hasExplicitTableProp("size") ? size.value : undefined,
|
||||
headerCellStyle: headerCellStyle.value,
|
||||
highlightCurrentRow: highlightCurrentRow.value,
|
||||
// Element Plus 默认值为 true,未显式传入时不应被 FaTable 覆盖成 false。
|
||||
selectOnIndeterminate: hasExplicitTableProp("selectOnIndeterminate")
|
||||
? props.selectOnIndeterminate
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: "pagination:size-change", val: number): void;
|
||||
@@ -378,6 +439,31 @@ const onRowDragEnd = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 树形表格行展开/收起变化时记录状态 */
|
||||
const onExpandChange = (row: Record<string, unknown>, expandedRows: Record<string, unknown>[]) => {
|
||||
const rowKey = (row as Record<string, unknown>)[props.rowKey as string];
|
||||
if (rowKey === undefined || rowKey === null) return;
|
||||
|
||||
const currentKeys = [...expandRowKeys.value];
|
||||
const isExpanded = expandedRows.some(
|
||||
(r) => (r as Record<string, unknown>)[props.rowKey as string] === rowKey
|
||||
);
|
||||
|
||||
if (isExpanded) {
|
||||
if (!currentKeys.includes(rowKey as string | number)) {
|
||||
currentKeys.push(rowKey as string | number);
|
||||
}
|
||||
} else {
|
||||
const idx = currentKeys.indexOf(rowKey as string | number);
|
||||
if (idx > -1) {
|
||||
currentKeys.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
expandRowKeys.value = currentKeys;
|
||||
saveExpandState(currentKeys);
|
||||
};
|
||||
|
||||
// 是否显示分页器
|
||||
const showPagination = computed(() => !!props.pagination);
|
||||
|
||||
@@ -505,23 +591,18 @@ const findTableHeader = () => {
|
||||
}
|
||||
};
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
// 访问响应式数据以建立依赖追踪
|
||||
void props.data?.length; // 追踪数据变化
|
||||
const shouldShow = props.showTableHeader;
|
||||
|
||||
// 只有在需要显示表格头部时才查找
|
||||
watch(
|
||||
() => props.showTableHeader,
|
||||
(shouldShow) => {
|
||||
if (shouldShow) {
|
||||
nextTick(() => {
|
||||
findTableHeader();
|
||||
});
|
||||
} else {
|
||||
// 不显示时清空引用
|
||||
tableHeaderRef.value = undefined;
|
||||
}
|
||||
},
|
||||
{ flush: "post" }
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
@@ -618,13 +699,56 @@ defineExpose({
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 空状态垂直居中 */
|
||||
/* 空状态垂直居中 + 优化间距 */
|
||||
&.is-empty {
|
||||
:deep(.el-table__body-wrapper) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.el-table__empty-block) {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
:deep(.el-empty) {
|
||||
.el-empty__image {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.el-empty__description {
|
||||
margin-top: 8px;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: var(--fa-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格行悬停行高亮(强化) */
|
||||
:deep(.el-table__body tr.el-table__row) {
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover > td.el-table__cell {
|
||||
background-color: var(--fa-hover-color) !important;
|
||||
}
|
||||
|
||||
&.current-row > td.el-table__cell {
|
||||
background-color: color-mix(in srgb, var(--el-color-primary) 8%, transparent) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 斑马纹优化 */
|
||||
:deep(.el-table--striped .el-table__body tr.el-table__row--striped) {
|
||||
td.el-table__cell {
|
||||
background-color: var(--fa-gray-100);
|
||||
}
|
||||
|
||||
&:hover td.el-table__cell {
|
||||
background-color: var(--fa-hover-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 分页按钮样式已统一由 FaPagination 组件处理 */
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
<!-- 原始内容 -->
|
||||
<span ref="textRef" class="inline-block">
|
||||
<slot>
|
||||
<span v-html="text"></span>
|
||||
<span v-html="sanitizedText"></span>
|
||||
</slot>
|
||||
</span>
|
||||
<!-- 克隆内容用于无缝循环 -->
|
||||
<span v-if="shouldClone" class="inline-block" :style="cloneSpacing">
|
||||
<slot>
|
||||
<span v-html="text"></span>
|
||||
<span v-html="sanitizedText"></span>
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
@@ -48,6 +48,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import DOMPurify from "dompurify";
|
||||
import {
|
||||
useElementSize,
|
||||
useRafFn,
|
||||
@@ -117,7 +118,6 @@ const settingStore = useSettingsStore();
|
||||
const { isDark } = storeToRefs(settingStore);
|
||||
|
||||
const containerRef = ref<HTMLElement>();
|
||||
const contentRef = ref<HTMLElement>();
|
||||
const textRef = ref<HTMLElement>();
|
||||
const isReady = ref(false);
|
||||
|
||||
@@ -129,6 +129,8 @@ const shouldClone = ref(false);
|
||||
const isHorizontal = computed(() => props.direction === "left" || props.direction === "right");
|
||||
const isReverse = computed(() => props.direction === "right" || props.direction === "down");
|
||||
|
||||
const sanitizedText = computed(() => DOMPurify.sanitize(props.text));
|
||||
|
||||
// 使用 VueUse 的 useElementSize 监听容器尺寸变化
|
||||
const { width: containerWidth, height: containerHeight } = useElementSize(containerRef);
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
</div>
|
||||
|
||||
<div class="text-wrap">
|
||||
<h1>{{ $t("login.leftView.title") }}</h1>
|
||||
<p>{{ $t("login.leftView.subTitle") }}</p>
|
||||
<h1>{{ configStore.configData?.login_title?.config_value || $t("login.leftView.title") }}</h1>
|
||||
<p>
|
||||
{{ configStore.configData?.login_subtitle?.config_value || $t("login.leftView.subTitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 几何装饰元素 -->
|
||||
@@ -114,7 +116,7 @@ const webLogoSrc = computed(
|
||||
);
|
||||
|
||||
const siteTitle = computed(
|
||||
() => configStore.configData.name?.config_value?.trim() || AppConfig.systemInfo.name
|
||||
() => configStore.configData.sys_name?.config_value?.trim() || AppConfig.systemInfo.name
|
||||
);
|
||||
|
||||
const DEFAULT_APP_VERSION = "3.0.0";
|
||||
|
||||
@@ -19,26 +19,19 @@
|
||||
</div>
|
||||
|
||||
<div class="login-mobile-code-row mb-[1.1rem] flex items-stretch gap-2 sm:gap-3">
|
||||
<div
|
||||
ref="otpWrapRef"
|
||||
class="flex min-w-0 flex-1 gap-1.5 sm:gap-2"
|
||||
@paste.prevent="onOtpPaste"
|
||||
>
|
||||
<input
|
||||
v-for="idx in otpIndices"
|
||||
:key="idx"
|
||||
:value="otpDigits[idx]"
|
||||
type="text"
|
||||
<div class="flex min-w-0 flex-1">
|
||||
<ElInputOtp
|
||||
v-model="otpCode"
|
||||
class="w-full"
|
||||
:length="6"
|
||||
size="large"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="1"
|
||||
class="login-mobile-otp-cell"
|
||||
@input="onOtpCellInput(idx, $event)"
|
||||
@keydown="onOtpCellKeydown(idx, $event)"
|
||||
autofocus
|
||||
@finish="onOtpFilled"
|
||||
/>
|
||||
</div>
|
||||
<ElButton
|
||||
class="login-mobile-sms-btn h-10 shrink-0 self-center px-3 sm:px-4"
|
||||
class="login-mobile-sms-btn h-10 shrink-0 px-3 sm:px-4"
|
||||
plain
|
||||
:disabled="smsCountdown > 0"
|
||||
@click="sendSmsCodeMock"
|
||||
@@ -76,7 +69,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Iphone } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage } from "@/utils/message";
|
||||
|
||||
defineOptions({ name: "FaLoginMobilePanel" });
|
||||
|
||||
@@ -93,9 +86,8 @@ const mobileForm = reactive({
|
||||
phone: "",
|
||||
});
|
||||
|
||||
const otpDigits = ref<string[]>(Array.from({ length: 6 }, () => ""));
|
||||
const otpIndices = [0, 1, 2, 3, 4, 5];
|
||||
const otpWrapRef = ref<HTMLElement | null>(null);
|
||||
const otpCode = ref("");
|
||||
|
||||
const smsCountdown = ref(0);
|
||||
let smsTimerId: number | null = null;
|
||||
|
||||
@@ -108,63 +100,13 @@ function clearSmsTimer() {
|
||||
|
||||
function resetMobileLoginUi() {
|
||||
mobileForm.phone = "";
|
||||
otpDigits.value = Array.from({ length: 6 }, () => "");
|
||||
otpCode.value = "";
|
||||
smsCountdown.value = 0;
|
||||
clearSmsTimer();
|
||||
}
|
||||
|
||||
defineExpose({ resetMobileLoginUi });
|
||||
|
||||
function focusOtpCell(index: number) {
|
||||
nextTick(() => {
|
||||
const root = otpWrapRef.value;
|
||||
if (!root) return;
|
||||
const inputs = root.querySelectorAll<HTMLInputElement>(".login-mobile-otp-cell");
|
||||
inputs[index]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function onOtpCellInput(index: number, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const digit = target.value.replace(/\D/g, "").slice(-1);
|
||||
otpDigits.value[index] = digit;
|
||||
target.value = digit;
|
||||
if (digit && index < 5) {
|
||||
focusOtpCell(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function onOtpCellKeydown(index: number, event: KeyboardEvent) {
|
||||
if (event.key === "Backspace" && !otpDigits.value[index] && index > 0) {
|
||||
event.preventDefault();
|
||||
otpDigits.value[index - 1] = "";
|
||||
focusOtpCell(index - 1);
|
||||
const root = otpWrapRef.value;
|
||||
const inputs = root?.querySelectorAll<HTMLInputElement>(".login-mobile-otp-cell");
|
||||
const prev = inputs?.[index - 1];
|
||||
if (prev) prev.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function onOtpPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData("text")?.replace(/\D/g, "").slice(0, 6) ?? "";
|
||||
if (!text) return;
|
||||
event.preventDefault();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
otpDigits.value[i] = text[i] ?? "";
|
||||
}
|
||||
nextTick(() => {
|
||||
const root = otpWrapRef.value;
|
||||
if (!root) return;
|
||||
const inputs = root.querySelectorAll<HTMLInputElement>(".login-mobile-otp-cell");
|
||||
inputs.forEach((el, i) => {
|
||||
el.value = otpDigits.value[i] ?? "";
|
||||
});
|
||||
const nextIdx = Math.min(text.length, 5);
|
||||
focusOtpCell(nextIdx);
|
||||
});
|
||||
}
|
||||
|
||||
function sendSmsCodeMock() {
|
||||
const phone = mobileForm.phone.trim();
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
@@ -183,14 +125,17 @@ function sendSmsCodeMock() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function onOtpFilled(value: string) {
|
||||
otpCode.value = value;
|
||||
}
|
||||
|
||||
function submitMobileLogin() {
|
||||
const phone = mobileForm.phone.trim();
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
ElMessage.warning(t("login.message.mobile.invalid"));
|
||||
return;
|
||||
}
|
||||
const code = otpDigits.value.join("");
|
||||
if (code.length !== 6) {
|
||||
if (otpCode.value.length !== 6) {
|
||||
ElMessage.warning(t("login.smsCodeRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LanguageEnum } from "@/enums/appEnum";
|
||||
import { computed } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -117,7 +118,7 @@ import { useSettingsStore, useUserStore, useConfigStore } from "@stores";
|
||||
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
|
||||
import { themeAnimation } from "@utils";
|
||||
import { languageOptions } from "@/locales";
|
||||
import { LanguageEnum } from "@/enums/appEnum";
|
||||
|
||||
import AppConfig from "@/config";
|
||||
import { LoginPanelAlign } from "@/components/views/fa-login/composables/useLoginPanelAlign";
|
||||
|
||||
@@ -176,7 +177,7 @@ const webLogoSrc = computed(
|
||||
);
|
||||
|
||||
const siteTitle = computed(
|
||||
() => configStore.configData.name?.config_value?.trim() || AppConfig.systemInfo.name
|
||||
() => configStore.configData.sys_name?.config_value?.trim() || AppConfig.systemInfo.name
|
||||
);
|
||||
|
||||
const displayVersion = computed(() => {
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
|
||||
import { MenuThemeEnum, MenuTypeEnum, SystemThemeEnum } from "@/enums/appEnum";
|
||||
import { SystemConfig } from "@/types/config";
|
||||
import { configImages } from "./assets/images";
|
||||
import fastEnterConfig from "./modules/fastEnter";
|
||||
import { headerBarConfig } from "./modules/headerBar";
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* 包含:应用列表、快速链接等配置
|
||||
*/
|
||||
import { WEB_LINKS } from "@utils";
|
||||
import type { FastEnterConfig } from "@/types/config";
|
||||
|
||||
const fastEnterConfig: FastEnterConfig = {
|
||||
// 显示条件(屏幕宽度)
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* @module config/modules/festival.builtin
|
||||
*/
|
||||
|
||||
import type { FestivalConfig } from "@/types/config";
|
||||
import hb from "@imgs/ceremony/hb.png";
|
||||
import sd from "@imgs/ceremony/sd.png";
|
||||
import yd from "@imgs/ceremony/yd.png";
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
|
||||
import { FestivalConfig } from "@/types/config";
|
||||
|
||||
export const festivalConfigList: FestivalConfig[] = [
|
||||
/**
|
||||
* 非节日常驻:全年顶栏公告(匹配优先级最低,见文件头「优先级」说明)
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
|
||||
import { HeaderBarFeatureConfig } from "@/types";
|
||||
|
||||
/**
|
||||
* 顶部栏功能配置对象
|
||||
*/
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
*/
|
||||
|
||||
import AppConfig from "@/config";
|
||||
import { SystemThemeEnum, MenuThemeEnum, MenuTypeEnum, ContainerWidthEnum } from "@/enums/appEnum";
|
||||
import { LayoutMode, ComponentSize, SidebarColor, ThemeMode, LanguageEnum } from "@/enums";
|
||||
import { LayoutMode, ComponentSize, SidebarColor, ThemeMode } from "@/enums";
|
||||
|
||||
const env = import.meta.env;
|
||||
const { pkg } = __APP_INFO__;
|
||||
@@ -63,7 +62,7 @@ export const SETTING_DEFAULT_CONFIG = {
|
||||
/** 组件大小 */
|
||||
size: ComponentSize.DEFAULT,
|
||||
/** 语言 */
|
||||
language: LanguageEnum.ZH_CN,
|
||||
language: LanguageEnum.ZH,
|
||||
/** 主题颜色 */
|
||||
themeColor: "#4080FF",
|
||||
/** 是否显示水印 */
|
||||
|
||||
@@ -42,7 +42,34 @@
|
||||
*/
|
||||
|
||||
import { App, Directive } from "vue";
|
||||
import hljs from "highlight.js";
|
||||
// highlight.js 按需导入:仅导入项目中实际使用的语言,减少打包体积
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import html from "highlight.js/lib/languages/xml";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import scss from "highlight.js/lib/languages/scss";
|
||||
import sql from "highlight.js/lib/languages/sql";
|
||||
import bash from "highlight.js/lib/languages/bash";
|
||||
import yaml from "highlight.js/lib/languages/yaml";
|
||||
import markdown from "highlight.js/lib/languages/markdown";
|
||||
|
||||
// 注册语言
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("html", html);
|
||||
hljs.registerLanguage("xml", html);
|
||||
hljs.registerLanguage("vue", html);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("scss", scss);
|
||||
hljs.registerLanguage("sql", sql);
|
||||
hljs.registerLanguage("bash", bash);
|
||||
hljs.registerLanguage("yaml", yaml);
|
||||
hljs.registerLanguage("markdown", markdown);
|
||||
|
||||
export type HighlightDirective = Directive<HTMLElement>;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ export * from "./codegen/query.enum";
|
||||
|
||||
export * from "./settings/layout.enum";
|
||||
export * from "./settings/theme.enum";
|
||||
export * from "./settings/locale.enum";
|
||||
export * from "./settings/device.enum";
|
||||
export * from "./settings/setting.enum";
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 语言枚举
|
||||
*/
|
||||
export const enum LanguageEnum {
|
||||
/**
|
||||
* 中文
|
||||
*/
|
||||
ZH_CN = "zh-cn",
|
||||
|
||||
/**
|
||||
* 英文
|
||||
*/
|
||||
EN = "en",
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "@/utils/message";
|
||||
import { onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import AiChatAPI from "@/api/module_ai/chat";
|
||||
import type { UseAiActionOptions } from "@/types/ai";
|
||||
|
||||
/**
|
||||
* AI 操作 Composable
|
||||
|
||||
@@ -34,7 +34,6 @@ import { useRoute } from "vue-router";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useUserStore } from "@stores";
|
||||
import { useAppMode } from "@/hooks/core/useAppMode";
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import { ROLE_ROOT } from "@/constants";
|
||||
|
||||
type AuthItem = NonNullable<AppRouteRecord["meta"]["authList"]>[number];
|
||||
|
||||
@@ -52,8 +52,6 @@ import { useSettingsStore } from "@stores";
|
||||
import { mittBus, formatToDate } from "@utils";
|
||||
import { festivalConfigList } from "@/config/modules/festival";
|
||||
import { buildBuiltinSolarFestivals } from "@/config/modules/festival.builtin";
|
||||
import type { FestivalConfig } from "@/types/config";
|
||||
|
||||
/** 手动配置 + 内置公历节日合并项(内部排序用) */
|
||||
type TaggedFestival = FestivalConfig & { _origin: "manual" | "builtin" };
|
||||
|
||||
|
||||
@@ -53,8 +53,6 @@ import { echarts, type EChartsOption } from "@/plugins/echarts";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useSettingsStore } from "@stores";
|
||||
import { getCssVar } from "@utils";
|
||||
import type { BaseChartProps, ChartThemeConfig, UseChartOptions } from "@/types/component/chart";
|
||||
|
||||
// 图表主题配置
|
||||
export const useChartOps = (): ChartThemeConfig => ({
|
||||
/** */
|
||||
@@ -94,6 +92,8 @@ export function useChart(options: UseChartOptions = {}) {
|
||||
let pendingOptions: EChartsOption | null = null;
|
||||
let resizeTimeoutId: number | null = null;
|
||||
let resizeFrameId: number | null = null;
|
||||
let initDelayTimerId: number | null = null;
|
||||
const multiDelayTimerIds: number[] = [];
|
||||
let isDestroyed = false;
|
||||
let emptyStateDiv: HTMLElement | null = null;
|
||||
|
||||
@@ -107,6 +107,12 @@ export function useChart(options: UseChartOptions = {}) {
|
||||
cancelAnimationFrame(resizeFrameId);
|
||||
resizeFrameId = null;
|
||||
}
|
||||
if (initDelayTimerId) {
|
||||
clearTimeout(initDelayTimerId);
|
||||
initDelayTimerId = null;
|
||||
}
|
||||
multiDelayTimerIds.forEach((id) => clearTimeout(id));
|
||||
multiDelayTimerIds.length = 0;
|
||||
};
|
||||
|
||||
// 使用 requestAnimationFrame 优化 resize 处理
|
||||
@@ -133,12 +139,17 @@ export function useChart(options: UseChartOptions = {}) {
|
||||
|
||||
// 多延迟resize处理 - 统一方法
|
||||
const multiDelayResize = (delays: readonly number[]) => {
|
||||
// 清理之前残留的定时器
|
||||
multiDelayTimerIds.forEach((id) => clearTimeout(id));
|
||||
multiDelayTimerIds.length = 0;
|
||||
|
||||
// 立即调用一次,快速响应
|
||||
nextTick(requestAnimationResize);
|
||||
|
||||
// 使用延迟时间,确保图表正确适应变化
|
||||
delays.forEach((delay) => {
|
||||
setTimeout(requestAnimationResize, delay);
|
||||
const id = window.setTimeout(requestAnimationResize, delay);
|
||||
multiDelayTimerIds.push(id);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -521,7 +532,10 @@ export function useChart(options: UseChartOptions = {}) {
|
||||
if (isContainerVisible(chartRef.value)) {
|
||||
// 容器可见,正常初始化
|
||||
if (initDelay > 0) {
|
||||
setTimeout(() => performChartInit(mergedOptions), initDelay);
|
||||
initDelayTimerId = window.setTimeout(() => {
|
||||
initDelayTimerId = null;
|
||||
performChartInit(mergedOptions);
|
||||
}, initDelay);
|
||||
} else {
|
||||
performChartInit(mergedOptions);
|
||||
}
|
||||
@@ -695,7 +709,8 @@ export function useChartComponent<T extends BaseChartProps>(options: UseChartCom
|
||||
const setupWatchers = () => {
|
||||
// 监听自定义数据源
|
||||
if (watchSources.length > 0) {
|
||||
const stopHandle = watch(watchSources, updateChart, { deep: true });
|
||||
// 无需 deep:watchSources 为 getter 数组,Vue 自动追踪 getter 内部的响应式依赖
|
||||
const stopHandle = watch(watchSources, updateChart);
|
||||
stopHandles.push(stopHandle);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 确认弹窗 —— 封装 ElMessageBox.confirm 常用配置
|
||||
*/
|
||||
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import { ElMessageBox } from "@/utils/message";
|
||||
|
||||
/** 删除确认 */
|
||||
export async function confirmDelete(message = "确认删除该项数据?"): Promise<void> {
|
||||
@@ -14,8 +14,11 @@ export async function confirmDelete(message = "确认删除该项数据?"): Prom
|
||||
}
|
||||
|
||||
/** 批量删除确认 */
|
||||
export async function confirmBatchDelete(count: number): Promise<void> {
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${count} 条数据吗?`, "批量删除", {
|
||||
export async function confirmBatchDelete(count: number, names?: string[]): Promise<void> {
|
||||
const detail = names?.length
|
||||
? `(${names.slice(0, 5).join("、")}${names.length > 5 ? `…等${count}条` : ""})`
|
||||
: "";
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${count} 条数据吗?${detail}`, "批量删除", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
|
||||
@@ -100,15 +100,24 @@ export function useCrudForm<T extends object>(options: {
|
||||
};
|
||||
|
||||
if (id && detailApi) {
|
||||
// 先打开弹窗再加载数据,避免点击后无响应
|
||||
if (type === "detail") {
|
||||
dialogVisible.title = titleMap.detail ?? defaultTitles.detail;
|
||||
} else if (type === "update") {
|
||||
dialogVisible.title = titleMap.update ?? defaultTitles.update;
|
||||
// update 时先重置表单,避免闪烁旧数据
|
||||
Object.assign(formData.value, initialFormData);
|
||||
}
|
||||
formRenderKey.value += 1;
|
||||
dialogVisible.visible = true;
|
||||
|
||||
const response = await detailApi(id);
|
||||
const data = response.data.data;
|
||||
if (type === "detail") {
|
||||
dialogVisible.title = titleMap.detail ?? defaultTitles.detail;
|
||||
if (detailFormData) {
|
||||
Object.assign(detailFormData.value, data ?? {});
|
||||
}
|
||||
} else if (type === "update") {
|
||||
dialogVisible.title = titleMap.update ?? defaultTitles.update;
|
||||
Object.assign(formData.value, data);
|
||||
}
|
||||
} else {
|
||||
@@ -118,12 +127,12 @@ export function useCrudForm<T extends object>(options: {
|
||||
if (extra) {
|
||||
Object.assign(formData.value, extra);
|
||||
}
|
||||
formRenderKey.value += 1;
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
formRenderKey.value += 1;
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
/** 提交表单 */
|
||||
/** 提交表单(提交后关闭弹窗) */
|
||||
async function handleSubmit() {
|
||||
const form = dataFormRef.value;
|
||||
if (!form) return;
|
||||
@@ -136,11 +145,35 @@ export function useCrudForm<T extends object>(options: {
|
||||
if (id && updateApi) {
|
||||
await updateApi(id, { id, ...formData.value });
|
||||
await onUpdateSuccess?.();
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
} else if (createApi) {
|
||||
await createApi(formData.value);
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
await onCreateSuccess?.();
|
||||
}
|
||||
await onSubmitSuccess?.(formData.value);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交表单并继续添加(提交后重置表单但不关闭弹窗,用于底部按钮方式) */
|
||||
async function handleSubmitAndContinue() {
|
||||
const form = dataFormRef.value;
|
||||
if (!form) return;
|
||||
const valid = await (form.validate as () => Promise<boolean>)().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
if (createApi) {
|
||||
await createApi(formData.value);
|
||||
await onCreateSuccess?.();
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
await onSubmitSuccess?.(formData.value);
|
||||
} catch (error: unknown) {
|
||||
@@ -156,6 +189,7 @@ export function useCrudForm<T extends object>(options: {
|
||||
handleCloseDialog,
|
||||
handleOpenDialog,
|
||||
handleSubmit,
|
||||
handleSubmitAndContinue,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* useEventBus — SSE 事件总线
|
||||
*
|
||||
* 通过 EventSource 连接服务端 SS E 端点,接收实时推送事件。
|
||||
* 连接建立后自动监听心跳,断线自动重连。
|
||||
*
|
||||
* ## 使用示例
|
||||
*
|
||||
* ```typescript
|
||||
* const { isConnected, subscribe } = useEventBus()
|
||||
*
|
||||
* // 监听支付成功事件
|
||||
* subscribe('payment_success', (data) => {
|
||||
* ElNotification({ title: '支付成功', message: `订单 ${data.order_no} 已支付`, type: 'success' })
|
||||
* })
|
||||
*
|
||||
* // 监听工单回复
|
||||
* subscribe('ticket_reply', (data) => {
|
||||
* ElNotification({ title: '工单回复', message: data.title, type: 'info' })
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @module useEventBus
|
||||
*/
|
||||
|
||||
import { ref, onUnmounted } from "vue";
|
||||
import { Auth } from "@utils";
|
||||
|
||||
/** SSE 事件回调 */
|
||||
type EventCallback = (data: Record<string, any>) => void;
|
||||
|
||||
/** 连接状态 */
|
||||
export type ConnectionState = "connecting" | "connected" | "disconnected";
|
||||
|
||||
export function useEventBus() {
|
||||
const isConnected = ref<ConnectionState>("disconnected");
|
||||
const eventSource = ref<EventSource | null>(null);
|
||||
const listeners = new Map<string, Set<EventCallback>>();
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_DELAY = 30000; // 最大重连间隔 30s
|
||||
|
||||
/** 获取 SSE 端点 URL */
|
||||
function getSSEUrl(): string {
|
||||
const token = Auth.getAccessToken();
|
||||
const baseURL = import.meta.env.VITE_APP_BASE_API || "";
|
||||
return `${baseURL}/common/sse/events?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/** 建立 SSE 连接 */
|
||||
function connect() {
|
||||
// 关闭旧连接
|
||||
disconnect();
|
||||
|
||||
const url = getSSEUrl();
|
||||
if (!url) return;
|
||||
|
||||
isConnected.value = "connecting";
|
||||
|
||||
try {
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
|
||||
es.onopen = () => {
|
||||
isConnected.value = "connected";
|
||||
reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
es.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// 心跳事件忽略
|
||||
if (data.type === "heartbeat") return;
|
||||
|
||||
// 分发到对应事件类型的回调
|
||||
const typeListeners = listeners.get(data.type);
|
||||
if (typeListeners) {
|
||||
typeListeners.forEach((cb) => cb(data));
|
||||
}
|
||||
|
||||
// 同时触发 '*' 通配监听(所有事件)
|
||||
const allListeners = listeners.get("*");
|
||||
if (allListeners) {
|
||||
allListeners.forEach((cb) => cb(data));
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,静默忽略
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
isConnected.value = "disconnected";
|
||||
es.close();
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
eventSource.value = es;
|
||||
} catch {
|
||||
isConnected.value = "disconnected";
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** 断开 SSE 连接 */
|
||||
function disconnect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close();
|
||||
eventSource.value = null;
|
||||
}
|
||||
isConnected.value = "disconnected";
|
||||
}
|
||||
|
||||
/** 指数退避重连 */
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
* @param type 事件类型,'*' 表示所有事件
|
||||
* @param callback 事件回调
|
||||
*/
|
||||
function subscribe(type: string, callback: EventCallback) {
|
||||
if (!listeners.has(type)) {
|
||||
listeners.set(type, new Set());
|
||||
}
|
||||
listeners.get(type)!.add(callback);
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => {
|
||||
listeners.get(type)?.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
// 组件卸载时自动断开
|
||||
onUnmounted(() => {
|
||||
disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
eventSource,
|
||||
};
|
||||
}
|
||||
@@ -17,8 +17,6 @@
|
||||
|
||||
import { computed } from "vue";
|
||||
import appConfig from "@/config";
|
||||
import type { FastEnterApplication, FastEnterQuickLink } from "@/types/config";
|
||||
|
||||
export function useFastEnter() {
|
||||
// 获取快速入口配置
|
||||
const fastEnterConfig = computed(() => appConfig.fastEnter);
|
||||
|
||||
@@ -19,7 +19,6 @@ import { computed } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useSettingsStore } from "@stores";
|
||||
import { headerBarConfig } from "@/config/modules/headerBar";
|
||||
import { HeaderBarFeatureConfig } from "@/types";
|
||||
|
||||
/**
|
||||
* 顶部栏功能管理
|
||||
|
||||
@@ -68,7 +68,7 @@ export function useLoading(defaultKey?: string): UseLoadingReturn {
|
||||
const loadingMap = ref<Record<string, boolean>>({});
|
||||
|
||||
function setLoading(key: string, val: boolean): void {
|
||||
loadingMap.value = { ...loadingMap.value, [key]: val };
|
||||
loadingMap.value[key] = val;
|
||||
}
|
||||
|
||||
function isLoading(key: string): boolean {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* useSiteConfig - 站点配置初始化(标题 + favicon)。
|
||||
*
|
||||
* 从 configStore 拉取系统配置,同步到浏览器标题和 favicon。
|
||||
* 通过 watch 响应配置变更(如管理员在后台修改后重新拉取时自动更新)。
|
||||
* 通过 watch 响应配置变更<|image|>(如管理员在后台修改后重新拉取时自动更新)。
|
||||
*
|
||||
* 应在 App.vue 的 onMounted 中调用。
|
||||
*/
|
||||
@@ -11,24 +11,34 @@ import { watch } from "vue";
|
||||
import { useConfigStore } from "@stores";
|
||||
|
||||
const updateFavicon = (url: string) => {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (link) link.href = url;
|
||||
};
|
||||
|
||||
const syncFromConfig = () => {
|
||||
const { name, favicon } = useConfigStore().configData;
|
||||
if (name?.config_value) document.title = name.config_value;
|
||||
if (favicon?.config_value) updateFavicon(favicon.config_value);
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"], link[rel="shortcut icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = url;
|
||||
};
|
||||
|
||||
export function useSiteConfig() {
|
||||
const configStore = useConfigStore();
|
||||
|
||||
/** 替换 document.title 中的站点名后缀 */
|
||||
const applyConfig = () => {
|
||||
const { sys_name, favicon } = configStore.configData;
|
||||
if (!sys_name?.config_value) return;
|
||||
const siteName = sys_name.config_value.trim();
|
||||
const existing = document.title;
|
||||
const dashIdx = existing.lastIndexOf(" - ");
|
||||
document.title = dashIdx > 0 ? `${existing.slice(0, dashIdx)} - ${siteName}` : siteName;
|
||||
if (favicon?.config_value) updateFavicon(favicon.config_value);
|
||||
};
|
||||
|
||||
/** 初始化:强制拉取配置并同步标题/favicon */
|
||||
const initSiteConfig = async () => {
|
||||
try {
|
||||
await configStore.getConfig(true);
|
||||
syncFromConfig();
|
||||
applyConfig();
|
||||
} catch (error) {
|
||||
console.error("[SiteConfig] 获取配置失败:", error);
|
||||
}
|
||||
@@ -37,7 +47,7 @@ export function useSiteConfig() {
|
||||
/** 配置更新后自动同步(管理员后台修改配置后重新拉取时) */
|
||||
watch(
|
||||
() => configStore.configData,
|
||||
() => syncFromConfig(),
|
||||
() => applyConfig(),
|
||||
{ deep: false }
|
||||
);
|
||||
|
||||
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
onDeactivated,
|
||||
nextTick,
|
||||
readonly,
|
||||
shallowRef,
|
||||
toRaw,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTableColumns } from "./useTableColumns";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import { MOBILE_BREAKPOINT } from "@utils/constants";
|
||||
import {
|
||||
TableCache,
|
||||
@@ -208,7 +210,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
const error = ref<TableError | null>(null);
|
||||
|
||||
// 表格数据
|
||||
const data = ref<TRecord[]>([]);
|
||||
const data = shallowRef<TRecord[]>([]);
|
||||
|
||||
// 请求取消控制器
|
||||
let abortController: AbortController | null = null;
|
||||
@@ -268,8 +270,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
|
||||
// 列配置
|
||||
const columnConfig = columnsFactory ? useTableColumns<TRecord>(columnsFactory) : null;
|
||||
const columns = columnConfig?.columns;
|
||||
const columnChecks = columnConfig?.columnChecks;
|
||||
const columns = (columnConfig?.columns ?? null) as ComputedRef<ColumnOption[]> | undefined;
|
||||
const columnChecks = (columnConfig?.columnChecks ?? null) as Ref<ColumnOption[]> | undefined;
|
||||
|
||||
// 是否有数据
|
||||
const hasData = computed(() => data.value.length > 0);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* 表格列配置:显隐、拖拽排序、增删改;与 `useTable` 的 `columnsFactory` 配合。
|
||||
* 导出 `getColumnVisibility` / `getColumnChecks` 供表头等处复用同一套 visible/checked 规则。
|
||||
*
|
||||
* 列设置(显示/隐藏/拖拽排序)自动持久化到 localStorage,以 `table-${route.path}` 为 key。
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { $t } from "@/locales";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
|
||||
/** selection / expand / index 等占位 prop,避免与业务列冲突 */
|
||||
const SPECIAL_COLUMNS: Record<string, { prop: string; label: string }> = {
|
||||
selection: { prop: "__selection__", label: $t("table.column.selection") },
|
||||
@@ -99,32 +100,103 @@ export interface DynamicColumnConfig<T = any> {
|
||||
export function useTableColumns<T = any>(
|
||||
columnsFactory: () => ColumnOption<T>[]
|
||||
): {
|
||||
columns: any;
|
||||
columnChecks: any;
|
||||
columns: ComputedRef<ColumnOption<T>[]>;
|
||||
columnChecks: Ref<ColumnOption<T>[]>;
|
||||
} & DynamicColumnConfig<T> {
|
||||
const route = useRoute();
|
||||
|
||||
/** localStorage 存储 key */
|
||||
const storageKey = computed(() => `table-${route.path}`);
|
||||
|
||||
/** 保存列设置到 localStorage */
|
||||
function saveColumnSettings() {
|
||||
try {
|
||||
const settings = {
|
||||
order: dynamicColumns.value.map((c) => getColumnKey(c)),
|
||||
visibility: Object.fromEntries(
|
||||
columnChecks.value.map((c) => [getColumnKey(c), getColumnVisibility(c)])
|
||||
),
|
||||
};
|
||||
localStorage.setItem(storageKey.value, JSON.stringify(settings));
|
||||
} catch {
|
||||
// localStorage 可能不可用,静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 localStorage 恢复列设置 */
|
||||
function restoreColumnSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey.value);
|
||||
if (!raw) return;
|
||||
const settings = JSON.parse(raw) as { order: string[]; visibility: Record<string, boolean> };
|
||||
if (!settings?.order?.length) return;
|
||||
|
||||
const defaultCols = columnsFactory();
|
||||
const defaultKeys = defaultCols.map((c) => getColumnKey(c));
|
||||
|
||||
// 按保存的顺序重排默认列,仅保留仍然存在的列
|
||||
const reordered: ColumnOption<T>[] = [];
|
||||
const added = new Set<string>();
|
||||
|
||||
// 按保存的顺序排列
|
||||
settings.order.forEach((key) => {
|
||||
const idx = defaultKeys.indexOf(key);
|
||||
if (idx >= 0) {
|
||||
reordered.push({ ...defaultCols[idx]! });
|
||||
added.add(key);
|
||||
}
|
||||
});
|
||||
// 追加新列(保存后新增的)
|
||||
defaultCols.forEach((col, idx) => {
|
||||
if (!added.has(defaultKeys[idx]!)) {
|
||||
reordered.push({ ...col });
|
||||
}
|
||||
});
|
||||
|
||||
dynamicColumns.value = reordered;
|
||||
|
||||
// 恢复可见性
|
||||
const visibilityMap = settings.visibility ?? {};
|
||||
const newChecks = getColumnChecks(reordered).map((c) => {
|
||||
const key = getColumnKey(c);
|
||||
const savedVis = visibilityMap[key];
|
||||
const finalVis = savedVis !== undefined ? savedVis : getColumnVisibility(c);
|
||||
return { ...c, checked: finalVis, visible: finalVis };
|
||||
});
|
||||
columnChecks.value = newChecks;
|
||||
} catch {
|
||||
// 解析失败时忽略,使用默认设置
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicColumns = ref<ColumnOption<T>[]>(columnsFactory());
|
||||
const columnChecks = ref<ColumnOption<T>[]>(getColumnChecks(dynamicColumns.value));
|
||||
|
||||
// 挂载时恢复列设置
|
||||
restoreColumnSettings();
|
||||
|
||||
// 当 dynamicColumns 变动时,重新生成 columnChecks 且保留已存在的显示状态
|
||||
watch(
|
||||
dynamicColumns,
|
||||
(newCols) => {
|
||||
const visibilityMap = new Map(
|
||||
columnChecks.value.map((c) => [getColumnKey(c), getColumnVisibility(c)])
|
||||
);
|
||||
const newChecks = getColumnChecks(newCols).map((c) => {
|
||||
const key = getColumnKey(c);
|
||||
const visibility = visibilityMap.has(key) ? visibilityMap.get(key) : getColumnVisibility(c);
|
||||
return {
|
||||
...c,
|
||||
checked: visibility,
|
||||
visible: visibility,
|
||||
};
|
||||
});
|
||||
columnChecks.value = newChecks;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
// 无需 deep: dynamicColumns 为 ref,所有变更均替换整个数组,Vue 自动检测引用变化
|
||||
watch(dynamicColumns, (newCols) => {
|
||||
const visibilityMap = new Map(
|
||||
columnChecks.value.map((c) => [getColumnKey(c), getColumnVisibility(c)])
|
||||
);
|
||||
const newChecks = getColumnChecks(newCols).map((c) => {
|
||||
const key = getColumnKey(c);
|
||||
const visibility = visibilityMap.has(key) ? visibilityMap.get(key) : getColumnVisibility(c);
|
||||
return {
|
||||
...c,
|
||||
checked: visibility,
|
||||
visible: visibility,
|
||||
};
|
||||
});
|
||||
columnChecks.value = newChecks;
|
||||
});
|
||||
|
||||
// 列设置变化时自动持久化(所有变更均替换整个数组引用,无需 deep)
|
||||
watch([dynamicColumns, columnChecks], () => {
|
||||
saveColumnSettings();
|
||||
});
|
||||
|
||||
// 当前显示列(基于 columnChecks 的 checked 或 visible)
|
||||
const columns = computed(() => {
|
||||
@@ -220,6 +292,11 @@ export function useTableColumns<T = any>(
|
||||
* 重置所有列
|
||||
*/
|
||||
resetColumns: () => {
|
||||
try {
|
||||
localStorage.removeItem(storageKey.value);
|
||||
} catch {
|
||||
// 静默忽略
|
||||
}
|
||||
dynamicColumns.value = columnsFactory();
|
||||
},
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user