This commit is contained in:
oneao committed 2026-03-19 22:15:36 +08:00
1 parent 4aaa93df7d
commit c330e52fd3
66 files changed
+7386 -4

No files matched your search

-1
View File
@@ -6,7 +6,6 @@
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="ai-demo" />
</profile>
</annotationProcessing>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
</component>
</project>
+4 -3
View File
@@ -21,9 +21,9 @@
</MavenGeneralSettings>
</option>
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 0
}]]></component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 0
}</component>
<component name="ProjectId" id="3B4WeoSJ6hbOtn69mZjcFoJWHtB" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
@@ -34,6 +34,7 @@
"Application.Main.executor": "Run",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "master",
"kotlin-language-version-configured": "true",
"onboarding.tips.debug.path": "D:/workspace/code/ai-demo/src/main/java/org/example/Main.java",
@@ -0,0 +1,6 @@
Chrome >= 49
Firefox >= 45
Safari >= 10
Edge >= 15
iOS >= 9
Android >= 4.4
+13
View File
@@ -0,0 +1,13 @@
# Application
# 应用名称(用于标题、日志等)
PUBLIC_APP_NAME=g3hy2026
# Server
# 应用部署基础路径(路由和静态资源 base)
PUBLIC_SERVER_PATH=/g3hy2026/
# 本地开发服务器端口
PUBLIC_SERVER_PORT=3000
# API
# 后端 API 基础地址
PUBLIC_API_BASE_URL=http://127.0.0.1:8084/api
+22
View File
@@ -0,0 +1,22 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"editor.cursorSmoothCaretAnimation": "on",
"oxc.fmt.configPath": ".oxfmtrc.json",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"Print Vue3 SFC": {
"scope": "vue",
"prefix": "v3",
"body": [
"<script lang=\"js\" setup>",
"",
"</script>",
"",
"<template>",
" <div class=\"\"></div>",
"</template>",
"",
"<style lang=\"css\" scoped>",
"",
"</style>",
],
"description": "生成 Vue3 SFC 文件(无注释)",
},
}
+36
View File
@@ -0,0 +1,36 @@
# Rsbuild project
## Setup
Install the dependencies:
```bash
pnpm install
```
## Get started
Start the dev server, and the app will be available at [http://localhost:3000](http://localhost:3000).
```bash
pnpm run dev
```
Build the app for production:
```bash
pnpm run build
```
Preview the production build locally:
```bash
pnpm run preview
```
## Learn more
To learn more about Rsbuild, check out the following resources:
- [Rsbuild documentation](https://rsbuild.rs) - explore Rsbuild features and APIs.
- [Rsbuild GitHub repository](https://github.com/web-infra-dev/rsbuild) - your feedback and contributions are welcome!
+45
View File
@@ -0,0 +1,45 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElIcon: typeof import('element-plus/es')['ElIcon']
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']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
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']
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']
G3EditTable: typeof import('./src/components/g3-table/G3EditTable.vue')['default']
G3Grid: typeof import('./src/components/g3-grid/g3-grid.vue')['default']
G3GridGroup: typeof import('./src/components/g3-grid/g3-grid-group.vue')['default']
G3GridItem: typeof import('./src/components/g3-grid/g3-grid-item.vue')['default']
G3Icon: typeof import('./src/components/g3-icon/index.vue')['default']
G3RecursiveColumn: typeof import('./src/components/g3-table/G3RecursiveColumn.vue')['default']
G3Select: typeof import('./src/components/g3-select/index.vue')['default']
G3TableView: typeof import('./src/components/g3-table/g3-table-view.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<script>
// IE11 检测
if (!!window.MSInputMethodContext && !!document.documentMode) {
document.write(
'<div style="display:flex;justify-content:center;align-items:center;height:100vh;font-size:20px;">' +
'您的浏览器版本过低,请使用 Chrome / Edge / Firefox 浏览器访问' +
'</div>'
);
}
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "rsbuild-project",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "rsbuild build",
"dev": "rsbuild dev --open",
"preview": "rsbuild preview"
},
"dependencies": {
"@iconify/vue": "^5.0.0",
"axios": "^1.13.6",
"element-plus": "^2.13.3",
"lodash": "^4.17.23",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"uuid": "^13.0.0",
"vue": "^3.5.26",
"vue-router": "^5.0.3",
"vue3-puzzle-vcode": "^1.1.7",
"vxe-pc-ui": "4.13.0",
"vxe-table": "4.17.49",
"xe-utils": "3.9.1"
},
"devDependencies": {
"@iconify/json": "^2.2.446",
"@rsbuild/core": "^1.7.1",
"@rsbuild/plugin-babel": "^1.1.2",
"@rsbuild/plugin-check-syntax": "^1.6.1",
"@rsbuild/plugin-sass": "^1.5.1",
"@rsbuild/plugin-vue": "^1.2.2",
"@rsbuild/plugin-vue-jsx": "^1.1.2",
"oxfmt": "^0.40.0",
"resize-observer-polyfill": "^1.5.1",
"unplugin-icons": "^23.0.1"
}
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,73 @@
// @ts-check
import { defineConfig } from "@rsbuild/core";
import { pluginVue } from "@rsbuild/plugin-vue";
import { pluginSass } from "@rsbuild/plugin-sass";
import AutoImport from "unplugin-auto-import/rspack";
import Components from "unplugin-vue-components/rspack";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
import { pluginCheckSyntax } from "@rsbuild/plugin-check-syntax";
import { pluginBabel } from "@rsbuild/plugin-babel";
import { pluginVueJsx } from "@rsbuild/plugin-vue-jsx";
import IconsResolver from "unplugin-icons/resolver";
import Icons from "unplugin-icons/rspack";
export default defineConfig({
plugins: [
pluginBabel({
include: /\.[jt]sx?$/,
}),
pluginVue(),
pluginVueJsx(),
pluginSass(),
pluginCheckSyntax({
ecmaVersion: 2015,
}),
],
tools: {
rspack: {
plugins: [
Icons({
compiler: "vue3",
autoInstall: true,
}),
AutoImport({
imports: ["vue", "vue-router"],
resolvers: [ElementPlusResolver()],
}),
Components({
dirs: ['src/components'],
dts: true,
deep: true,
resolvers: [ElementPlusResolver(), IconsResolver()],
}),
],
},
},
source: {
include: [{ not: /[\\/]core-js[\\/]/ }],
entry: {
index: "./src/main.js",
},
tsconfigPath: "./jsconfig.json",
},
output: {
polyfill: "usage",
},
html: {
template: "./index.html",
mountId: "app",
meta: {
viewport: false,
},
favicon: "./public/logo.png",
title: process.env.PUBLIC_APP_NAME,
},
server: {
base: process.env.PUBLIC_SERVER_PATH || "/",
port: Number(process.env.PUBLIC_SERVER_PORT) || 3000,
},
});
+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script lang="js" setup></script>
<style scoped></style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,47 @@
<script setup lang="js">
const props = defineProps({
column: {
type: Number,
default: 1,
},
width: {
type: [String, Number],
},
border: {
type: Boolean,
default: true,
},
labelWidth: {
type: [String, Number],
},
});
const gridStyle = computed(() => ({
width: props.width
? typeof props.width === "number"
? props.width + "px"
: props.width
: "100%",
gridTemplateColumns: `repeat(${props.column}, 1fr)`,
}));
// 传递给子组件
provide("G3GridGroupConfig", toRefs(props));
</script>
<template>
<div class="g3-grid-group" :class="{ border: props.border }" :style="gridStyle">
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.g3-grid-group {
display: grid;
&.border {
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
}
</style>
@@ -0,0 +1,223 @@
<script setup lang="js">
// 子组件
const props = defineProps({
border: {
type: Boolean,
default: undefined,
},
labelWidth: {
type: [String, Number],
default: undefined,
},
height: {
type: [String, Number],
},
span: {
type: Number,
default: 1,
},
label: {
type: String,
},
content: {
type: String,
},
labelAlign: {
type: String,
default: "right",
validator: (v) => ["left", "center", "right"].includes(v),
},
required: {
type: Boolean,
default: false,
},
});
const parentConfig = inject("G3GridGroupConfig", {});
const mergedBorder = computed(() => {
const val = props.border !== undefined ? props.border : parentConfig.border?.value;
return val;
});
const gridItemStyle = computed(() => {
const style = {
gridColumn: `span ${props.span}`,
};
if (props.height !== undefined && props.height !== null) {
style.height = typeof props.height === "number" ? props.height + "px" : props.height;
}
return style;
});
const gridLabelStyle = computed(() => {
const val = props.labelWidth ?? parentConfig.labelWidth?.value;
const alignVal = props.labelAlign ?? parentConfig.labelAlign?.value ?? "right";
const justifyMap = {
left: "flex-start",
center: "center",
right: "flex-end",
};
return {
flex: `0 0 ${typeof val === "number" ? val + "px" : val}`,
display: "flex",
alignItems: "center",
justifyContent: justifyMap[alignVal],
};
});
const requiredStyle = computed(() => {
const alignVal = props.labelAlign ?? parentConfig.labelAlign?.value ?? "right";
return alignVal === "right" ? { flexDirection: "row-reverse" } : {};
});
</script>
<template>
<div class="g3-grid-item" :class="{ border: mergedBorder }" :style="gridItemStyle">
<!-- label -->
<div
class="label"
:class="{ required, 'is-slot': $slots.label }"
:style="[gridLabelStyle, requiredStyle]"
>
<span class="label-inner">
<slot name="label">
{{ label }}
</slot>
</span>
</div>
<!-- content -->
<div class="content">
<slot>
{{ content }}
</slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.g3-grid-item {
display: flex;
align-items: stretch;
&.border {
border-top: 1px solid #ccc;
border-left: 1px solid #ccc;
}
.label {
background-color: #f9f9f9;
border-right: 1px solid #ccc;
font-size: 14px;
height: 100%;
display: flex;
position: relative;
min-width: 0;
&.required::before {
content: "*";
color: #f56c6c;
margin-right: 4px;
font-weight: bold;
}
&.is-slot .label-inner {
padding: 0;
}
.label-inner {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
padding: 0px 4px;
}
}
.content {
flex: 1;
background-color: #fff;
height: 100%;
display: flex;
align-items: center;
}
:deep(.el-input) {
width: 100%;
height: 100%;
.el-input__wrapper {
width: 100%;
height: 100%;
border-radius: 0;
box-shadow: none;
border: none;
transition: box-shadow 0.15s;
&:hover {
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-7);
}
&.is-focus {
box-shadow: inset 0 0 0 1px var(--el-color-primary);
}
}
}
:deep(.el-select) {
width: 100% !important;
height: 100% !important;
.el-select__wrapper {
width: 100% !important;
height: 100% !important;
border-radius: 0;
min-height: 0px;
box-shadow: none;
border: none;
transition: box-shadow 0.15s;
&.is-hovering {
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-7);
}
&.is-focused {
box-shadow: inset 0 0 0 1px var(--el-color-primary);
}
}
}
:deep(.el-textarea) {
height: 100%;
background: #fff;
.el-textarea__inner {
height: 100%;
background: #fff;
border-radius: 0px;
box-shadow: unset;
min-height: 100% !important;
z-index: 10;
padding: 0px 4px;
display: flex;
align-items: center;
&:hover {
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-7);
}
&:focus {
box-shadow: inset 0 0 0 1px var(--el-color-primary);
outline: none;
z-index: 12;
}
}
}
}
</style>
@@ -0,0 +1,44 @@
<script setup lang="js">
const props = defineProps({
direction: {
type: String,
default: "row",
validator: (v) => ["row", "column"].includes(v),
},
gap: {
type: [String, Number],
default: 28,
},
});
</script>
<template>
<div
class="g3-grid"
:class="props.direction === 'row' ? 'row-gap' : 'column-gap'"
:style="{
'--gap': typeof props.gap === 'number' ? props.gap + 'px' : props.gap,
}"
>
<slot />
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/mixins.scss" as *;
.g3-grid {
display: flex;
flex-wrap: wrap;
&.row-gap {
flex-direction: row;
@include flexGapRow(var(--gap));
}
&.column-gap {
flex-direction: column;
@include flexGapColumn(var(--gap));
}
}
</style>
@@ -0,0 +1,72 @@
<script setup>
import { Icon } from "@iconify/vue";
import { computed } from "vue";
// Props
const props = defineProps({
icon: {
type: String,
required: true,
},
size: {
type: [Number, String],
},
color: {
type: String,
default: "currentColor",
},
spin: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["click"]);
const fontStyle = computed(() => ({
...(props.size ? { fontSize: `${props.size}px` } : {}),
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 1,
}));
</script>
<template>
<i
class="g3-icon"
:class="[props.spin && 'g3-icon-spin']"
:style="fontStyle"
@click="(e) => emit('click', e)"
>
<Icon v-if="props.icon" :icon="props.icon" :color="props.color" class="g3-icon-svg" />
<slot />
</i>
</template>
<style scoped>
.g3-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1em;
height: 1em;
}
.g3-icon-svg {
display: block;
width: 1em;
height: 1em;
}
.g3-icon-spin {
animation: spin 1s linear infinite;
--rotate-angle: 360deg;
}
@keyframes spin {
to {
transform: rotate(var(--rotate-angle));
}
}
</style>
@@ -0,0 +1,368 @@
<script lang="js" setup>
import { loadPageDataApi } from "@/request/api";
import { ClickOutside as vClickOutside } from "element-plus";
import { useThrottle } from "@/utils/throttle";
// ===============================
// Props
// ===============================
const props = defineProps({
modelValue: { type: null, default: "" },
label: { type: String, default: "" },
labelKey: { type: String, default: "b_name" },
valueKey: { type: String, default: "b_id" },
fitInputWidth: { type: Boolean, default: true },
viewName: { type: String, default: "" },
searchCondition: { type: String, default: "" },
searchColumns: { type: Array, default: () => [] },
orderField: { type: String, default: "" },
sql: { type: String, default: "" },
});
// ===============================
// Emits
// ===============================
const emits = defineEmits(["update:modelValue", "update:label", "change"]);
// ===============================
// Refs & Reactive
// ===============================
const innerInput = ref(props.label); // 输入框内部值
const inputRef = ref(); // el-input ref
const popoverRef = ref(); // el-popover ref
const scrollbarRef = ref(); // 滚动条 ref
const popoverVisible = ref(false); // 弹窗显示状态
const popoverWidth = ref(""); // 弹窗宽度
const options = ref([]); // 下拉选项
const loading = ref(false); // 加载状态
const inputFocus = ref(false); // 输入框焦点状态
const highlightIndex = ref(0); // 高亮索引
const inputChange = ref(false); // 输入变化标记
const pagination = reactive({ page: 1, size: 10, total: 0 }); // 分页
const popperPaneRef = computed(() => popoverRef.value?.popperRef?.contentRef); // Popover 内容 DOM
const searchColumns = computed(() => [
props.labelKey,
props.valueKey,
...props.searchColumns,
]); // 搜索列
// ===============================
// Watchers
// ===============================
watch(
() => props.label,
(newVal) => {
innerInput.value = newVal;
},
);
// ===============================
// Popover 生命周期
// ===============================
function onPopoverBeforeEnter() {
if (props.fitInputWidth)
popoverWidth.value = `${inputRef.value.$el.offsetWidth}px`;
}
function onPopoverAfterLeave() {
pagination.page = 1;
pagination.total = 0;
options.value = [];
highlightIndex.value = 0;
inputChange.value = false;
}
function onPopoverBeforeLeave() {
// 弹窗关闭前恢复或清空状态
if (props.label) {
if (innerInput.value === "") {
emits("update:label", "");
emits("update:modelValue", "");
emits("change", null);
} else {
innerInput.value = props.label;
}
}
}
// ===============================
// 输入框事件
// ===============================
function onFocus() {
inputFocus.value = true;
}
function onBlur() {
inputFocus.value = false;
}
function onInput(value) {
pagination.page = 1;
pagination.total = 0;
highlightIndex.value = 0;
inputChange.value = true;
fetchOptions(value);
}
function onClick(event) {
event.stopPropagation();
if (!popoverVisible.value) {
inputChange.value = false;
fetchOptions("");
}
}
function closePopover() {
popoverVisible.value = false;
}
// ===============================
// 键盘操作
// ===============================
function onKeydown(e) {
if (e.key === "ArrowDown") {
e.preventDefault();
moveHighlight(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
moveHighlight(-1);
} else if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
if (!popoverVisible.value) {
inputChange.value = false;
fetchOptions("");
} else {
let index = highlightIndex.value;
if (index < 0) index = 0;
handleSelectOption(options.value[index]);
}
} else if (e.key === "Escape") {
popoverVisible.value = false;
}
}
// ===============================
// 高亮与滚动
// ===============================
function moveHighlight(direction) {
const total = options.value.length;
if (!total) return;
let newIndex = highlightIndex.value + direction;
if (newIndex < 0) newIndex = total - 1;
if (newIndex >= total) newIndex = 0;
highlightIndex.value = newIndex;
scrollToOption(newIndex);
}
function scrollToOption(index) {
nextTick(() => {
const wrap = scrollbarRef.value?.wrapRef;
if (!wrap) return;
const opts = wrap.querySelectorAll(".option");
const el = opts[index];
if (el) el.scrollIntoView({ block: "nearest" });
});
}
// ===============================
// 数据请求
// ===============================
function _fetchOptions(searchTitle) {
loading.value = true;
let searchCondition = props.searchCondition || "";
if (searchTitle) {
const condition = `${props.labelKey} like '%${searchTitle}%'`;
searchCondition = searchCondition
? `${searchCondition} AND ${condition}`
: condition;
}
if (!inputChange.value) searchCondition = "";
loadPageDataApi({
page: pagination.page,
size: pagination.size,
viewName: props.viewName,
searchColumns: searchColumns.value,
searchCondition,
orderField: props.orderField,
})
.then((res) => {
if (res.success) {
options.value =
pagination.page === 1
? res.data.rows
: [...options.value, ...res.data.rows];
pagination.total = res.data.total;
popoverVisible.value = true
}
})
.finally(() => {
loading.value = false;
});
}
const fetchOptions = useThrottle(_fetchOptions, 300);
// ===============================
// 滚动加载
// ===============================
function onScrollbarReachEnd() {
if (loading.value) return;
if (pagination.page * pagination.size >= pagination.total) return;
pagination.page += 1;
fetchOptions(innerInput.value);
}
// ===============================
// 选中操作
// ===============================
function handleSelectOption(option) {
if (!option) return;
emits("update:label", option[props.labelKey]);
emits("update:modelValue", option[props.valueKey]);
emits("change", option);
popoverVisible.value = false;
}
</script>
<template>
<el-popover
ref="popoverRef"
trigger="manual"
:visible="popoverVisible"
placement="bottom"
popper-style="padding:0"
transition="el-zoom-in-top"
:width="popoverWidth"
@before-enter="onPopoverBeforeEnter"
@before-leave="onPopoverBeforeLeave"
@after-leave="onPopoverAfterLeave"
>
<template #reference>
<el-input
v-model="innerInput"
ref="inputRef"
@click.stop="onClick"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
v-click-outside:[popperPaneRef]="closePopover"
@keydown="onKeydown"
v-bind="$attrs"
>
<template #suffix>
<el-icon
class="el-input__icon arrow-icon"
:class="{
rotate: inputFocus && !loading,
spinning: loading,
}"
>
<G3Icon :icon="loading ? 'lucide:loader' : 'ep:arrow-down'" />
</el-icon>
</template>
</el-input>
</template>
<div class="option-wrapper" @mousedown.prevent>
<div v-if="!loading && options.length === 0" class="empty">
<span>暂无数据</span>
</div>
<template v-else>
<el-scrollbar
ref="scrollbarRef"
max-height="260px"
:distance="20"
@end-reached="onScrollbarReachEnd"
>
<div
v-for="(item, index) in options"
:key="item[props.valueKey]"
class="option"
:class="{
selected: props.modelValue === item[valueKey],
highlighted: highlightIndex === index,
}"
@click="handleSelectOption(item)"
>
{{ item[labelKey] }}
</div>
</el-scrollbar>
</template>
</div>
</el-popover>
</template>
<style lang="scss" scoped>
.arrow-icon {
transition: transform 0.25s ease;
}
.arrow-icon.rotate {
transform: rotate(180deg);
}
.arrow-icon.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.option-wrapper {
.loading {
height: 60px;
}
.empty {
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder);
font-size: var(--el-font-size-small);
}
.option {
font-size: var(--el-font-size-base);
padding: 0 32px 0 20px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--el-text-color-regular);
height: 34px;
line-height: 34px;
box-sizing: border-box;
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-light);
}
&.selected {
color: var(--el-color-primary);
font-weight: 700;
}
&.highlighted {
background-color: var(--el-fill-color-light);
}
}
}
</style>
@@ -0,0 +1,7 @@
<script lang="js" setup></script>
<template>
<div class=""></div>
</template>
<style lang="scss" scoped></style>
@@ -0,0 +1,17 @@
<script lang="js" setup>
defineOptions({
name: "G3RecursiveColumn",
});
const isGroup = computed(() => {
return Array.isArray(props.column.children) && props.column.children.length > 0;
});
</script>
<template>
<vxe-colgroup v-if="isGroup" :title="column.label" :field="column.field">
</vxe-colgroup>
</template>
<style lang="scss" scoped></style>
@@ -0,0 +1,20 @@
<script lang="js" setup>
const props = defineProps({
field: Object,
});
</script>
<template>
<template v-if="field && field.col_edit_type">
<el-input v-if="field.col_edit_type === 'textarea'" type="textarea" v-bind="$attrs" />
<el-date-picker v-else-if="field.col_edit_type === 'date'" type="date" v-bind="$attrs" />
<el-date-picker
v-else-if="field.col_edit_type === 'datetime'"
type="datetime"
v-bind="$attrs"
/>
<el-input v-else v-bind="$attrs" />
</template>
</template>
<style lang="css" scoped></style>
@@ -0,0 +1,25 @@
<script lang="js" setup>
const props = defineProps({
viewName: {
type: String,
required: true,
},
});
const loading = ref(true);
</script>
<template>
<vxe-table
border
stripe
header-align="center"
:column-drag-config="{
showIcon: false,
trigger: 'cell',
}"
>
</vxe-table>
</template>
<style lang="scss" scoped></style>
@@ -0,0 +1,109 @@
<script setup>
import { useTabStore } from "@/store/tab";
import emitter from "@/utils/emitter";
import { useRoute } from "vue-router";
const tabStore = useTabStore();
const refreshKey = ref(0);
// keep-alive include keys
const includeKeys = computed(() => tabStore.cachedKeys);
// 缓存 wrapper 组件实例
const wrapperMap = new Map();
// 刷新函数
function refresh() {
refreshKey.value += 1;
}
// 生成 wrapper 组件,用于 keep-alive
function formatComponentInstance(component, fullPath) {
if (!component) return null;
let wrapper = wrapperMap.get(fullPath);
if (!wrapper) {
wrapper = defineComponent({
name: fullPath,
inheritAttrs: false,
setup(_, { attrs }) {
return () => h(component, attrs); // 保证 props 响应式
},
});
wrapperMap.set(fullPath, wrapper);
}
return wrapper;
}
// watch tabs 变化,自动清理 wrapper
watch(
() => tabStore.tabs,
(tabs) => {
const alive = new Set(tabs.map((t) => t.fullPath));
for (const key of wrapperMap.keys()) {
if (!alive.has(key)) {
wrapperMap.delete(key);
}
}
},
{ deep: true },
);
// 监听刷新事件
onMounted(() => {
emitter.on("view.refresh", refresh);
});
onUnmounted(() => {
emitter.off("view.refresh", refresh);
});
</script>
<template>
<div class="g3-content">
<RouterView v-slot="{ Component, route: childRoute }">
<Transition name="fade" mode="out-in">
<template v-if="childRoute.meta.keepAlive">
<keep-alive :include="includeKeys">
<component
:is="formatComponentInstance(Component, childRoute.fullPath)"
:key="childRoute.fullPath + '?' + refreshKey"
/>
</keep-alive>
</template>
<template v-else>
<component :is="Component" :key="childRoute.fullPath + '?' + refreshKey" />
</template>
</Transition>
</RouterView>
</div>
</template>
<style scoped lang="scss">
.g3-content {
flex: 1;
overflow: hidden;
box-sizing: border-box;
position: relative;
margin: 6px;
}
/* 动画效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>
@@ -0,0 +1,50 @@
<template>
<el-sub-menu v-if="hasChildren" :index="menuIndex">
<template #title>
<el-icon v-if="item.icon">
<G3Icon :icon="item.icon" />
</el-icon>
<span class="menu-label">{{ item.label }}</span>
</template>
<MenuItem
v-for="(child, idx) in item.children"
:key="child.path || idx"
:item="child"
:parent-index="menuIndex + '-' + idx"
/>
</el-sub-menu>
<el-menu-item
v-else
:index="item.path"
:route="{
path: item.path,
}"
>
<el-icon v-if="item.icon">
<G3Icon :icon="item.icon" />
</el-icon>
<span class="menu-label">{{ item.label }}</span>
</el-menu-item>
</template>
<script setup lang="ts">
const props = defineProps({
item: { type: Object, required: true },
parentIndex: { type: String, default: "0" },
});
const hasChildren = props.item.children && props.item.children.length > 0;
// 父菜单没有 path,用索引生成唯一 index,子菜单 path 就用真实路由
const menuIndex = props.item.path || props.parentIndex;
</script>
<style scoped>
.menu-label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,162 @@
<script lang="js" setup>
import { loadDataApi } from "@/request/api";
import MenuItem from "./menu-item.vue";
import { useRoute } from "vue-router";
const title = ref("G3后台管理系统");
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;
}
});
});
const menuList = ref([
{
label: "首页",
path: "/",
icon: "lucide:house",
},
{
label: "业务管理",
icon: "lucide:briefcase-business",
children: [
{
label: "海运管理",
path: "/business",
},
{
label: "装箱统计",
path: "/business/field",
},
],
},
{
label: "系统管理",
icon: "lucide:settings",
children: [
{
label: "字段配置",
path: "/system/field",
},
],
},
]);
const selectedKey = computed(() => route.fullPath);
function handleMenuChange(val) {
router.push(val);
}
</script>
<template>
<div class="g3-menu">
<div class="g3-logo">
<div class="logo-inner">
<img src="@/assets/logo.png" alt="logo" />
<div class="title">{{ title }}</div>
</div>
</div>
<el-menu
class="el-menu-vertical"
:collapse-transition="false"
:default-active="selectedKey"
@select="handleMenuChange"
>
<MenuItem
v-for="(item, idx) in menuList"
:key="item.path || idx"
:item="item"
:parent-index="idx.toString()"
/>
</el-menu>
</div>
</template>
<style lang="scss" scoped>
.g3-menu {
height: 100%;
width: 100%;
background-color: #ffffff;
display: flex;
flex-direction: column;
}
.g3-logo {
display: flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 16px 4px;
cursor: pointer;
width: 100%;
.logo-inner {
display: flex;
align-items: center;
max-width: 100%;
}
img {
height: 24px;
width: 80px;
flex-shrink: 0;
}
.title {
margin-left: 10px;
font-size: 16px;
font-weight: 700;
color: #70cfb1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - 90px);
}
}
.el-menu-vertical {
overflow-y: auto;
border: none;
margin: 6px;
display: flex;
flex-direction: column;
gap: 4px;
:deep(.el-sub-menu__title) {
height: 36px !important;
line-height: 36px !important;
margin-bottom: 4px;
}
:deep(.el-menu-item) {
height: 36px !important;
line-height: 36px !important;
border-radius: 6px;
&.is-active {
background-color: var(--el-color-primary-light-8);
color: var(--el-color-primary);
}
}
:deep(.el-sub-menu) {
.el-sub-menu__title {
border-radius: 6px;
}
.el-menu-item {
padding-left: 48px !important;
}
&.is-active {
.el-sub-menu__title {
color: var(--el-color-primary);
}
}
}
}
</style>
@@ -0,0 +1,352 @@
<script lang="js" setup>
import { useTabStore } from "@/store/tab";
import { useRoute } from "vue-router";
import emitter from "@/utils/emitter";
const { refs, setRefs } = useRefs();
const showScroll = ref(false);
const route = useRoute();
const router = useRouter();
const tabStore = useTabStore();
const fullScreen = ref(false);
const emits = defineEmits(["fullScreen-change"]);
watch(
() => tabStore.activeTab,
(newVal) => {
nextTick(() => {
updateScroll();
const tabIndex = tabStore.findTabIndex(newVal);
moveScroll(tabIndex);
});
},
);
function useRefs() {
const refs = reactive({});
function setRefs(name) {
return (el) => {
refs[name] = el;
return () => refs[name];
};
}
return { refs, setRefs };
}
function updateScroll() {
const wrap = refs.scroller;
if (!wrap) return;
showScroll.value = wrap.scrollWidth > wrap.clientWidth;
}
function moveScroll(index) {
nextTick(() => {
const el = refs[`item-${index}`];
const wrap = refs.scroller;
if (!el || !wrap) return;
let attempts = 0;
const maxAttempts = 5;
const scrollToActiveTab = () => {
const scrollLeft =
el.getBoundingClientRect().left -
wrap.getBoundingClientRect().left +
wrap.scrollLeft -
wrap.clientWidth / 2 +
el.clientWidth / 2;
wrap.scrollLeft = scrollLeft;
if (
Math.abs(wrap.scrollLeft - scrollLeft) > 1 &&
attempts < maxAttempts
) {
attempts++;
requestAnimationFrame(scrollToActiveTab);
}
};
scrollToActiveTab();
});
}
// 切换标签
function switchTab(tab, index) {
router.push(tab.fullPath);
moveScroll(index);
}
// 关闭标签
async function closeTab(tab) {
const activePath = await tabStore.closeTab(tab.fullPath);
if (activePath && route.fullPath !== activePath) {
router.push(activePath);
} else if (!activePath) {
router.push("/");
}
}
function closeLeft(data) {
const currentIndex = tabStore.tabs.findIndex(
(item) => item.fullPath === data.fullPath,
);
if (currentIndex === -1) return;
// 关闭左侧所有标签
tabStore.tabs.slice(0, currentIndex).forEach((item) => {
const index = tabStore.tabs.findIndex((t) => t.fullPath === item.fullPath);
if (index !== -1) tabStore.tabs.splice(index, 1);
if (item.fullPath === route.fullPath) {
router.push(data.fullPath);
}
});
}
function closeRight(data) {
const currentIndex = tabStore.tabs.findIndex(
(item) => item.fullPath === data.fullPath,
);
if (currentIndex === -1) return;
tabStore.tabs.slice(currentIndex + 1).forEach((item) => {
const index = tabStore.tabs.findIndex((t) => t.fullPath === item.fullPath);
if (index !== -1) tabStore.tabs.splice(index, 1);
if (item.fullPath === route.fullPath) {
router.push(data.fullPath);
}
});
}
function closeOther(tab) {
tabStore.tabs = tabStore.tabs.filter((t) => t.fullPath === tab.fullPath);
router.push(tab.fullPath);
}
function closeAll() {
tabStore.tabs = [];
router.push("/");
}
function toHome() {
router.push("/");
}
function refreshPage() {
emitter.emit("view.refresh");
}
function fullScreenPage() {
fullScreen.value = !fullScreen.value;
emits("fullScreen-change", fullScreen.value);
}
</script>
<template>
<div class="g3-tabs">
<div class="g3-tab-op">
<el-tooltip content="刷新">
<div class="g3-action-icon" @click="refreshPage">
<G3Icon icon="lucide:refresh-ccw" />
</div>
</el-tooltip>
<el-tooltip content="首页">
<div class="g3-action-icon" @click="toHome">
<G3Icon icon="lucide:house" />
</div>
</el-tooltip>
<el-tooltip :content="fullScreen ? '恢复' : '全屏'">
<div class="g3-action-icon" @click="fullScreenPage">
<G3Icon :icon="fullScreen ? 'lucide:minimize' : 'lucide:maximize'" />
</div>
</el-tooltip>
</div>
<div class="g3-divider"></div>
<div v-if="showScroll" class="g3-action-icon g3-scroll-more">
<G3Icon icon="lucide:chevrons-left" />
</div>
<div class="g3-tab-container">
<div :ref="setRefs('scroller')" class="app-tab-scroller">
<el-dropdown
v-for="(item, index) in tabStore.tabs"
:key="item.fullPath"
trigger="contextmenu"
:persistent="false"
>
<template #dropdown>
<el-dropdown-menu v-if="item.fullPath !== '/'">
<el-dropdown-item @click="closeTab(item)">
关闭
</el-dropdown-item>
<el-dropdown-item @click="closeOther(item)">
关闭其他
</el-dropdown-item>
<el-dropdown-item @click="closeLeft(item)">
关闭左侧
</el-dropdown-item>
<el-dropdown-item @click="closeRight(item)">
关闭右侧
</el-dropdown-item>
<el-dropdown-item @click="closeAll"> 关闭所有 </el-dropdown-item>
</el-dropdown-menu>
</template>
<div
:ref="setRefs(`item-${index}`)"
class="app-tab-item"
:class="{ active: item.fullPath === tabStore.activeTab }"
@click="switchTab(item)"
>
<span class="label">{{ item.title }}</span>
<G3Icon
v-if="item.fullPath !== '/'"
icon="lucide:x"
size="14"
class="icon"
@click.stop="closeTab(item)"
/>
</div>
</el-dropdown>
</div>
</div>
<div
v-if="showScroll"
class="g3-action-icon g3-scroll-more"
style="margin: 0px 10px"
>
<G3Icon icon="lucide:chevrons-right" />
</div>
</div>
</template>
<style lang="scss" scoped>
.g3-tabs {
display: flex;
align-items: center;
height: 38px;
padding-left: 10px;
user-select: none;
background-color: #fff;
border-bottom: 1px solid #e4e4e7;
}
.g3-tab-op {
display: flex;
align-items: center;
list-style: none;
}
.g3-action-icon {
display: flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s ease-in-out;
outline: none;
cursor: pointer;
flex-shrink: 0;
margin-right: 8px;
&:hover {
border-color: var(--border-color-hover);
}
&:last-child {
margin-right: 0px;
}
}
.g3-divider {
width: 1px;
height: 18px;
background-color: #ddd;
margin: 0px 12px;
}
.g3-tab-container {
height: 30px;
flex: 1;
position: relative;
overflow: hidden;
:deep(.el-dropdown) {
margin-right: 12px;
&:last-child {
margin-right: 0px;
}
}
}
.app-tab-scroller {
height: 100%;
width: 100%;
white-space: nowrap;
position: absolute;
left: 0;
top: 0;
scrollbar-width: none;
overflow: hidden;
scroll-behavior: smooth;
display: flex;
align-items: center;
}
.app-tab-item {
display: inline-flex;
align-items: center;
justify-content: space-between;
height: 28px;
padding: 0px 8px;
cursor: pointer;
color: #18181b;
border-radius: 5px;
border: 1px solid var(--border-color);
background-color: #fff;
.label {
font-size: 14px;
line-height: 1;
letter-spacing: 0.05em;
}
.icon {
margin-left: 5px;
}
&:hover {
background-color: var(--el-color-primary-light-8);
}
&.active {
background-color: var(--el-color-primary-light-8);
.label {
color: var(--el-color-primary);
}
.icon {
color: var(--el-color-primary);
}
&:hover {
background-color: var(--el-color-primary-light-8);
}
}
}
.g3-scroll-more {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
</style>
@@ -0,0 +1,107 @@
<script lang="js" setup>
const props = defineProps({
collapse: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(["collapse"]);
function onCollapse() {
emits("collapse");
}
</script>
<template>
<div class="g3-topbar">
<div class="g3-topbar-left">
<div class="g3-action-icon" @click="onCollapse()">
<G3Icon
:icon="
props.collapse
? 'lucide:list-indent-increase'
: 'lucide:list-indent-decrease'
"
/>
</div>
</div>
<div class="g3-topbar-right">
<div class="user-card compact">
<G3Icon icon="lucide:circle-user-round" size="18" />
<div class="user-info">
<span class="user-name">001</span>
<!-- <span class="divider">·</span>
<span class="user-org">g3cd</span> -->
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.g3-topbar {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 100%;
height: 42px;
flex-shrink: 0;
border-bottom: 1px solid #e4e4e7;
padding: 0 10px;
background-color: #fff;
}
.g3-action-icon {
display: flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s ease-in-out;
outline: none;
cursor: pointer;
flex-shrink: 0;
&:hover {
border-color: var(--border-color-hover);
}
}
.user-card {
display: flex;
align-items: center;
padding: 4px 14px;
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
border-color: var(--border-color-hover);
}
.user-info {
margin-left: 10px;
font-size: 14px;
line-height: 1;
}
.user-name {
font-weight: 500;
color: #333;
}
.user-org {
color: #999;
font-size: 13px;
}
.divider {
margin: 0 4px;
color: #bbb;
}
}
</style>
@@ -0,0 +1,98 @@
<script lang="js" setup>
import Menu from "./components/menu.vue";
import Topbar from "./components/topbar.vue";
import Tab from "./components/tab.vue";
import Content from "./components/content.vue";
const isCollapse = ref(false);
const leftSize = ref(220);
const fullScreen = ref(false);
function handleCollapse() {
if (leftSize.value < 220) {
leftSize.value = 220;
isCollapse.value = false;
} else {
leftSize.value = 0;
isCollapse.value = true;
}
}
function handleResizeEnd(index, sizes) {
if (sizes[0] < 220) {
isCollapse.value = true;
} else {
isCollapse.value = false;
}
leftSize.value = sizes[0];
}
function onFullScreenChange(val) {
fullScreen.value = val;
if (fullScreen.value) {
leftSize.value = 0;
} else {
handleCollapse();
}
}
</script>
<template>
<div class="g3-layout">
<el-splitter @resize-end="handleResizeEnd">
<el-splitter-panel :min="0" :size="leftSize" :max="400">
<div class="g3-layout-left">
<Menu />
</div>
</el-splitter-panel>
<el-splitter-panel>
<div class="g3-layout-right">
<Topbar
:collapse="isCollapse"
@collapse="handleCollapse"
:style="fullScreen ? { height: '0', border: 'none' } : {}"
/>
<Tab @full-screen-change="onFullScreenChange" />
<Content />
</div>
</el-splitter-panel>
</el-splitter>
</div>
</template>
<style lang="scss" scoped>
.g3-layout {
display: flex;
width: 100%;
height: 100%;
background-color: #fafafa;
overflow: hidden;
:deep(.el-splitter-bar__dragger-horizontal::after),
:deep(.el-splitter-bar__dragger-horizontal::before) {
width: 1px;
}
:deep(.el-splitter-bar__dragger::after),
:deep(.el-splitter-bar__dragger::before) {
background-color: #fff;
}
}
.g3-layout-left {
flex-shrink: 0;
height: 100%;
overflow: hidden;
border-right: 1px solid #bfbfc6;
box-shadow: 3px 0 8px rgba(0, 0, 0, 0.05);
transition: width 0.3s ease;
}
.g3-layout-right {
flex: 1;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>
+53
View File
@@ -0,0 +1,53 @@
import { createApp } from "vue";
import App from "./App.vue";
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import router from "@/router/index";
import ResizeObserver from "resize-observer-polyfill";
import { SetupPlugins } from "./plugins";
import "element-plus/dist/index.css";
import VxeUIBase from "vxe-pc-ui";
import "vxe-pc-ui/es/style.css";
import VxeUITable from "vxe-table";
import "vxe-table/es/style.css";
import "./style.css";
// ElementPlus 兼容低版本浏览器
window.ResizeObserver = ResizeObserver;
VxeUITable.setConfig({
table: {
stripe: true,
border: "full",
cellConfig: {
height: 28,
},
headerCellConfig: {
height: 32,
},
footerCellConfig: {
height: 32,
},
},
});
// pinia
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
SetupPlugins();
// 创建 Vue 应用
const app = createApp(App);
app.use(pinia);
app.use(router);
app.use(VxeUIBase);
app.use(VxeUITable);
app.mount("#app");
@@ -0,0 +1,12 @@
/**
* 用到的图标集在这里添加,可以离线使用
*/
import { addCollection } from "@iconify/vue";
import Lucide from "@iconify/json/json/lucide.json";
import ElementPlus from "@iconify/json/json/ep.json";
export function SetupIcons() {
addCollection(Lucide);
addCollection(ElementPlus);
}
@@ -0,0 +1,5 @@
import { SetupIcons } from "./icon";
export function SetupPlugins() {
SetupIcons();
}
@@ -0,0 +1,100 @@
import { httpPost } from ".";
// 登录
export function loginApi(orgid, userid, password) {
return httpPost(`/auth/login`, {
orgid,
userid,
password,
});
}
// ============================= data =============================
export function loadDataApi(viewName, searchCondition = "", orderField = "", searchColumns = []) {
return httpPost("/data/loadData", {
viewName,
searchCondition,
orderField,
searchColumns,
});
}
export function loadDataBySqlApi(sql) {
return httpPost("/data/loadDataBySql", {
sql,
});
}
/**
* 分页加载表格数据
* @param {Object} params 参数对象
* @param {number} params.page 当前页
* @param {number} params.size 每页条数
* @param {string} params.viewName 视图名称
* @param {string} [params.searchCondition] 搜索条件
* @param {string} [params.orderField] 排序字段
* @param {string[]} [params.searchColumns] 搜索列
* @returns {Promise<{
* success: boolean,
* data: {
* rows: Array<Record<string, any>>,
* total: number,
* page: number,
* size: number
* },
* code: number,
* message: string
* }>} 返回 Promise 对象
*/
export function loadPageDataApi(
{ page, size, viewName, searchCondition = "", orderField = "", searchColumns = [] },
config = {},
) {
return httpPost(
"/data/page",
{
page,
size,
viewName,
searchCondition,
orderField,
searchColumns,
},
config,
);
}
export function saveObjectApi(data) {
return httpPost("/data/saveObject", data);
}
export function saveObjectKeysApi(data) {
return httpPost("/data/saveObjectKeys", data);
}
// ============================= data =============================
// ============================= column =============================
export function loadViewColumnsApi(viewName) {
return httpPost("/column/view", {
viewName,
});
}
export function loadEditColumnsApi(viewName) {
return httpPost("/column/edit", {
viewName,
});
}
export function loadAllColumnsApi(viewName) {
return httpPost("/column/all", {
viewName,
});
}
export function checkColumnsApi(viewName) {
return httpPost("/column/check", {
viewName,
});
}
// ============================= column =============================
@@ -0,0 +1,116 @@
import axios from "axios";
import { ElMessage } from "element-plus";
import { useUserStore } from "@/store/user";
import router from "@/router";
let hasShown401 = false;
const axiosInstance = axios.create({
// baseURL: "http://127.0.0.1:8084/api",
baseURL: process.env.PUBLIC_API_BASE_URL,
timeout: 30000,
});
axiosInstance.interceptors.request.use(
(config) => {
const userStore = useUserStore();
const token = userStore.userInfo.token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
axiosInstance.interceptors.response.use(
(response) => {
const resData = response.data;
resData.success = resData.code === 200;
if (resData.code === 401) {
if (!hasShown401) {
hasShown401 = true;
const userStore = useUserStore();
ElMessage.warning("登录已过期,请重新登录");
userStore.resetUserInfo();
router.replace("/login");
}
} else if (resData.code === 500) {
// 系统异常
ElMessage.error(resData.message || "系统异常,请稍后再试");
} else if (resData.code === 1000) {
// 业务异常
ElMessage.error(resData.message || "业务异常,请稍后再试");
}
return response;
},
(error) => {
let message = "请求失败";
if (error.code === "ECONNABORTED") {
message = "请求超时,请稍后重试";
} else if (error.message === "Network Error") {
message = "网络异常,请检查网络连接";
} else if (error.response) {
const status = error.response.status;
switch (status) {
case 400:
message = "请求参数错误";
break;
case 404:
message = "请求接口不存在";
break;
case 500:
message = "服务器异常";
break;
default:
message = error.response.data?.msg || `请求失败 (${status})`;
}
}
// ElMessage.error(message);
return Promise.reject(error);
},
);
/**
* 通用 API 响应类型
* @template T
* @typedef {Object} ApiResponse
* @property {boolean} success - 请求是否成功
* @property {number} code - 状态码
* @property {string} message - 消息
* @property {T} data - 数据
*/
/**
* 通用 HTTP POST 请求
* @template T
* @param {string} url
* @param {any} data
* @param {any} config
* @returns {Promise<ApiResponse<T>>}
*/
export function httpPost(url, data, config) {
return axiosInstance
.post(url, data, {
headers: {
"Content-Type": "application/json",
},
...config,
})
.then((res) => res.data);
}
export function reset401Flag() {
hasShown401 = false;
}
export default axiosInstance;
@@ -0,0 +1,64 @@
import { createRouter, createWebHistory } from "vue-router";
import { routes } from "./routes";
import { useUserStore } from "@/store/user";
import { ElMessage } from "element-plus";
import { useTabStore } from "@/store/tab";
import NProgress from "nprogress";
NProgress.configure({ easing: "ease", speed: 500 });
const router = createRouter({
history: createWebHistory(import.meta.env.PUBLIC_SERVER_PATH || "/"),
routes,
});
router.beforeEach((to) => {
NProgress?.start?.();
const userStore = useUserStore();
// if (!userStore.isLogined() && to.path !== "/login") {
// ElMessage.warning("登录已过期,请先登录");
// return "/login";
// }
handleTabPath(to);
return;
});
router.afterEach(() => {
NProgress?.done?.();
});
function handleTabPath(to) {
const path = to.path;
if (path === "/login") {
return;
}
const meta = to.meta;
const query = to.query;
// 处理标签页
if (meta.title) {
let displayTitle = meta.title;
if (query.id) {
displayTitle = `${meta.title} ${query.id}`;
}
const tabStore = useTabStore();
tabStore.addTab({
path: path,
fullPath: to.fullPath,
title: displayTitle,
params: to.params,
meta: to.meta,
query: to.query,
});
}
}
export default router;
@@ -0,0 +1,40 @@
import BaseLayout from "@/layout/index.vue";
export const routes = [
{
path: "/",
component: BaseLayout,
children: [
{
path: "/",
component: () => import("@/views/index.vue"),
meta: {
title: "首页",
keepAlive: true,
},
},
// ==================== 业务管理 ====================
{
path: "/business",
component: () => import("@/views/business/index.vue"),
meta: {
title: "海运业务",
keepAlive: true,
},
},
// ==================== 系统管理 ====================
{
path: "/system/field",
component: () => import("@/views/system/field/index.vue"),
meta: {
title: "字段配置",
keepAlive: true,
},
},
],
},
{
path: "/login",
component: () => import("@/views/auth/login.vue"),
},
];
@@ -0,0 +1,215 @@
CREATE TABLE s_columnlib2 (
-- 主键,自增
b_id INT IDENTITY(1,1) PRIMARY KEY,
-- 基本标识
col_table_name VARCHAR(50) NOT NULL, -- 表名
col_field_name VARCHAR(50) NOT NULL, -- 字段名
col_field_name_v VARCHAR(50), -- 字段别名/显示名
col_caption VARCHAR(50), -- 字段描述
-- 展示相关
col_visible CHAR(1) DEFAULT '1', -- 是否可见
col_width INT, -- 宽度
col_position INT, -- 显示位置
col_group_index INT, -- 分组编号
col_color VARCHAR(50), -- 颜色
col_display_format VARCHAR(50), -- 显示格式
col_show_summary CHAR(1) DEFAULT '0', -- 是否统计汇总
-- 校验 / 默认
col_allow_null CHAR(1) DEFAULT '1', -- 是否允许空值
col_allow_repeat CHAR(1) DEFAULT '1', -- 是否允许重复
col_allow_readonly CHAR(1) DEFAULT '0', -- 是否只读
col_allow_reserve CHAR(1) DEFAULT '0', -- 是否保留字段
col_default_value TEXT, -- 默认值
-- 查询相关
col_search CHAR(1) DEFAULT '0', -- 是否可搜索
col_search_order INT, -- 搜索顺序
col_search_group_index INT, -- 搜索分组编号
-- 类型定义
col_type VARCHAR(50), -- 字段类型
col_show_property CHAR(1) DEFAULT '0', -- 显示属性
-- 编辑相关
col_edit CHAR(1) DEFAULT '0', -- 是否可编辑
col_edit_position INT, -- 编辑位置
col_edit_width INT, -- 编辑宽度
col_edit_height INT, -- 编辑高度
col_edit_group_index INT, -- 编辑分组编号
col_edit_batch CHAR(1) DEFAULT '0', -- 是否批量编辑
col_edit_source_data VARCHAR(50), -- 编辑数据源表
col_edit_source_field VARCHAR(50), -- 编辑数据源字段
-- 唯一约束
CONSTRAINT uk_tablename_fieldname UNIQUE (col_table_name, col_field_name)
);
INSERT INTO s_columnlib (
col_table_name,
col_field_name,
col_field_name_v,
col_caption,
col_width,
col_visible,
col_position,
col_show_summary,
col_group_index,
col_allow_null,
col_allow_repeat,
col_default_value,
col_allow_readonly,
col_color,
col_search,
col_search_order,
col_allow_reserve,
col_display_format,
col_search_group_index,
col_type,
col_show_property,
col_edit,
col_edit_position,
col_edit_width,
col_edit_height,
col_edit_group_index,
col_edit_batch,
col_edit_source_data,
col_edit_source_field
)
SELECT
col_TableName,
col_FieldName,
col_FieldName_v,
col_Caption,
TRY_CAST(col_Width AS INT) AS col_width,
col_Visible,
TRY_CAST(col_Position AS INT) AS col_position,
col_ShowSummary,
TRY_CAST(col_BandIndex AS INT) AS col_group_index, -- 分组编号
col_AllowNull,
col_AllowRepeat,
col_DefaultValue,
col_ReadOnly,
col_Color,
col_CanSearch, -- 是否可搜索
TRY_CAST(col_SearchOrder AS INT) AS col_search_order, -- 搜索顺序
col_NeedReserve, -- 保留字段
col_DisplayFormat,
TRY_CAST(col_SearchGroupIndex AS INT) AS col_search_group_index,
col_type,
col_edit_type,
col_ShowDetail, -- 显示属性
col_edit,
TRY_CAST(col_edit_position AS INT) AS col_edit_position,
TRY_CAST(col_edit_width AS INT) AS col_edit_width,
TRY_CAST(col_edit_height AS INT) AS col_edit_height,
TRY_CAST(col_edit_grouping AS INT) AS col_edit_group_index, -- 编辑分组编号
col_edit_batch,
col_edit_source_table, -- 编辑数据源表
col_edit_source_field
FROM s_columnLib3
WHERE
-- 只保留关键字段不为空的行,例如表名和字段名必须有值
col_TableName IS NOT NULL AND col_FieldName IS NOT NULL;
CREATE PROCEDURE [dbo].[G3_CheckColumnSettings_New]
@viewname SYSNAME
AS
BEGIN
SET NOCOUNT ON;
/* ========== 1. 取视图 / 表字段 ========== */
DECLARE @Tmp TABLE (name SYSNAME);
INSERT INTO @Tmp (name)
SELECT c.name
FROM sys.columns c
WHERE c.object_id = OBJECT_ID(@viewname);
/* ========== 2. 删除不存在的字段配置 ========== */
DELETE s
FROM s_columnLib s
WHERE s.col_table_name = @viewname
AND NOT EXISTS (
SELECT 1
FROM @Tmp t
WHERE t.name = s.col_field_name
);
/* ========== 3. 计算起始 position ========== */
DECLARE @startPos INT;
SELECT @startPos = ISNULL(MAX(col_position), 0) + 10
FROM s_columnLib
WHERE col_table_name = @viewname;
/* ========== 4. 插入新增字段配置 ========== */
INSERT INTO s_columnLib (
col_table_name,
col_field_name,
col_field_name_v,
col_caption,
col_width,
col_visible,
col_position,
col_show_summary,
col_group_index,
col_allow_null,
col_allow_repeat,
col_allow_readonly,
col_allow_reserve,
col_default_value,
col_search,
col_search_order,
col_search_group_index,
col_type,
col_show_property,
col_edit,
col_edit_position,
col_edit_width,
col_edit_height,
col_edit_group_index,
col_edit_batch,
col_edit_source_data,
col_edit_source_field,
col_display_format
)
SELECT
@viewname,
t.name,
'', -- col_field_name_v 默认空
t.name, -- col_caption 默认用字段名
60, -- col_width 默认 60
'1', -- col_visible 默认显示
@startPos + ROW_NUMBER() OVER (ORDER BY t.name) * 10,
'0', -- col_show_summary 默认 0
0, -- col_group_index 默认 0
'1', -- col_allow_null 默认允许
'1', -- col_allow_repeat 默认允许
'0', -- col_allow_readonly 默认不只读
'0', -- col_allow_reserve 默认 0
'', -- col_default_value 默认空
'0', -- col_search 默认 0
0, -- col_search_index 默认 0
0,
'', -- col_type 默认空
'0', -- col_show_property 默认 0
'0', -- col_edit 默认 0
0, -- col_edit_position 默认 0
0, -- col_edit_width 默认 0
0, -- col_edit_height 默认 0
0, -- col_edit_group_index 默认 0
'0', -- col_edit_batch 默认 0
'', -- col_edit_source_data 默认空
'', -- col_edit_source_field 默认空
'' -- col_display_format 默认空
FROM @Tmp t
WHERE NOT EXISTS (
SELECT 1
FROM s_columnLib s
WHERE s.col_table_name = @viewname
AND s.col_field_name = t.name
);
END
Whitespace-only changes.
@@ -0,0 +1,74 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useTabStore = defineStore("tabs", () => {
const tabs = ref([{ title: "首页", path: "/", fullPath: "/" }]);
const activeTab = ref("/");
const cachedKeys = ref(["/"]);
const tabMap = computed(() => {
const map = new Map();
tabs.value.forEach((t) => map.set(t.fullPath, t));
return map;
});
const addTab = (tab) => {
const existing = tabMap.value.get(tab.fullPath);
if (existing) {
existing.title = tab.title;
} else {
tabs.value.push(tab);
if (tab.meta?.keepAlive && !cachedKeys.value.includes(tab.fullPath)) {
cachedKeys.value.push(tab.fullPath);
}
}
activeTab.value = tab.fullPath;
};
const setActiveTab = (fullPath) => {
if (tabMap.value.has(fullPath)) activeTab.value = fullPath;
};
const closeTab = (fullPath) => {
if (fullPath === "/") return activeTab.value;
const index = tabs.value.findIndex((t) => t.fullPath === fullPath);
if (index === -1) return activeTab.value;
tabs.value.splice(index, 1);
const cacheIndex = cachedKeys.value.indexOf(fullPath);
if (cacheIndex !== -1) cachedKeys.value.splice(cacheIndex, 1);
if (activeTab.value === fullPath) {
const newIndex = Math.max(index - 1, 0);
activeTab.value = tabs.value[newIndex].fullPath;
}
return activeTab.value;
};
const resetTabs = () => {
const homeTab = { title: "首页", path: "/", fullPath: "/" };
tabs.value = [homeTab];
activeTab.value = "/";
cachedKeys.value = ["/"];
};
const currentActiveTab = computed(() => tabMap.value.get(activeTab.value));
const findTabIndex = (fullPath) => {
return tabs.value.findIndex((t) => t.fullPath === fullPath);
};
return {
tabs,
activeTab,
cachedKeys,
addTab,
setActiveTab,
closeTab,
resetTabs,
currentActiveTab,
findTabIndex,
};
});
@@ -0,0 +1,41 @@
import { defineStore } from "pinia";
import { useRoute } from "vue-router";
export const useUserStore = defineStore(
"userInfo",
() => {
const userInfo = ref({
orgid: "",
userid: "",
username: "",
token: "",
});
function setUserInfo(orgid, userid, username, token) {
userInfo.value.orgid = orgid;
userInfo.value.userid = userid;
userInfo.value.token = token;
userInfo.value.username = username;
}
function resetUserInfo() {
userInfo.value.token = "";
}
function isLogined() {
return !!userInfo.value.orgid && !!userInfo.value.userid && !!userInfo.value.token;
}
return {
userInfo,
setUserInfo,
resetUserInfo,
isLogined,
};
},
{
persist: {
paths: ["userInfo"],
},
},
);
+22
View File
@@ -0,0 +1,22 @@
@import "./styles/nprogress.css";
@import "./styles/element-plus.scss";
@import "./styles/vxe-table.scss";
html,
body,
#app {
width: 100%;
height: 100%;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
outline: none;
}
:root {
--border-color: #dcdfe6;
--border-color-hover: var(--el-color-primary);
}
@@ -0,0 +1,25 @@
:root {
--el-color-primary: #165dff;
--el-component-size-small: 24px;
--el-component-size: 28px;
--el-component-size-large: 32px;
--el-loading-spinner-size: 36px !important;
}
.el-button {
height: 28px;
line-height: 0;
}
.el-button--small {
height: 24px;
}
.el-button--large {
height: 32px;
}
.el-button + .el-button {
margin-left: 0px ;
}
@@ -0,0 +1,56 @@
@mixin flexGapRow($size) {
gap: $size;
& > * {
margin-right: $size;
}
& > *:last-child {
margin-right: 0;
}
}
@mixin flexGapColumn($size) {
gap: $size;
& > * {
margin-bottom: $size;
}
& > *:last-child {
margin-bottom: 0;
}
}
/**
* 兼容低版本浏览器的 Flex Wrap 布局 Mixin
* 支持浏览器:自动检测 gap 支持,不支持时回退到负边距方案
* @param {number} $row-gap - 行间距
* @param {number} $column-gap - 列间距
* @param {string} $direction: row - 布局方向 (row|column)
*/
@mixin flexGapWrap($row-gap, $column-gap, $direction: row) {
display: flex;
flex-wrap: wrap;
flex-direction: $direction;
// 现代浏览器:直接使用 gap
@supports (gap: 1px) {
gap: $row-gap $column-gap;
}
// 低版本浏览器回退方案
@supports not (gap: 1px) {
@if $direction == row {
// 横向布局:用负边距补偿
margin: -$row-gap / 2 (-$column-gap / 2);
& > * {
margin: $row-gap / 2 $column-gap / 2;
}
} @else {
// 纵向布局
margin: (-$row-gap / 2) (-$column-gap / 2);
& > * {
margin: $row-gap / 2 $column-gap / 2;
}
}
}
}
@@ -0,0 +1,80 @@
/* Make clicks pass-through */
#nprogress {
pointer-events: none;
}
#nprogress .bar {
position: fixed;
top: 0;
left: 0;
z-index: 1031;
width: 100%;
height: 2px;
background: var(--el-color-primary);
}
/* Fancy blur effect */
#nprogress .peg {
position: absolute;
right: 0px;
display: block;
width: 100px;
height: 100%;
box-shadow:
0 0 10px var(--el-color-primary),
0 0 5px var(--el-color-primary);
opacity: 1;
-webkit-transform: rotate(3deg) translate(0px, -4px);
-ms-transform: rotate(3deg) translate(0px, -4px);
transform: rotate(3deg) translate(0px, -4px);
}
/* Remove these to get rid of the spinner */
#nprogress .spinner {
position: fixed;
top: 15px;
right: 15px;
z-index: 1031;
display: block;
}
#nprogress .spinner-icon {
box-sizing: border-box;
width: 18px;
height: 18px;
border: solid 2px transparent;
border-top-color: var(--el-color-primary);
border-left-color: var(--el-color-primary);
border-radius: 50%;
-webkit-animation: nprogress-spinner 300ms linear infinite;
animation: nprogress-spinner 300ms linear infinite;
}
.nprogress-custom-parent {
position: relative;
overflow: hidden;
}
.nprogress-custom-parent #nprogress .spinner,
.nprogress-custom-parent #nprogress .bar {
position: absolute;
}
@-webkit-keyframes nprogress-spinner {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@@ -0,0 +1,26 @@
.vxe-table {
.el-checkbox {
height: 100%;
}
.col--edit {
.vxe-cell {
padding: 1px !important;
.vxe-cell--wrapper {
height: 100%;
.el-input,
.el-select {
height: 100%;
line-height: 100%;
.el-input__wrapper,
.el-select__wrapper {
height: 100%;
border-radius: 0;
}
}
}
}
}
}
@@ -0,0 +1,35 @@
/**
* 对比新旧对象,只返回变化字段,同时保留主键
* @param {Object} newData 新对象
* @param {Object} oldData 旧对象
* @param {Array<string>} primaryKeys 主键字段数组
* @param {Array<string>} excludeKeys 排除字段(可选)
* @returns {{ isChanged: boolean, result: Object }} 明确返回值结构
*/
export function diffObject(newData, oldData, primaryKeys = [], excludeKeys = []) {
const result = {};
let isChanged = false;
// 先把主键字段放入结果
for (const pk of primaryKeys) {
if (pk in newData) {
result[pk] = newData[pk];
}
}
for (const key of Object.keys(newData)) {
if (excludeKeys.includes(key)) continue;
if (key.startsWith("_") || key.startsWith("v_")) continue; // 可选:忽略 v_ 前缀字段
const newVal = newData[key];
const oldVal = oldData[key];
// 只比较原始类型
if (newVal !== oldVal) {
result[key] = newVal;
isChanged = true;
}
}
return { isChanged, result };
}
@@ -0,0 +1,5 @@
import mitt from "mitt";
const emitter = mitt();
export default emitter;
@@ -0,0 +1,5 @@
import throttle from "lodash/throttle";
export function useThrottle(fn, delay = 150) {
return throttle(fn, delay);
}
@@ -0,0 +1,10 @@
// utils/uuid.js
import { v4 as uuidv4 } from "uuid";
/**
* 获取唯一 ID
*/
export function getUuid() {
const id = uuidv4();
return id;
}
@@ -0,0 +1,421 @@
<script lang="js" setup>
import { loginApi } from "@/request/api";
import { useUserStore } from "@/store/user";
import { ElMessage } from "element-plus";
import { useRouter } from "vue-router";
import Vcode from "vue3-puzzle-vcode";
import { reset401Flag } from "@/request";
const router = useRouter();
const userStore = useUserStore();
const form = ref({
orgid: userStore.userInfo.orgid,
userid: userStore.userInfo.userid,
password: "",
});
const showVcode = ref(false);
const loginLoading = ref(false);
const linkList = ref([
{
title: "新手上路",
icon: "solar:key-bold",
href: "http://bbs.g3soft.cn/forum.php?mod=viewthread&tid=115&extra=page%3D1",
},
{
title: "会员活动",
icon: "mingcute:vip-1-fill",
href: "http://www.g3soft.cn/help/guide/promotion.html",
},
{
title: "视频教程",
icon: "material-symbols:auto-videocam-rounded",
href: "http://bbs.g3soft.cn/forum.php?mod=viewthread&tid=86&extra=page%3D1",
},
{
title: "常见问题",
icon: "ic:baseline-help",
href: "http://bbs.g3soft.cn/forum.php?mod=forumdisplay&fid=37",
},
]);
const endLinkList = ref([
{
title: "圆通官网",
href: "http://www.g3soft.cn",
},
{
title: "申请试用",
href: "http://dns.g3soft.cn/demo/jsys/login/login.jsp",
},
{
title: "联系我们",
href: "http://www.g3soft.cn/aboutus.html",
},
{
title: "客服热线: 0574-87812201",
href: "",
},
]);
function toHref(href) {
if (href) {
window.open(href, "_blank");
}
}
function onVcodeShow() {
if (form.value.orgid === "") {
ElMessage.error("机构码不能为空!");
return;
}
if (form.value.userid === "") {
ElMessage.error("用户名不能为空!");
return;
}
showVcode.value = true;
}
function onVcodeClose() {
showVcode.value = false;
}
function onVcodeSuccess() {
onVcodeClose();
loginLoading.value = true;
try {
loginApi(form.value.orgid, form.value.userid, form.value.password).then(
(res) => {
if (res.success) {
ElMessage.success("登录成功");
userStore.setUserInfo(
form.value.orgid,
form.value.userid,
res.data.username,
res.data.token,
);
router.push("/");
reset401Flag();
}
},
);
} finally {
loginLoading.value = false;
}
}
</script>
<template>
<div class="login-container">
<div class="login-header">
<div class="logo">
<img src="@/assets/logo.png" />
</div>
<div class="divider"></div>
<div class="name">网上服务平台</div>
</div>
<div class="login-body">
<div class="login-main">
<div class="tip">登录</div>
<div class="split"></div>
<div class="form">
<div class="form-item">
<div class="label required">机构码</div>
<el-input v-model="form.orgid">
<template #prefix>
<G3Icon icon="lucide:building" size="18" />
</template>
</el-input>
</div>
<div class="form-item">
<div class="label required">账号</div>
<el-input v-model="form.userid">
<template #prefix>
<G3Icon icon="lucide:user-round" size="18" />
</template>
</el-input>
</div>
<div class="form-item">
<div class="label">密码</div>
<el-input v-model="form.password" show-password>
<template #prefix>
<G3Icon icon="lucide:lock-keyhole" size="18" />
</template>
</el-input>
</div>
<el-button
size="large"
long
type="primary"
style="
background-color: #33dcd0;
border-color: #00d3c4;
font-size: 18px;
"
@click="onVcodeShow"
:loading="loginLoading"
>
登录
</el-button>
</div>
</div>
</div>
<div class="service-container">
<div class="service-title">
<div class="service-left"></div>
<div class="service-core">服务中心</div>
<div class="service-right"></div>
</div>
<div class="service-link">
<template v-for="(item, index) in linkList" :key="index">
<div class="link-item" @click="toHref(item.href)">
<div class="link-icon">
<G3Icon :icon="item.icon" size="38" color="#6cd6b3" />
</div>
<div class="link-title">{{ item.title }}</div>
</div>
</template>
</div>
</div>
<div class="end-container">
<template v-for="(item, index) in endLinkList" :key="index">
<div class="end-li" @click="toHref(item.href)">
{{ item.title }}
</div>
<div v-if="index !== endLinkList.length - 1" class="end-line"></div>
</template>
</div>
<Vcode :show="showVcode" @success="onVcodeSuccess" @close="onVcodeClose" />
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/mixins.scss" as *;
.login-container {
display: flex;
flex-direction: column;
}
.login-header {
width: 50%;
display: flex;
align-items: center;
height: 81px;
margin-left: 25%;
.logo {
img {
height: 40px;
width: 120px;
}
}
.divider {
width: 1px;
height: 28px;
background-color: #ddd;
margin: 0px 24px;
}
.name {
color: #595959;
}
}
.login-body {
background-image: url("@/assets/login-bg.jpg");
width: 100%;
height: 420px;
position: relative;
.login-main {
position: absolute;
right: 25%;
top: 50%;
transform: translateY(-50%);
background-color: #fdfdfd;
padding: 24px 28px;
border-radius: 8px;
display: flex;
align-items: center;
flex-direction: column;
width: 320px;
}
.tip {
font-weight: bold;
font-size: 16px;
margin: 0 30px;
vertical-align: super;
}
.split {
height: 1px;
width: 100%;
border-bottom: 3px solid #00d3c4;
margin: 0 auto;
margin-top: 10px;
}
.form {
margin-top: 16px;
width: 100%;
display: flex;
flex-direction: column;
.form-item {
margin-bottom: 12px;
.label {
font-size: 14px;
margin-bottom: 8px;
}
.required {
&::before {
content: "*";
color: red;
margin-right: 4px;
}
}
:deep(.el-input__wrapper) {
height: 38px;
}
}
}
}
.service-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-bottom: 1px solid #e8e8e8;
padding-bottom: 56px;
flex-shrink: 0;
.service-title {
width: 100%;
max-width: 1200px;
margin: 40px 0px;
padding: 0 40px;
box-sizing: border-box;
.service-left {
position: relative;
display: inline-block;
width: 38%;
height: 2px;
background-color: #cccccc;
vertical-align: middle;
&::after {
content: "";
position: absolute;
right: -6px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
background: #ccc;
border-radius: 50%;
}
}
.service-core {
display: inline-block;
width: 24%;
font-size: 24px;
text-align: center;
overflow: hidden;
height: 34px;
vertical-align: middle;
}
.service-right {
position: relative;
display: inline-block;
width: 38%;
height: 2px;
background-color: #cccccc;
vertical-align: middle;
&::before {
content: "";
position: absolute;
left: -6px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
background: #ccc;
border-radius: 50%;
}
}
}
.service-link {
display: flex;
align-items: center;
justify-content: center;
@include flexGapWrap(40px, 160px);
.link-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
.link-icon {
padding: 12px;
border-radius: 999px;
border: 2px solid #6cd6b3;
display: flex;
align-items: center;
justify-content: center;
}
.link-title {
margin-top: 16px;
}
}
}
}
.end-container {
display: flex;
align-items: center;
justify-content: center;
color: #595959;
margin: 24px 0px;
.end-li {
font-size: 12px;
line-height: 12px;
display: inline-block;
cursor: pointer;
}
.end-line {
margin: 0 22px;
width: 1px;
height: 14px;
border-left: 1px solid #595959;
}
}
</style>
@@ -0,0 +1,11 @@
<script lang="js" setup>
</script>
<template>
<div class=""></div>
</template>
<style lang="css" scoped>
</style>
@@ -0,0 +1,9 @@
<script lang="js" setup></script>
<template>
<div class="">
<div></div>
</div>
</template>
<style lang="css" scoped></style>
@@ -0,0 +1,49 @@
<script setup lang="js">
const value = ref("");
const options = [
{
value: "Option1",
label: "Option1",
},
{
value: "Option2",
label: "Option2",
},
{
value: "Option3",
label: "Option3",
},
{
value: "Option4",
label: "Option4",
},
{
value: "Option5",
label: "Option5",
},
];
</script>
<template>
<G3Grid direction="row">
<G3GridGroup :label-width="80" :width="440" :column="2">
<G3GridItem label="1222222222222222222222222" content="2" :span="1" required>
<el-input type="textarea" v-model="value" />
</G3GridItem>
<G3GridItem label="3" :span="1">
<el-input />
</G3GridItem>
</G3GridGroup>
<G3GridGroup :label-width="80" :width="440" :column="2">
<G3GridItem label="1222222222222222222222222" content="2" :span="1" required>
<el-input type="textarea" v-model="value" />
</G3GridItem>
<G3GridItem label="3" :span="1">
<el-input />
</G3GridItem>
</G3GridGroup>
</G3Grid>
</template>
@@ -0,0 +1,714 @@
<script lang="js" setup>
import {
checkColumnsApi,
loadDataApi,
loadAllColumnsApi,
loadDataBySqlApi,
saveObjectApi,
} from "@/request/api";
import { ElMessage } from "element-plus";
import PreviewSql from "./preview-sql.vue";
import { diffObject } from "@/utils/diff";
const tableRef = ref();
const loading = ref(false);
const leftSize = ref(200);
const isCollapse = ref(false);
const showSql = ref(false);
const treeData = [{ b_id: "root", mc_b: "全部分类", isLeaf: false, children: [] }];
const treeProps = {
children: "children",
label: "mc_b",
};
const currentNode = ref(null);
const currentModule = ref(null);
const isSelectedNode = computed(() => !!currentNode.value);
const tableData = ref([]);
const currentRow = ref({});
const isSelectedRow = computed(() => !!currentRow.value?.col_FieldName);
let oldRowData = {};
const columns = [
{
prop: "col_field_name",
label: "字段",
width: 140,
fixed: "left",
},
{
prop: "col_caption",
label: "标题",
width: 100,
fixed: "left",
type: "input",
},
{
prop: "col_field_name_v",
label: "视图字段",
width: 100,
type: "input",
},
{
prop: "col_group_index",
label: "分组",
width: 80,
type: "input",
},
{
prop: "col_visible",
label: "显示",
width: 80,
type: "checkbox",
},
{
prop: "col_allow_readonly",
label: "只读",
width: 80,
type: "checkbox",
},
{
prop: "col_allow_null",
label: "可不输",
width: 80,
type: "checkbox",
},
{
prop: "col_allow_repeat",
label: "可重复",
width: 80,
type: "checkbox",
},
{
prop: "col_allow_reserve",
label: "可保留",
width: 80,
type: "checkbox",
},
{
prop: "col_search",
label: "可查找",
width: 80,
type: "checkbox",
},
{
prop: "col_search_order",
label: "查找顺序",
width: 100,
type: "input",
},
{
prop: "col_search_group_index",
label: "查找分组",
width: 100,
type: "input",
},
{
prop: "col_type",
label: "字段类型",
width: 100,
type: "option",
},
{
prop: "col_default_value",
label: "默认值",
width: 80,
type: "input",
},
{
prop: "col_width",
label: "宽度",
width: 80,
type: "input",
},
{
prop: "col_position",
label: "位置",
width: 80,
type: "input",
},
{
prop: "col_show_summary",
label: "合计",
width: 80,
type: "checkbox",
},
{
prop: "col_display_format",
label: "显示格式",
width: 100,
type: "input",
},
{
prop: "col_edit",
label: "编辑",
width: 80,
type: "checkbox",
},
{
prop: "col_edit_position",
label: "编辑位置",
width: 100,
type: "input",
},
{
prop: "col_edit_width",
label: "编辑宽度",
width: 100,
type: "input",
},
{
prop: "col_edit_height",
label: "编辑高度",
width: 100,
type: "input",
},
{
prop: "col_edit_source_data",
label: "编辑数据",
width: 160,
type: "input",
},
{
prop: "col_edit_source_field",
label: "下拉表显示字段",
width: 100,
type: "input",
},
{
prop: "col_edit_group_index",
label: "编辑分组",
width: 100,
type: "input",
},
{
prop: "col_edit_batch",
label: "批量编辑",
width: 100,
type: "checkbox",
},
{
prop: "col_show_property",
label: "属性",
width: 80,
type: "checkbox",
},
];
const editTypeOptions = [
{
value: "input",
label: "input",
},
{
value: "textarea",
label: "textarea",
},
{
value: "date",
label: "date",
},
{
value: "datetime",
label: "datetime",
},
{
value: "singleId",
label: "singleId",
},
{
value: "singleName",
label: "singleName",
},
{
value: "option",
label: "option",
},
{
value: "checkbox",
label: "checkbox",
},
{
value: "radio",
label: "radio",
},
];
function getOptions(field) {
if (field === "col_type") {
return editTypeOptions;
}
}
function updateCollapse() {
if (isCollapse.value) {
isCollapse.value = false;
leftSize.value = 200;
} else {
isCollapse.value = true;
leftSize.value = 0;
}
}
async function handleTreeLoad(node, resolve) {
try {
const { level: nodeLevel, data: nodeData } = node;
if (nodeLevel === 0) {
resolve([
{
b_id: "root",
mc_b: "全部分类",
children: [],
isLeaf: false,
},
]);
return;
}
const currentLevel = nodeLevel - 1;
let sql = "";
if (currentLevel === 0) {
sql = `SELECT * FROM s_module_bmfl WHERE js='1' AND b_canuse='1' ORDER BY b_xh`;
} else {
if (!nodeData) {
resolve([]);
return;
}
const parentField = `bm_${currentLevel}_b`;
const parentCondition = ` AND ${parentField}='${nodeData.b_id}'`;
const childLevel = currentLevel + 1;
sql = `
SELECT s_module_bmfl.*, s_module.b_id AS b_moduleid
FROM s_module_bmfl
LEFT JOIN s_module ON s_module.b_cate_id = s_module_bmfl.b_id
WHERE s_module_bmfl.b_canuse='1'
AND js='${childLevel}'
${parentCondition}
AND s_module_bmfl.b_canuse='1'
ORDER BY b_xh
`;
}
const res = await loadDataBySqlApi(sql);
if (res.success) {
resolve(res.data);
} else {
resolve([]);
}
} catch (err) {
console.error(err);
resolve([]);
}
}
async function handleTreeNodeClick(nodeData) {
if (!nodeData || nodeData.b_id === "root" || !nodeData.b_moduleid) return;
currentNode.value = nodeData;
loading.value = true;
try {
const res = await loadDataApi("s_module", `b_id = '${nodeData.b_moduleid}'`);
if (res.success) {
currentModule.value = res.data[0] || null;
if (currentModule.value?.b_ViewTable) {
const viewTable = currentModule.value?.b_ViewTable;
await loadDatas(viewTable);
}
}
} catch (err) {
console.error("加载模块或列失败", err);
} finally {
loading.value = false;
}
}
// 加载数据
async function loadDatas(viewName) {
if (!viewName) {
return;
}
const res = await loadAllColumnsApi(viewName);
if (res.success) {
tableData.value = res.data;
}
}
// 编辑关闭事件
async function handleEditClosed({ row, rowIndex, column, columnIndex }) {
const $table = tableRef.value;
if ($table) {
await handleSaveRow(row);
}
}
// 编辑激活事件
function handleEditActivated({ row }) {
const $table = tableRef.value;
if ($table) {
oldRowData = JSON.parse(JSON.stringify(row));
}
}
// 选中行事件
function handleCurrentRowChange({ newValue }) {
currentRow.value = newValue;
}
// 保存
function handleSaveRow(row) {
if (!row?.b_id) return;
const diffRowResult = diffObject(row, oldRowData, ["b_id"], []);
if (!diffRowResult.isChanged) {
return;
}
const saveData = [
{
tableName: "s_columnLib",
keyField: "b_id",
inserts: [],
updates: [diffRowResult.result],
deletes: [],
log: "0",
user: "0",
},
];
saveObjectApi(saveData).then((res) => {
if (res.success) {
ElMessage.success("保存成功");
}
});
}
// 检查
function handleCheckColumns() {
if (!currentModule.value) {
return;
}
const viewName = currentModule.value.b_ViewTable || "";
if (!viewName) {
return;
}
loading.value = true;
checkColumnsApi(viewName)
.then(async (res) => {
if (res.success) {
ElMessage.success("检查成功");
await loadDatas(viewName);
}
})
.finally(() => {
loading.value = false;
});
}
</script>
<template>
<div class="g3-wrapper">
<el-splitter>
<el-splitter-panel :size="leftSize" :min="0" :max="260">
<div class="g3-wrapper-left">
<div class="tree-header">
<el-text>模块名</el-text>
<div class="tree-options"></div>
</div>
<div class="tree-container">
<el-scrollbar style="flex: 1">
<el-tree
lazy
highlight-current
node-key="b_id"
:data="treeData"
:props="treeProps"
:load="handleTreeLoad"
:default-expanded-keys="['root']"
@node-click="handleTreeNodeClick"
/>
</el-scrollbar>
</div>
</div>
</el-splitter-panel>
<el-splitter-panel style="margin-left: 8px">
<div class="g3-wrapper-right">
<div class="content">
<div class="content-head">
<div class="content-head-left">
<el-button type="primary" :disabled="!isSelectedNode" @click="handleCheckColumns"
>检查</el-button
>
<el-button type="primary" :disabled="!isSelectedNode">浏览栏位预览</el-button>
<el-button type="primary" :disabled="!isSelectedNode">编辑栏位预览</el-button>
<el-button type="primary" :disabled="!isSelectedNode">属性预览</el-button>
<el-button type="primary">修改标签</el-button>
<el-button type="primary" :disabled="!isSelectedNode" @click="showSql = !showSql">
SQL语句
</el-button>
</div>
<div class="content-head-right">
<el-input placeholder="请输入关键词搜索" />
<el-button type="primary">搜索</el-button>
</div>
</div>
<div class="content-table">
<vxe-table
ref="tableRef"
height="100%"
:data="tableData"
:loading="loading"
:row-config="{
useKey: true,
keyField: 'col_FieldName',
isCurrent: true,
}"
:column-config="{
resizable: true,
useKey: true,
}"
:edit-config="{
trigger: 'click',
mode: 'row',
autoClear: false,
showStatus: false,
showIcon: false,
}"
:scroll-x="{
gt: 0,
enabled: true,
}"
:scroll-y="{
gt: 0,
enabled: true,
}"
show-overflow
show-header-overflow
@current-row-change="handleCurrentRowChange"
@edit-closed="handleEditClosed"
@edit-activated="handleEditActivated"
>
<vxe-column type="seq" width="50" fixed="left" />
<vxe-column
v-for="column in columns"
:key="column.prop"
:field="column.prop"
:title="column.label"
:width="column.width"
align="center"
sortable
:edit-render="{ autoFocus: true }"
:fixed="column.fixed"
>
<template #default="{ row }">
<ElCheckbox
v-if="column.type === 'checkbox'"
true-value="1"
false-value="0"
:checked="row[column.prop] === '1'"
/>
<template v-else>
{{ row[column.prop] }}
</template>
</template>
<template #edit="{ row }">
<ElCheckbox
v-if="column.type === 'checkbox'"
v-model="row[column.prop]"
true-value="1"
false-value="0"
/>
<el-select
v-else-if="column.type === 'option'"
v-model="row[column.prop]"
size="small"
placeholder=" "
>
<el-option
v-for="item in getOptions(column.prop)"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<ElInput
v-else-if="column.type === 'input'"
v-model="row[column.prop]"
size="small"
/>
<template v-else>
{{ row[column.prop] }}
</template>
</template>
</vxe-column>
</vxe-table>
</div>
</div>
</div>
</el-splitter-panel>
</el-splitter>
<PreviewSql v-model="showSql" :data="tableData" :viewtable="currentModule?.b_ViewTable" />
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/mixins.scss" as *;
.g3-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
:deep(.el-splitter-bar__dragger-horizontal::after),
:deep(.el-splitter-bar__dragger-horizontal::before) {
width: 1px;
}
:deep(.el-splitter-bar__dragger::after),
:deep(.el-splitter-bar__dragger::before) {
background-color: #f2f6fc;
}
}
.g3-wrapper-left {
height: 100%;
width: 100%;
background-color: #fff;
.tree-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 10px;
border-bottom: 1px solid #f2f6fc;
}
.tree-options {
display: flex;
align-items: center;
.item {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
margin-left: 5px;
cursor: pointer;
border-radius: 6px;
font-size: 16px;
height: 26px;
width: 26px;
color: #333;
transition: background-color 0.3s ease;
&:hover {
background-color: #f5f7fa;
}
}
}
.tree-container {
height: calc(100% - 40px);
padding: 10px;
display: flex;
flex-direction: column;
}
}
.g3-wrapper-right {
flex: 1;
transition: width 0.3s;
height: 100%;
display: flex;
flex-direction: column;
background-color: #fff;
.head {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 10px;
border-bottom: 1px solid #f2f6fc;
flex-shrink: 0;
.options {
display: flex;
align-items: center;
.item {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
margin-left: 5px;
border-radius: 6px;
font-size: 16px;
height: 26px;
width: 26px;
color: #333;
transition: background-color 0.3s ease;
}
.item-bg {
cursor: pointer;
background-color: #f5f7fa;
}
}
}
.content {
padding: 10px;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
.content-head {
display: flex;
align-items: center;
justify-content: space-between;
.content-head-left {
display: flex;
@include flexGapWrap(4px, 8px);
}
.content-head-right {
display: flex;
@include flexGapWrap(4px, 8px);
flex-wrap: nowrap;
min-width: 200px;
}
}
.content-table {
margin-top: 8px;
flex: 1;
}
}
}
</style>
Whitespace-only changes.
@@ -0,0 +1,71 @@
<template>
<el-dialog v-bind="$attrs" title="Sql预览" width="1000px" class="dialog" @open="initData">
<div class="sql-preview">
{{ sqlStr }}
</div>
</el-dialog>
</template>
<script setup>
import { ref } from "vue";
const props = defineProps({
data: {
type: null,
},
viewtable: {
type: null,
},
});
const sqlStr = ref("");
function initData() {
if (!props.viewtable) return;
const vrows = props.data || [];
const sqlArr = [];
// 删除语句
const deleteSql = `DELETE FROM s_columnlib WHERE col_TableName='${props.viewtable}';`;
sqlArr.push(deleteSql);
// 插入语句
vrows.forEach((row) => {
const insertSql =
"INSERT INTO s_columnlib(col_TableName,col_FieldName,col_Caption,col_Width,col_Visible,col_Position," +
"col_ShowSummary,col_BandIndex,col_AllowNull,col_AllowRepeat,col_DefaultValue,col_ReadOnly,col_Color," +
"col_CanSearch,col_SearchOrder,col_NeedReserve,col_sorted,col_DisplayFormat,col_type,col_datatype,col_foot)";
const valueSql =
`VALUES('${props.viewtable}', '${row.col_FieldName}', '${row.col_Caption}', '${row.col_Width}', '${row.col_Visible}', '${row.col_Position}', ` +
`'${row.col_ShowSummary}', '${row.col_BandIndex}', '${row.col_AllowNull}', '${row.col_AllowRepeat}', '${row.col_DefaultValue}', '${row.col_ReadOnly}', '${row.col_Color}', ` +
`'${row.col_CanSearch}', '${row.col_SearchOrder}', '${row.col_NeedReserve}', '${row.col_sorted}', '${row.col_DisplayFormat}', '${row.col_type}', '${row.col_datatype}', '${row.col_foot}');`;
sqlArr.push(insertSql);
sqlArr.push(valueSql);
});
// 合并为字符串
sqlStr.value = sqlArr.join("\n");
}
</script>
<style scoped>
.dialog-content {
display: flex;
gap: 16px;
}
.sql-preview {
width: 100%;
height: 500px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
padding: 8px;
border: 1px solid #e0e0e0;
background-color: #fafafa;
box-sizing: border-box;
}
</style>
@@ -0,0 +1,7 @@
<script lang="js" setup></script>
<template>
<div class=""></div>
</template>
<style lang="scss" scoped></style>
@@ -0,0 +1,11 @@
<script lang="js" setup>
const router = useRouter();
</script>
<template>
<div class="">
test-detail
</div>
</template>
<style lang="scss" scoped></style>
@@ -0,0 +1,7 @@
<script lang="js" setup></script>
<template>
<div class=""></div>
</template>
<style lang="scss" scoped></style>
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="zh-CN">
<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;
}
/* 每行一个 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>
</head>
<body>
<h2>自适应 Descriptions 示例</h2>
<!-- 第一行 2 列 -->
<div class="row" style="grid-template-columns: repeat(2, 1fr);">
<div class="item">
<span class="label">姓名:</span>
<span class="value">张三</span>
</div>
<div class="item">
<span class="label">年龄:</span>
<span class="value">18</span>
</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>
<!-- 第三行 3 列 -->
<div class="row" style="grid-template-columns: repeat(3, 1fr);">
<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 class="item">
<span class="label">爱好:</span>
<span class="value">篮球</span>
</div>
</div>
</body>
</html>