This commit is contained in:
biybuy 2026-02-24 18:33:14 +08:00
parent 3eb0641842
commit 84bf956f73
28 changed files with 1887 additions and 1232 deletions

Binary file not shown.

View File

@ -5,6 +5,7 @@
<link rel="icon" type="image/png" href="/src/assets/logo.png" /> <link rel="icon" type="image/png" href="/src/assets/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nanafa-哪哪发物流,发哪都方便</title> <title>nanafa-哪哪发物流,发哪都方便</title>
<link rel="stylesheet" type="text/css" media="print" href="/print-lock.css" />
<!-- DNS 预解析,加速资源加载 --> <!-- DNS 预解析,加速资源加载 -->
<link rel="dns-prefetch" href="//43.139.210.35" /> <link rel="dns-prefetch" href="//43.139.210.35" />
<!-- 预连接,提前建立连接 --> <!-- 预连接,提前建立连接 -->

View File

@ -27,8 +27,16 @@ server {
# 静态文件根目录 # 静态文件根目录
root /usr/share/nginx/html; root /usr/share/nginx/html;
# ============ API 代理(同源请求,避免跨域 405)============ # ============ API 前端路由(必须先于 /api/ 代理)============
location ^~ /api/ { # 直接访问 /api/downstreamInterface 等页面路由时返回 index.html,由 SPA 接管,避免 404
# 使用正则且放在 /api/ 代理之前:^~ 优先级高于正则,故不能再用 ^~ /api/,下面用正则代理其余 /api/
location ~ ^/api/(upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface(|/newPort)|appList(|/createApplication)|requestLog|callStatistics|apiDebugger|apiTest)(/|$) {
root /usr/share/nginx/html;
try_files /index.html =200;
}
# ============ API 代理(仅真实后端接口,同源请求避免跨域 405)============
location ~ ^/api/ {
rewrite ^/api/(.*)$ /itfx/$1 break; rewrite ^/api/(.*)$ /itfx/$1 break;
proxy_pass http://api.nanafa.com; proxy_pass http://api.nanafa.com;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@ -10,7 +10,7 @@
"scripts": { "scripts": {
"preinstall": "node -e \"if (!process.env.npm_config_user_agent || !process.env.npm_config_user_agent.includes('pnpm')) { console.error('Please use pnpm to install dependencies.'); process.exit(1); }\"", "preinstall": "node -e \"if (!process.env.npm_config_user_agent || !process.env.npm_config_user_agent.includes('pnpm')) { console.error('Please use pnpm to install dependencies.'); process.exit(1); }\"",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "node --max-old-space-size=4096 node_modules/vite/bin/vite.js build",
"report": "cross-env REPORT=true npm run build", "report": "cross-env REPORT=true npm run build",
"preview": "npm run build && vite preview --host", "preview": "npm run build && vite preview --host",
"type:check": "vue-tsc --noEmit --skipLibCheck", "type:check": "vue-tsc --noEmit --skipLibCheck",
@ -33,23 +33,22 @@
] ]
}, },
"dependencies": { "dependencies": {
"@antv/g2plot": "^2.4.35",
"@arco-design/web-vue": "^2.44.7", "@arco-design/web-vue": "^2.44.7",
"@handsontable/vue3": "^16.2.0", "@handsontable/vue3": "^16.2.0",
"@monaco-editor/loader": "^1.5.0", "@monaco-editor/loader": "^1.5.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@vueuse/core": "^9.3.0", "@vueuse/core": "^9.3.0",
"ag-grid-community": "^35.0.0",
"ag-grid-vue3": "^35.0.0",
"arco-design-pro-vue": "^2.7.3", "arco-design-pro-vue": "^2.7.3",
"axios": "^0.24.0", "axios": "^0.24.0",
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"diff": "^8.0.2", "diff": "^8.0.2",
"echarts": "^5.4.0", "echarts": "^5.4.0",
"element-plus": "^2.13.2",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"handsontable": "^16.2.0", "handsontable": "^16.2.0",
"hyperformula": "^3.1.1", "hyperformula": "^3.1.1",
"interactjs": "^1.10.27",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mitt": "^3.0.0", "mitt": "^3.0.0",
@ -59,6 +58,7 @@
"query-string": "^8.0.3", "query-string": "^8.0.3",
"sm-crypto": "^0.3.13", "sm-crypto": "^0.3.13",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"vg-print": "^1.0.420",
"vue": "^3.2.40", "vue": "^3.2.40",
"vue-click-outside": "^1.1.0", "vue-click-outside": "^1.1.0",
"vue-echarts": "^6.2.3", "vue-echarts": "^6.2.3",

510
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

297
public/print-lock.css Normal file
View File

@ -0,0 +1,297 @@
@media print {
body {
margin: 0px;
padding: 0px;
}
}
@page {
margin: 0;
}
.hiprint-printPaper * {
box-sizing: border-box;
-moz-box-sizing: border-box; /* Firefox */
-webkit-box-sizing: border-box; /* Safari */
image-rendering: -webkit-optimize-contrast; /* 让图片/标尺稍微清楚一点 */
}
.hiprint-printPaper *:focus {
outline: -webkit-focus-ring-color auto 0px;
}
.hiprint-page-break-avoid {
page-break-after: avoid;
}
.hiprint-printPaper {
position: relative;
padding: 0 0 0 0;
page-break-after: always;
-webkit-user-select: none; /* Chrome/Safari/Opera */
-moz-user-select: none; /* Firefox */
user-select: none;
overflow-x: hidden;
overflow: hidden;
}
.hiprint-printPaper .hiprint-printPaper-content {
position: relative;
}
.hiprint-printPaper.design {
overflow: visible;
}
.hiprint-printTemplate .hiprint-printPanel {
page-break-after: always;
}
.hiprint-printPaper, hiprint-printPanel {
box-sizing: border-box;
border: 0px;
}
.hiprint-printPanel .hiprint-printPaper:last-child {
page-break-after: avoid;
}
.hiprint-printTemplate .hiprint-printPanel:last-child {
page-break-after: avoid;
}
.hiprint-printPaper .hideheaderLinetarget {
border-top: 0px dashed rgb(201, 190, 190) !important;
}
.hiprint-printPaper .hidefooterLinetarget {
border-top: 0px dashed rgb(201, 190, 190) !important;
}
.hiprint-printPaper.design {
border: 1px dashed rgba(170,170,170,0.7);
}
.design .hiprint-printElement-table-content, .design .hiprint-printElement-longText-content {
overflow: hidden;
box-sizing: border-box;
}
.design .resize-panel {
box-sizing: border-box;
border: 1px dotted;
}
.hiprint-printElement-text {
background-color: transparent;
background-repeat: repeat;
padding: 0 0 0 0;
border: 0.75pt none rgb(0,0,0);
direction: ltr;
font-family: 'SimSun';
font-size: 9pt;
font-style: normal;
font-weight: normal;
padding-bottom: 0pt;
padding-left: 0pt;
padding-right: 0pt;
padding-top: 0pt;
text-align: left;
text-decoration: none;
line-height: 9.75pt;
box-sizing: border-box;
word-wrap: break-word;
word-break: break-all;
}
.design .hiprint-printElement-text-content {
border: 1px dashed rgb(206, 188, 188);
box-sizing: border-box;
}
.hiprint-printElement-longText {
background-color: transparent;
background-repeat: repeat;
border: 0.75pt none rgb(0,0,0);
direction: ltr;
font-family: 'SimSun';
font-size: 9pt;
font-style: normal;
font-weight: normal;
padding-bottom: 0pt;
padding-left: 0pt;
padding-right: 0pt;
padding-top: 0pt;
text-align: left;
text-decoration: none;
line-height: 9.75pt;
box-sizing: border-box;
word-wrap: break-word;
word-break: break-all;
/*white-space: pre-wrap*/
}
.hiprint-printElement-table {
background-color: transparent;
background-repeat: repeat;
color: rgb(0,0,0);
border-color: rgb(0,0,0);
border-style: none;
direction: ltr;
font-family: 'SimSun';
font-size: 9pt;
font-style: normal;
font-weight: normal;
padding-bottom: 0pt;
padding-left: 0pt;
padding-right: 0pt;
padding-top: 0pt;
text-align: left;
text-decoration: none;
padding: 0 0 0 0;
box-sizing: border-box;
line-height: 9.75pt;
}
.hiprint-printElement-table thead {
background: #e8e8e8;
font-weight: 700;
}
.hiprint-printElement-tableTarget, .hiprint-printElement-tableTarget tr, .hiprint-printElement-tableTarget td {
border-color: rgb(0,0,0);
/*border-style: none;*/
/*border: 1px solid rgb(0,0,0);*/
font-weight: normal;
direction: ltr;
padding-bottom: 0pt;
padding-left: 4pt;
padding-right: 4pt;
padding-top: 0pt;
text-decoration: none;
vertical-align: middle;
box-sizing: border-box;
word-wrap: break-word;
word-break: break-all;
/*line-height: 9.75pt;
font-size: 9pt;*/
}
/*.hiprint-printElement-tableTarget tr,*/
.hiprint-printElement-tableTarget td {
height: 18pt;
}
/* 边框样式(用于打印时保持边框) */
table.hiprint-printElement-tableTarget { width: 100%; }
.hiprint-printElement-tableTarget-border-all { border: 1px solid; }
.hiprint-printElement-tableTarget-border-none { border: 0px solid; }
.hiprint-printElement-tableTarget-border-lr { border-left: 1px solid; border-right: 1px solid; }
.hiprint-printElement-tableTarget-border-left { border-left: 1px solid; }
.hiprint-printElement-tableTarget-border-right { border-right: 1px solid; }
.hiprint-printElement-tableTarget-border-tb { border-top: 1px solid; border-bottom: 1px solid; }
.hiprint-printElement-tableTarget-border-top { border-top: 1px solid; }
.hiprint-printElement-tableTarget-border-bottom { border-bottom: 1px solid; }
.hiprint-printElement-tableTarget-border-td-none td { border: 0px solid; }
.hiprint-printElement-tableTarget-border-td-all td:not(:nth-last-child(-n+2)) { border-right: 1px solid; }
.hiprint-printElement-tableTarget-border-td-all td { border-left: 1px solid; }
.hiprint-printElement-tableTarget-border-td-all td:last-child:first-child { border-left: none; }
.hiprint-printPaper .hiprint-paperNumber {
font-size: 9pt;
}
.design .hiprint-printElement-table-handle {
position: absolute;
height: 12pt;
width: 12pt;
background: red;
z-index:1;
}
.hiprint-printPaper .hiprint-paperNumber-disabled {
float: right !important;
right: 0 !important;
color: gainsboro !important;
}
.hiprint-printElement-vline, .hiprint-printElement-hline {
border: 0px none rgb(0,0,0);
}
.hiprint-printElement-vline {
border-left: 0.75pt solid #000;
border-right: 0px none rgb(0,0,0) !important;
border-bottom: 0px none rgb(0,0,0) !important;
border-top: 0px none rgb(0,0,0) !important;
}
.hiprint-printElement-hline {
border-top: 0.75pt solid #000;
border-right: 0px none rgb(0,0,0) !important;
border-bottom: 0px none rgb(0,0,0) !important;
border-left: 0px none rgb(0,0,0) !important;
}
.hiprint-printElement-oval, .hiprint-printElement-rect {
border: 0.75pt solid #000;
}
.hiprint-text-content-middle {
display:table;
}
.hiprint-text-content-middle>div {
display: table-cell;
vertical-align:middle
}
.hiprint-text-content-bottom {
display: table;
}
.hiprint-text-content-bottom > div {
display: table-cell;
vertical-align: bottom
}
/*hi-grid-row */
.hi-grid-row {
position: relative;
height: auto;
margin-right: 0;
margin-left: 0;
zoom: 1;
display: block;
box-sizing: border-box;
}
.hi-grid-row::after, .hi-grid-row::before {
display: table;
content: '';
box-sizing: border-box;
}
.hi-grid-col {
display: block;
box-sizing: border-box;
position: relative;
float: left;
flex: 0 0 auto;
}
.table-grid-row {
margin-left: -0pt;
margin-right: -0pt;
}
.tableGridColumnsGutterRow {
padding-left: 0pt;
padding-right: 0pt;
}
.hiprint-gridColumnsFooter {
text-align: left;
clear: both;
}

View File

@ -38,5 +38,9 @@ export type MenuUpdateDto = {
* 描述 * 描述
*/ */
description?: string | null; description?: string | null;
/**
* 排序
*/
order?: number | null;
}; };

View File

@ -129,26 +129,13 @@
return findNode(props.treeData); return findNode(props.treeData);
} }
/** 二级菜单序号:按父级分组,每组从 1 重新开始 */
function getNodeIndex(key: string): number | '' { function getNodeIndex(key: string): number | '' {
if (!key || !props.treeData?.length) return ''; if (!key || !props.treeData?.length) return '';
let index = 0;
function findIndex(nodes: CustomLeftMenuTreeNode[]): number | null {
for (const node of nodes) {
if (node.children?.length) {
for (const child of node.children) {
index++;
if (child.key === key) return index;
}
}
}
return null;
}
for (const category of props.treeData) { for (const category of props.treeData) {
if (category.children?.length) { const children = category.children || [];
for (const child of category.children) { for (let i = 0; i < children.length; i++) {
index++; if (children[i].key === key) return i + 1;
if (child.key === key) return index;
}
} }
} }
return ''; return '';
@ -305,10 +292,13 @@
</td> </td>
</tr> </tr>
<tr v-if="getNodeData(key)?.isLeaf && (showNodeCode && getNodeData(key)?.code || $slots['node-second-row-extra'])"> <tr v-if="getNodeData(key)?.isLeaf && (showNodeCode && getNodeData(key)?.code || $slots['node-second-row-extra'])">
<td class="node-code-cell"> <td v-if="showNodeCode && getNodeData(key)?.code" class="node-code-cell">
<span v-if="showNodeCode && getNodeData(key)?.code" class="interface-code">{{ getNodeData(key)?.code }}</span> <span class="interface-code">{{ getNodeData(key)?.code }}</span>
</td> </td>
<td class="node-actions-cell node-second-row-extra-cell"> <td
:class="['node-actions-cell', 'node-second-row-extra-cell']"
:colspan="(showNodeCode && getNodeData(key)?.code) ? 1 : 2"
>
<span class="node-second-row-extra-slot"> <span class="node-second-row-extra-slot">
<slot name="node-second-row-extra" :node-data="getNodeData(key)" :node-key="key"></slot> <slot name="node-second-row-extra" :node-data="getNodeData(key)" :node-key="key"></slot>
</span> </span>
@ -559,6 +549,15 @@
.map-count .count-num { .map-count .count-num {
color: #000 !important; color: #000 !important;
} }
/* 选中时第二行插槽(如创建时间)保持深色,不变白 */
.node-second-row-extra-slot {
color: #666 !important;
.create-time {
color: #666 !important;
}
}
} }
.node-table { .node-table {

View File

@ -13,6 +13,11 @@
<Block :options="contentOpts" :title="$t('settings.content')" /> <Block :options="contentOpts" :title="$t('settings.content')" />
<Block :options="othersOpts" :title="$t('settings.otherSettings')" /> <Block :options="othersOpts" :title="$t('settings.otherSettings')" />
<a-alert>{{ $t('settings.alertContent') }}</a-alert> <a-alert>{{ $t('settings.alertContent') }}</a-alert>
<div class="restore-default-wrap">
<a-button type="outline" long @click="restoreDefault">
{{ $t('settings.restoreDefault') }}
</a-button>
</div>
</a-drawer> </a-drawer>
</template> </template>
@ -72,6 +77,8 @@
}, },
]); ]);
const DEFAULT_THEME_COLOR = '#132c44';
const cancel = () => { const cancel = () => {
appStore.updateSettings({ globalSettings: false }); appStore.updateSettings({ globalSettings: false });
emit('cancel'); emit('cancel');
@ -81,5 +88,15 @@
await copy(text); await copy(text);
Message.success(t('settings.copySettings.message')); Message.success(t('settings.copySettings.message'));
}; };
const restoreDefault = () => {
appStore.updateSettings({ themeColor: DEFAULT_THEME_COLOR });
Message.success(t('settings.restoreDefault.message'));
};
</script> </script>
<style scoped>
.restore-default-wrap {
margin-top: 16px;
}
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="tab-bar"> <div class="tab-bar">
<div class="tab-bar-scroll"> <div class="tab-bar-scroll">
<div class="tags-wrap"> <div ref="tagsWrapRef" class="tags-wrap">
<tab-item <tab-item
v-for="(tag, index) in tagList" v-for="(tag, index) in tagList"
:key="tag.fullPath" :key="tag.fullPath"
@ -14,8 +14,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import type { RouteLocationNormalized } from 'vue-router'; import type { RouteLocationNormalized } from 'vue-router';
import Sortable from 'sortablejs';
import { import {
listenerRouteChange, listenerRouteChange,
removeRouteListener, removeRouteListener,
@ -27,6 +28,9 @@
const tabBarStore = useTabBarStore(); const tabBarStore = useTabBarStore();
const affixRef = ref(); const affixRef = ref();
const tagsWrapRef = ref<HTMLElement | null>(null);
let sortableInstance: Sortable | null = null;
const tagList = computed(() => { const tagList = computed(() => {
return tabBarStore.getTabList; return tabBarStore.getTabList;
}); });
@ -37,7 +41,7 @@
watch( watch(
() => appStore.navbar, () => appStore.navbar,
() => { () => {
affixRef.value.updatePosition(); affixRef.value?.updatePosition();
} }
); );
listenerRouteChange((route: RouteLocationNormalized) => { listenerRouteChange((route: RouteLocationNormalized) => {
@ -53,7 +57,25 @@
} }
}, true); }, true);
onMounted(() => {
if (tagsWrapRef.value) {
sortableInstance = new Sortable(tagsWrapRef.value, {
animation: 150,
filter: '.arco-tag-close-btn, .arco-tag-refresh-btn', // 点击关闭/刷新不触发拖拽
preventOnFilter: true,
onEnd(evt) {
const { oldIndex, newIndex } = evt;
if (oldIndex != null && newIndex != null && oldIndex !== newIndex) {
tabBarStore.reorderTabs(oldIndex, newIndex);
}
},
});
}
});
onUnmounted(() => { onUnmounted(() => {
sortableInstance?.destroy();
sortableInstance = null;
removeRouteListener(); removeRouteListener();
}); });
</script> </script>
@ -89,6 +111,16 @@
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
.tag-link {
cursor: grab;
}
&.sortable-ghost {
opacity: 0.4;
}
&.sortable-drag {
cursor: grabbing;
}
&:first-child { &:first-child {
.arco-tag-close-btn { .arco-tag-close-btn {
display: none; display: none;

View File

@ -7,7 +7,7 @@
"hideMenu": false, "hideMenu": false,
"menuCollapse": false, "menuCollapse": false,
"footer": false, "footer": false,
"themeColor": "#2D8C8C", "themeColor": "#132c44",
"menuWidth": 220, "menuWidth": 220,
"globalSettings": false, "globalSettings": false,
"device": "desktop", "device": "desktop",

15
src/env.d.ts vendored
View File

@ -1,3 +1,18 @@
declare module 'vg-print' {
import type { DefineComponent } from 'vue';
export const FullDesigner: DefineComponent<Record<string, unknown>, {}, any>;
export const hiprint: { register(options: { authKey: string }): void };
export const createTemplate: (json: unknown) => unknown;
export const printBrowser: (tpl: unknown, data: unknown) => void;
const plugin: { install: (app: unknown) => void };
export default plugin;
}
declare module 'vg-print/style.css' {
const css: string;
export default css;
}
declare module 'vue-request-editor' { declare module 'vue-request-editor' {
import type { DefineComponent } from 'vue'; import type { DefineComponent } from 'vue';
export const RequestEditor: DefineComponent<Record<string, unknown>, {}, any>; export const RequestEditor: DefineComponent<Record<string, unknown>, {}, any>;

View File

@ -74,6 +74,7 @@ export default {
'menu.channel.weightRules': 'Weight Rules', 'menu.channel.weightRules': 'Weight Rules',
'menu.channel.weightRounding': 'Weight Rounding', 'menu.channel.weightRounding': 'Weight Rounding',
'menu.channel.autoTrigger': 'Auto Trigger', 'menu.channel.autoTrigger': 'Auto Trigger',
'menu.channel.labelDesign': 'Label Design',
'menu.track': 'Track', 'menu.track': 'Track',
'menu.track.trackOrder': 'Track Order', 'menu.track.trackOrder': 'Track Order',
'menu.track.warningOrder': 'Warning Order', 'menu.track.warningOrder': 'Warning Order',
@ -1296,6 +1297,25 @@ export default {
'system.interface.import.success': 'Imported successfully', 'system.interface.import.success': 'Imported successfully',
'system.interface.import.fail': 'Import failed', 'system.interface.import.fail': 'Import failed',
// Channel - Label Design
'channel.labelDesign.pageTitle': 'Label Template Config',
'channel.labelDesign.configTitle': 'Template Config',
'channel.labelDesign.sizeLabel': 'Label Size (W×H)',
'channel.labelDesign.widthPlaceholder': 'Width',
'channel.labelDesign.heightPlaceholder': 'Height',
'channel.labelDesign.titleLabel': 'Label Title',
'channel.labelDesign.titlePlaceholder': 'Enter label title',
'channel.labelDesign.contentLabel': 'Label Content',
'channel.labelDesign.contentPlaceholder': 'Enter label content',
'channel.labelDesign.apply': 'Apply',
'channel.labelDesign.reset': 'Reset',
'channel.labelDesign.previewTitle': 'Preview (drag to move, resize from corner)',
'channel.labelDesign.sizeMinError': 'Min size is 50px',
'channel.labelDesign.noTitle': 'No title',
'channel.labelDesign.noContent': 'No content',
'channel.labelDesign.applySuccess': 'Config applied',
'channel.labelDesign.resetSuccess': 'Config reset',
// Channel - Sales Products // Channel - Sales Products
'channel.salesProducts.search.placeholder': 'Enter product name', 'channel.salesProducts.search.placeholder': 'Enter product name',
'channel.salesProducts.search.refresh': 'Refresh list', 'channel.salesProducts.search.refresh': 'Refresh list',
@ -1455,6 +1475,7 @@ export default {
'channel.serviceChannels.createTime': 'Created: {time}', 'channel.serviceChannels.createTime': 'Created: {time}',
// Channel - Blacklist/Remote Zones (shared) // Channel - Blacklist/Remote Zones (shared)
'channel.area.listTitle': 'List',
'channel.area.search.placeholder': 'Enter keyword', 'channel.area.search.placeholder': 'Enter keyword',
'channel.area.search.refresh': 'Refresh list', 'channel.area.search.refresh': 'Refresh list',
'channel.area.search.addArea': 'Add area', 'channel.area.search.addArea': 'Add area',
@ -1506,6 +1527,7 @@ export default {
'channel.area.search.button.reset': 'Reset', 'channel.area.search.button.reset': 'Reset',
// Channel - Weight Rules // Channel - Weight Rules
'channel.weightRules.listTitle': 'List',
'channel.weightRules.search.placeholder': 'Enter keyword', 'channel.weightRules.search.placeholder': 'Enter keyword',
'channel.weightRules.search.refresh': 'Refresh list', 'channel.weightRules.search.refresh': 'Refresh list',
'channel.weightRules.search.addRule': 'Add rule', 'channel.weightRules.search.addRule': 'Add rule',
@ -1556,6 +1578,7 @@ export default {
'channel.weightRules.updateTime': 'Updated: {time}', 'channel.weightRules.updateTime': 'Updated: {time}',
// Channel - Weight Rounding // Channel - Weight Rounding
'channel.weightRounding.listTitle': 'List',
'channel.weightRounding.search.placeholder': 'Enter keyword', 'channel.weightRounding.search.placeholder': 'Enter keyword',
'channel.weightRounding.search.refresh': 'Refresh list', 'channel.weightRounding.search.refresh': 'Refresh list',
'channel.weightRounding.search.addRounding': 'Add rounding', 'channel.weightRounding.search.addRounding': 'Add rounding',

View File

@ -22,6 +22,8 @@ export default {
'settings.copySettings': 'Copy Settings', 'settings.copySettings': 'Copy Settings',
'settings.copySettings.message': 'settings.copySettings.message':
'Copy succeeded, please paste to file src/settings.json.', 'Copy succeeded, please paste to file src/settings.json.',
'settings.restoreDefault': 'Restore Default',
'settings.restoreDefault.message': 'Theme color has been restored to default',
'settings.close': 'Close', 'settings.close': 'Close',
'settings.color.tooltip': 'settings.color.tooltip':
'10 gradient colors generated according to the theme color', '10 gradient colors generated according to the theme color',

View File

@ -74,6 +74,7 @@ export default {
'menu.channel.weightRules': '计重规则', 'menu.channel.weightRules': '计重规则',
'menu.channel.weightRounding': '计重进位', 'menu.channel.weightRounding': '计重进位',
'menu.channel.autoTrigger': '自动触发', 'menu.channel.autoTrigger': '自动触发',
'menu.channel.labelDesign': '标签设计',
'menu.track': '轨迹', 'menu.track': '轨迹',
'menu.track.trackOrder': '轨迹订单', 'menu.track.trackOrder': '轨迹订单',
'menu.track.warningOrder': '预警订单', 'menu.track.warningOrder': '预警订单',
@ -1297,6 +1298,25 @@ export default {
'system.interface.import.success': '导入成功', 'system.interface.import.success': '导入成功',
'system.interface.import.fail': '导入失败', 'system.interface.import.fail': '导入失败',
// 渠道 - 标签设计
'channel.labelDesign.pageTitle': '标签模板配置',
'channel.labelDesign.configTitle': '模板配置',
'channel.labelDesign.sizeLabel': '标签尺寸 (宽×高)',
'channel.labelDesign.widthPlaceholder': '宽度',
'channel.labelDesign.heightPlaceholder': '高度',
'channel.labelDesign.titleLabel': '标签标题',
'channel.labelDesign.titlePlaceholder': '请输入标签标题',
'channel.labelDesign.contentLabel': '标签内容',
'channel.labelDesign.contentPlaceholder': '请输入标签内容',
'channel.labelDesign.apply': '应用配置',
'channel.labelDesign.reset': '重置',
'channel.labelDesign.previewTitle': '模板预览 (可拖拽调整位置、右下角缩放调整尺寸)',
'channel.labelDesign.sizeMinError': '标签尺寸最小值为 50px,请重新输入',
'channel.labelDesign.noTitle': '无标题',
'channel.labelDesign.noContent': '无内容',
'channel.labelDesign.applySuccess': '配置已应用',
'channel.labelDesign.resetSuccess': '配置已重置',
// 渠道 - 销售产品 // 渠道 - 销售产品
'channel.salesProducts.search.placeholder': '请输入产品名称', 'channel.salesProducts.search.placeholder': '请输入产品名称',
'channel.salesProducts.search.refresh': '刷新列表', 'channel.salesProducts.search.refresh': '刷新列表',
@ -1457,6 +1477,7 @@ export default {
'channel.serviceChannels.createTime': '创建: {time}', 'channel.serviceChannels.createTime': '创建: {time}',
// 渠道 - 黑名单区/偏远分区(共用) // 渠道 - 黑名单区/偏远分区(共用)
'channel.area.listTitle': '列表',
'channel.area.search.placeholder': '请输入关键字', 'channel.area.search.placeholder': '请输入关键字',
'channel.area.search.refresh': '刷新列表', 'channel.area.search.refresh': '刷新列表',
'channel.area.search.addArea': '新增分区', 'channel.area.search.addArea': '新增分区',
@ -1508,6 +1529,7 @@ export default {
'channel.area.search.button.reset': '重置', 'channel.area.search.button.reset': '重置',
// 渠道 - 计重规则 // 渠道 - 计重规则
'channel.weightRules.listTitle': '列表',
'channel.weightRules.search.placeholder': '请输入关键字', 'channel.weightRules.search.placeholder': '请输入关键字',
'channel.weightRules.search.refresh': '刷新列表', 'channel.weightRules.search.refresh': '刷新列表',
'channel.weightRules.search.addRule': '新增规则', 'channel.weightRules.search.addRule': '新增规则',
@ -1558,6 +1580,7 @@ export default {
'channel.weightRules.updateTime': '更新: {time}', 'channel.weightRules.updateTime': '更新: {time}',
// 渠道 - 计重进位 // 渠道 - 计重进位
'channel.weightRounding.listTitle': '列表',
'channel.weightRounding.search.placeholder': '请输入关键字', 'channel.weightRounding.search.placeholder': '请输入关键字',
'channel.weightRounding.search.refresh': '刷新列表', 'channel.weightRounding.search.refresh': '刷新列表',
'channel.weightRounding.search.addRounding': '新增进位', 'channel.weightRounding.search.addRounding': '新增进位',

View File

@ -22,6 +22,8 @@ export default {
'settings.copySettings': '复制配置', 'settings.copySettings': '复制配置',
'settings.copySettings.message': 'settings.copySettings.message':
'复制成功,请粘贴到 src/settings.json 文件中', '复制成功,请粘贴到 src/settings.json 文件中',
'settings.restoreDefault': '恢复默认',
'settings.restoreDefault.message': '主题色已恢复为默认',
'settings.close': '关闭', 'settings.close': '关闭',
'settings.color.tooltip': 'settings.color.tooltip':
'根据主题颜色生成的 10 个梯度色(将配置复制到项目中,主题色才能对亮色 / 暗黑模式同时生效)', '根据主题颜色生成的 10 个梯度色(将配置复制到项目中,主题色才能对亮色 / 暗黑模式同时生效)',

View File

@ -1,6 +1,8 @@
import { createApp, watch } from 'vue'; import { createApp, watch } from 'vue';
import ArcoVue from '@arco-design/web-vue'; import ArcoVue from '@arco-design/web-vue';
import ArcoVueIcon from '@arco-design/web-vue/es/icon'; import ArcoVueIcon from '@arco-design/web-vue/es/icon';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import VxeUITable from 'vxe-table'; import VxeUITable from 'vxe-table';
import 'vxe-table/lib/style.css'; import 'vxe-table/lib/style.css';
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
@ -19,6 +21,7 @@ import globalProperties from './plugins/globalProperties';
import './mock'; import './mock';
// 导入 ECharts 配置,确保组件在应用启动时注册 // 导入 ECharts 配置,确保组件在应用启动时注册
import './utils/echarts'; import './utils/echarts';
// 初始化 RBAC token(从localStorage恢复) // 初始化 RBAC token(从localStorage恢复)
import { setAuthToken } from './api/rbac'; import { setAuthToken } from './api/rbac';
import { applyThemeColor } from '@/utils/themeColor'; import { applyThemeColor } from '@/utils/themeColor';
@ -119,6 +122,8 @@ const app = createApp(App);
app.use(ArcoVue, {}); app.use(ArcoVue, {});
app.use(ArcoVueIcon); app.use(ArcoVueIcon);
// vg-print 内部使用 Element Plus 组件(el-row、el-col 等),需全局注册
app.use(ElementPlus);
app.use(router); app.use(router);
app.use(store); app.use(store);

View File

@ -75,6 +75,15 @@ const CHANNEL: AppRouteRecordRaw = {
requiresAuth: true, requiresAuth: true,
}, },
}, },
{
path: 'labelDesign',
name: 'LabelDesign',
component: () => import('@/views/channel/labelDesign/index.vue'),
meta: {
locale: 'menu.channel.labelDesign',
requiresAuth: true,
},
},
], ],
}; };

View File

@ -75,6 +75,14 @@ const useAppStore = defineStore('tabBar', {
this.deleteTag(idx, tag); this.deleteTag(idx, tag);
} }
}, },
/** 拖动排序标签:将 fromIndex 位置的标签移动到 toIndex */
reorderTabs(fromIndex: number, toIndex: number) {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
const list = [...this.tagList];
const [item] = list.splice(fromIndex, 1);
list.splice(toIndex, 0, item);
this.freshTabList(list);
},
}, },
}); });

View File

@ -4,84 +4,38 @@
<BasicLayoutLeftRight :left-width="280"> <BasicLayoutLeftRight :left-width="280">
<template #left> <template #left>
<div class="left-sidebar"> <div class="left-sidebar">
<div class="sidebar-header"> <CustomLeftMenu
<div class="search-box"> :tree-data="leftTreeData"
<a-input :selected-keys="leftSelectedKeys"
v-model="searchModel.keyword" v-model:expanded-keys="leftExpandedKeys"
:placeholder="t('channel.area.search.placeholder')" :search-keyword="searchModel.keyword"
allow-clear @update:search-keyword="(v) => (searchModel.keyword = v)"
size="small" :search-placeholder="t('channel.area.search.placeholder')"
@change="onFormSubmitClick" :refresh-title="t('channel.area.search.refresh')"
@clear="onFormSubmitClick" :add-title="t('channel.area.search.addArea')"
@keyup.enter="onFormSubmitClick" show-node-index
> :show-method="false"
<template #prefix> :show-map-count="false"
<icon-search /> :show-node-code="false"
</template> show-node-actions
</a-input> :delete-label="t('channel.area.actions.delete')"
<div class="search-actions"> @search="onFormSubmitClick"
<a-button @refresh="onFormSubmitClick"
type="text" @add="handleOpenModal('add')"
size="small" @select="handleSelectFromTree"
class="action-icon-btn refresh-btn" @delete-node="handleDeleteLeftNode"
@click="onFormSubmitClick" >
:title="t('channel.area.search.refresh')" <template #node-menu="{ nodeData }">
> <a-doption value="edit" @click="handleOpenModal('edit', nodeData)">
↻ <IconEdit />
</a-button> {{ t('channel.area.actions.edit') }}
<a-button </a-doption>
type="text" <a-doption value="delete" @click="handleDelData(nodeData?.id)">
size="small" <IconDelete />
class="action-icon-btn add-btn" {{ t('channel.area.actions.delete') }}
@click="handleOpenModal('add')" </a-doption>
:title="t('channel.area.search.addArea')" </template>
> </CustomLeftMenu>
+
</a-button>
</div>
</div>
</div>
<div class="menu-list">
<div
v-for="(item, index) in leftMenuData"
:key="item.id"
class="menu-item"
:class="{ 'menu-item-selected': currentId?.id === item.id }"
@click="cellClick(item)"
>
<div class="menu-item-content">
<span class="menu-index">{{ index + 1 }}</span>
<span class="menu-text">{{ item.name }}</span>
</div>
<div class="menu-actions">
<a-button
type="text"
size="small"
class="edit-btn"
@click.stop="handleOpenModal('edit', item)"
>
<template #icon>
<IconEdit />
</template>
</a-button>
<a-button
type="text"
size="small"
class="delete-btn"
@click.stop="handleDelData(item.id)"
>
<template #icon>
<IconDelete />
</template>
</a-button>
</div>
</div>
<!-- 空状态 -->
<div v-if="!leftMenuData.length" class="empty-menu">
<a-empty :description="t('channel.area.empty')" />
</div>
</div>
<ModalForm <ModalForm
ref="modalFormLeftRef" ref="modalFormLeftRef"
@ -216,6 +170,8 @@
import { $baseMessage } from '@/utils'; import { $baseMessage } from '@/utils';
import { IconEdit, IconDelete, IconPlus, IconSearch } from '@arco-design/web-vue/es/icon'; import { IconEdit, IconDelete, IconPlus, IconSearch } from '@arco-design/web-vue/es/icon';
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue';
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types';
import TemplateScroll from './components/TemplateScroll.vue'; import TemplateScroll from './components/TemplateScroll.vue';
@ -486,6 +442,38 @@
// 左侧菜单数据 // 左侧菜单数据
const leftMenuData = ref<any[]>([]); const leftMenuData = ref<any[]>([]);
const leftExpandedKeys = ref<string[]>(['category-list']);
const leftTreeData = computed<CustomLeftMenuTreeNode[]>(() => [
{
key: 'category-list',
title: t('channel.area.listTitle'),
children: leftMenuData.value.map((item) => ({
key: `item-${item.id}`,
title: item.name,
isLeaf: true,
id: item.id,
name: item.name,
...item,
})),
},
]);
const leftSelectedKeys = computed(() =>
currentId.value?.id ? [`item-${currentId.value.id}`] : []
);
function handleSelectFromTree(keys: string[], info: any) {
const key = info.node?.key;
if (!key || !key.startsWith('item-')) return;
const id = Number(key.replace('item-', ''));
const item = leftMenuData.value.find((i) => i.id === id);
if (item) cellClick(item);
}
function handleDeleteLeftNode(nodeData: any) {
if (nodeData?.id != null) handleDelData(nodeData.id);
}
const tableRef = ref<any>(null); const tableRef = ref<any>(null);
const tableProps = reactive<any>({ const tableProps = reactive<any>({
@ -797,42 +785,13 @@
} }
} }
/* 左侧使用 CustomLeftMenu,仅保留布局(与销售产品一致) */
.left-sidebar { .left-sidebar {
width: 280px; width: 280px;
background: var(--app-sidebar-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
border-top: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 24px;
display: flex;
flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
height: 100%; height: 100%;
padding: 12px; display: flex;
box-shadow: flex-direction: column;
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
overflow: hidden;
color: #fff;
position: relative;
}
.left-sidebar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%);
pointer-events: none;
z-index: 1;
} }
.sidebar-header { .sidebar-header {

View File

@ -0,0 +1,77 @@
<template>
<div class="label-design-page">
<FullDesigner
ref="designerRef"
default-lang="cn"
:hi-auto-connect="false"
@save="onSave"
>
<!-- 覆盖默认左侧区域,隐藏 logo + 标题(插槽可能不生效,配合 CSS 隐藏 .header-left) -->
<template #headerLeft></template>
<!-- 覆盖默认右侧按钮区域,彻底隐藏“反馈”等按钮 -->
<template #headerRight></template>
</FullDesigner>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { FullDesigner, hiprint } from 'vg-print';
import 'vg-print/style.css';
defineOptions({ name: 'LabelDesign' });
// 仅在显式开启本地打印客户端时,才注册 hiprint(避免全站无意义的本地 WebSocket 连接)
const enableHiprintClient = import.meta.env.VITE_ENABLE_HIPRINT_CLIENT === 'true';
// 模块级防重复注册
let hiprintRegistered = false;
if (enableHiprintClient && !hiprintRegistered) {
hiprint.register({
authKey: 'eyJrIjoiZ21jNTc2MDMzNyJ9',
});
hiprintRegistered = true;
}
const designerRef = ref<InstanceType<typeof FullDesigner> | null>(null);
function onSave(payload: { template: unknown; data: unknown }) {
// 模板 JSON + 打印数据,后续可持久化到后端
// console.log('保存模板:', payload);
}
</script>
<style scoped>
.label-design-page {
padding: 16px;
background-color: var(--color-bg-1);
min-height: calc(100vh - 86px);
}
/* 顶部栏背景透明(第一行 .primary-toolbar + 第二行 .secondary-toolbar) */
.label-design-page :deep(.primary-toolbar),
.label-design-page :deep(.primary-toolbar .toolbar-content),
.label-design-page :deep(.secondary-toolbar),
.label-design-page :deep(.secondary-toolbar .toolbar-content) {
background: transparent !important;
background-color: transparent !important;
}
/* 顶部中间/右侧区域也透明 */
.label-design-page :deep(.header-center),
.label-design-page :deep(.header-right.toolbar-right) {
background: transparent !important;
background-color: transparent !important;
}
/* 隐藏 vg-print 顶部左侧 logo + 文本(打包代码中为 .header-left / .toolbar-logo / .toolbar-title) */
.label-design-page :deep(.header-left) {
display: none !important;
}
/* 隐藏 vg-print 内置的反馈/帮助按钮(class: btn-feedback) */
.label-design-page :deep(.btn-feedback) {
display: none !important;
}
</style>

View File

@ -4,84 +4,38 @@
<BasicLayoutLeftRight :left-width="280"> <BasicLayoutLeftRight :left-width="280">
<template #left> <template #left>
<div class="left-sidebar"> <div class="left-sidebar">
<div class="sidebar-header"> <CustomLeftMenu
<div class="search-box"> :tree-data="leftTreeData"
<a-input :selected-keys="leftSelectedKeys"
v-model="searchModel.keyword" v-model:expanded-keys="leftExpandedKeys"
:placeholder="t('channel.area.search.placeholder')" :search-keyword="searchModel.keyword"
allow-clear @update:search-keyword="(v) => (searchModel.keyword = v)"
size="small" :search-placeholder="t('channel.area.search.placeholder')"
@change="onFormSubmitClick" :refresh-title="t('channel.area.search.refresh')"
@clear="onFormSubmitClick" :add-title="t('channel.area.search.addArea')"
@keyup.enter="onFormSubmitClick" show-node-index
> :show-method="false"
<template #prefix> :show-map-count="false"
<icon-search /> :show-node-code="false"
</template> show-node-actions
</a-input> :delete-label="t('channel.area.actions.delete')"
<div class="search-actions"> @search="onFormSubmitClick"
<a-button @refresh="onFormSubmitClick"
type="text" @add="handleOpenModal('add')"
size="small" @select="handleSelectFromTree"
class="action-icon-btn refresh-btn" @delete-node="handleDeleteLeftNode"
@click="onFormSubmitClick" >
:title="t('channel.area.search.refresh')" <template #node-menu="{ nodeData }">
> <a-doption value="edit" @click="handleOpenModal('edit', nodeData)">
↻ <IconEdit />
</a-button> {{ t('channel.area.actions.edit') }}
<a-button </a-doption>
type="text" <a-doption value="delete" @click="handleDelData(nodeData?.id)">
size="small" <IconDelete />
class="action-icon-btn add-btn" {{ t('channel.area.actions.delete') }}
@click="handleOpenModal('add')" </a-doption>
:title="t('channel.area.search.addArea')" </template>
> </CustomLeftMenu>
+
</a-button>
</div>
</div>
</div>
<div class="menu-list">
<div
v-for="(item, index) in leftMenuData"
:key="item.id"
class="menu-item"
:class="{ 'menu-item-selected': currentId?.id === item.id }"
@click="cellClick(item)"
>
<div class="menu-item-content">
<span class="menu-index">{{ index + 1 }}</span>
<span class="menu-text">{{ item.name }}</span>
</div>
<div class="menu-actions">
<a-button
type="text"
size="small"
class="edit-btn"
@click.stop="handleOpenModal('edit', item)"
>
<template #icon>
<IconEdit />
</template>
</a-button>
<a-button
type="text"
size="small"
class="delete-btn"
@click.stop="handleDelData(item.id)"
>
<template #icon>
<IconDelete />
</template>
</a-button>
</div>
</div>
<!-- 空状态 -->
<div v-if="!leftMenuData.length" class="empty-menu">
<a-empty :description="t('channel.area.empty')" />
</div>
</div>
<ModalForm <ModalForm
ref="modalFormLeftRef" ref="modalFormLeftRef"
@ -216,6 +170,8 @@
import { $baseMessage } from '@/utils'; import { $baseMessage } from '@/utils';
import { IconEdit, IconDelete, IconPlus, IconSearch } from '@arco-design/web-vue/es/icon'; import { IconEdit, IconDelete, IconPlus, IconSearch } from '@arco-design/web-vue/es/icon';
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue';
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types';
import TemplateScroll from './components/TemplateScroll.vue'; import TemplateScroll from './components/TemplateScroll.vue';
@ -486,6 +442,38 @@
// 左侧菜单数据 // 左侧菜单数据
const leftMenuData = ref<any[]>([]); const leftMenuData = ref<any[]>([]);
const leftExpandedKeys = ref<string[]>(['category-list']);
const leftTreeData = computed<CustomLeftMenuTreeNode[]>(() => [
{
key: 'category-list',
title: t('channel.area.listTitle'),
children: leftMenuData.value.map((item) => ({
key: `item-${item.id}`,
title: item.name,
isLeaf: true,
id: item.id,
name: item.name,
...item,
})),
},
]);
const leftSelectedKeys = computed(() =>
currentId.value?.id ? [`item-${currentId.value.id}`] : []
);
function handleSelectFromTree(keys: string[], info: any) {
const key = info.node?.key;
if (!key || !key.startsWith('item-')) return;
const id = Number(key.replace('item-', ''));
const item = leftMenuData.value.find((i) => i.id === id);
if (item) cellClick(item);
}
function handleDeleteLeftNode(nodeData: any) {
if (nodeData?.id != null) handleDelData(nodeData.id);
}
const tableRef = ref<any>(null); const tableRef = ref<any>(null);
const tableProps = reactive<any>({ const tableProps = reactive<any>({
@ -797,42 +785,13 @@
} }
} }
/* 左侧使用 CustomLeftMenu,仅保留布局(与销售产品一致) */
.left-sidebar { .left-sidebar {
width: 280px; width: 280px;
background: var(--app-sidebar-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
border-top: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 24px;
display: flex;
flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
height: 100%; height: 100%;
padding: 12px; display: flex;
box-shadow: flex-direction: column;
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
overflow: hidden;
color: #fff;
position: relative;
}
.left-sidebar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%);
pointer-events: none;
z-index: 1;
} }
.sidebar-header { .sidebar-header {

View File

@ -4,6 +4,8 @@
import { Message, Modal, Input } from '@arco-design/web-vue'; import { Message, Modal, Input } from '@arco-design/web-vue';
import ModalPro from '@/components/ModalPro/index.vue'; import ModalPro from '@/components/ModalPro/index.vue';
import useModal from '@/components/ModalPro/useModal'; import useModal from '@/components/ModalPro/useModal';
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue';
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { diffJson } from 'diff'; import { diffJson } from 'diff';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -164,6 +166,50 @@
addProductModalVisible.value = false; addProductModalVisible.value = false;
}; };
// 左侧菜单:筛选后的产品列表(按名称搜索)
const filteredProductList = computed(() => {
if (!searchKeyword.value) return productList.value;
return productList.value.filter((p: any) =>
p.name?.toLowerCase().includes(searchKeyword.value.toLowerCase())
);
});
// 左侧菜单:产品树数据(单分类 + 子节点,供 CustomLeftMenu 使用)
const productTreeData = computed<CustomLeftMenuTreeNode[]>(() => {
const list = filteredProductList.value || [];
if (list.length === 0) return [];
return [
{
key: 'category-products',
title: '产品',
children: list.map((p: any) => ({
key: `item-${p.id}`,
title: p.name,
isLeaf: true,
id: p.id,
name: p.name,
...p,
})),
},
];
});
const productSelectedKeys = computed(() =>
selectedProduct.value !== null ? [`item-${selectedProduct.value}`] : []
);
const productExpandedKeys = ref(['category-products']);
function handleProductSelectFromTree(keys: string[], info: any) {
const node = info?.node;
if (!node || !node.isLeaf) return;
const key = node.key;
if (!key.startsWith('item-')) return;
const id = Number(key.slice(5));
const product = filteredProductList.value.find((p: any) => p.id === id);
if (product) handleSelectProduct(product);
}
// 选择产品 // 选择产品
const handleSelectProduct = (product: any) => { const handleSelectProduct = (product: any) => {
selectedProduct.value = product.id; selectedProduct.value = product.id;
@ -225,6 +271,30 @@
activeTab.value = key; activeTab.value = key;
}; };
// 状态开关滑动切换:记录触摸起点
const statusTouchStartX = ref(0)
const STATUS_SWIPE_THRESHOLD = 40
function onStatusSwipeStart(e: TouchEvent) {
statusTouchStartX.value = e.touches[0].clientX
}
function onStatusSwipeEnd(record: any, e: TouchEvent) {
const endX = e.changedTouches[0].clientX
if (Math.abs(endX - statusTouchStartX.value) >= STATUS_SWIPE_THRESHOLD) {
record.status = !record.status
handleTogglePriceStatus(record)
}
}
function onProductSwitchSwipeEnd(nodeData: any, e: TouchEvent) {
const endX = e.changedTouches[0].clientX
if (Math.abs(endX - statusTouchStartX.value) >= STATUS_SWIPE_THRESHOLD) {
const product = productList.value.find((p: any) => p.id === nodeData?.id)
if (product) {
product.isActive = !product.isActive
handleToggleProduct(product)
}
}
}
// 保存选择弹窗 // 保存选择弹窗
const saveConfirmModalVisible = ref(false); const saveConfirmModalVisible = ref(false);
const currentSaveType = ref<'config' | 'price' | 'rules'>('config'); const currentSaveType = ref<'config' | 'price' | 'rules'>('config');
@ -1569,75 +1639,61 @@
<template> <template>
<div class="sales-product-layout"> <div class="sales-product-layout">
<!-- 左侧产品列表 --> <!-- 左侧产品列表:使用公共组件 CustomLeftMenu -->
<div class="left-sidebar"> <div class="left-sidebar">
<div class="sidebar-header"> <CustomLeftMenu
<div class="search-box"> :tree-data="productTreeData"
<a-input :selected-keys="productSelectedKeys"
v-model="searchKeyword" v-model:expanded-keys="productExpandedKeys"
:placeholder="t('channel.salesProducts.search.placeholder')" v-model:search-keyword="searchKeyword"
allow-clear :search-placeholder="t('channel.salesProducts.search.placeholder')"
size="small" :refresh-title="t('channel.salesProducts.search.refresh')"
@change="handleSearch" :add-title="t('channel.salesProducts.search.addProduct')"
@clear="handleSearch" :show-node-index="true"
> :show-method="false"
<template #prefix> :show-map-count="false"
<icon-search /> :show-node-code="false"
</template> :show-node-actions="true"
</a-input> :delete-label="t('channel.salesProducts.actions.delete')"
<div class="search-actions"> @search="handleSearch"
<a-button @refresh="handleSearch"
type="text" @add="handleAddProduct"
size="small" @select="handleProductSelectFromTree"
class="action-icon-btn refresh-btn" @delete-node="(nodeData) => {
@click="handleSearch" const product = productList.find((p: any) => p.id === nodeData?.id);
:title="t('channel.salesProducts.search.refresh')" if (product) handleDeleteProduct(product);
> }"
↻ >
</a-button> <template #node-second-row-extra="{ nodeData }">
<a-button <table class="product-second-row-table">
type="text" <tr>
size="small" <td class="product-row-left">
class="action-icon-btn add-btn" <span class="create-time">创建: {{ nodeData?.createTime }}</span>
@click="handleAddProduct" </td>
:title="t('channel.salesProducts.search.addProduct')" <td class="product-row-right">
> <span
+ class="switch-swipe-wrapper"
</a-button> @click.capture.stop="() => {
</div> const product = productList.find((p: any) => p.id === nodeData?.id);
</div> if (product) { product.isActive = !product.isActive; handleToggleProduct(product); }
</div> }"
@touchstart.passive="onStatusSwipeStart"
<div class="product-list"> @touchend="(e) => onProductSwitchSwipeEnd(nodeData, e)"
<div >
v-for="product in productList" <a-switch
:key="product.id" :model-value="nodeData?.isActive"
:class="['product-item', { active: selectedProduct === product.id }]" size="small"
@click="handleSelectProduct(product)" type="circle"
> class="product-active-switch"
<div class="product-info"> checked-color="#00b42a"
<div class="product-header"> unchecked-color="#f53f3f"
<div class="product-name">{{ product.name }}</div> />
<a-switch </span>
v-model="product.isActive" </td>
size="small" </tr>
@change="handleToggleProduct(product)" </table>
/> </template>
</div> </CustomLeftMenu>
<div class="product-meta">
<span class="create-time">创建: {{ product.createTime }}</span>
<a-button
type="text"
size="small"
class="delete-btn"
@click.stop="handleDeleteProduct(product)"
>
<icon-delete />
</a-button>
</div>
</div>
</div>
</div>
</div> </div>
<!-- 右侧产品配置 --> <!-- 右侧产品配置 -->
@ -1664,6 +1720,14 @@
<span style="margin-left: 16px; color: #8c8c8c;">生效时间:{{ effectiveTime }}</span> <span style="margin-left: 16px; color: #8c8c8c;">生效时间:{{ effectiveTime }}</span>
</div> </div>
<div class="price-header-actions"> <div class="price-header-actions">
<a-button
type="secondary"
class="add-price-btn"
@click="handleAddPrice"
>
<icon-plus class="btn-icon" />
{{ t('channel.salesProducts.actions.add') }}
</a-button>
<a-button <a-button
type="primary" type="primary"
class="save-btn" class="save-btn"
@ -1671,14 +1735,6 @@
> >
{{ t('channel.salesProducts.actions.save') }} {{ t('channel.salesProducts.actions.save') }}
</a-button> </a-button>
<a-button
type="secondary"
class="add-price-btn"
@click="handleAddPrice"
>
{{ t('channel.salesProducts.actions.add') }}
<icon-plus class="btn-icon" />
</a-button>
</div> </div>
</div> </div>
<div class="price-table-container"> <div class="price-table-container">
@ -1690,11 +1746,21 @@
class="price-table" class="price-table"
> >
<template #status="{ record }"> <template #status="{ record }">
<a-switch <div
v-model="record.status" class="switch-swipe-wrapper"
size="small" @click.capture.stop="() => { record.status = !record.status; handleTogglePriceStatus(record); }"
@change="handleTogglePriceStatus(record)" @touchstart.passive="onStatusSwipeStart"
/> @touchend="(e) => onStatusSwipeEnd(record, e)"
>
<a-switch
:model-value="record.status"
type="circle"
size="small"
class="product-active-switch"
checked-color="#00b42a"
unchecked-color="#f53f3f"
/>
</div>
</template> </template>
<template #operations="{ record }"> <template #operations="{ record }">
<a-space> <a-space>
@ -2870,204 +2936,65 @@
overflow: hidden; overflow: hidden;
} }
/* 左侧使用 CustomLeftMenu,仅保留布局(参考应用列表) */
.left-sidebar { .left-sidebar {
width: 280px; width: 280px;
background: var(--app-sidebar-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
border-top: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 24px;
display: flex;
flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
height: 100%; height: 100%;
padding: 12px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
overflow: hidden;
color: #fff;
position: relative;
}
.left-sidebar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%);
pointer-events: none;
z-index: 1;
}
.sidebar-header {
padding: 10px 12px !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
min-height: 48px !important;
display: flex !important;
align-items: center !important;
color: #fff;
}
.search-box {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 8px; }
/* 第二行表格:创建时间对齐 node-title,开关对齐 node-actions-cell */
.product-second-row-table {
width: 100%; width: 100%;
border-collapse: collapse;
table-layout: fixed;
} }
.search-actions { .product-row-left {
display: flex; text-align: left;
gap: 4px;
}
.action-icon-btn {
width: 28px;
height: 28px;
padding: 0; padding: 0;
display: flex; padding-left: 24px; /* 与 node-title 左侧对齐:node-number(min-width 16px + margin-right 8px) */
align-items: center; vertical-align: middle;
justify-content: center; width: auto;
color: #fff; font-size: 12px;
border: 1px solid transparent; color: rgba(255, 255, 255, 0.65);
border-radius: 4px;
font-size: 16px; .create-time {
font-weight: bold; overflow: hidden;
text-overflow: ellipsis;
&:hover { white-space: nowrap;
background-color: rgba(255, 255, 255, 0.1);
}
&.add-btn {
color: var(--app-primary-color) !important;
background-color: var(--app-primary-color-light) !important;
border: 1px solid var(--app-primary-color) !important;
border-radius: 50% !important;
width: 28px !important;
height: 28px !important;
min-width: 28px !important;
max-width: 28px !important;
min-height: 28px !important;
max-height: 28px !important;
padding: 0 !important;
line-height: 28px !important;
&:hover {
color: #ffffff !important;
background-color: var(--app-primary-color) !important;
border-color: var(--app-primary-color) !important;
}
}
&.refresh-btn {
color: #fff;
&:hover {
color: var(--app-primary-color);
}
} }
} }
.product-list { .product-row-right {
flex: 1; text-align: right;
padding: 8px 0 0; padding: 0;
overflow-y: auto; width: 30px; /* 与 CustomLeftMenu 的 node-actions-cell 同宽 */
color: #fff; vertical-align: middle;
background: transparent;
border-radius: 16px;
padding-right: 4px;
} }
.product-list::-webkit-scrollbar { /* 开关:启用-绿色,停用-红色(与应用列表一致) */
width: 6px; .left-sidebar :deep(.product-active-switch.arco-switch-checked) {
background-color: #00b42a !important;
}
.left-sidebar :deep(.product-active-switch:not(.arco-switch-checked)) {
background-color: #f53f3f !important;
} }
.product-list::-webkit-scrollbar-track { :deep(.node-second-row-extra-slot) {
background: rgba(0, 0, 0, 0.08); display: block;
border-radius: 8px;
}
.product-list::-webkit-scrollbar-thumb {
background: rgba(86, 144, 236, 0.65);
border-radius: 8px;
}
.product-list::-webkit-scrollbar-thumb:hover {
background: rgba(59, 97, 148, 0.85);
}
.product-item {
width: 100%; width: 100%;
box-sizing: border-box; }
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 10px 12px; :deep(.node-second-row-extra-cell) {
margin: 8px 0; text-align: left;
border-radius: 18px; }
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
cursor: pointer; /* 选中时创建时间保持深色,不变白 */
background: rgba(255, 255, 255, 0.02); :deep(.tree-node.selected .create-time) {
color: #666 !important;
&:hover {
background-color: rgba(13, 29, 61, 0.8);
border-color: rgba(255, 255, 255, 0.2);
transform: translateX(4px);
}
&.active {
background: #FFF394;
box-shadow: 0 10px 20px rgba(255, 243, 148, 0.4);
border-color: #FFF394 !important;
color: #000 !important;
.product-name,
.create-time {
color: #000 !important;
}
}
.product-info {
width: 100%;
.product-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.product-name {
font-size: 14px;
font-weight: 600;
color: #fff;
}
}
.product-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
.create-time {
flex: 1;
}
.delete-btn {
color: rgba(255, 255, 255, 0.65);
&:hover {
color: #f53f3f;
}
}
}
}
} }
.right-content.content-panel { .right-content.content-panel {
@ -3079,6 +3006,10 @@
overflow: hidden; overflow: hidden;
} }
.switch-swipe-wrapper {
display: inline-block;
}
.tabs-section { .tabs-section {
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8;
padding: 0 24px; padding: 0 24px;
@ -3229,41 +3160,28 @@
.action-btn { .action-btn {
color: var(--app-primary-color); color: var(--app-primary-color);
:deep(svg) {
color: var(--app-primary-color);
fill: var(--app-primary-color);
}
&:hover { &:hover {
color: #1a6b6b; color: #1a6b6b;
background-color: #f0f9f9; background-color: #f0f9f9;
:deep(svg) {
color: #1a6b6b;
fill: #1a6b6b;
}
} }
&.delete-btn { &.delete-btn {
color: #f53f3f; color: #f53f3f;
:deep(svg) {
color: #f53f3f;
fill: #f53f3f;
}
&:hover { &:hover {
color: #d32f2f; color: #d32f2f;
background-color: #fff1f0; background-color: #fff1f0;
:deep(svg) {
color: #d32f2f;
fill: #d32f2f;
}
} }
} }
} }
:deep(.product-active-switch.arco-switch-checked) {
background-color: #00b42a !important;
}
:deep(.product-active-switch:not(.arco-switch-checked)) {
background-color: #f53f3f !important;
}
} }
} }
@ -3706,38 +3624,18 @@
.action-btn { .action-btn {
color: var(--app-primary-color); color: var(--app-primary-color);
:deep(svg) {
color: var(--app-primary-color);
fill: var(--app-primary-color);
}
&:hover { &:hover {
color: #1a6b6b; color: #1a6b6b;
background-color: #f0f9f9; background-color: #f0f9f9;
:deep(svg) {
color: #1a6b6b;
fill: #1a6b6b;
}
} }
&.delete-btn { &.delete-btn {
color: #f53f3f; color: #f53f3f;
:deep(svg) {
color: #f53f3f;
fill: #f53f3f;
}
&:hover { &:hover {
color: #d32f2f; color: #d32f2f;
background-color: #fff1f0; background-color: #fff1f0;
:deep(svg) {
color: #d32f2f;
fill: #d32f2f;
}
} }
} }
} }

View File

@ -1,111 +1,60 @@
<template> <template>
<div class="service-channel-layout"> <div class="service-channel-layout">
<!-- 左侧渠道列表 --> <!-- 左侧渠道列表:使用公共组件 CustomLeftMenu(参考销售产品样式) -->
<div class="channel-sidebar"> <div class="channel-sidebar">
<div class="sidebar-header"> <CustomLeftMenu
<div class="search-box"> :tree-data="channelTreeData"
<a-input :selected-keys="channelSelectedKeys"
v-model="searchKeyword" v-model:expanded-keys="channelExpandedKeys"
:placeholder="t('channel.serviceChannels.search.placeholder')" v-model:search-keyword="searchKeyword"
allow-clear :search-placeholder="t('channel.serviceChannels.search.placeholder')"
size="small" :refresh-title="t('channel.serviceChannels.search.refresh')"
@change="handleSearch" :add-title="t('channel.serviceChannels.search.addProvider')"
@clear="handleSearch" :show-node-index="true"
> :show-method="false"
<template #prefix> :show-map-count="false"
<icon-search /> :show-node-code="false"
</template> :show-node-actions="true"
</a-input> :delete-label="t('channel.serviceChannels.actions.delete')"
<div class="search-actions"> @search="handleSearch"
<a-button @refresh="handleSearch"
type="text" @add="handleAddProvider"
size="small" @select="handleChannelSelectFromTree"
class="action-icon-btn refresh-btn" @delete-node="(nodeData) => {
@click="handleSearch" const channel = findChannelById(nodeData?.id)
:title="t('channel.serviceChannels.search.refresh')" if (channel) handleDeleteChannel(channel)
> }"
↻ >
</a-button> <template #node-second-row-extra="{ nodeData }">
<a-button <table class="channel-second-row-table">
type="text" <tr>
size="small" <td class="channel-row-left">
class="action-icon-btn add-btn" <span class="create-time">{{ t('channel.serviceChannels.createTime', { time: nodeData?.createTime }) }}</span>
@click="handleAddProvider" </td>
:title="t('channel.serviceChannels.search.addProvider')" <td class="channel-row-right">
> <span
+ class="switch-swipe-wrapper"
</a-button> @click.capture.stop="() => {
</div> const channel = findChannelById(nodeData?.id)
</div> if (channel) { channel.isActive = !channel.isActive; handleToggleChannel(channel); }
</div> }"
@touchstart.passive="onStatusSwipeStart"
<div class="channel-list"> @touchend="(e) => onChannelSwitchSwipeEnd(nodeData, e)"
<div class="channel-group" v-for="group in channelGroups" :key="group.id">
<div class="group-header">
<div class="group-info">
<icon-folder />
<span class="group-name">{{ group.name }}</span>
</div>
<div class="group-actions">
<a-dropdown trigger="click" position="br">
<a-button
type="text"
size="small"
class="group-action-btn more-btn"
> >
<icon-more /> <a-switch
</a-button> :model-value="nodeData?.isActive"
<template #content> size="small"
<a-doption @click="handleAddChannelToGroup(group)"> type="circle"
<icon-plus style="margin-right: 8px;" /> class="channel-active-switch"
新增服务渠道 checked-color="#00b42a"
</a-doption> unchecked-color="#f53f3f"
<a-doption @click="handleEditGroup(group)">
<icon-edit style="margin-right: 8px;" />
编辑服务商
</a-doption>
<a-doption @click="handleDeleteGroup(group)" class="delete-option">
<icon-delete style="margin-right: 8px;" />
删除服务商
</a-doption>
</template>
</a-dropdown>
</div>
</div>
<div class="group-channels">
<div
v-for="channel in group.channels"
:key="channel.id"
class="channel-item"
:class="{ active: selectedChannel?.id === channel.id }"
@click="selectChannel(channel)"
>
<div class="channel-info">
<div class="channel-header">
<div class="channel-name">{{ channel.name }}</div>
<a-switch
v-model="channel.isActive"
size="small"
@change="handleToggleChannel(channel)"
/> />
</div> </span>
<div class="channel-meta"> </td>
<span class="create-time">{{ t('channel.serviceChannels.createTime', { time: channel.createTime }) }}</span> </tr>
<a-button </table>
type="text" </template>
size="small" </CustomLeftMenu>
class="delete-btn"
@click.stop="handleDeleteChannel(channel)"
>
<icon-delete />
</a-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<!-- 右侧配置区域 --> <!-- 右侧配置区域 -->
@ -217,6 +166,14 @@
<span style="margin-left: 16px; color: #8c8c8c;">生效时间:{{ effectiveTime }}</span> <span style="margin-left: 16px; color: #8c8c8c;">生效时间:{{ effectiveTime }}</span>
</div> </div>
<div class="price-header-actions"> <div class="price-header-actions">
<a-button
type="secondary"
class="add-price-btn"
@click="handleAddPrice"
>
<icon-plus class="btn-icon" />
{{ t('channel.serviceChannels.actions.add') }}
</a-button>
<a-button <a-button
type="primary" type="primary"
class="save-btn" class="save-btn"
@ -224,14 +181,6 @@
> >
{{ t('channel.serviceChannels.actions.save') }} {{ t('channel.serviceChannels.actions.save') }}
</a-button> </a-button>
<a-button
type="secondary"
class="add-price-btn"
@click="handleAddPrice"
>
{{ t('channel.serviceChannels.actions.add') }}
<icon-plus class="btn-icon" />
</a-button>
</div> </div>
</div> </div>
<div class="price-table-container"> <div class="price-table-container">
@ -243,11 +192,21 @@
class="price-table" class="price-table"
> >
<template #status="{ record }"> <template #status="{ record }">
<a-switch <div
v-model="record.status" class="switch-swipe-wrapper"
size="small" @click.capture.stop="() => { record.status = !record.status; handleTogglePriceStatus(record); }"
@change="handleTogglePriceStatus(record)" @touchstart.passive="onStatusSwipeStart"
/> @touchend="(e) => onStatusSwipeEnd(record, e)"
>
<a-switch
:model-value="record.status"
type="circle"
size="small"
class="product-active-switch"
checked-color="#00b42a"
unchecked-color="#f53f3f"
/>
</div>
</template> </template>
<template #operations="{ record }"> <template #operations="{ record }">
<a-space> <a-space>
@ -1118,7 +1077,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, nextTick, onMounted } from 'vue' import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestionCircleFill, IconFolder, IconMore } from '@arco-design/web-vue/es/icon' import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestionCircleFill, IconFolder, IconMore } from '@arco-design/web-vue/es/icon'
import { Message, Modal, Input } from '@arco-design/web-vue' import { Message, Modal, Input } from '@arco-design/web-vue'
@ -1130,6 +1089,8 @@ import * as XLSX from 'xlsx'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { getAppGetList } from '@/api/ApplicationServices/apps' import { getAppGetList } from '@/api/ApplicationServices/apps'
import { getServiceProviderGetServiceProviderDropdownList } from '@/api/interface-serviceProvider/serviceProvider' import { getServiceProviderGetServiceProviderDropdownList } from '@/api/interface-serviceProvider/serviceProvider'
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
defineOptions({ defineOptions({
name: 'ServiceChannel' name: 'ServiceChannel'
@ -1187,6 +1148,74 @@ const channelGroups = ref([
} }
]) ])
// 左侧菜单:按关键词筛选渠道组(组名或渠道名)
const filteredChannelGroups = computed(() => {
const kw = searchKeyword.value?.toLowerCase() || ''
if (!kw) return channelGroups.value
return channelGroups.value
.map((group: any) => {
const nameMatch = group.name?.toLowerCase().includes(kw)
const channels = (group.channels || []).filter((ch: any) =>
ch.name?.toLowerCase().includes(kw)
)
if (nameMatch || channels.length > 0) {
return { ...group, channels: nameMatch ? (group.channels || []) : channels }
}
return null
})
.filter(Boolean)
})
// 左侧菜单:渠道树数据(组为父节点,渠道为子节点)
const channelTreeData = computed<CustomLeftMenuTreeNode[]>(() => {
const groups = filteredChannelGroups.value || []
return groups.map((group: any) => ({
key: `group-${group.id}`,
title: group.name,
children: (group.channels || []).map((ch: any) => ({
key: `item-${ch.id}`,
title: ch.name,
isLeaf: true,
id: ch.id,
name: ch.name,
...ch
}))
}))
})
const channelSelectedKeys = computed(() =>
selectedChannel.value ? [`item-${(selectedChannel.value as any).id}`] : []
)
const channelExpandedKeys = ref<string[]>(
channelGroups.value.map((g: any) => `group-${g.id}`)
)
watch(
() => channelGroups.value.map((g: any) => `group-${g.id}`),
(keys) => {
channelExpandedKeys.value = keys
},
{ deep: true }
)
function findChannelById(id: number | string) {
for (const group of channelGroups.value) {
const ch = (group.channels || []).find((c: any) => c.id === id)
if (ch) return ch
}
return null
}
function handleChannelSelectFromTree(keys: string[], info: any) {
const node = info?.node
if (!node || !node.isLeaf) return
const key = node.key
if (!key.startsWith('item-')) return
const id = Number(key.slice(5))
const channel = findChannelById(id)
if (channel) selectChannel(channel)
}
// 表单数据 // 表单数据
const formData = reactive({ const formData = reactive({
channelCode: '', channelCode: '',
@ -2190,6 +2219,30 @@ const handleTogglePriceStatus = (record: any) => {
record.status = !record.status record.status = !record.status
} }
// 状态开关滑动切换
const statusTouchStartX = ref(0)
const STATUS_SWIPE_THRESHOLD = 40
function onStatusSwipeStart(e: TouchEvent) {
statusTouchStartX.value = e.touches[0].clientX
}
function onStatusSwipeEnd(record: any, e: TouchEvent) {
const endX = e.changedTouches[0].clientX
if (Math.abs(endX - statusTouchStartX.value) >= STATUS_SWIPE_THRESHOLD) {
record.status = !record.status
handleTogglePriceStatus(record)
}
}
function onChannelSwitchSwipeEnd(nodeData: any, e: TouchEvent) {
const endX = e.changedTouches[0].clientX
if (Math.abs(endX - statusTouchStartX.value) >= STATUS_SWIPE_THRESHOLD) {
const channel = findChannelById(nodeData?.id)
if (channel) {
channel.isActive = !channel.isActive
handleToggleChannel(channel)
}
}
}
// 新增服务商 // 新增服务商
const handleAddProvider = () => { const handleAddProvider = () => {
editProviderForm.providerName = '' editProviderForm.providerName = ''
@ -2822,275 +2875,64 @@ const handleCancelSave = () => {
overflow: hidden; overflow: hidden;
} }
/* 左侧使用 CustomLeftMenu,仅保留布局(参考销售产品) */
.channel-sidebar { .channel-sidebar {
width: 280px; width: 280px;
background: var(--app-sidebar-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
border-top: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 24px;
display: flex;
flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
height: 100%; height: 100%;
padding: 12px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
overflow: hidden;
color: #fff;
position: relative;
}
.channel-sidebar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%);
pointer-events: none;
z-index: 1;
}
.sidebar-header {
padding: 10px 12px !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
min-height: 48px !important;
display: flex !important;
align-items: center !important;
color: #fff;
}
.search-box {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 8px; }
/* 第二行表格:创建时间对齐 node-title,开关对齐 node-actions-cell */
.channel-second-row-table {
width: 100%; width: 100%;
border-collapse: collapse;
table-layout: fixed;
} }
.search-actions { .channel-row-left {
display: flex; text-align: left;
gap: 4px;
}
.action-icon-btn {
width: 28px;
height: 28px;
padding: 0; padding: 0;
display: flex; padding-left: 24px;
align-items: center; vertical-align: middle;
justify-content: center; width: auto;
color: #fff; font-size: 12px;
border: 1px solid transparent; color: rgba(255, 255, 255, 0.65);
border-radius: 4px;
font-size: 16px; .create-time {
font-weight: bold; overflow: hidden;
text-overflow: ellipsis;
&:hover { white-space: nowrap;
background-color: rgba(255, 255, 255, 0.1);
}
&.add-btn {
color: var(--app-primary-color) !important;
background-color: var(--app-primary-color-light) !important;
border: 1px solid var(--app-primary-color) !important;
border-radius: 50% !important;
width: 28px !important;
height: 28px !important;
min-width: 28px !important;
max-width: 28px !important;
min-height: 28px !important;
max-height: 28px !important;
padding: 0 !important;
line-height: 28px !important;
&:hover {
color: #ffffff !important;
background-color: var(--app-primary-color) !important;
border-color: var(--app-primary-color) !important;
}
}
&.refresh-btn {
color: #fff;
&:hover {
color: var(--app-primary-color);
}
} }
} }
.channel-list { .channel-row-right {
flex: 1; text-align: right;
padding: 8px 0 0; padding: 0;
overflow-y: auto; width: 30px;
color: #fff; vertical-align: middle;
background: transparent;
border-radius: 16px;
padding-right: 4px;
} }
.channel-list::-webkit-scrollbar { .channel-sidebar :deep(.channel-active-switch.arco-switch-checked) {
width: 6px; background-color: #00b42a !important;
}
.channel-sidebar :deep(.channel-active-switch:not(.arco-switch-checked)) {
background-color: #f53f3f !important;
} }
.channel-list::-webkit-scrollbar-track { :deep(.node-second-row-extra-slot) {
background: rgba(0, 0, 0, 0.08); display: block;
border-radius: 8px;
}
.channel-list::-webkit-scrollbar-thumb {
background: rgba(86, 144, 236, 0.65);
border-radius: 8px;
}
.channel-list::-webkit-scrollbar-thumb:hover {
background: rgba(59, 97, 148, 0.85);
}
.channel-group {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding: 0 8px;
.group-info {
display: flex;
align-items: center;
gap: 8px;
.group-name {
font-weight: 500;
color: #fff;
font-size: 14px;
}
}
.group-actions {
display: flex;
gap: 0px;
align-items: center;
.group-action-btn {
color: rgba(255, 255, 255, 0.65);
&:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
}
&.more-btn {
color: rgba(255, 255, 255, 0.65);
&:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
}
}
}
}
// 下拉菜单选项样式
:deep(.arco-dropdown-option) {
&.delete-option {
color: #f53f3f;
&:hover {
background-color: #fff1f0;
}
}
}
}
.group-channels {
padding-left: 16px;
}
.channel-item {
width: 100%; width: 100%;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 10px 12px;
margin: 8px 0;
border-radius: 18px;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
cursor: pointer;
background: rgba(255, 255, 255, 0.02);
&:hover {
background-color: rgba(13, 29, 61, 0.8);
border-color: rgba(255, 255, 255, 0.2);
transform: translateX(4px);
}
&.active {
background: #FFF394;
box-shadow: 0 10px 20px rgba(255, 243, 148, 0.4);
border-color: #FFF394 !important;
color: #000 !important;
.channel-name,
.create-time {
color: #000 !important;
}
}
&:last-child {
margin-bottom: 0;
}
} }
.channel-info { :deep(.node-second-row-extra-cell) {
width: 100%; text-align: left;
}
.channel-header {
display: flex; /* 选中时创建时间保持深色,不变白 */
justify-content: space-between; :deep(.tree-node.selected .create-time) {
align-items: center; color: #666 !important;
margin-bottom: 8px;
.channel-name {
font-weight: 600;
color: #fff;
font-size: 14px;
}
}
.channel-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
.create-time {
flex: 1;
}
.delete-btn {
color: rgba(255, 255, 255, 0.65);
&:hover {
color: #f53f3f;
}
}
}
} }
.channel-content.content-panel { .channel-content.content-panel {
@ -3102,6 +2944,10 @@ const handleCancelSave = () => {
overflow: hidden; overflow: hidden;
} }
.switch-swipe-wrapper {
display: inline-block;
}
.tabs-section { .tabs-section {
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8;
padding: 0 24px; padding: 0 24px;
@ -3320,6 +3166,13 @@ const handleCancelSave = () => {
} }
} }
} }
:deep(.product-active-switch.arco-switch-checked) {
background-color: #00b42a !important;
}
:deep(.product-active-switch:not(.arco-switch-checked)) {
background-color: #f53f3f !important;
}
} }
} }

View File

@ -4,95 +4,48 @@
<BasicLayoutLeftRight :left-width="280"> <BasicLayoutLeftRight :left-width="280">
<template #left> <template #left>
<div class="left-sidebar"> <div class="left-sidebar">
<div class="sidebar-header"> <CustomLeftMenu
<div class="search-box"> :tree-data="leftTreeData"
<a-input :selected-keys="leftSelectedKeys"
v-model="searchModel.keyword" v-model:expanded-keys="leftExpandedKeys"
:placeholder="t('channel.weightRounding.search.placeholder')" :search-keyword="searchModel.keyword"
allow-clear @update:search-keyword="(v) => (searchModel.keyword = v)"
size="small" :search-placeholder="t('channel.weightRounding.search.placeholder')"
@change="onFormSubmitClick" :refresh-title="t('channel.weightRounding.search.refresh')"
@clear="onFormSubmitClick" :add-title="t('channel.weightRounding.search.addRounding')"
@keyup.enter="onFormSubmitClick" show-node-index
> :show-method="false"
<template #prefix> :show-map-count="false"
<icon-search /> :show-node-code="false"
</template> show-node-actions
</a-input> :delete-label="t('channel.weightRounding.actions.delete')"
<div class="search-actions"> @search="onFormSubmitClick"
<a-button @refresh="onFormSubmitClick"
type="text" @add="handleOpenModal('add')"
size="small" @select="handleSelectFromTree"
class="action-icon-btn refresh-btn" @delete-node="handleDeleteLeftNode"
@click="onFormSubmitClick" >
:title="t('channel.weightRounding.search.refresh')" <template #node-menu="{ nodeData }">
> <a-doption value="edit" @click="handleOpenModal('edit', nodeData)">
↻ <IconEdit />
</a-button> {{ t('channel.area.actions.edit') }}
<a-button </a-doption>
type="text" <a-doption value="delete" @click="handleDelData(nodeData?.id)">
size="small" <IconDelete />
class="action-icon-btn add-btn" {{ t('channel.weightRounding.actions.delete') }}
@click="handleOpenModal('add')" </a-doption>
:title="t('channel.weightRounding.search.addRounding')" </template>
> <template #node-second-row-extra="{ nodeData }">
+ <table class="product-second-row-table">
</a-button> <tr>
</div> <td class="product-row-left">
</div> <span class="update-time">{{ t('channel.weightRounding.updateTime', { time: nodeData?.updateTime ?? '' }) }}</span>
</div> </td>
<div class="menu-list"> <td class="product-row-right"></td>
<div </tr>
v-for="(item, index) in leftMenuData" </table>
:key="item.id" </template>
class="menu-item" </CustomLeftMenu>
:class="{ 'menu-item-selected': currentId?.id === item.id }"
@click="cellClick(item)"
>
<div class="menu-item-content">
<span class="menu-text">{{ item.name }}</span>
<div class="menu-actions">
<a-button
type="text"
size="small"
class="edit-btn"
@click.stop="handleOpenModal('edit', item)"
>
<template #icon>
<IconEdit />
</template>
</a-button>
<a-button
type="text"
size="small"
class="delete-btn"
@click.stop="handleDelData(item.id)"
>
<template #icon>
<IconDelete />
</template>
</a-button>
</div>
</div>
<div class="menu-tags">
<span
v-for="tag in item.tags"
:key="tag"
class="tag"
>
{{ tag }}
</span>
</div>
<div class="menu-update">
{{ t('channel.weightRounding.updateTime', { time: item.updateTime }) }}
</div>
</div>
<!-- 空状态 -->
<div v-if="!leftMenuData.length" class="empty-menu">
<a-empty :description="t('channel.weightRounding.empty')" />
</div>
</div>
<ModalForm <ModalForm
ref="modalFormLeftRef" ref="modalFormLeftRef"
@ -217,6 +170,8 @@
import { $baseMessage } from '@/utils'; import { $baseMessage } from '@/utils';
import { IconEdit, IconDelete, IconPlus, IconSearch } from '@arco-design/web-vue/es/icon'; import { IconEdit, IconDelete, IconPlus, IconSearch } from '@arco-design/web-vue/es/icon';
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue';
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types';
const userStore = useUserStore(); const userStore = useUserStore();
const { t } = useI18n(); const { t } = useI18n();
@ -420,6 +375,38 @@
updateTime: '2023.6.6 12:45' updateTime: '2023.6.6 12:45'
} }
]); ]);
const leftExpandedKeys = ref<string[]>(['category-list']);
const leftTreeData = computed<CustomLeftMenuTreeNode[]>(() => [
{
key: 'category-list',
title: t('channel.weightRounding.listTitle'),
children: leftMenuData.value.map((item) => ({
key: `item-${item.id}`,
title: item.name,
isLeaf: true,
id: item.id,
name: item.name,
...item,
})),
},
]);
const leftSelectedKeys = computed(() =>
currentId.value?.id ? [`item-${currentId.value.id}`] : []
);
function handleSelectFromTree(keys: string[], info: any) {
const key = info.node?.key;
if (!key || !key.startsWith('item-')) return;
const id = Number(key.replace('item-', ''));
const item = leftMenuData.value.find((i) => i.id === id);
if (item) cellClick(item);
}
function handleDeleteLeftNode(nodeData: any) {
if (nodeData?.id != null) handleDelData(nodeData.id);
}
// 表格数据 // 表格数据
const roundingData = ref<any[]>([ const roundingData = ref<any[]>([
@ -763,42 +750,56 @@
} }
} }
/* 左侧使用 CustomLeftMenu,仅保留布局(与销售产品一致) */
.left-sidebar { .left-sidebar {
width: 280px; width: 280px;
background: var(--app-sidebar-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
border-top: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 24px;
display: flex;
flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
height: 100%; height: 100%;
padding: 12px; display: flex;
box-shadow: flex-direction: column;
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
overflow: hidden;
color: #fff;
position: relative;
} }
.left-sidebar::before { /* 第二行表格:更新时间布局与样式对齐销售产品的创建时间 */
content: ''; .left-sidebar .product-second-row-table {
position: absolute; width: 100%;
top: 0; border-collapse: collapse;
left: 0; table-layout: fixed;
right: 0; }
height: 1px;
background: linear-gradient(90deg, .left-sidebar .product-row-left {
transparent 0%, text-align: left;
rgba(255, 255, 255, 0.3) 50%, padding: 0;
transparent 100%); padding-left: 24px;
pointer-events: none; vertical-align: middle;
z-index: 1; width: auto;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
.update-time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.left-sidebar .product-row-right {
text-align: right;
padding: 0;
width: 30px;
vertical-align: middle;
}
.left-sidebar :deep(.node-second-row-extra-slot) {
display: block;
width: 100%;
}
.left-sidebar :deep(.node-second-row-extra-cell) {
text-align: left;
}
.left-sidebar :deep(.tree-node.selected .update-time) {
color: #666 !important;
} }
.sidebar-header { .sidebar-header {

View File

@ -4,95 +4,48 @@
<BasicLayoutLeftRight :left-width="280"> <BasicLayoutLeftRight :left-width="280">
<template #left> <template #left>
<div class="left-sidebar"> <div class="left-sidebar">
<div class="sidebar-header"> <CustomLeftMenu
<div class="search-box"> :tree-data="leftTreeData"
<a-input :selected-keys="leftSelectedKeys"
v-model="searchModel.keyword" v-model:expanded-keys="leftExpandedKeys"
:placeholder="t('channel.weightRules.search.placeholder')" :search-keyword="searchModel.keyword"
allow-clear @update:search-keyword="(v) => (searchModel.keyword = v)"
size="small" :search-placeholder="t('channel.weightRules.search.placeholder')"
@change="onFormSubmitClick" :refresh-title="t('channel.weightRules.search.refresh')"
@clear="onFormSubmitClick" :add-title="t('channel.weightRules.search.addRule')"
@keyup.enter="onFormSubmitClick" show-node-index
> :show-method="false"
<template #prefix> :show-map-count="false"
<icon-search /> :show-node-code="false"
</template> show-node-actions
</a-input> :delete-label="t('channel.weightRules.actions.delete')"
<div class="search-actions"> @search="onFormSubmitClick"
<a-button @refresh="onFormSubmitClick"
type="text" @add="handleOpenModal('add')"
size="small" @select="handleSelectFromTree"
class="action-icon-btn refresh-btn" @delete-node="handleDeleteLeftNode"
@click="onFormSubmitClick" >
:title="t('channel.weightRules.search.refresh')" <template #node-menu="{ nodeData }">
> <a-doption value="edit" @click="handleOpenModal('edit', nodeData)">
↻ <IconEdit />
</a-button> {{ t('channel.area.actions.edit') }}
<a-button </a-doption>
type="text" <a-doption value="delete" @click="handleDelData(nodeData?.id)">
size="small" <IconDelete />
class="action-icon-btn add-btn" {{ t('channel.weightRules.actions.delete') }}
@click="handleOpenModal('add')" </a-doption>
:title="t('channel.weightRules.search.addRule')" </template>
> <template #node-second-row-extra="{ nodeData }">
+ <table class="product-second-row-table">
</a-button> <tr>
</div> <td class="product-row-left">
</div> <span class="update-time">{{ t('channel.weightRules.updateTime', { time: nodeData?.updateTime ?? '' }) }}</span>
</div> </td>
<div class="menu-list"> <td class="product-row-right"></td>
<div </tr>
v-for="(item, index) in leftMenuData" </table>
:key="item.id" </template>
class="menu-item" </CustomLeftMenu>
:class="{ 'menu-item-selected': currentId?.id === item.id }"
@click="cellClick(item)"
>
<div class="menu-item-content">
<span class="menu-text">{{ item.name }}</span>
<div class="menu-actions">
<a-button
type="text"
size="small"
class="edit-btn"
@click.stop="handleOpenModal('edit', item)"
>
<template #icon>
<IconEdit />
</template>
</a-button>
<a-button
type="text"
size="small"
class="delete-btn"
@click.stop="handleDelData(item.id)"
>
<template #icon>
<IconDelete />
</template>
</a-button>
</div>
</div>
<div class="menu-tags">
<span
v-for="tag in item.tags"
:key="tag"
class="tag"
>
{{ tag }}
</span>
</div>
<div class="menu-update">
{{ t('channel.weightRules.updateTime', { time: item.updateTime }) }}
</div>
</div>
<!-- 空状态 -->
<div v-if="!leftMenuData.length" class="empty-menu">
<a-empty :description="t('channel.weightRules.empty')" />
</div>
</div>
<ModalForm <ModalForm
ref="modalFormLeftRef" ref="modalFormLeftRef"
@ -217,6 +170,8 @@
import { $baseMessage } from '@/utils'; import { $baseMessage } from '@/utils';
import { IconEdit, IconDelete, IconPlus, IconSearch } from '@arco-design/web-vue/es/icon'; import { IconEdit, IconDelete, IconPlus, IconSearch } from '@arco-design/web-vue/es/icon';
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue';
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types';
const userStore = useUserStore(); const userStore = useUserStore();
const { t } = useI18n(); const { t } = useI18n();
@ -421,6 +376,38 @@
updateTime: '2023.6.6 12:45' updateTime: '2023.6.6 12:45'
} }
]); ]);
const leftExpandedKeys = ref<string[]>(['category-list']);
const leftTreeData = computed<CustomLeftMenuTreeNode[]>(() => [
{
key: 'category-list',
title: t('channel.weightRules.listTitle'),
children: leftMenuData.value.map((item) => ({
key: `item-${item.id}`,
title: item.name,
isLeaf: true,
id: item.id,
name: item.name,
...item,
})),
},
]);
const leftSelectedKeys = computed(() =>
currentId.value?.id ? [`item-${currentId.value.id}`] : []
);
function handleSelectFromTree(keys: string[], info: any) {
const key = info.node?.key;
if (!key || !key.startsWith('item-')) return;
const id = Number(key.replace('item-', ''));
const item = leftMenuData.value.find((i) => i.id === id);
if (item) cellClick(item);
}
function handleDeleteLeftNode(nodeData: any) {
if (nodeData?.id != null) handleDelData(nodeData.id);
}
// 表格数据 // 表格数据
const rulesData = ref<any[]>([ const rulesData = ref<any[]>([
@ -756,42 +743,56 @@
} }
} }
/* 左侧使用 CustomLeftMenu,仅保留布局(与销售产品一致) */
.left-sidebar { .left-sidebar {
width: 280px; width: 280px;
background: var(--app-sidebar-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
border-top: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 24px;
display: flex;
flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
height: 100%; height: 100%;
padding: 12px; display: flex;
box-shadow: flex-direction: column;
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
overflow: hidden;
color: #fff;
position: relative;
} }
.left-sidebar::before { /* 第二行表格:更新时间布局与样式对齐销售产品的创建时间 */
content: ''; .left-sidebar .product-second-row-table {
position: absolute; width: 100%;
top: 0; border-collapse: collapse;
left: 0; table-layout: fixed;
right: 0; }
height: 1px;
background: linear-gradient(90deg, .left-sidebar .product-row-left {
transparent 0%, text-align: left;
rgba(255, 255, 255, 0.3) 50%, padding: 0;
transparent 100%); padding-left: 24px;
pointer-events: none; vertical-align: middle;
z-index: 1; width: auto;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
.update-time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.left-sidebar .product-row-right {
text-align: right;
padding: 0;
width: 30px;
vertical-align: middle;
}
.left-sidebar :deep(.node-second-row-extra-slot) {
display: block;
width: 100%;
}
.left-sidebar :deep(.node-second-row-extra-cell) {
text-align: left;
}
.left-sidebar :deep(.tree-node.selected .update-time) {
color: #666 !important;
} }
.sidebar-header { .sidebar-header {

View File

@ -345,6 +345,7 @@ async function handleModalOk() {
icon: formState.icon || undefined, icon: formState.icon || undefined,
route: formState.route || undefined, route: formState.route || undefined,
component: formState.component || undefined, component: formState.component || undefined,
order: formState.order ?? undefined,
isHidden: formState.isHidden, isHidden: formState.isHidden,
isCached: formState.isCached, isCached: formState.isCached,
description: formState.description || undefined, description: formState.description || undefined,

View File

@ -1,4 +1,5 @@
import { resolve } from 'path'; import { resolve } from 'path';
import type { Plugin, UserConfig } from 'vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx'; import vueJsx from '@vitejs/plugin-vue-jsx';
@ -15,7 +16,6 @@ export default defineConfig({
fs: { fs: {
strict: true, strict: true,
}, },
historyApiFallback: true,
// 添加中间件,在代理之前拦截前端路由 // 添加中间件,在代理之前拦截前端路由
middlewareMode: false, middlewareMode: false,
proxy: { proxy: {
@ -176,9 +176,10 @@ export default defineConfig({
console.log('[Vite Middleware] Intercepting frontend route:', url); console.log('[Vite Middleware] Intercepting frontend route:', url);
// 标记请求,让代理中间件知道不要代理 // 标记请求,让代理中间件知道不要代理
(req as any).__skipProxy = true; (req as any).__skipProxy = true;
// 标记为前端路由
(req as any).__isFrontendRoute = true; (req as any).__isFrontendRoute = true;
// 继续处理,让 Vite 的 SPA 路由处理(通过 historyApiFallback) // 关键:将 URL 重写为 /index.html,否则以 /api 开头的路径在 Vite 中不会走 SPA 回退,会直接 404
// /track/* 等非 /api 路径会正常走 SPA 回退,所以只有 /api/* 前端路由需要显式重写
req.url = '/index.html';
next(); next();
return; return;
} }
@ -212,13 +213,11 @@ export default defineConfig({
// 注意:如果服务器不支持压缩,可以禁用此插件 // 注意:如果服务器不支持压缩,可以禁用此插件
// 只保留 brotli 压缩(压缩率更高,文件数量减少一半) // 只保留 brotli 压缩(压缩率更高,文件数量减少一半)
// 如果需要同时支持 gzip 和 brotli,取消下面的注释 // 如果需要同时支持 gzip 和 brotli,取消下面的注释
...(process.env.NODE_ENV === 'production' ...(process.env.NODE_ENV === 'production'
? [ ? ((): Plugin[] => {
// 只启用 brotli 压缩(减少文件数量) const p = configCompressPlugin('brotli');
...configCompressPlugin('brotli'), return Array.isArray(p) ? p : [p];
// 如果需要同时支持 gzip,取消下面的注释 })()
// ...configCompressPlugin('gzip'),
]
: []), : []),
], ],
resolve: { resolve: {
@ -278,75 +277,31 @@ export default defineConfig({
build: { build: {
// 启用压缩(使用esbuild,Vite默认,性能更好) // 启用压缩(使用esbuild,Vite默认,性能更好)
minify: 'esbuild', minify: 'esbuild',
// 生产环境移除console和debugger // 生产环境移除 console 和 debugger
esbuild: { esbuild: {
drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : [], drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : [],
}, },
// 启用CSS代码分割 // 启用CSS代码分割
cssCodeSplit: true, cssCodeSplit: true,
// 生成source map(可选,生产环境可以关闭以减小体积) // 生成 source map(默认关闭,避免线上包体积变大;需要排查问题时可临时打开)
sourcemap: false, sourcemap: false,
// 构建输出配置 // 构建输出配置:使用 Vite 默认的代码分割策略,避免过度自定义导致库初始化顺序问题
rollupOptions: { rollupOptions: {
output: { output: {
// 手动代码分割,优化缓存 // 确保文件名包含 hash,便于浏览器缓存
manualChunks: (id) => {
// node_modules中的包单独打包
if (id.includes('node_modules')) {
// vue核心库和vue相关库打包在一起,确保加载顺序
if (id.includes('vue') || id.includes('vue-router') || id.includes('pinia') || id.includes('@vue')) {
return 'vue-vendor';
}
// monaco-editor单独打包(按需加载,不阻塞初始加载)
if (id.includes('monaco-editor')) {
return 'monaco-editor';
}
// echarts 相关库单独打包
if (id.includes('echarts') || id.includes('zrender')) {
return 'echarts-vendor';
}
// arco-design相关
if (id.includes('@arco-design')) {
return 'arco-vendor';
}
// vxe-table相关
if (id.includes('vxe-table') || id.includes('xe-utils')) {
return 'vxe-vendor';
}
// axios 和请求相关库
if (id.includes('axios') || id.includes('query-string')) {
return 'http-vendor';
}
// lodash 单独打包(如果使用较多)
if (id.includes('lodash')) {
return 'lodash-vendor';
}
// dayjs 单独打包
if (id.includes('dayjs')) {
return 'dayjs-vendor';
}
// handsontable 单独打包(如果使用)
if (id.includes('handsontable')) {
return 'handsontable-vendor';
}
// 其他第三方库进一步拆分
// 按大小拆分,避免单个vendor包过大
return 'vendor';
}
},
// 确保文件名包含hash,便于浏览器缓存
chunkFileNames: 'assets/js/[name]-[hash].js', chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js', entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: (assetInfo) => { assetFileNames: (assetInfo) => {
const info = assetInfo.name.split('.'); const name = assetInfo.name ?? 'asset';
const ext = info[info.length - 1]; const info = name.split('.');
if (/\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/i.test(assetInfo.name)) { const ext = info[info.length - 1] ?? 'bin';
if (/\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/i.test(name)) {
return `assets/media/[name]-[hash].${ext}`; return `assets/media/[name]-[hash].${ext}`;
} }
if (/\.(png|jpe?g|gif|svg|webp|avif)(\?.*)?$/.test(assetInfo.name)) { if (/\.(png|jpe?g|gif|svg|webp|avif)(\?.*)?$/.test(name)) {
return `assets/img/[name]-[hash].${ext}`; return `assets/img/[name]-[hash].${ext}`;
} }
if (/\.(woff2?|eot|ttf|otf)(\?.*)?$/i.test(assetInfo.name)) { if (/\.(woff2?|eot|ttf|otf)(\?.*)?$/i.test(name)) {
return `assets/fonts/[name]-[hash].${ext}`; return `assets/fonts/[name]-[hash].${ext}`;
} }
return `assets/[name]-[hash].${ext}`; return `assets/[name]-[hash].${ext}`;
@ -357,10 +312,6 @@ export default defineConfig({
chunkSizeWarningLimit: 2000, chunkSizeWarningLimit: 2000,
// 启用 terser 压缩以获得更好的压缩率(可选,esbuild更快但压缩率稍低) // 启用 terser 压缩以获得更好的压缩率(可选,esbuild更快但压缩率稍低)
// minify: 'terser', // 如果需要更好的压缩率,可以启用,但构建会慢一些 // minify: 'terser', // 如果需要更好的压缩率,可以启用,但构建会慢一些
// 启用 rollup 的 tree-shaking
treeshake: {
moduleSideEffects: 'no-external',
},
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
@ -374,4 +325,4 @@ export default defineConfig({
}, },
}, },
}, },
}); } as UserConfig);