This commit is contained in:
oneao committed 2026-03-25 17:31:22 +08:00
1 parent 11bcef03e1
commit a4e59dd5d1
39 files changed
+2333 -459

No files matched your search

@@ -2,6 +2,7 @@ package cn.g3soft.config;
import cn.g3soft.interceptor.AuthInterceptor;
import cn.g3soft.interceptor.DataSourceInterceptor;
import cn.g3soft.interceptor.RequestBodyCacheInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -10,6 +11,9 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new RequestBodyCacheInterceptor())
.addPathPatterns("/**");
registry.addInterceptor(new AuthInterceptor())
.addPathPatterns("/**")
.excludePathPatterns(
@@ -0,0 +1,45 @@
package cn.g3soft.exception;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
@Data
@Builder
public class ExceptionDetailInfo {
/** 异常记录的唯一ID */
private Long id;
/** 异常发生时间 */
private Date time;
/** 请求的URI地址 */
private String uri;
/** HTTP请求方法,如GET、POST等 */
private String httpMethod;
/** 抛出异常的类名 */
private String className;
/** 抛出异常的方法名 */
private String methodName;
/** 抛出异常的文件名 */
private String fileName;
/** 抛出异常的代码行号 */
private Integer lineNumber;
/** 异常的简短信息 */
private String summary;
/** 异常的详细堆栈信息 */
private String detailMessage;
/** 请求参数(可能为JSON字符串等) */
private String params;
}
@@ -0,0 +1,33 @@
package cn.g3soft.exception;
import cn.g3soft.interceptor.RequestBodyCacheInterceptor;
import cn.g3soft.utils.ExceptionUtils;
import cn.g3soft.utils.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public Result<ExceptionDetailInfo> handleException(Exception ex, HttpServletRequest request) {
String requestBody = RequestBodyCacheInterceptor.getRequestBodyFromThreadLocal();
ExceptionDetailInfo info = ExceptionUtils.extractExceptionInfo(ex, requestBody, "cn.g3soft");
log.error("异常发生在 {}#{}({}:{}),ID: {},信息: {}",
info.getClassName(),
info.getMethodName(),
info.getFileName(),
info.getLineNumber(),
info.getId(),
info.getSummary());
ex.printStackTrace();
return Result.fail(Result.SYSTEM_ERROR_CODE,info);
}
}
@@ -0,0 +1,60 @@
package cn.g3soft.interceptor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
@Component
public class RequestBodyCacheInterceptor implements HandlerInterceptor {
private static final ThreadLocal<String> requestBodyThreadLocal = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 仅在异常发生时读取请求体
if (response.isCommitted()) {
return true;
}
// 缓存请求体到 ThreadLocal
if (isExceptionRequest(request)) {
String requestBody = getRequestBody(request);
requestBodyThreadLocal.set(requestBody); // 将请求体保存到 ThreadLocal
}
return true;
}
private boolean isExceptionRequest(HttpServletRequest request) {
// 你可以根据某些条件判断是否需要读取请求体,比如请求头或者请求的 URI
// 这里简单示范,假设在异常时才读取
return true; // 仅在异常发生时才读取请求体
}
private String getRequestBody(HttpServletRequest request) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(request.getInputStream(), StandardCharsets.UTF_8));
StringBuilder bodyBuilder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
bodyBuilder.append(line);
}
return bodyBuilder.toString();
}
// 提供获取请求体的方法
public static String getRequestBodyFromThreadLocal() {
return requestBodyThreadLocal.get();
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清除ThreadLocal中的请求体
requestBodyThreadLocal.remove();
}
}
@@ -219,18 +219,18 @@ public class DbUtils {
throw new RuntimeException("只允许执行SELECT查询");
}
Matcher matcher = ILLEGAL_SQL_PATTERN.matcher(sql);
if (matcher.find()) {
throw new RuntimeException("检测到非法SQL关键字: " + matcher.group());
}
if (sql.contains(";")) {
throw new RuntimeException("SQL包含非法字符 ';'");
}
if (sql.contains("--") || sql.contains("/*") || sql.contains("*/")) {
throw new RuntimeException("SQL包含非法注释");
}
// Matcher matcher = ILLEGAL_SQL_PATTERN.matcher(sql);
// if (matcher.find()) {
// throw new RuntimeException("检测到非法SQL关键字: " + matcher.group());
// }
//
// if (sql.contains(";")) {
// throw new RuntimeException("SQL包含非法字符 ';'");
// }
//
// if (sql.contains("--") || sql.contains("/*") || sql.contains("*/")) {
// throw new RuntimeException("SQL包含非法注释");
// }
}
/**
@@ -244,11 +244,11 @@ public class DbUtils {
throw new RuntimeException("插入数据不能为空");
}
Object pkValue = getPkValue(data, pkColumn);
if(ObjectUtils.isEmpty(pkValue)){
throw new RuntimeException("主键不能为空");
}
// Object pkValue = getPkValue(data, pkColumn);
//
// if(ObjectUtils.isEmpty(pkValue)){
// throw new RuntimeException("主键不能为空");
// }
String columns = data.keySet()
.stream()
@@ -277,19 +277,19 @@ public class DbUtils {
throw new RuntimeException("插入数据不能为空");
}
if (pkColumns == null || pkColumns.isEmpty()) {
throw new RuntimeException("主键不能为空");
}
// 校验主键
for (String pk : pkColumns) {
validateIdentifier(pk);
Object pkValue = getPkValue(data, pk);
if (ObjectUtils.isEmpty(pkValue)) {
throw new RuntimeException("主键不能为空:" + pk);
}
}
// if (pkColumns == null || pkColumns.isEmpty()) {
// throw new RuntimeException("主键不能为空");
// }
//
// // 校验主键
// for (String pk : pkColumns) {
// validateIdentifier(pk);
//
// Object pkValue = getPkValue(data, pk);
// if (ObjectUtils.isEmpty(pkValue)) {
// throw new RuntimeException("主键不能为空:" + pk);
// }
// }
String columns = data.keySet()
.stream()
@@ -0,0 +1,134 @@
package cn.g3soft.utils;
import cn.g3soft.exception.ExceptionDetailInfo;
import cn.g3soft.utils.uniqueId.idgen.UniqueId;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
public class ExceptionUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 根据异常和请求信息,生成统一的异常信息对象 ExceptionDetailInfo
*
* @param ex 异常对象
* @param request HttpServletRequest,用来获取请求路径和方法等
* @param projectBasePackage 项目基础包名,如 "com.error",用来定位业务代码异常栈帧
* @return ExceptionDetailInfo 封装的异常信息
*/
public static ExceptionDetailInfo extractExceptionInfo(Exception ex, HttpServletRequest request, String projectBasePackage) {
StackTraceElement[] stackTrace = ex.getStackTrace();
StackTraceElement source = null;
if (stackTrace != null) {
for (StackTraceElement element : stackTrace) {
if (element.getClassName().startsWith(projectBasePackage)) {
source = element;
break;
}
}
if (source == null && stackTrace.length > 0) {
source = stackTrace[0];
}
}
Map<String, String[]> parameterMap = request.getParameterMap();
String briefMessage;
if (ex.getMessage() != null) {
String[] lines = ex.getMessage().split("\n");
// 遍历每一行,找到第一行“非空、非符号”的有效信息
String usefulLine = null;
for (String line : lines) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
usefulLine = trimmed;
break;
}
}
// 如果找到有效行,就用它;否则仅用异常类名
if (usefulLine != null) {
briefMessage = ex.getClass().getSimpleName() + ": " + usefulLine;
} else {
briefMessage = ex.getClass().getSimpleName();
}
} else {
briefMessage = ex.getClass().getSimpleName();
}
String paramJson = null;
// 先尝试从parameterMap获取普通参数
Map<String, String> paramMap = new HashMap<>();
Enumeration<String> paramNames = request.getParameterNames();
while (paramNames.hasMoreElements()) {
String paramName = paramNames.nextElement();
paramMap.put(paramName, request.getParameter(paramName));
}
// 如果普通参数为空,尝试读取请求体(用于@RequestBody)
if (paramMap.isEmpty()) {
try {
// 获取请求体内容
ServletInputStream inputStream = request.getInputStream();
BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, StandardCharsets.UTF_8));
StringBuilder bodyBuilder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
bodyBuilder.append(line);
}
String requestBody = bodyBuilder.toString();
if (!requestBody.isEmpty()) {
// 如果是JSON格式,可以尝试解析
try {
// 尝试将JSON字符串格式化为可读格式
ObjectMapper mapper = new ObjectMapper();
Object jsonObject = mapper.readValue(requestBody, Object.class);
paramJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonObject);
} catch (Exception e) {
// 如果不是JSON,直接使用原始字符串
paramJson = requestBody.length() > 1000 ?
requestBody.substring(0, 1000) + "..." : requestBody;
}
} else {
paramJson = "{}";
}
} catch (IOException e) {
paramJson = "请求体读取失败: " + e.getMessage();
}
} else {
// 如果有普通参数,则序列化普通参数
try {
paramJson = JsonUtils.toJson(paramMap);
} catch (Exception e) {
paramJson = "参数序列化失败: " + e.getMessage();
}
}
return ExceptionDetailInfo.builder()
.id(UniqueId.nextId())
.uri(request.getRequestURI())
.httpMethod(request.getMethod())
.className(source != null ? source.getClassName() : null)
.methodName(source != null ? source.getMethodName() : null)
.fileName(source != null ? source.getFileName() : null)
.lineNumber(source != null ? source.getLineNumber() : null)
.summary(briefMessage)
.detailMessage(org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace(ex))
.time(new Date())
.params(paramJson)
.build();
}
}
@@ -60,6 +60,10 @@ public class Result<T> {
return new Result<>(code, message, null);
}
public static <T> Result<T> fail(Integer code, T data) {
return new Result<>(code, "", data);
}
public static <T> Result<T> fail(String message, T data) {
return new Result<>(BUSINESS_ERROR_CODE, message, data);
}
+12
View File
@@ -15,9 +15,11 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -27,13 +29,23 @@ declare module 'vue' {
ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSplitter: typeof import('element-plus/es')['ElSplitter']
ElSplitterPanel: typeof import('element-plus/es')['ElSplitterPanel']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
G3Column: typeof import('./src/components/g3-table/g3-column.vue')['default']
+1
View File
@@ -15,6 +15,7 @@
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.13.6",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"element-plus": "^2.13.3",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
+23
View File
@@ -26,6 +26,9 @@ importers:
dayjs:
specifier: ^1.11.20
version: 1.11.20
echarts:
specifier: ^6.0.0
version: 6.0.0
element-plus:
specifier: ^2.13.3
version: 2.13.5(vue@3.5.30)
@@ -1147,6 +1150,9 @@ packages:
duplexer2@0.1.4:
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
electron-to-chromium@1.5.313:
resolution: {integrity: sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==}
@@ -1925,6 +1931,9 @@ packages:
traverse@0.3.9:
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2074,6 +2083,9 @@ packages:
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
engines: {node: '>= 10'}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
snapshots:
'@antfu/install-pkg@1.1.0':
@@ -3236,6 +3248,11 @@ snapshots:
dependencies:
readable-stream: 2.3.8
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
electron-to-chromium@1.5.313: {}
element-plus@2.13.5(vue@3.5.30):
@@ -3948,6 +3965,8 @@ snapshots:
traverse@0.3.9: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
type@2.7.3: {}
@@ -4117,3 +4136,7 @@ snapshots:
archiver-utils: 3.0.4
compress-commons: 4.1.2
readable-stream: 3.6.2
zrender@6.0.0:
dependencies:
tslib: 2.3.0
@@ -0,0 +1,22 @@
import ErrorModal from "./index.vue";
export const ErrorModalService = {
show(responseData) {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp({
render() {
return h(ErrorModal, {
responseData: responseData,
onClose: () => {
app.unmount();
document.body.removeChild(container);
},
});
},
});
app.mount(container);
},
};
@@ -0,0 +1,386 @@
<template>
<div class="error-modal-wrapper" @click.self="handleClose">
<div v-if="visible" class="error-modal" @click.stop>
<div class="error-modal-header">
<span class="error-modal-title"
>⚠️ 系统异常提示 - {{ props.responseData.message }} -
{{ props.responseData.code || 500 }}</span
>
<div class="error-modal-close-btn" @click="handleClose" role="button" tabindex="0">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</div>
</div>
<div class="error-modal-body">
<div class="log-item" v-for="item in logList" :key="item.label">
<div class="log-label">{{ item.label }}</div>
<div class="log-value">
<pre v-if="item.label === '堆栈跟踪' || item.label === '请求参数'" class="code-block">{{
item.value
}}</pre>
<div v-else>{{ item.value }}</div>
</div>
</div>
</div>
<div class="error-modal-footer">
<div class="error-modal-tip">
<svg
width="24"
height="24"
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<span>请将错误信息 导出 或 复制 发送给管理员!</span>
</div>
<div class="error-modal-actions">
<div class="btn txt-btn" role="button" tabindex="0" @click="exportAsTxt">
<svg
class="btn-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" />
<path d="M14 2v6h6m-9 4H9m3 3H9m6-9h-1" />
<path d="M12 18v-6m2 3h-4" />
</svg>
<span>导出TXT</span>
</div>
<div class="btn data-btn" role="button" tabindex="0" @click="copyInfo">
<svg
class="btn-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<span>复制信息</span>
</div>
<div class="btn cancel-btn" role="button" tabindex="0" @click="handleClose">
<svg
class="btn-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6m0-6l6 6" />
</svg>
<span>取消</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="js">
import { ref, computed, onMounted, nextTick } from "vue";
import { copyText } from "@/utils/common";
const props = defineProps({
responseData: {
type: [Object],
},
onClose: {
type: Function,
required: true,
},
});
const visible = ref(false);
onMounted(async () => {
await nextTick();
visible.value = true;
});
const logList = computed(() => [
{ label: "ID 唯一标识", value: props.responseData.data.id ?? "" },
{ label: "发生时间", value: props.responseData.data.time?.toString() ?? "" },
{ label: "请求路径", value: props.responseData.data.uri ?? "" },
{ label: "HTTP 方法", value: props.responseData.data.httpMethod ?? "" },
{
label: "类 - 方法",
value: `${props.responseData.data.className ?? ""} - ${props.responseData.data.methodName ?? ""}`,
},
{
label: "代码位置",
value: `${props.responseData.data.fileName ?? ""} - ${props.responseData.data.lineNumber ?? ""}`,
},
{ label: "错误主要信息", value: props.responseData.data.summary ?? "" },
{ label: "堆栈跟踪", value: props.responseData.data.detailMessage ?? "" },
{
label: "请求参数",
value: props.responseData.data.params
? JSON.stringify(JSON.parse(props.responseData.data.params), null, 2)
: "",
},
]);
function handleClose() {
if (visible.value) {
visible.value = false;
props.onClose();
}
}
function exportAsTxt() {
const content = logList.value.map((item) => `${item.label}:\n${item.value}\n`).join("\n");
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "错误信息日志.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function copyInfo() {
copyText(JSON.stringify(props.responseData));
}
</script>
<style lang="scss" scoped>
@keyframes modalFadeIn {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.error-modal-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
.error-modal {
animation: modalFadeIn 0.3s forwards;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 4px;
width: 1000px;
overflow: hidden;
background-color: white;
display: flex;
flex-direction: column;
.error-modal-header {
height: 46px;
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid rgb(229, 230, 235);
line-height: 20px;
background-color: #e04848;
.error-modal-title {
font-weight: bold;
font-size: 20px;
color: #fff;
}
.error-modal-close-btn {
padding: 4px;
margin-left: auto;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #fff;
transition:
background-color 0.2s ease,
color 0.2s ease;
svg {
width: 20px;
height: 20px;
}
}
.error-modal-close-btn:hover {
background-color: rgba(255, 255, 255, 0.15);
color: #fff;
border-radius: 2px;
}
}
.error-modal-body {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
padding: 16px 0px;
max-width: 100%;
box-sizing: border-box; /* 包含内边距 */
.log-item {
display: grid;
grid-template-columns: 120px 1fr;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid #d9d9d9;
&:last-child {
border-bottom: none;
}
.log-label {
color: #2b2b2b;
font-weight: 600;
text-align: right;
opacity: 0.9;
}
.log-value {
word-break: break-all;
.code-block {
background: #f1f1f1;
padding: 4px 12px;
border-radius: 4px;
max-height: 120px;
overflow-x: auto;
overflow-y: auto;
white-space: pre-wrap; /* 保留换行和缩进空格 */
font-family: Consolas, monospace;
font-size: 13px;
box-sizing: border-box;
}
}
}
}
.error-modal-footer {
border-top: 1px solid rgb(229, 230, 235);
padding: 16px;
height: 56px;
display: flex;
align-items: center;
.error-modal-tip {
display: flex;
align-items: center;
color: #ff7d00;
gap: 8px;
font-size: 16px;
}
.error-modal-actions {
display: flex;
gap: 12px;
margin-left: auto;
.btn {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
&:focus-visible {
outline: 2px solid #1976d2;
outline-offset: 2px;
}
.btn-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
/* 导出按钮样式 */
&.txt-btn {
background: #1976d2;
color: white;
&:hover {
background: #1565c0;
}
&:active {
background: #0d47a1;
}
}
/* 复制按钮样式 */
&.data-btn {
color: #1976d2;
border-color: #90caf9;
&:active {
background: #90caf9;
}
}
/* 取消按钮样式 */
&.cancel-btn {
background: #f5f5f5;
color: #757575;
&:hover {
background: #eeeeee;
}
&:active {
background: #e0e0e0;
}
}
}
}
}
}
}
</style>
@@ -2,18 +2,17 @@
import { loadDataApi } from "@/request/api";
import MenuItem from "./menu-item.vue";
import { useRoute } from "vue-router";
const title = ref("G3后台管理系统");
const title = ref("集装箱管理系统");
const route = useRoute();
const router = useRouter();
onMounted(() => {
loadDataApi("s_keyvalue", `b_key='bodycaption'`, ``, ["b_value"]).then((res) => {
if (res.success) {
title.value = res.data[0].b_value;
}
});
// loadDataApi("s_keyvalue", `b_key='bodycaption'`, ``, ["b_value"]).then((res) => {
// if (res.success) {
// title.value = res.data[0].b_value;
// }
// });
});
const menuList = ref([
@@ -32,17 +31,12 @@ const menuList = ref([
icon: "lucide:settings",
children: [
{
label: "办公管理",
children: [
{
label: "企业公告",
path: "/system/oa/notice",
},
{
label: "用户提醒",
path: "/system/oa/reminder",
},
],
label: "企业公告",
path: "/system/oa/notice",
},
{
label: "用户提醒",
path: "/system/oa/reminder",
},
{
label: "字段配置",
@@ -71,7 +65,7 @@ function handleMenuChange(val) {
<div class="g3-menu">
<div class="g3-logo">
<div class="logo-inner">
<img src="@/assets/logo.png" alt="logo" />
<!-- <img src="@/assets/logo.png" alt="logo" /> -->
<div class="title">{{ title }}</div>
</div>
</div>
@@ -129,7 +123,6 @@ function handleMenuChange(val) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - 90px);
}
}
@@ -4,6 +4,7 @@ import { useTabStore } from "@/store/tab";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import UpdatePassword from "./update-password.vue";
import { usePermissionStore } from "@/store/permission";
const props = defineProps({
collapse: {
@@ -18,6 +19,7 @@ const router = useRouter();
const tabStore = useTabStore();
const userStore = useUserStore();
const permissionStore = usePermissionStore();
const showUpdatePassword = ref(false);
@@ -28,6 +30,7 @@ function onCollapse() {
function logout() {
userStore.resetUserInfo();
tabStore.resetTabs();
permissionStore.reset();
ElMessage.success("已成功退出");
@@ -40,11 +43,7 @@ function logout() {
<div class="g3-topbar-left">
<div class="g3-action-icon" @click="onCollapse()">
<G3Icon
:icon="
props.collapse
? 'lucide:list-indent-increase'
: 'lucide:list-indent-decrease'
"
:icon="props.collapse ? 'lucide:list-indent-increase' : 'lucide:list-indent-decrease'"
/>
</div>
</div>
@@ -55,9 +54,7 @@ function logout() {
<G3Icon icon="lucide:circle-user-round" size="18" />
<div class="user-info">
<span class="user-name">
{{
userStore.userInfo.username || userStore.userInfo.userid || "空"
}}
{{ userStore.userInfo.username || userStore.userInfo.userid || "空" }}
</span>
</div>
</div>
@@ -5,8 +5,10 @@
import { addCollection } from "@iconify/vue";
import Lucide from "@iconify/json/json/lucide.json";
import ElementPlus from "@iconify/json/json/ep.json";
import Mingcute from "@iconify/json/json/mingcute.json";
export function SetupIcons() {
addCollection(Lucide);
addCollection(ElementPlus);
addCollection(Mingcute);
}
@@ -2,6 +2,7 @@ import axios from "axios";
import { ElMessage } from "element-plus";
import { useUserStore } from "@/store/user";
import router from "@/router";
import { ErrorModalService } from "@/components/g3-error";
let hasShown401 = false;
@@ -40,6 +41,8 @@ axiosInstance.interceptors.response.use(
router.replace("/login");
}
} else if (resData.code === 500) {
ErrorModalService.show(resData);
// 系统异常
ElMessage.error(resData.message || "系统异常,请稍后再试");
} else if (resData.code === 1000) {
+19 -6
View File
@@ -4,6 +4,7 @@ import { useUserStore } from "@/store/user";
import { ElMessage } from "element-plus";
import { useTabStore } from "@/store/tab";
import NProgress from "nprogress";
import { usePermissionStore } from "@/store/permission";
NProgress.configure({ easing: "ease", speed: 500 });
@@ -12,19 +13,31 @@ const router = createRouter({
routes,
});
router.beforeEach((to) => {
router.beforeEach(async (to) => {
NProgress?.start?.();
const userStore = useUserStore();
const permissionStore = usePermissionStore();
// if (!userStore.isLogined() && to.path !== "/login") {
// ElMessage.warning("登录已过期,请先登录");
// return "/login";
// }
if (!userStore.isLogined() && to.path !== "/login") {
ElMessage.warning("登录已过期,请先登录");
return "/login";
}
if (to.meta.moduleId) {
await permissionStore.init();
const hasPermission = permissionStore.hasModulePermission(to.meta.moduleId);
if (!hasPermission) {
ElMessage.warning("您没有权限访问此页面");
return "/";
}
}
handleTabPath(to);
return;
return true;
});
router.afterEach(() => {
@@ -7,27 +7,20 @@ export const routes = [
children: [
{
path: "/",
component: () => import("@/views/index.vue"),
component: () => import("@/views/home/index.vue"),
meta: {
title: "首页",
keepAlive: true,
},
},
// ==================== 业务管理 ====================
{
path: "/business",
component: () => import("@/views/business/index.vue"),
meta: {
title: "海运业务",
keepAlive: true,
},
},
{
path: "/container",
component: () => import("@/views/container/index.vue"),
meta: {
title: "集装箱管理",
keepAlive: true,
moduleId: "v_container",
},
},
// ==================== 系统管理 ====================
@@ -45,6 +38,7 @@ export const routes = [
meta: {
title: "权限管理",
keepAlive: true,
moduleId: "s_SetQX",
},
},
{
@@ -53,6 +47,7 @@ export const routes = [
meta: {
title: "模块设置",
keepAlive: true,
moduleId: "s_module",
},
},
{
@@ -61,6 +56,7 @@ export const routes = [
meta: {
title: "企业公告",
keepAlive: true,
moduleId: "oa_notices",
},
},
{
@@ -69,6 +65,7 @@ export const routes = [
meta: {
title: "用户提醒",
keepAlive: true,
moduleId: "s_awokemodule",
},
},
// ==================== 个人中心 ====================
@@ -0,0 +1,101 @@
import { loadDataApi } from "@/request/api";
import { useUserStore } from "./user";
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { useRoute } from "vue-router";
export const usePermissionStore = defineStore("permission", () => {
const route = useRoute();
const userStore = useUserStore();
const modulePermissions = ref([]);
const actionPermissions = ref([]);
const isInitialized = ref(false);
function reset() {
modulePermissions.value = [];
actionPermissions.value = [];
isInitialized.value = false;
}
async function init() {
if (isInitialized.value || !userStore.isLogined()) {
// 如果已初始化或者未登录,直接跳过
return;
}
try {
const moduleResponse = await loadDataApi(
"b_User_Qx",
`father='${userStore.userInfo.userid}'`,
"",
["ModuleID", "QxType", "ControlType", "ShareType"],
);
if (moduleResponse.success) {
modulePermissions.value = moduleResponse.data || [];
} else {
throw new Error(moduleResponse.message || "查询可用账号失败");
}
const powerResponse = await loadDataApi(
"b_User_power",
`UserID='${userStore.userInfo.userid}'`,
"",
["ModuleID", "b_canuse", "b_caption", "b_ivalue"],
);
if (powerResponse.success) {
actionPermissions.value = powerResponse.data || [];
} else {
throw new Error(powerResponse.message || "查询可用账号失败");
}
isInitialized.value = true;
} catch (error) {
console.error("初始化权限失败", error);
}
}
function hasModulePermission(moduleid) {
if (userStore.userInfo.userid === "g3soft") {
return true;
}
const module = modulePermissions.value.find((item) => item.ModuleID === moduleid);
if (!module) return false;
if (module.QxType === "禁用") return false;
// 其他情况允许
return true;
}
function hasActionPermission(id) {
if (userStore.userInfo.userid === "g3soft") {
return true;
}
const moduleid = route.meta.moduleId;
if (!moduleid) return false;
if (!hasModulePermission(moduleid)) return false;
const actions = actionPermissions.value.filter((item) => item.ModuleID === moduleid);
const action = actions.find((item) => Number(item.b_ivalue) === Number(id));
if (!action) return false;
if (action.b_canuse === "0") return false;
// 其他情况允许
return true;
}
return {
init,
reset,
modulePermissions,
hasModulePermission,
hasActionPermission,
};
});
@@ -0,0 +1,53 @@
import { ElMessage } from "element-plus";
/**
* 复制文本到剪切板
* @param text 复制内容
* @returns 是否复制成功
*/
export function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
// 现代浏览器和安全环境下,直接用 Clipboard API
return navigator.clipboard
.writeText(text)
.then(() => {
ElMessage.success("复制成功");
})
.catch((err) => {
ElMessage.error("复制失败:" + err);
});
} else {
// 退回到传统方式
fallbackCopyText(text);
}
}
/**
* 复制文本到剪切板的传统方式
* @param text 复制内容
*/
function fallbackCopyText(text) {
const textArea = document.createElement("textarea");
textArea.value = text;
// 避免滚动到页面底部
textArea.style.position = "fixed";
textArea.style.top = "0";
textArea.style.left = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
if (successful) {
console.log("复制成功(fallback)");
} else {
console.log("复制失败(fallback)");
}
} catch (err) {
console.error("复制失败(fallback)", err);
}
document.body.removeChild(textArea);
}
+12 -7
View File
@@ -311,14 +311,19 @@ export function downloadZip(files, fileName = "文件合集", callback) {
const addedFiles = new Set();
const tasks = files.map((item) => {
return getAliyunFileBlob(item.mx_mapfilename).then((res) => {
const uniqueFileName = getUniqueFileName(item.mx_filename, addedFiles);
zip.file(uniqueFileName, res, { binary: true });
addedFiles.add(uniqueFileName);
});
return getAliyunFileBlob(item.mx_mapfilename)
.then((res) => {
const uniqueFileName = getUniqueFileName(item.mx_filename, addedFiles);
zip.file(uniqueFileName, res, { binary: true });
addedFiles.add(uniqueFileName);
})
.catch((err) => {
// 捕获单个文件下载失败的错误,不影响其他文件下载
console.error(`Failed to download file: ${item.mx_filename}`, err);
});
});
// ✅ 关键:return
// 关键:使用 Promise.all 确保所有任务完成
return Promise.all(tasks)
.then(() => zip.generateAsync({ type: "blob" }))
.then((res) => {
@@ -327,7 +332,7 @@ export function downloadZip(files, fileName = "文件合集", callback) {
})
.catch((err) => {
callback && callback();
throw err; // ✅ 继续往外抛
throw err;
});
}
@@ -6,8 +6,11 @@ import { useRouter } from "vue-router";
import Vcode from "vue3-puzzle-vcode";
import { reset401Flag } from "@/request";
import { usePermissionStore } from "@/store/permission";
const router = useRouter();
const userStore = useUserStore();
const permissionStore = usePermissionStore();
const form = ref({
orgid: userStore.userInfo.orgid,
@@ -21,7 +24,7 @@ const loginLoading = ref(false);
const linkList = ref([
{
title: "新手上路",
icon: "solar:key-bold",
icon: "mingcute:key-2-fill",
href: "http://bbs.g3soft.cn/forum.php?mod=viewthread&tid=115&extra=page%3D1",
},
{
@@ -31,12 +34,12 @@ const linkList = ref([
},
{
title: "视频教程",
icon: "material-symbols:auto-videocam-rounded",
icon: "mingcute:video-fill",
href: "http://bbs.g3soft.cn/forum.php?mod=viewthread&tid=86&extra=page%3D1",
},
{
title: "常见问题",
icon: "ic:baseline-help",
icon: "mingcute:question-fill",
href: "http://bbs.g3soft.cn/forum.php?mod=forumdisplay&fid=37",
},
]);
@@ -82,29 +85,30 @@ function onVcodeClose() {
showVcode.value = false;
}
function onVcodeSuccess() {
async function onVcodeSuccess() {
onVcodeClose();
loginLoading.value = true;
try {
loginApi(form.value.orgid, form.value.userid, form.value.password).then(
(res) => {
if (res.success) {
ElMessage.success("登录成功");
loginApi(form.value.orgid, form.value.userid, form.value.password).then(async (res) => {
if (res.success) {
userStore.setUserInfo(
form.value.orgid,
form.value.userid,
res.data.username,
res.data.token,
);
userStore.setUserInfo(
form.value.orgid,
form.value.userid,
res.data.username,
res.data.token,
);
router.push("/");
await permissionStore.init();
reset401Flag();
}
},
);
ElMessage.success("登录成功");
router.push("/");
reset401Flag();
}
});
} finally {
loginLoading.value = false;
}
@@ -157,11 +161,7 @@ function onVcodeSuccess() {
size="large"
long
type="primary"
style="
background-color: #33dcd0;
border-color: #00d3c4;
font-size: 18px;
"
style="background-color: #33dcd0; border-color: #00d3c4; font-size: 18px"
@click="onVcodeShow"
:loading="loginLoading"
>
@@ -1,11 +0,0 @@
<script lang="js" setup>
</script>
<template>
<div class=""></div>
</template>
<style lang="css" scoped>
</style>
@@ -1,9 +0,0 @@
<script lang="js" setup></script>
<template>
<div class="">
<div></div>
</div>
</template>
<style lang="css" scoped></style>
@@ -13,9 +13,12 @@ import { formatToDate } from "@/utils/time";
import { ElLoading, ElMessage } from "element-plus";
import { saveAs } from "file-saver";
import JSZip from "jszip";
import { usePermissionStore } from "@/store/permission";
const tableRef = ref();
const permissionStore = usePermissionStore();
const columns = ref([]);
const tableData = ref([]);
@@ -53,9 +56,7 @@ const viewName = "v_container";
const showPreview = ref(false);
const previewIndex = ref(-1);
const previewImageList = computed(() => {
return tableData.value
.map((item) => item.b_container_img)
.filter((img) => img);
return tableData.value.map((item) => item.b_container_img).filter((img) => img);
});
const currentRow = ref({});
@@ -188,12 +189,9 @@ async function deleteRows(rows = []) {
const safeDeleteIds = deleteIds.map((id) => Number(id));
// 使用安全的 SQL 查询
const fileRes = await loadDataApi(
"bf_file",
`father IN (${safeDeleteIds.join(",")})`,
"",
["subid"],
);
const fileRes = await loadDataApi("bf_file", `father IN (${safeDeleteIds.join(",")})`, "", [
"subid",
]);
if (!fileRes.success) {
return;
@@ -269,11 +267,7 @@ async function handleDownloadOne(data) {
});
try {
const fileRes = await loadDataApi(
"bf_file",
`father = '${data.b_id}'`,
"mx_xh asc",
);
const fileRes = await loadDataApi("bf_file", `father = ${data.b_id}`, "mx_xh asc");
if (!fileRes.success) {
ElMessage.error("获取附件失败");
@@ -281,11 +275,7 @@ async function handleDownloadOne(data) {
}
const downloadFiles = fileRes.data.map((item, index) => ({
mx_filename: buildFileName(
data.b_container_no,
index + 1,
item.mx_filename,
),
mx_filename: buildFileName(data.b_container_no, index + 1, item.mx_filename),
mx_mapfilename: item.mx_mapfilename,
}));
@@ -348,11 +338,7 @@ async function handleDownloadBatch() {
// 👉 先统计总文件数(用于进度)
for (const item of records) {
const fileRes = await loadDataApi(
"bf_file",
`father = '${item.b_id}'`,
"",
);
const fileRes = await loadDataApi("bf_file", `father = '${item.b_id}'`, "");
if (fileRes.success) {
total += (fileRes.data || []).length;
}
@@ -360,17 +346,11 @@ async function handleDownloadBatch() {
// 👉 正式处理
for (const item of records) {
const folderName = `${item.b_container_no}_${formatToDate(
item.b_inputdatetime,
)}`;
const folderName = `${item.b_container_no}_${formatToDate(item.b_inputdatetime)}`;
const folder = zip.folder(folderName);
const fileRes = await loadDataApi(
"bf_file",
`father = '${item.b_id}'`,
"mx_xh asc",
);
const fileRes = await loadDataApi("bf_file", `father = '${item.b_id}'`, "mx_xh asc");
if (!fileRes.success) {
ElMessage.error("获取附件失败");
@@ -387,8 +367,7 @@ async function handleDownloadBatch() {
const blob = await res.blob();
const fileName =
buildFileName(item.b_container_no, index + 1, file.mx_filename) ||
"未命名文件";
buildFileName(item.b_container_no, index + 1, file.mx_filename) || "未命名文件";
folder.file(fileName, blob);
} catch (e) {
@@ -478,13 +457,21 @@ function buildFileName(containerNo, index, fileName) {
<div class="g3-content">
<div class="content-top">
<div>
<el-button type="primary" @click="handleDownloadBatch">
<el-button
type="primary"
@click="handleDownloadBatch"
:disabled="!permissionStore.hasActionPermission(1)"
>
<el-icon class="el-icon--left">
<G3Icon icon="lucide:cloud-download" />
</el-icon>
批量下载
</el-button>
<el-button type="danger" @click="handleDeleteRows">
<el-button
type="danger"
@click="handleDeleteRows"
:disabled="!permissionStore.hasActionPermission(2)"
>
<el-icon class="el-icon--left">
<G3Icon icon="lucide:trash-2" />
</el-icon>
@@ -500,7 +487,7 @@ function buildFileName(containerNo, index, fileName) {
</el-button>
</el-tooltip>
<el-tooltip content="刷新">
<el-button circle>
<el-button circle @click="loadTableDatas">
<el-icon>
<G3Icon icon="lucide:refresh-ccw" />
</el-icon>
@@ -547,14 +534,7 @@ function buildFileName(containerNo, index, fileName) {
}"
height="100%"
>
<vxe-column
field="seq"
type="seq"
width="50"
align="center"
title=" "
fixed="left"
/>
<vxe-column field="seq" type="seq" width="50" align="center" title=" " fixed="left" />
<vxe-column type="checkbox" width="50" align="center" fixed="left" />
<vxe-column
v-for="column in columns"
@@ -575,30 +555,40 @@ function buildFileName(containerNo, index, fileName) {
/>
</template>
<template v-else>
{{
formatValueByFormat(
row[column.col_FieldName],
column.col_DisplayFormat,
)
}}
{{ formatValueByFormat(row[column.col_FieldName], column.col_DisplayFormat) }}
</template>
</template>
</vxe-column>
<vxe-column width="220" align="center" fixed="right" title="操作">
<template #default="{ row }">
<el-button link type="primary" @click="handleDownloadOne(row)">
<el-button
link
type="primary"
@click="handleDownloadOne(row)"
:disabled="!permissionStore.hasActionPermission(1)"
>
<el-icon class="el-icon--left">
<G3Icon icon="lucide:cloud-download" />
</el-icon>
下载
</el-button>
<el-button link type="primary" @click="handleFileShow(row)">
<el-button
link
type="primary"
@click="handleFileShow(row)"
:disabled="!permissionStore.hasActionPermission(3)"
>
<el-icon class="el-icon--left">
<G3Icon icon="lucide:file-image" />
</el-icon>
图片
</el-button>
<el-button link type="danger" @click="handleDeleteRow(row)">
<el-button
link
type="danger"
@click="handleDeleteRow(row)"
:disabled="!permissionStore.hasActionPermission(2)"
>
<el-icon class="el-icon--left">
<G3Icon icon="lucide:trash-2" />
</el-icon>
@@ -0,0 +1,119 @@
<template>
<div class="g3-bar">
<el-card style="width: 100%; border: 0px" shadow="never">
<template #header>
<div class="card-header">
<div>{{ props.item.b_name }}</div>
</div>
</template>
<div style="background-color: #fff; height: 336px">
<G3Echarts v-if="option" :option="option" style="height: 330px" />
</div>
</el-card>
</div>
</template>
<script setup>
import G3Echarts from "../g3-echarts.vue";
import { useUserStore } from "@/store/user";
import { loadDataBySqlApi } from "@/request/api";
const userStore = useUserStore();
const option = ref(null);
const update_datetime = ref("");
const props = defineProps({
item: Object,
});
const loadChartData = async (item) => {
if (!item) return;
update_datetime.value = item.b_update_datetime;
let seriesData = [];
const b_graph_name = item.b_graph_name;
const b_graph_x = item.b_graph_x.split(",").map((m) => {
const date = new Date();
date.setMonth(date.getMonth() - Number(m));
return `${date.getFullYear()}-${date.getMonth() + 1}月`;
});
// 数据来源
if (item.b_result) {
seriesData = item.b_result.split(",").map(Number);
} else {
const sqlQuery = item.b_polyline.replace(/:b_inputuser_id/g, `'${userStore.userInfo.userid}'`);
const res = await loadDataBySqlApi(sqlQuery);
if (res.success && res.data.length > 0) {
seriesData = Object.values(res.data[0]);
}
}
option.value = {
color: ["#87cefa"],
tooltip: { trigger: "axis" },
legend: {
top: 20,
data: [b_graph_name],
},
grid: { y2: 24 },
xAxis: {
type: "category",
data: b_graph_x,
},
yAxis: {
type: "value",
},
series: [
{
name: b_graph_name,
type: "bar",
data: seriesData,
barWidth: "25%",
label: {
show: true,
position: "top",
formatter: "{c}",
},
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#2378f7" },
{ offset: 0.5, color: "#2378f7" },
{ offset: 1, color: "#7fbcf6" },
]),
},
markLine: {
data: [{ type: "average", name: "平均值" }],
},
},
],
};
};
watch(
() => props.item?.b_id,
() => {
loadChartData(props.item);
},
{ immediate: true },
);
</script>
<style lang="scss" scoped>
.g3-bar {
width: 50%;
height: 394px;
}
.card-header {
display: flex;
justify-content: space-between;
}
:deep(.el-card__header) {
padding: 13px 20px 13px 20px;
}
.g3-refresh {
cursor: pointer;
}
</style>
@@ -0,0 +1,158 @@
<template>
<div class="g3-bar">
<el-card style="width: 100%; border: 0px" shadow="never">
<template #header>
<div class="card-header">
<div>{{ props.item.b_name }}</div>
</div>
</template>
<div style="background-color: #fff; height: 336px">
<G3Echarts v-if="option" :option="option" style="height: 330px" />
</div>
</el-card>
</div>
</template>
<script lang="js" setup>
import G3Echarts from "../g3-echarts.vue";
import { useUserStore } from "@/store/user";
import { loadDataBySqlApi } from "@/request/api";
const userStore = useUserStore();
const option = ref(null);
const update_datetime = ref("");
const props = defineProps({
item: Object,
});
const loadChartData = async (item) => {
if (!item) return;
update_datetime.value = item.b_update_datetime;
let seriesData = [];
let seriesData2 = [];
const b_graph_name = item.b_graph_name.split(",");
const b_graph_x = item.b_graph_x.split(",");
const b_graph_y = item.b_graph_y_name;
// 数据来源
if (item.b_result) {
seriesData = item.b_result.split(",").map(Number);
seriesData2 = item.b_result2.split(",").map(Number);
} else {
const sqlQuery = item.b_polyline.replace(/:b_inputuser_id/g, `'${userStore.userInfo.userid}'`);
const res = await loadDataBySqlApi(sqlQuery);
if (res.success && res.data.length > 0) {
seriesData = Object.values(res.data[0]);
}
const sqlQuery2 = item.b_polyline.replace(/:b_inputuser_id/g, `'${userStore.userInfo.userid}'`);
const res2 = await getDataBySql(sqlQuery2);
if (res2.success && res2.data.length > 0) {
seriesData2 = Object.values(res2.data[0]);
}
}
option.value = {
tooltip: {
trigger: "axis",
axisPointer: {
type: "cross",
label: {
backgroundColor: "#6a7985",
},
},
},
legend: {
top: 20,
data: b_graph_name, // Legend labels for both datasets
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true,
},
xAxis: {
type: "category",
data: b_graph_x, // Replace with your x-axis categories
},
yAxis: {
name: b_graph_y,
type: "value",
},
series: [
{
name: b_graph_name[0], // Name of the first dataset
type: "line",
emphasis: {
focus: "series",
}, // Line chart type
data: seriesData, // Replace with your first dataset values
smooth: true, // Smooth curve
areaStyle: {
// Area chart style (filled area)
color: "rgba(119,201,255)", // Set the fill color with transparency
},
lineStyle: {
color: "#37a2da", // Line color for the first dataset
},
itemStyle: {
color: "#37a2da",
},
},
{
name: b_graph_name[1], // Name of the second dataset
type: "line",
emphasis: {
focus: "series",
}, // Line chart type
data: seriesData2, // Replace with your second dataset values
smooth: true, // Smooth curve
areaStyle: {
// Area chart style (filled area)
color: "rgba(120,216,216)", // Set the fill color with transparency
},
lineStyle: {
color: "#ffd85c", // Line color for the second dataset
},
itemStyle: {
color: "#ffd85c",
},
},
],
};
};
watch(
() => props.item?.b_id,
() => {
loadChartData(props.item);
},
{ immediate: true },
);
</script>
<style lang="scss" scoped>
.g3-bar {
width: 50%;
height: 394px;
padding-right: 15px;
padding-left: 15px;
}
.card-header {
display: flex;
justify-content: space-between;
}
:deep(.el-card__header) {
padding: 13px 20px 13px 20px;
}
.g3-refresh {
cursor: pointer;
}
</style>
@@ -0,0 +1,128 @@
<template>
<div class="g3-bar">
<el-card style="width: 100%; border: 0px" shadow="never">
<template #header>
<div class="card-header">
<div>{{ props.item.b_name }}</div>
</div>
</template>
<div style="background-color: #fff; height: 336px">
<G3Echarts v-if="option" :option="option" style="height: 330px" />
</div>
</el-card>
</div>
</template>
<script lang="js" setup>
import G3Echarts from "../g3-echarts.vue";
import { useUserStore } from "@/store/user";
import { loadDataBySqlApi } from "@/request/api";
const userStore = useUserStore();
const option = ref(null);
const update_datetime = ref("");
const props = defineProps({
item: Object,
});
const loadChartData = async (item) => {
if (!item) return;
update_datetime.value = item.b_update_datetime;
let seriesData = [];
let data_splice = [];
const b_graph_name = item.b_graph_name.split(",");
const b_graph_x = item.b_graph_x.split(",");
// 数据来源
if (item.b_result) {
data_splice = item.b_result.split(",").map(Number);
seriesData = data_splice.map((value, index) => ({
value,
name: b_graph_x[index],
}));
} else {
const sqlQuery = item.b_polyline.replace(/:b_inputuser_id/g, `'${userStore.userInfo.userid}'`);
const res = await loadDataBySqlApi(sqlQuery);
if (res.success && res.data.length > 0) {
data_splice = Object.values(res.data[0]);
}
seriesData = data_splice.map((value, index) => ({
value,
name: b_graph_x[index],
}));
}
option.value = {
title: {
text: b_graph_name[0],
subtext: b_graph_name[1],
left: "center",
},
tooltip: {
trigger: "item",
},
legend: {
orient: "vertical",
left: "left",
top: 20,
},
series: [
{
name: b_graph_name[0],
type: "pie",
radius: "50%",
data: seriesData,
itemStyle: {
normal: {
color: function (params) {
const colorList = ["#37a2da", "#67e0e3", "#ffdb5c", "#ff9f7f", "#e062ae"];
return colorList[params.dataIndex];
},
},
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
},
],
};
};
watch(
() => props.item?.b_id,
() => {
loadChartData(props.item);
},
{ immediate: true },
);
</script>
<style lang="scss" scoped>
.g3-bar {
width: 50%;
height: 394px;
padding-right: 15px;
padding-left: 15px;
}
.card-header {
display: flex;
justify-content: space-between;
}
:deep(.el-card__header) {
padding: 13px 20px 13px 20px;
}
.g3-refresh {
cursor: pointer;
}
</style>
@@ -0,0 +1,160 @@
<template>
<div class="g3-bar">
<el-card style="width: 100%; border: 0px" shadow="never">
<template #header>
<div class="card-header">
<div>{{ props.item.b_name }}</div>
</div>
</template>
<div style="background-color: #fff; height: 336px">
<G3Echarts v-if="option" :option="option" style="height: 330px" />
</div>
</el-card>
</div>
</template>
<script setup>
import G3Echarts from "../g3-echarts.vue";
import { useUserStore } from "@/store/user";
import { loadDataBySqlApi } from "@/request/api";
const userStore = useUserStore();
const option = ref(null);
const update_datetime = ref("");
const props = defineProps({
item: Object,
});
const loadChartData = async (item) => {
if (!item) return;
update_datetime.value = item.b_update_datetime;
let seriesData = [];
const b_graph_name = item.b_graph_name;
const b_graph_x = item.b_graph_x.split(",").map((m) => {
const date = new Date();
date.setMonth(date.getMonth() - Number(m));
return `${date.getFullYear()}-${date.getMonth() + 1}月`;
});
// 数据来源
if (item.b_result) {
seriesData = item.b_result.split(",").map(Number);
} else {
const sqlQuery = item.b_polyline.replace(/:b_inputuser_id/g, `'${userStore.userInfo.userid}'`);
const res = await loadDataBySqlApi(sqlQuery);
if (res.success && res.data.length > 0) {
seriesData = Object.values(res.records[0]);
}
}
option.value = {
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow",
},
},
legend: {},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
top: "10%",
containLabel: true,
},
xAxis: {
type: "value",
boundaryGap: [0, 0.01],
},
yAxis: {
type: "category",
data: b_graph_x, // Your categories
axisLine: {
show: true, // Show or hide the axis line
lineStyle: {
color: "#dcdcdc", // Color of the axis line
width: 1, // Width of the axis line
type: "solid", // Style of the axis line (solid, dashed, dotted)
},
},
axisTick: {
show: false, // Hide the ticks if not needed
},
axisLabel: {
show: true, // Show axis labels
textStyle: {
color: "#333", // Font color
fontSize: 12, // Font size
fontFamily: "Arial", // Font family
fontWeight: "normal", // Font weight
},
interval: 0,
margin: 15,
},
},
series: [
{
//name: b_graph_name,
type: "bar",
data: seriesData,
label: {
show: true, // 显示标签
position: "right", // 标签位置
// 可以通过 formatter 自定义显示格式
formatter: "{c}", // {c} 表示数据值
},
barCategoryGap: "60%",
itemStyle: {
color: function (params) {
var colors = [
"#1990ff",
"#1990ff",
"#1990ff",
"#1990ff",
"#1990ff",
"#1990ff",
"#1990ff",
"#48bfe3",
"#ff904d",
"#fc619d",
]; // Define your custom colors here
return colors[params.dataIndex]; // Use dataIndex to assign a color to each bar
},
borderRadius: [25, 25, 25, 25],
},
},
],
};
};
watch(
() => props.item?.b_id,
() => {
loadChartData(props.item);
},
{ immediate: true },
);
</script>
<style lang="scss" scoped>
.g3-bar {
width: 50%;
height: 394px;
padding-right: 15px;
padding-left: 15px;
}
.card-header {
display: flex;
justify-content: space-between;
}
:deep(.el-card__header) {
padding: 13px 20px 13px 20px;
}
.g3-refresh {
cursor: pointer;
}
</style>
@@ -0,0 +1,102 @@
<script setup lang="js">
import { useUserStore } from "@/store/user";
import { loadDataBySqlApi } from "@/request/api";
import { getDateTime } from "@/utils/time";
const props = defineProps({
item: Object,
});
const userStore = useUserStore();
const value = ref("0");
const flag_date = ref(false);
const update_datetime = ref("");
async function load() {
if (props.item.b_result) {
value.value = props.item.b_result;
update_datetime.value = props.item.b_update_datetime;
flag_date.value = true;
} else {
const sql = props.item.b_sql.replace(/:b_inputuser_id/g, `'${userStore.userInfo.userid}'`);
const res = await loadDataBySqlApi(sql);
if (res.success && res.data.length > 0) {
value.value = Object.values(res.data[0]).join(",");
}
if (res.success) {
update_datetime.value = getDateTime();
}
}
}
onMounted(async () => {
await load();
});
</script>
<template>
<div class="g3-value-card">
<div class="header">
<span class="label">{{ props.item.b_name }}</span>
<G3Icon :icon="props.item.b_img" class="icon" />
</div>
<div class="container">
<span class="num"> {{ value }}</span>
</div>
<div class="footer">
<span> 刷新时间:{{ update_datetime }} </span>
</div>
</div>
</template>
<style lang="scss" scoped>
.g3-value-card {
border-radius: 10px;
margin-bottom: 10px;
border: 1px solid #f2f6fc;
background-color: #fff;
color: #303133;
user-select: none;
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
min-height: 50px;
.label {
font-size: 15px;
}
.icon {
font-size: 30px;
background-color: #f5f7fa;
padding: 5px;
border-radius: 6px;
}
}
.container {
padding: 0 20px;
min-height: 50px;
.num {
font-size: 32px;
}
}
.footer {
display: flex;
align-items: center;
height: 50px;
margin: 0 5px;
padding: 0 15px;
box-sizing: border-box;
font-size: 12px;
}
}
</style>
@@ -0,0 +1,66 @@
<template>
<div ref="chartRef" class="g3-echarts"></div>
</template>
<script setup>
// 在 G3Echarts 组件中
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
import * as echarts from "echarts";
const props = defineProps({
option: {
type: Object,
required: true,
},
});
const chartRef = ref(null);
let chartInstance = null;
let resizeObserver = null;
// 初始化图表
const initChart = () => {
if (!chartRef.value || chartInstance) return;
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(props.option);
// 使用 ResizeObserver 监听容器尺寸变化
resizeObserver = new ResizeObserver(() => {
chartInstance?.resize();
});
resizeObserver.observe(chartRef.value);
};
onMounted(() => {
// 直接使用 nextTick 通常就够了
nextTick(() => {
initChart();
});
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
resizeObserver = null;
chartInstance?.dispose();
chartInstance = null;
});
// 监听 option 更新
watch(
() => props.option,
(val) => {
if (chartInstance && val) {
chartInstance.setOption(val, true);
}
},
{ deep: true },
);
</script>
<style lang="scss" scoped>
.g3-echarts {
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,50 @@
<script lang="js" setup>
import Notice from "./notice.vue";
import Service from "./service.vue";
import UserReminder from "./user-reminder.vue";
</script>
<template>
<div class="g3-container">
<div class="g3-main">
<div class="notice">
<Notice />
</div>
<div class="service">
<Service />
</div>
</div>
<div class="g3-other">
<UserReminder />
</div>
</div>
</template>
<style lang="scss" scoped>
.g3-container {
height: 100%;
overflow-y: auto;
}
.g3-main {
display: flex;
width: 100%;
height: 340px;
.notice {
min-width: 0;
width: calc(25% - 16px);
}
.service {
margin-left: 16px;
min-width: 0;
flex: 1;
}
}
.g3-other {
width: 100%;
margin-top: 16px;
}
</style>
@@ -0,0 +1,131 @@
<script lang="js" setup>
import { loadDataApi } from "@/request/api";
import { formatToDateTime } from "@/utils/time";
const dataList = ref([]);
const showDialog = ref(false);
const dialogData = ref({});
const showFile = ref(false);
onMounted(async () => {
const loadRes = await loadDataApi("oa_notices", ` b_visible='1'`, "b_inputdate desc");
if (loadRes.success) {
dataList.value = loadRes.data;
}
});
function handleShowDialog(data) {
showDialog.value = true;
dialogData.value = data;
}
</script>
<template>
<el-card shadow="never" class="g3-card">
<template #header>
<div class="g3-card-header">
<span class="title">企业公告</span>
</div>
</template>
<div v-for="item in dataList" class="notice-item" @click="handleShowDialog(item)">
{{ item.b_subject }}
</div>
<el-dialog v-model="showDialog" :title="dialogData.b_subject" :width="800">
<div class="section">
<div class="section-title">发布人</div>
<div class="section-value">
{{ dialogData.b_updateuser_id || dialogData.b_inputuser_id }}
</div>
</div>
<div class="section">
<div class="section-title">发布时间</div>
<div class="section-value">
{{ formatToDateTime(dialogData.b_updatedatetime || dialogData.b_inputdatetime) }}
</div>
</div>
<div>
<div class="section-title">公告内容</div>
<WangEditor
v-model="dialogData.b_info"
:father="dialogData.b_id"
module-id="oa_notices"
display-mode="view"
/>
</div>
<template #footer>
<el-button type="primary" @click="showFile = true">
<el-icon class="el-icon--left">
<G3Icon icon="lucide:paperclip" />
</el-icon>
查看附件
</el-button>
</template>
</el-dialog>
<G3File v-model="showFile" :father="dialogData.b_id" module-id="oa_notices" readonly />
</el-card>
</template>
<style lang="scss" scoped>
.g3-card {
height: 100%;
width: 100%;
border: none;
:deep(.el-card__header) {
padding: 10px 12px !important;
}
:deep(.el-card__body) {
width: 100%;
padding: 10px 12px !important;
}
}
.g3-card-header {
.title {
color: #676a6c;
font-weight: 500;
}
}
.notice-item {
padding: 4px 0px;
cursor: pointer;
border-bottom: 1px solid rgb(239, 239, 245);
width: 100%;
display: block;
font-size: 15px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
&:hover {
color: #337ab7;
}
}
.section {
display: flex;
align-items: center;
margin-bottom: 12px;
.section-title {
color: #606266;
width: 80px;
margin-right: 10px;
flex-shrink: 0;
}
.section-value {
word-break: break-all;
}
}
</style>
@@ -0,0 +1,108 @@
<script lang="js" setup>
import { loadDataApi, loadDataBySqlApi } from "@/request/api";
import G3Echarts from "./g3-echarts.vue";
const dataList = ref([]);
onMounted(async () => {
const loadRes = await loadDataApi("v_cotaniner_echarts");
if (loadRes.success) {
dataList.value = loadRes.data;
}
});
const activeSegment = ref(0);
const segOptions = [
{ label: "按周", value: 0 },
{ label: "按月", value: 1 },
{ label: "按年", value: 2 },
];
// Computed property to dynamically change the title
const chartTitle = computed(() => {
switch (activeSegment.value) {
case 0:
return "最近一周上传数据";
case 1:
return "最近一个月上传数据";
case 2:
return "最近一年上传数据";
default:
return "";
}
});
const chartOption = computed(() => {
const dates = dataList.value.map((item) => item.b_date);
const uploadCounts = dataList.value.map((item) => item.b_count);
return {
title: {
text: chartTitle.value,
},
tooltip: {
trigger: "axis",
},
legend: {
data: ["上传数量"],
},
xAxis: {
type: "category",
data: dates,
},
yAxis: {
type: "value",
axisLabel: {
formatter: "{value}", // 直接输出整数
},
axisTick: {
alignWithLabel: true, // 确保刻度对齐
},
minInterval: 1, // 确保间隔为 1
},
series: [
{
name: "上传数量",
data: uploadCounts,
type: "line", // 修改为折线图
smooth: true, // 平滑曲线
symbol: "circle", // 数据点使用圆形
symbolSize: 6, // 数据点的大小
},
],
};
});
function handleSegmentChange(val) {}
</script>
<template>
<div class="g3-card">
<!-- <el-segmented
v-model="activeSegment"
:options="segOptions"
class="segmented-control"
@change="handleSegmentChange"
/> -->
<G3Echarts :option="chartOption" />
</div>
</template>
<style lang="scss" scoped>
.g3-card {
height: 100%;
width: 100%;
border: none;
background-color: #fff;
position: relative;
}
.segmented-control {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
}
</style>
@@ -0,0 +1,65 @@
<template>
<div class="g3-user-reminder">
<template v-for="item in data" :key="item.b_id">
<ChartPie v-if="item.b_graph === '饼图'" :item="item" />
<ChartLine v-else-if="item.b_graph === '折线图'" :item="item" />
<ChartReleaseBar
v-else-if="item.b_graph === '纵柱状图'"
:item="item"
style="margin-bottom: 25px"
/>
<ChartBar v-else-if="item.b_graph === '柱状图'" :item="item" />
<ValueCard v-else :item="item" class="value-card" />
</template>
</div>
</template>
<script setup lang="js">
import ChartBar from "./components/chart-bar.vue";
import ChartReleaseBar from "./components/chart-release-bar.vue";
import ChartLine from "./components/chart-line.vue";
import ChartPie from "./components/chart-pie.vue";
import ValueCard from "./components/value-card.vue";
import { loadDataBySqlApi } from "@/request/api";
const data = ref([]);
const init = async () => {
const res = await loadDataBySqlApi(`select * from s_awokemodule order by b_xh`);
if (res.success && res.data.length > 0) {
data.value = res.data;
console.log(data.value);
}
};
onMounted(async () => {
await init();
});
</script>
<style lang="scss" scoped>
@use "@/styles/mixins.scss" as *;
.g3-user-reminder {
width: 100%;
@include flexGapWrap(16px, 16px);
.value-card {
width: calc(25% - 16px);
}
.value-card:last-child {
width: 25%;
}
}
.g3-content {
color: #337ab7;
padding: 10px 0px;
cursor: pointer;
border-bottom: 1px solid #e7eaec;
}
:deep(.el-card__body) {
padding: 0px 20px;
}
.el-card {
border: 0px;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { loadDataBySqlApi, saveObjectApi } from "@/request/api";
import { getUniqueIdApi, loadDataBySqlApi, saveObjectApi, saveObjectKeysApi } from "@/request/api";
import { ElMessage, ElMessageBox } from "element-plus";
const props = defineProps({
@@ -54,9 +54,7 @@ const handleRowClick = (row, column, event) => {
}
// 关闭之前行的编辑状态
if (currentEditRow.value !== null) {
const prevRow = qxData.value.find(
(item) => item.id === currentEditRow.value,
);
const prevRow = qxData.value.find((item) => item.id === currentEditRow.value);
if (prevRow) {
console.log(prevRow);
const oldrow = prevRow.originalData;
@@ -95,12 +93,10 @@ const isDataModified = (obj1, obj2) => {
};
// 新增记录
const addNewRow = () => {
async function addNewRow() {
// 如果当前有编辑中的行,先处理
if (currentEditRow.value !== null) {
const prevRow = qxData.value.find(
(item) => item.id === currentEditRow.value,
);
const prevRow = qxData.value.find((item) => item.id === currentEditRow.value);
if (prevRow) {
// handleRowChange(prevRow)
prevRow.isEditing = false;
@@ -109,9 +105,13 @@ const addNewRow = () => {
// 计算新的排序号(最大值+1)
const maxSort =
qxData.value.length > 0
? Math.max(...qxData.value.map((item) => item.b_xh || 0))
: 0;
qxData.value.length > 0 ? Math.max(...qxData.value.map((item) => item.b_xh || 0)) : 0;
const uniqueRes = await getUniqueIdApi();
if (!uniqueRes.success) {
return;
}
const newRow = {
id: generateId(),
@@ -141,7 +141,7 @@ const addNewRow = () => {
});
console.log("新增记录:", newRow);
};
}
// 生成唯一ID
let idCounter = 2;
const generateId = () => {
@@ -194,7 +194,7 @@ async function Load_powerData() {
formPowerData.value = [];
let sql = `
select b_ivalue,max(b_caption) b_caption,max(b_des) b_des from s_module_power
select b_ivalue,max(b_caption) b_caption,max(b_des) b_des from s_module_power
group by b_ivalue order by b_ivalue
`;
console.log("原始sql:", sql);
@@ -307,7 +307,7 @@ const close = (resetData = true) => {
const handleSave = async () => {
// 验证所有修改过的行
const invalidRows = qxData.value.filter((row) => row.isModified || row.isNew);
/*
/*
if (invalidRows.length > 0) {
ElMessage.error('请填写完整的模块和Tag值')
return
@@ -345,7 +345,7 @@ const handleSave = async () => {
const requestData = [
{
tableName: "s_module_power",
keyField: "b_id",
keyFields: ['ModuleID','b_ivalue'],
inserts,
updates,
deletes,
@@ -353,13 +353,13 @@ const handleSave = async () => {
];
try {
const response = await saveObjectApi(requestData);
const response = await saveObjectKeysApi(requestData);
if (response.data?.success) {
if (response.success) {
ElMessage.success("保存成功");
await initData(); // 重新加载数据,这会更新组列表
} else {
throw new Error(response.data?.message || "保存失败");
throw new Error(response.message || "保存失败");
}
} catch (err) {
console.error("保存出错:", err);
@@ -407,10 +407,7 @@ const sqlContent = ref("");
const showqxSql = (row) => {
dialogSQLVisible.value = true;
try {
let sql =
`delete from s_module_power where ModuleID=' ` +
loading.current_moduleid +
`';\n\n`;
let sql = `delete from s_module_power where ModuleID=' ` + loading.current_moduleid + `';\n\n`;
// 使用 reduce 或 forEach 拼接
qxData.value.forEach((item, index) => {
// 建议添加数据校验
@@ -476,29 +473,15 @@ const handleCopySql = async () => {
:max-height="tableHeight"
@row-click="handlemoduleClick"
>
<el-table-column
prop="b_id"
label="模块ID"
align="center"
width="150"
>
<el-table-column prop="b_id" label="模块ID" align="center" width="150">
<template #default="{ row }">
<el-input v-if="row.isEditing" v-model="row.b_id" size="small" />
<span v-else>{{ row.b_id }}</span>
</template>
</el-table-column>
<el-table-column
prop="b_ChineseName"
label="模块名称"
align="center"
width="120"
>
<el-table-column prop="b_ChineseName" label="模块名称" align="center" width="120">
<template #default="{ row }">
<el-input
v-if="row.isEditing"
v-model="row.b_ChineseName"
size="small"
/>
<el-input v-if="row.isEditing" v-model="row.b_ChineseName" size="small" />
<span v-else>{{ row.b_ChineseName }}</span>
</template>
</el-table-column>
@@ -509,24 +492,14 @@ const handleCopySql = async () => {
<div class="g3-row-main">
<div style="width: 100%; margin-bottom: 4px">
<div style="display: flex">
<el-button
type="primary"
:loading="loading.save"
@click="addNewRow"
>新增</el-button
>
<el-button type="primary" :loading="loading.save" @click="addNewRow">
新增
</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button type="primary" @click="showqxSql">SQL语句</el-button>
</div>
</div>
<div
style="
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
"
>
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0">
<el-table
v-loading="loading.qxtable"
:data="qxData"
@@ -536,59 +509,26 @@ const handleCopySql = async () => {
:max-height="tableHeight"
@row-click="handleRowClick"
>
<el-table-column
prop="b_xh"
label="排序"
align="center"
width="50"
>
<el-table-column prop="b_xh" label="排序" align="center" width="50">
<template #default="{ row }">
<el-input
v-if="row.isEditing"
v-model="row.b_xh"
size="small"
/>
<el-input v-if="row.isEditing" v-model="row.b_xh" size="small" />
<span v-else>{{ row.b_xh }}</span>
</template>
</el-table-column>
<el-table-column
v-if="false"
prop="b_id"
label="流水号"
align="center"
width="0"
>
<el-table-column v-if="false" prop="b_id" label="流水号" align="center" width="0">
<template #default="{ row }">
<el-input
v-if="row.isEditing"
v-model="row.b_id"
size="small"
/>
<el-input v-if="row.isEditing" v-model="row.b_id" size="small" />
<span v-else>{{ row.b_id }}</span>
</template>
</el-table-column>
<el-table-column
prop="ModuleID"
label="对应模块"
align="center"
width="120"
>
<el-table-column prop="ModuleID" label="对应模块" align="center" width="120">
<template #default="{ row }">
<el-input
v-if="row.isEditing"
v-model="row.ModuleID"
size="small"
/>
<el-input v-if="row.isEditing" v-model="row.ModuleID" size="small" />
<span v-else>{{ row.ModuleID }}</span>
</template>
</el-table-column>
<el-table-column
prop="b_ivalue"
label="Tag值"
align="center"
width="60"
>
<el-table-column prop="b_ivalue" label="Tag值" align="center" width="60">
<template #default="{ row }">
<el-input
v-if="row.isEditing"
@@ -600,12 +540,7 @@ const handleCopySql = async () => {
<span v-else>{{ row.b_ivalue }}</span>
</template>
</el-table-column>
<el-table-column
prop="b_caption"
label="Caption"
align="center"
width="130"
>
<el-table-column prop="b_caption" label="Caption" align="center" width="130">
<template #default="{ row }">
<el-input
v-if="row.isEditing"
@@ -617,12 +552,7 @@ const handleCopySql = async () => {
<span v-else>{{ row.b_caption }}</span>
</template>
</el-table-column>
<el-table-column
prop="b_des"
label="描述"
align="center"
width="170"
>
<el-table-column prop="b_des" label="描述" align="center" width="170">
<template #default="{ row }">
<el-input
v-if="row.isEditing"
@@ -634,19 +564,9 @@ const handleCopySql = async () => {
<span v-else>{{ row.b_des }}</span>
</template>
</el-table-column>
<el-table-column
fixed="right"
label="操作"
align="center"
width="100"
>
<el-table-column fixed="right" label="操作" align="center" width="100">
<template #default="{ row, $index }">
<el-button
:disabled="false"
type="primary"
link
@click="deleteRow(row, $index)"
>
<el-button :disabled="false" type="primary" link @click="deleteRow(row, $index)">
删除
</el-button>
</template>
@@ -659,22 +579,10 @@ const handleCopySql = async () => {
<div class="g3-row-main">
<div style="width: 100%; margin-bottom: 4px">
<div style="display: flex">
<el-button
type="primary"
:loading="loading.save"
@click="gethispower"
>←导</el-button
>
<el-button type="primary" :loading="loading.save" @click="gethispower">←导</el-button>
</div>
</div>
<div
style="
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
"
>
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0">
<el-table
v-loading="loading.powertable"
:data="formPowerData"
@@ -685,40 +593,17 @@ const handleCopySql = async () => {
ref="powerTableRef"
@selection-change="handleSelectionChange"
>
<el-table-column
type="selection"
width="40"
align="center"
fixed
/>
<el-table-column type="selection" width="40" align="center" fixed />
<el-table-column
prop="b_ivalue"
label="Tag值"
align="center"
width="60"
>
<el-table-column prop="b_ivalue" label="Tag值" align="center" width="60">
<template #default="{ row }">
<el-input
v-if="row.isEditing"
v-model="row.b_ivalue"
size="small"
/>
<el-input v-if="row.isEditing" v-model="row.b_ivalue" size="small" />
<span v-else>{{ row.b_ivalue }}</span>
</template>
</el-table-column>
<el-table-column
prop="b_caption"
label="Caption"
align="center"
width="100"
>
<el-table-column prop="b_caption" label="Caption" align="center" width="100">
<template #default="{ row }">
<el-input
v-if="row.isEditing"
v-model="row.b_caption"
size="small"
/>
<el-input v-if="row.isEditing" v-model="row.b_caption" size="small" />
<span v-else>{{ row.b_caption }}</span>
</template>
</el-table-column>
@@ -53,12 +53,12 @@ function getTableData() {
let searchCondition = "";
if (searchForm.value.name !== "") {
searchCondition = ` (b_id LIKE '%${searchForm.value.name}%' OR b_name LIKE '%${searchForm.value.name}%')`;
if (userid !== "g3soft") {
searchCondition = ` b_id <> 'g3soft' `;
}
if (userid !== "g3soft") {
searchCondition += `AND b_id != 'g3soft' `;
if (searchForm.value.name !== "") {
searchCondition += `AND (b_id LIKE '%${searchForm.value.name}%' OR b_name LIKE '%${searchForm.value.name}%')`;
}
loadPageDataApi({
@@ -209,9 +209,7 @@ async function deleteRows(rows = []) {
return;
}
const selectedIds = rows
.filter((row) => row.b_id !== "g3soft")
.map((row) => row.b_id);
const selectedIds = rows.filter((row) => row.b_id !== "g3soft").map((row) => row.b_id);
const delectedIds = rows
.filter((row) => !$table.isInsertByRow(row) && row.b_id !== "g3soft")
@@ -280,8 +278,7 @@ async function handleSaveRow(row) {
// 1. 检查表格内重复(本地数据)
const duplicate = tableData.value.some(
(item) =>
item.b_id?.trim().toUpperCase() === editId && item.b_id !== row.b_id, // 排除自己
(item) => item.b_id?.trim().toUpperCase() === editId && item.b_id !== row.b_id, // 排除自己
);
if (duplicate) {
ElMessage.warning("账号重复,请修改");
@@ -392,9 +389,7 @@ function handleShowBatchCopyDialog() {
if ($table) {
const records = $table.getCheckboxRecords();
const filteredRecords = records.filter(
(item) => !$table.isInsertByRow(item),
);
const filteredRecords = records.filter((item) => !$table.isInsertByRow(item));
const bIds = filteredRecords.map((item) => item.b_id);
@@ -437,21 +432,13 @@ function handleShowBatchCopyDialog() {
</el-icon>
批量复制权限
</el-button>
<el-button
type="primary"
@click="handleShowCopyDialog"
:disabled="!isSelectedRow"
>
<el-button type="primary" @click="handleShowCopyDialog" :disabled="!isSelectedRow">
<el-icon class="el-icon--left">
<G3Icon icon="lucide:copy" />
</el-icon>
复制权限
</el-button>
<el-button
type="primary"
:disabled="!isSelectedRow"
@click="showEmployee = true"
>
<el-button type="primary" :disabled="!isSelectedRow" @click="showEmployee = true">
<el-icon class="el-icon--left">
<G3Icon icon="lucide:book-user" />
</el-icon>
@@ -573,18 +560,10 @@ function handleShowBatchCopyDialog() {
}"
>
<template #default="{ row }">
<el-checkbox
v-model="row.b_canuse"
true-value="1"
false-value="0"
/>
<el-checkbox v-model="row.b_canuse" true-value="1" false-value="0" />
</template>
<template #edit="{ row }">
<el-checkbox
v-model="row.b_canuse"
true-value="1"
false-value="0"
/>
<el-checkbox v-model="row.b_canuse" true-value="1" false-value="0" />
</template>
</vxe-column>
<vxe-column
@@ -628,12 +607,7 @@ function handleShowBatchCopyDialog() {
>
保存
</el-button>
<el-button
link
@click="cancelEditRow(row)"
@click.stop
style="margin-left: 6px"
>
<el-button link @click="cancelEditRow(row)" @click.stop style="margin-left: 6px">
取消
</el-button>
</template>
+153 -84
View File
@@ -1,101 +1,170 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>自适应 Descriptions 每行自动填满</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
}
<meta charset="UTF-8" />
<title>数据看板</title>
/* 每行一个 grid 容器 */
.row {
display: grid;
border-top: 1px solid #ccc;
border-left: 1px solid #ccc;
margin-bottom: 10px;
gap: 0;
/* 紧凑像表格 */
}
/* item 样式 */
.item {
display: flex;
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
min-height: 40px;
}
.label {
flex: 0 0 100px;
/* label 固定宽度 */
background-color: #e0f7fa;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid #ccc;
}
.value {
flex: 1;
background-color: #fff9c4;
display: flex;
align-items: center;
justify-content: center;
}
/* 响应式:小屏幕每个 item 占满整行 */
@media (max-width: 500px) {
.row {
grid-template-columns: 1fr !important;
}
}
</style>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
</head>
<body>
<body class="bg-gray-100">
<h2>自适应 Descriptions 示例</h2>
<div class="p-4">
<!-- 第一行 2 列 -->
<div class="row" style="grid-template-columns: repeat(2, 1fr);">
<div class="item">
<span class="label">姓名:</span>
<span class="value">张三</span>
<!-- 顶部 -->
<div class="flex justify-between items-center mb-4">
<h1 class="text-xl font-bold">数据看板</h1>
<button id="refreshBtn" class="bg-blue-500 text-white px-3 py-1 rounded" onclick="refreshData()">
🔄 刷新
</button>
</div>
<div class="item">
<span class="label">年龄:</span>
<span class="value">18</span>
<!-- KPI -->
<div id="kpi" class="grid grid-cols-6 gap-3 mb-4"></div>
<!-- ⭐ 公告(不占全宽,简洁版) -->
<div class="bg-orange-100 border border-orange-300 text-orange-800 px-4 py-3 rounded mb-4 w-full sm:w-2/3 md:w-1/2">
<div class="text-lg font-bold">📢 公告:</div>
<div id="notice" class="text-sm space-y-3">
<!-- 公告内容会由 JS 填充 -->
</div>
</div>
<!-- 趋势图 -->
<div class="bg-white p-4 rounded shadow mb-4">
<div class="mb-2 font-semibold">近7天上传趋势</div>
<div id="trend" style="height:260px;"></div>
</div>
<!-- 中间区域 -->
<div class="grid grid-cols-3 gap-4">
<!-- 排行 -->
<div class="bg-white p-4 rounded shadow col-span-2">
<div class="mb-2 font-semibold">操作员上传排行</div>
<div id="rank" style="height:260px;"></div>
</div>
<!-- 最新上传 -->
<div class="bg-white p-4 rounded shadow">
<div class="mb-2 font-semibold">最新上传</div>
<table class="w-full text-sm">
<thead>
<tr class="text-gray-500">
<th>箱号</th>
<th>操作员</th>
<th>时间</th>
<th>数量</th>
</tr>
</thead>
<tbody id="table"></tbody>
</table>
</div>
</div>
</div>
<!-- 第二行 1 列 -->
<div class="row" style="grid-template-columns: repeat(1, 1fr);">
<div class="item">
<span class="label">邮箱:</span>
<span class="value">zhangsan@example.com</span>
</div>
</div>
<script>
// 模拟数据
function getData() {
return {
kpi: [
["今日箱数", rand(80, 150)],
["照片总数", rand(300, 800)],
["活跃操作员", rand(5, 20)],
["最近上传", "10:" + rand(10, 59)],
["异常箱数", rand(0, 10)],
["待处理", rand(0, 5)],
],
trend: Array.from({ length: 7 }, () => rand(50, 150)),
rank: Array.from({ length: 5 }, () => rand(40, 120)),
users: ["张三", "李四", "王五", "赵六", "钱七"],
list: [
["MSCU1234567", "张三", "10:30", 5],
["TEMU7654321", "李四", "10:20", 4],
["CMAU9988776", "王五", "10:10", 6],
],
notice: [
"📌 今日起必须上传封条照片",
"📌 晚班请补录遗漏数据",
"📌 周五晚系统维护"
],
}
}
<!-- 第三行 3 列 -->
<div class="row" style="grid-template-columns: repeat(3, 1fr);">
<div class="item">
<span class="label">城市:</span>
<span class="value">北京</span>
function rand(min, max) { return Math.floor(Math.random() * (max - min) + min) }
let trendChart = echarts.init(document.getElementById('trend'));
let rankChart = echarts.init(document.getElementById('rank'));
function render() {
const data = getData();
// KPI 渲染
document.getElementById("kpi").innerHTML = data.kpi.map(i => `
<div class="bg-white p-3 rounded shadow">
<div class="text-gray-400 text-xs">${i[0]}</div>
<div class="text-xl font-bold mt-1">${i[1]}</div>
</div>
<div class="item">
<span class="label">职业:</span>
<span class="value">学生</span>
</div>
<div class="item">
<span class="label">爱好:</span>
<span class="value">篮球</span>
</div>
</div>
`).join("");
// 公告内容
document.getElementById("notice").innerHTML =
data.notice.map(i => `
<div class="bg-orange-50 border-l-4 border-orange-400 px-4 py-2 rounded shadow-sm">
${i}
</div>
`).join("");
// 表格渲染
document.getElementById("table").innerHTML = data.list.map(i => `
<tr class="border-t">
<td>${i[0]}</td>
<td>${i[1]}</td>
<td>${i[2]}</td>
<td>${i[3]}</td>
</tr>
`).join("");
// 趋势图
trendChart.setOption({
xAxis: { type: 'category', data: ['一', '二', '三', '四', '五', '六', '日'] },
yAxis: { type: 'value' },
series: [{
data: data.trend,
type: 'line',
smooth: true
}]
});
// 排行图
rankChart.setOption({
xAxis: { type: 'category', data: data.users },
yAxis: { type: 'value' },
series: [{
data: data.rank,
type: 'bar'
}]
});
}
function refreshData() {
const btn = document.getElementById("refreshBtn");
btn.innerText = "刷新中...";
btn.disabled = true;
setTimeout(() => {
render();
btn.innerText = "🔄 刷新";
btn.disabled = false;
}, 800);
}
render();
</script>
</body>
@@ -14,6 +14,7 @@ import userReminder from '@/views/home-page/userReminder.vue'
<serviceVolume />
</div>
</div>
<div class="g3-row g3-userReminder">
<userReminder />
</div>