Compare commits
11 Commits
ce383e6f95
...
715666b5b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 715666b5b6 | |||
| 8439f64c40 | |||
| a4dab891cf | |||
| da90d7c356 | |||
| 202f4bbd44 | |||
| 3d6aa6026c | |||
| 28e339a048 | |||
| 49dbde11e0 | |||
| a7399c997d | |||
| aaa3710314 | |||
| 880f986b8a |
@@ -27,6 +27,7 @@
|
|||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"qs": "^6.15.0",
|
"qs": "^6.15.0",
|
||||||
"vue": "^3.3.0",
|
"vue": "^3.3.0",
|
||||||
|
"vue-icons-plus": "^0.1.8",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+35
-3
@@ -20,12 +20,12 @@ importers:
|
|||||||
china-area-data:
|
china-area-data:
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.1
|
version: 5.0.1
|
||||||
cropperjs:
|
|
||||||
specifier: ^1.6.2
|
|
||||||
version: 1.6.2
|
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.21
|
specifier: ^1.11.21
|
||||||
version: 1.11.21
|
version: 1.11.21
|
||||||
|
echarts:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.6.0
|
||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
@@ -38,6 +38,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.5.40(typescript@5.9.3)
|
version: 3.5.40(typescript@5.9.3)
|
||||||
|
vue-icons-plus:
|
||||||
|
specifier: ^0.1.8
|
||||||
|
version: 0.1.8(vue@3.5.40(typescript@5.9.3))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.6.4(vue@3.5.40(typescript@5.9.3))
|
version: 4.6.4(vue@3.5.40(typescript@5.9.3))
|
||||||
@@ -1150,6 +1153,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
echarts@5.6.0:
|
||||||
|
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -2096,6 +2102,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.2.0'
|
typescript: '>=4.2.0'
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2182,6 +2191,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '>=6.0.0'
|
eslint: '>=6.0.0'
|
||||||
|
|
||||||
|
vue-icons-plus@0.1.8:
|
||||||
|
resolution: {integrity: sha512-Xc4hDsD/oP9waSUf44nSaFBhUPo+QkpKclx0S7//5BRACpXymctbit02epek0VRW6nb81pR486XmxPP/ofm2yQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: '>=2.7.0'
|
||||||
|
|
||||||
vue-router@4.6.4:
|
vue-router@4.6.4:
|
||||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2258,6 +2272,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zrender@5.6.1:
|
||||||
|
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@ant-design/colors@6.0.0':
|
'@ant-design/colors@6.0.0':
|
||||||
@@ -3330,6 +3347,11 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
echarts@5.6.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 5.6.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.395: {}
|
electron-to-chromium@1.5.395: {}
|
||||||
@@ -4325,6 +4347,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -4388,6 +4412,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
vue-icons-plus@0.1.8(vue@3.5.40(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.40(typescript@5.9.3)
|
||||||
|
|
||||||
vue-router@4.6.4(vue@3.5.40(typescript@5.9.3)):
|
vue-router@4.6.4(vue@3.5.40(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
@@ -4459,3 +4487,7 @@ snapshots:
|
|||||||
yargs-parser: 21.1.1
|
yargs-parser: 21.1.1
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
zrender@5.6.1:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
|||||||
@@ -114,3 +114,32 @@ a {
|
|||||||
.ant-tag {
|
.ant-tag {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 页面切换动画(KeepAlive + RouterView) =====
|
||||||
|
|
||||||
|
.page-fade-slide-enter-active,
|
||||||
|
.page-fade-slide-leave-active {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-fade-slide-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-fade-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-fade-slide-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 表格内可点击数字链接(等宽数字,避免列宽抖动)
|
||||||
|
.tabularLink {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 汇总 ──
|
// ── 汇总 ──
|
||||||
.summary {
|
.summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -88,3 +94,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 二次确认弹窗 =====
|
||||||
|
|
||||||
|
.confirmContent {
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
margin: 0 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmTip {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.toggleConfirmModal),
|
||||||
|
:global(.deleteConfirmModal) {
|
||||||
|
.ant-modal-confirm-body {
|
||||||
|
padding: 24px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
.statusTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
|
||||||
|
&.dimmed {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
color: #1677ff;
|
||||||
|
background: rgba(22, 119, 255, 0.08);
|
||||||
|
border-color: rgba(22, 119, 255, 0.18);
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background: #1677ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #389e0d;
|
||||||
|
background: rgba(82, 196, 26, 0.1);
|
||||||
|
border-color: rgba(82, 196, 26, 0.22);
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background: #52c41a;
|
||||||
|
box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: #d48806;
|
||||||
|
background: rgba(250, 173, 20, 0.12);
|
||||||
|
border-color: rgba(250, 173, 20, 0.28);
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background: #faad14;
|
||||||
|
box-shadow: 0 0 0 2px rgba(250, 173, 20, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #cf1322;
|
||||||
|
background: rgba(255, 77, 79, 0.08);
|
||||||
|
border-color: rgba(255, 77, 79, 0.2);
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background: #ff4d4f;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.orange {
|
||||||
|
color: #d46b08;
|
||||||
|
background: rgba(250, 140, 22, 0.1);
|
||||||
|
border-color: rgba(250, 140, 22, 0.22);
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background: #fa8c16;
|
||||||
|
box-shadow: 0 0 0 2px rgba(250, 140, 22, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cyan {
|
||||||
|
color: #08979c;
|
||||||
|
background: rgba(19, 194, 194, 0.1);
|
||||||
|
border-color: rgba(19, 194, 194, 0.22);
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background: #13c2c2;
|
||||||
|
box-shadow: 0 0 0 2px rgba(19, 194, 194, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.purple {
|
||||||
|
color: #531dab;
|
||||||
|
background: rgba(114, 46, 209, 0.08);
|
||||||
|
border-color: rgba(114, 46, 209, 0.2);
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background: #722ed1;
|
||||||
|
box-shadow: 0 0 0 2px rgba(114, 46, 209, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.neutral {
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.default {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
background: #fafafa;
|
||||||
|
border-color: #f0f0f0;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineComponent, type PropType } from 'vue';
|
||||||
|
import styles from './StatusTag.module.less';
|
||||||
|
|
||||||
|
export type StatusTagTone =
|
||||||
|
| 'primary'
|
||||||
|
| 'success'
|
||||||
|
| 'warning'
|
||||||
|
| 'danger'
|
||||||
|
| 'orange'
|
||||||
|
| 'cyan'
|
||||||
|
| 'purple'
|
||||||
|
| 'neutral'
|
||||||
|
| 'default';
|
||||||
|
|
||||||
|
const StatusTag = defineComponent({
|
||||||
|
name: 'StatusTag',
|
||||||
|
props: {
|
||||||
|
label: { type: String, required: true },
|
||||||
|
tone: { type: String as PropType<StatusTagTone>, default: 'default' },
|
||||||
|
showDot: { type: Boolean, default: true },
|
||||||
|
dimmed: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => (
|
||||||
|
<span
|
||||||
|
class={[styles.statusTag, styles[props.tone], props.dimmed && styles.dimmed]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{props.showDot ? <span class={styles.dot} /> : null}
|
||||||
|
<span>{props.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default StatusTag;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as StatusTag } from './StatusTag';
|
||||||
|
export type { StatusTagTone } from './StatusTag';
|
||||||
@@ -4,4 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
export { default as HelloWorld } from './HelloWorld';
|
export { default as HelloWorld } from './HelloWorld';
|
||||||
export { default as ChangePasswordModal } from './ChangePasswordModal';
|
export { default as ChangePasswordModal } from './ChangePasswordModal';
|
||||||
export { default as PageTabs } from './pageTabs/PageTabs';
|
export { default as PageTabs } from './PageTabs/PageTabs';
|
||||||
|
export { StatusTag } from './StatusTag';
|
||||||
|
export type { StatusTagTone } from './StatusTag';
|
||||||
|
|||||||
@@ -1,164 +1,229 @@
|
|||||||
@primary: #1677ff;
|
@primary: #1677ff;
|
||||||
|
|
||||||
.pageTabsMain {
|
.pageTabsWrap {
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
.navBtn {
|
||||||
height: 0;
|
position: absolute;
|
||||||
}
|
top: 50%;
|
||||||
|
z-index: 5;
|
||||||
.tabItem {
|
display: inline-flex;
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
justify-content: center;
|
||||||
user-select: none;
|
width: 28px;
|
||||||
transition: all 0.15s ease;
|
height: 28px;
|
||||||
padding: 0 8px 0 14px;
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
box-shadow: 0 0 4px rgba(214, 214, 214, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
background-color 0.15s;
|
||||||
|
|
||||||
&:global(:not(.dragging)) {
|
&:hover {
|
||||||
cursor: pointer;
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(.tabItemActive) {
|
&:active {
|
||||||
&:hover {
|
color: rgba(0, 0, 0, 0.88);
|
||||||
> .divider {
|
background: #ebebeb;
|
||||||
opacity: 0;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& + .tabItem {
|
.navBtnLeft {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtnRight {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:global(:not(.dragging)) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.tabItemActive) {
|
||||||
|
&:hover {
|
||||||
> .divider {
|
> .divider {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& + .tabItem {
|
||||||
|
> .divider {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabContent {
|
||||||
|
background-color: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabClose:hover {
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tabItemActive {
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
& + .tabItem {
|
||||||
|
> .divider {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabTitle {
|
||||||
|
color: @primary;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabIcon {
|
||||||
|
color: @primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBg {
|
||||||
|
&.tabBgActive {
|
||||||
|
.tabBgInner {
|
||||||
|
background-color: fade(@primary, 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBgCurveBefore,
|
||||||
|
.tabBgCurveAfter {
|
||||||
|
fill: fade(@primary, 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tabItemActive {
|
.divider {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: -1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 calc(8px - 1px);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBgInner {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
margin 0.15s,
|
||||||
|
border-radius 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBgCurveBefore {
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
bottom: 0;
|
||||||
|
fill: transparent;
|
||||||
|
transition: fill 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBgCurveAfter {
|
||||||
|
position: absolute;
|
||||||
|
right: -1px;
|
||||||
|
bottom: 0;
|
||||||
|
fill: transparent;
|
||||||
|
transition: fill 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabContent {
|
||||||
|
position: relative;
|
||||||
|
height: 34px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
display: inline-flex;
|
||||||
& + .tabItem {
|
align-items: center;
|
||||||
> .divider {
|
gap: 6px;
|
||||||
opacity: 0 !important;
|
width: fit-content;
|
||||||
}
|
flex-shrink: 0;
|
||||||
}
|
margin: 0 12px;
|
||||||
|
padding: 4px 4px 4px 10px;
|
||||||
.tabTitle {
|
border-radius: 9px;
|
||||||
color: @primary;
|
transition: background-color 0.35s;
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabBg {
|
|
||||||
&.tabBgActive {
|
|
||||||
.tabBgInner {
|
|
||||||
background-color: fade(@primary, 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabBgCurveBefore,
|
|
||||||
.tabBgCurveAfter {
|
|
||||||
fill: fade(@primary, 8%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(.tabItemActive) {
|
.tabIcon {
|
||||||
.tabTitleCon:hover {
|
display: inline-flex;
|
||||||
background-color: rgba(0, 0, 0, 0.06);
|
align-items: center;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
.tabTitle {
|
||||||
position: absolute;
|
min-width: 0;
|
||||||
left: 7px;
|
max-width: 160px;
|
||||||
top: 50%;
|
font-size: 14px;
|
||||||
z-index: 0;
|
line-height: 1.4;
|
||||||
width: 1px;
|
color: rgba(0, 0, 0, 0.65);
|
||||||
height: 16px;
|
white-space: nowrap;
|
||||||
background-color: #e8e8e8;
|
overflow: hidden;
|
||||||
transform: translateY(-50%);
|
text-overflow: ellipsis;
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabBg {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: -1;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0 calc(7px - 1px);
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabBgInner {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 7px 7px 0 0;
|
|
||||||
transition:
|
|
||||||
background-color 0.15s,
|
|
||||||
margin 0.15s,
|
|
||||||
border-radius 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabBgCurveBefore {
|
|
||||||
position: absolute;
|
|
||||||
left: -1px;
|
|
||||||
bottom: 0;
|
|
||||||
fill: transparent;
|
|
||||||
transition: fill 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabBgCurveAfter {
|
|
||||||
position: absolute;
|
|
||||||
right: -1px;
|
|
||||||
bottom: 0;
|
|
||||||
fill: transparent;
|
|
||||||
transition: fill 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabClose {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
line-height: 16px;
|
|
||||||
font-size: 10px;
|
|
||||||
text-align: center;
|
|
||||||
margin-right: 12px;
|
|
||||||
color: rgba(0, 0, 0, 0.45);
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: rgba(0, 0, 0, 0.85);
|
|
||||||
background-color: rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.tabTitleCon {
|
.tabClose {
|
||||||
height: 28px;
|
flex-shrink: 0;
|
||||||
display: flex;
|
width: 18px;
|
||||||
justify-content: center;
|
height: 18px;
|
||||||
align-items: center;
|
line-height: 18px;
|
||||||
border-radius: 4px;
|
font-size: 11px;
|
||||||
}
|
text-align: center;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
.tabTitle {
|
border-radius: 50%;
|
||||||
position: relative;
|
cursor: pointer;
|
||||||
z-index: 2;
|
transition: all 0.15s;
|
||||||
flex: 1;
|
}
|
||||||
min-width: 0;
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(0, 0, 0, 0.65);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { defineComponent, type PropType } from 'vue';
|
import {
|
||||||
|
defineComponent,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
type PropType,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
|
||||||
import type { TabView } from '@/types';
|
import type { TabView } from '@/types';
|
||||||
|
import { useState } from '@/hooks';
|
||||||
|
import { renderRouteIcon } from '@/utils/routeIcon';
|
||||||
import styles from './PageTabs.module.less';
|
import styles from './PageTabs.module.less';
|
||||||
|
|
||||||
|
const SCROLL_STEP = 240;
|
||||||
|
|
||||||
|
type TabKeyHandler = (key: string) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chrome 风格的页面标签栏组件
|
* Chrome 风格的页面标签栏组件
|
||||||
* 参考 VBEN tabs-chrome 的视觉设计:
|
* - 无滚动条,滚轮横向滚动
|
||||||
* - 标签重叠排列(负 margin)
|
* - 溢出时显示左右导航按钮
|
||||||
* - 激活态:圆角背景 + SVG 底部曲线
|
|
||||||
* - hover 态:浅灰背景
|
|
||||||
* - 标签间分隔线
|
|
||||||
* - 关闭按钮
|
|
||||||
*/
|
*/
|
||||||
const PageTabs = defineComponent({
|
const PageTabs = defineComponent({
|
||||||
name: 'PageTabs',
|
name: 'PageTabs',
|
||||||
@@ -20,11 +31,74 @@ const PageTabs = defineComponent({
|
|||||||
/** 当前激活 key */
|
/** 当前激活 key */
|
||||||
activeKey: { type: String, required: true },
|
activeKey: { type: String, required: true },
|
||||||
/** 关闭标签 */
|
/** 关闭标签 */
|
||||||
onClose: { type: Function as PropType<(key: string) => void>, default: undefined },
|
onClose: { type: Function as PropType<TabKeyHandler>, default: undefined },
|
||||||
/** 切换标签 */
|
/** 切换标签 */
|
||||||
onChange: { type: Function as PropType<(key: string) => void>, default: undefined },
|
onChange: { type: Function as PropType<TabKeyHandler>, default: undefined },
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
|
const scrollRef = ref<HTMLElement>();
|
||||||
|
const tabItemRefs = ref<Record<string, HTMLElement | undefined>>({});
|
||||||
|
const [showNavLeft, setShowNavLeft] = useState(false);
|
||||||
|
const [showNavRight, setShowNavRight] = useState(false);
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const updateScrollState = () => {
|
||||||
|
const el = scrollRef.value;
|
||||||
|
if (!el) {
|
||||||
|
setShowNavLeft(false);
|
||||||
|
setShowNavRight(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = el;
|
||||||
|
const canScroll = scrollWidth - clientWidth > 1;
|
||||||
|
|
||||||
|
setShowNavLeft(canScroll && scrollLeft > 1);
|
||||||
|
setShowNavRight(canScroll && scrollLeft + clientWidth < scrollWidth - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollByStep = (direction: -1 | 1) => {
|
||||||
|
const el = scrollRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollBy({ left: direction * SCROLL_STEP, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
const el = scrollRef.value;
|
||||||
|
if (!el || el.scrollWidth <= el.clientWidth) return;
|
||||||
|
|
||||||
|
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||||||
|
if (delta === 0) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
el.scrollLeft += delta;
|
||||||
|
updateScrollState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollActiveTabIntoView = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
tabItemRefs.value[props.activeKey]?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'nearest',
|
||||||
|
});
|
||||||
|
updateScrollState();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.activeKey, scrollActiveTabIntoView);
|
||||||
|
watch(() => props.tabs.map((t) => t.path).join(','), scrollActiveTabIntoView);
|
||||||
|
|
||||||
|
const setTabItemRef = (path: string, el: Element | null) => {
|
||||||
|
if (el) {
|
||||||
|
tabItemRefs.value[path] = el as HTMLElement;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete tabItemRefs.value[path];
|
||||||
|
};
|
||||||
|
|
||||||
const handleClick = (key: string) => {
|
const handleClick = (key: string) => {
|
||||||
if (key !== props.activeKey && props.onChange) {
|
if (key !== props.activeKey && props.onChange) {
|
||||||
props.onChange(key);
|
props.onChange(key);
|
||||||
@@ -42,47 +116,91 @@ const PageTabs = defineComponent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => (
|
onMounted(() => {
|
||||||
<div class={styles.pageTabsMain}>
|
const el = scrollRef.value;
|
||||||
{props.tabs.map((tab, i) => {
|
if (!el) return;
|
||||||
const isActive = tab.path === props.activeKey;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tab.path}
|
|
||||||
class={[styles.tabItem, isActive && styles.tabItemActive].filter(Boolean).join(' ')}
|
|
||||||
onClick={() => handleClick(tab.path)}
|
|
||||||
>
|
|
||||||
{/* 分隔线:第一个标签 / 激活标签 / 上一个就是激活的 → 不显示 */}
|
|
||||||
<div
|
|
||||||
v-show={i !== 0 && !isActive && props.tabs[i - 1]?.path !== props.activeKey}
|
|
||||||
class={styles.divider}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Chrome 风格背景:激活态 */}
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
<div class={[styles.tabBg, isActive && styles.tabBgActive].filter(Boolean).join(' ')}>
|
el.addEventListener('scroll', updateScrollState, { passive: true });
|
||||||
<div class={styles.tabBgInner} />
|
|
||||||
{/* 左下曲线 */}
|
|
||||||
<svg class={styles.tabBgCurveBefore} height="7" width="7">
|
|
||||||
<path d="M 0 7 A 7 7 0 0 0 7 0 L 7 7 Z" />
|
|
||||||
</svg>
|
|
||||||
{/* 右下曲线 */}
|
|
||||||
<svg class={styles.tabBgCurveAfter} height="7" width="7">
|
|
||||||
<path d="M 0 0 A 7 7 0 0 0 7 7 L 0 7 Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={styles.tabTitleCon}>
|
resizeObserver = new ResizeObserver(updateScrollState);
|
||||||
{/* 标签文字(flex 子元素,与关闭按钮同行) */}
|
resizeObserver.observe(el);
|
||||||
<span class={styles.tabTitle}>{tab.title}</span>
|
updateScrollState();
|
||||||
</div>
|
});
|
||||||
|
|
||||||
{/* 关闭按钮(flex 子元素,不参与 shrink) */}
|
onBeforeUnmount(() => {
|
||||||
<span class={styles.tabClose} onClick={(e: Event) => handleClose(tab.path, e)}>
|
const el = scrollRef.value;
|
||||||
✕
|
if (el) {
|
||||||
</span>
|
el.removeEventListener('wheel', handleWheel);
|
||||||
|
el.removeEventListener('scroll', updateScrollState);
|
||||||
|
}
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderTab = (tab: TabView, i: number) => {
|
||||||
|
const isActive = tab.path === props.activeKey;
|
||||||
|
const showDivider = i !== 0 && !isActive && props.tabs[i - 1]?.path !== props.activeKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.path}
|
||||||
|
ref={(el) => setTabItemRef(tab.path, el as Element | null)}
|
||||||
|
class={[styles.tabItem, isActive && styles.tabItemActive].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => handleClick(tab.path)}
|
||||||
|
>
|
||||||
|
<div v-show={showDivider} class={styles.divider} />
|
||||||
|
|
||||||
|
<div class={[styles.tabBg, isActive && styles.tabBgActive].filter(Boolean).join(' ')}>
|
||||||
|
<div class={styles.tabBgInner} />
|
||||||
|
<svg class={styles.tabBgCurveBefore} height="8" width="8">
|
||||||
|
<path d="M 0 8 A 8 8 0 0 0 8 0 L 8 8 Z" />
|
||||||
|
</svg>
|
||||||
|
<svg class={styles.tabBgCurveAfter} height="8" width="8">
|
||||||
|
<path d="M 0 0 A 8 8 0 0 0 8 8 L 0 8 Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.tabContent}>
|
||||||
|
{tab.icon ? (
|
||||||
|
<span class={styles.tabIcon}>{renderRouteIcon(tab.icon, { size: 16 })}</span>
|
||||||
|
) : null}
|
||||||
|
<div class={styles.tabTitle}>{tab.title}</div>
|
||||||
|
<div class={styles.tabClose} onClick={(e: Event) => handleClose(tab.path, e)}>
|
||||||
|
✕
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class={styles.pageTabsWrap}>
|
||||||
|
{showNavLeft.value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={[styles.navBtn, styles.navBtnLeft].join(' ')}
|
||||||
|
aria-label="向左滚动标签"
|
||||||
|
onClick={() => scrollByStep(-1)}
|
||||||
|
>
|
||||||
|
<LeftOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={scrollRef} class={styles.pageTabs}>
|
||||||
|
{props.tabs.map((tab, i) => renderTab(tab, i))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNavRight.value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={[styles.navBtn, styles.navBtnRight].join(' ')}
|
||||||
|
aria-label="向右滚动标签"
|
||||||
|
onClick={() => scrollByStep(1)}
|
||||||
|
>
|
||||||
|
<RightOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ import type { MenuNode } from '@/types';
|
|||||||
*
|
*
|
||||||
* ```json
|
* ```json
|
||||||
* [
|
* [
|
||||||
* { "id": "dashboard", "name": "工作台", "path": "dashboard", "component": "dashboard", "icon": "DashboardOutlined" },
|
* { "id": "dashboard", "name": "工作台", "path": "dashboard", "component": "dashboard", "icon": "AiOutlineDashboard" },
|
||||||
* {
|
* {
|
||||||
* "id": "events", "name": "赛事管理", "path": "events", "icon": "TrophyOutlined",
|
* "id": "events", "name": "赛事管理", "path": "events", "icon": "AiOutlineTrophy",
|
||||||
* "children": [
|
* "children": [
|
||||||
* { "id": "events_list", "name": "赛事列表", "path": "events/list", "component": "events/list" },
|
* { "id": "events_list", "name": "赛事列表", "path": "events/list", "component": "events/list", "icon": "AiOutlineUnorderedList" }
|
||||||
* { "id": "events_orders", "name": "订单管理", "path": "events/orders", "component": "events/orders" }
|
|
||||||
* ]
|
* ]
|
||||||
* }
|
* }
|
||||||
* ]
|
* ]
|
||||||
@@ -29,7 +28,7 @@ import type { MenuNode } from '@/types';
|
|||||||
* | name | string | 是 | 菜单名称/路由 name |
|
* | name | string | 是 | 菜单名称/路由 name |
|
||||||
* | path | string | 是 | 路由相对路径(不带 / 前缀) |
|
* | path | string | 是 | 路由相对路径(不带 / 前缀) |
|
||||||
* | component | string | 否 | 组件路径(映射 src/pages/ 下目录,如 "events/list") |
|
* | component | string | 否 | 组件路径(映射 src/pages/ 下目录,如 "events/list") |
|
||||||
* | icon | string | 否 | antd 图标名(如 DashboardOutlined) |
|
* | icon | string | 否 | vue-icons-plus 图标名(如 AiOutlineTrophy,需在 routeIcon.tsx 注册) |
|
||||||
* | sort | number | 否 | 排序权重(越小越前) |
|
* | sort | number | 否 | 排序权重(越小越前) |
|
||||||
* | hideInMenu | boolean | 否 | 是否在菜单中隐藏 |
|
* | hideInMenu | boolean | 否 | 是否在菜单中隐藏 |
|
||||||
* | externalLink | string | 否 | 外链地址 |
|
* | externalLink | string | 否 | 外链地址 |
|
||||||
@@ -47,7 +46,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
id: 'events',
|
id: 'events',
|
||||||
name: '赛事管理',
|
name: '赛事管理',
|
||||||
path: 'events',
|
path: 'events',
|
||||||
icon: 'TrophyOutlined',
|
icon: 'AiOutlineTrophy',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'events_list',
|
id: 'events_list',
|
||||||
@@ -55,6 +54,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/list',
|
path: 'events/list',
|
||||||
component: 'events/list',
|
component: 'events/list',
|
||||||
componentName: 'EventList',
|
componentName: 'EventList',
|
||||||
|
icon: 'AiOutlineUnorderedList',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'events_orders',
|
id: 'events_orders',
|
||||||
@@ -62,6 +62,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/orders',
|
path: 'events/orders',
|
||||||
component: 'events/orders',
|
component: 'events/orders',
|
||||||
componentName: 'EventOrders',
|
componentName: 'EventOrders',
|
||||||
|
icon: 'AiOutlineShoppingCart',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'events_users',
|
id: 'events_users',
|
||||||
@@ -69,6 +70,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/users',
|
path: 'events/users',
|
||||||
component: 'events/users',
|
component: 'events/users',
|
||||||
componentName: 'EventUsers',
|
componentName: 'EventUsers',
|
||||||
|
icon: 'AiOutlineTeam',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'events_banner',
|
id: 'events_banner',
|
||||||
@@ -76,6 +78,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/banner',
|
path: 'events/banner',
|
||||||
component: 'events/banner',
|
component: 'events/banner',
|
||||||
componentName: 'EventBanner',
|
componentName: 'EventBanner',
|
||||||
|
icon: 'AiOutlinePicture',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'events_logs',
|
id: 'events_logs',
|
||||||
@@ -83,6 +86,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/logs',
|
path: 'events/logs',
|
||||||
component: 'events/logs',
|
component: 'events/logs',
|
||||||
componentName: 'EventLogs',
|
componentName: 'EventLogs',
|
||||||
|
icon: 'AiOutlineHistory',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -90,7 +94,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
id: 'finance',
|
id: 'finance',
|
||||||
name: '账务管理',
|
name: '账务管理',
|
||||||
path: 'finance',
|
path: 'finance',
|
||||||
icon: 'TransactionOutlined',
|
icon: 'AiOutlineTransaction',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'finance_withdraw',
|
id: 'finance_withdraw',
|
||||||
@@ -98,6 +102,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'finance/withdraw',
|
path: 'finance/withdraw',
|
||||||
component: 'finance/withdraw',
|
component: 'finance/withdraw',
|
||||||
componentName: 'FinanceWithdraw',
|
componentName: 'FinanceWithdraw',
|
||||||
|
icon: 'AiOutlineMoneyCollect',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'finance_wallet',
|
id: 'finance_wallet',
|
||||||
@@ -105,6 +110,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'finance/wallet',
|
path: 'finance/wallet',
|
||||||
component: 'finance/wallet',
|
component: 'finance/wallet',
|
||||||
componentName: 'FinanceWallet',
|
componentName: 'FinanceWallet',
|
||||||
|
icon: 'AiOutlineWallet',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'finance_payments',
|
id: 'finance_payments',
|
||||||
@@ -112,6 +118,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'finance/payments',
|
path: 'finance/payments',
|
||||||
component: 'finance/payments',
|
component: 'finance/payments',
|
||||||
componentName: 'FinancePayments',
|
componentName: 'FinancePayments',
|
||||||
|
icon: 'AiOutlinePayCircle',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'finance_reports',
|
id: 'finance_reports',
|
||||||
@@ -119,6 +126,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'finance/reports',
|
path: 'finance/reports',
|
||||||
component: 'finance/reports',
|
component: 'finance/reports',
|
||||||
componentName: 'FinanceReports',
|
componentName: 'FinanceReports',
|
||||||
|
icon: 'AiOutlineBarChart',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -126,7 +134,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
id: 'system',
|
id: 'system',
|
||||||
name: '系统管理',
|
name: '系统管理',
|
||||||
path: 'system',
|
path: 'system',
|
||||||
icon: 'SettingOutlined',
|
icon: 'AiOutlineSetting',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'system_users',
|
id: 'system_users',
|
||||||
@@ -134,6 +142,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'system/users',
|
path: 'system/users',
|
||||||
component: 'system/users',
|
component: 'system/users',
|
||||||
componentName: 'SystemUsers',
|
componentName: 'SystemUsers',
|
||||||
|
icon: 'AiOutlineUser',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'system_roles',
|
id: 'system_roles',
|
||||||
@@ -141,6 +150,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'system/roles',
|
path: 'system/roles',
|
||||||
component: 'system/roles',
|
component: 'system/roles',
|
||||||
componentName: 'SystemRoles',
|
componentName: 'SystemRoles',
|
||||||
|
icon: 'AiOutlineSafetyCertificate',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'system_logs',
|
id: 'system_logs',
|
||||||
@@ -148,6 +158,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'system/logs',
|
path: 'system/logs',
|
||||||
component: 'system/logs',
|
component: 'system/logs',
|
||||||
componentName: 'SystemLogs',
|
componentName: 'SystemLogs',
|
||||||
|
icon: 'AiOutlineAudit',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-4
@@ -3,6 +3,7 @@ import { useState } from './useState';
|
|||||||
import { useEffect } from './useEffect';
|
import { useEffect } from './useEffect';
|
||||||
import { useMenuStore } from '@/stores/menuStore';
|
import { useMenuStore } from '@/stores/menuStore';
|
||||||
import { usePermissionStore } from '@/stores/permissionStore';
|
import { usePermissionStore } from '@/stores/permissionStore';
|
||||||
|
import { useTabsStore } from '@/stores/tabsStore';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
|
|
||||||
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
|
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
|
||||||
@@ -24,17 +25,20 @@ function createAuth() {
|
|||||||
/**
|
/**
|
||||||
* 登录:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页
|
* 登录:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页
|
||||||
*/
|
*/
|
||||||
const login = async (newToken: string) => {
|
const login = async (newToken: string, targetPath?: string) => {
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
|
|
||||||
const { loadMenu, homePath } = useMenuStore();
|
const { loadMenu, homePath, isRoutePathAvailable } = useMenuStore();
|
||||||
const { loadPermissions } = usePermissionStore();
|
const { loadPermissions } = usePermissionStore();
|
||||||
|
|
||||||
// 并行拉取菜单和权限
|
// 并行拉取菜单和权限
|
||||||
await Promise.all([loadMenu(), loadPermissions()]);
|
await Promise.all([loadMenu(), loadPermissions()]);
|
||||||
|
|
||||||
// 跳转到首页(动态路由已注册完成)
|
const nextPath =
|
||||||
router.push(homePath.value);
|
targetPath && isRoutePathAvailable(targetPath) ? targetPath : homePath.value || '/404';
|
||||||
|
|
||||||
|
// 动态路由注册完成后,再跳转到有效目标页
|
||||||
|
router.push(nextPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +49,9 @@ function createAuth() {
|
|||||||
|
|
||||||
const { clearMenu } = useMenuStore();
|
const { clearMenu } = useMenuStore();
|
||||||
const { clearPermissions } = usePermissionStore();
|
const { clearPermissions } = usePermissionStore();
|
||||||
|
const { clearTabs } = useTabsStore();
|
||||||
|
|
||||||
|
clearTabs();
|
||||||
clearMenu();
|
clearMenu();
|
||||||
clearPermissions();
|
clearPermissions();
|
||||||
|
|
||||||
|
|||||||
@@ -79,20 +79,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
:global(.anticon) {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuItemDanger {
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -103,6 +89,15 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pageView {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -120,9 +115,27 @@
|
|||||||
|
|
||||||
.pageTabs {
|
.pageTabs {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px 16px 0 16px;
|
height: 48px;
|
||||||
height: 42px;
|
padding: 4px 8px 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown 菜单通过 portal 挂到 body,不在 .basicLayoutMain 内。
|
||||||
|
* 若嵌套在 .basicLayoutMain 下,CSS Modules 会生成「父级 + 子级」选择器,导致匹配失败。
|
||||||
|
*/
|
||||||
|
.menuItem {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
:global(.anticon) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItemDanger {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|||||||
+20
-35
@@ -1,25 +1,20 @@
|
|||||||
import { computed, defineComponent, h, ref } from 'vue';
|
import { computed, defineComponent, h, Transition, KeepAlive } from 'vue';
|
||||||
import type { CSSProperties } from 'vue';
|
import type { CSSProperties } from 'vue';
|
||||||
import { KeepAlive } from 'vue';
|
|
||||||
import { useRouter, useRoute, RouterView } from 'vue-router';
|
import { useRouter, useRoute, RouterView } from 'vue-router';
|
||||||
import { Layout, Menu, Modal, Breadcrumb, Dropdown, message } from 'ant-design-vue';
|
import { Layout, Menu, Modal, Breadcrumb, Dropdown, message } from 'ant-design-vue';
|
||||||
import type { MenuProps } from 'ant-design-vue';
|
import type { MenuProps } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
InfoCircleOutlined,
|
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
SettingOutlined,
|
|
||||||
TransactionOutlined,
|
|
||||||
TrophyOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { useMenuStore } from '@/stores/menuStore';
|
import { useMenuStore } from '@/stores/menuStore';
|
||||||
import { useUserStore } from '@/stores/userStore';
|
import { useUserStore } from '@/stores/userStore';
|
||||||
import { useTabsStore } from '@/stores/tabsStore';
|
import { useTabsStore } from '@/stores/tabsStore';
|
||||||
import { useState, useBreadcrumb, auth } from '@/hooks';
|
import { useState, useBreadcrumb, auth } from '@/hooks';
|
||||||
import { ChangePasswordModal, PageTabs } from '@/components';
|
import { ChangePasswordModal, PageTabs } from '@/components';
|
||||||
|
import { renderRouteIcon } from '@/utils/routeIcon';
|
||||||
import logoutSvg from '@/assets/img/icon/logout.svg';
|
import logoutSvg from '@/assets/img/icon/logout.svg';
|
||||||
import styles from './BasicLayout.module.less';
|
import styles from './BasicLayout.module.less';
|
||||||
|
|
||||||
@@ -48,22 +43,9 @@ const footerStyle: CSSProperties = {
|
|||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 图标名 → 组件映射
|
|
||||||
* 后端下发的 icon 字符串在此映射为 antd icon 组件
|
|
||||||
* 扩展新图标只需在此添加一行
|
|
||||||
*/
|
|
||||||
const ICON_MAP: Record<string, any> = {
|
|
||||||
DashboardOutlined,
|
|
||||||
InfoCircleOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
TransactionOutlined,
|
|
||||||
TrophyOutlined,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 menuStore 中的 menuItems 转为 antd Menu 的 items 格式
|
* 将 menuStore 中的 menuItems 转为 antd Menu 的 items 格式
|
||||||
* 递归处理 iconName → icon VNode
|
* 递归处理 iconName → vue-icons-plus 图标 VNode
|
||||||
*/
|
*/
|
||||||
function resolveMenuItems(items: any[]): MenuProps['items'] {
|
function resolveMenuItems(items: any[]): MenuProps['items'] {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
@@ -72,9 +54,8 @@ function resolveMenuItems(items: any[]): MenuProps['items'] {
|
|||||||
label: item.label,
|
label: item.label,
|
||||||
};
|
};
|
||||||
|
|
||||||
// iconName 字符串转为 VNode
|
if (item.iconName) {
|
||||||
if (item.iconName && ICON_MAP[item.iconName]) {
|
resolved.icon = () => renderRouteIcon(item.iconName, { size: 16 });
|
||||||
resolved.icon = () => h(ICON_MAP[item.iconName]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.children?.length) {
|
if (item.children?.length) {
|
||||||
@@ -93,7 +74,7 @@ export default defineComponent({
|
|||||||
setup() {
|
setup() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const collapsed = ref(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const { menuItems } = useMenuStore();
|
const { menuItems } = useMenuStore();
|
||||||
const { phone } = useUserStore();
|
const { phone } = useUserStore();
|
||||||
@@ -109,7 +90,7 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleCollapsed = () => {
|
const toggleCollapsed = () => {
|
||||||
collapsed.value = !collapsed.value;
|
setCollapsed(!collapsed.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabKey = computed(() => route.path);
|
const activeTabKey = computed(() => route.path);
|
||||||
@@ -263,15 +244,19 @@ export default defineComponent({
|
|||||||
include 是组件 name 列表,所以页面组件需要在 defineComponent 中显式给出 name
|
include 是组件 name 列表,所以页面组件需要在 defineComponent 中显式给出 name
|
||||||
*/}
|
*/}
|
||||||
<Content class={styles.content}>
|
<Content class={styles.content}>
|
||||||
<RouterView
|
<div class={styles.pageView}>
|
||||||
v-slots={{
|
<RouterView
|
||||||
default: ({ Component }: any) => (
|
v-slots={{
|
||||||
<KeepAlive include={cachedViews.value}>
|
default: ({ Component, route }: any) => (
|
||||||
<Component />
|
<KeepAlive include={cachedViews.value}>
|
||||||
</KeepAlive>
|
<Transition name="page-fade-slide" mode="out-in">
|
||||||
),
|
{Component ? h(Component, { key: route.path }) : null}
|
||||||
}}
|
</Transition>
|
||||||
/>
|
</KeepAlive>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
<Footer style={footerStyle}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer>
|
<Footer style={footerStyle}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.uploadIcon {
|
.uploadIcon {
|
||||||
font-size: 32px;
|
font-size: 24px;
|
||||||
color: #999;
|
color: #999;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineComponent, ref, reactive } from 'vue';
|
import { defineComponent, ref, reactive } from 'vue';
|
||||||
import { useEffect } from '@/hooks';
|
import { useEffect, useState } from '@/hooks';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
Form,
|
Form,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS, REGION_OPTIONS } from '../model/useBannerModel';
|
import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS, REGION_OPTIONS } from '../model/useBannerModel';
|
||||||
import { ImageCropper } from '@yp-component/root';
|
import { ImageCropper } from '@yp-component/root';
|
||||||
|
import { FileImageOutlined } from '@ant-design/icons-vue';
|
||||||
import { uploadFile, OssUploadType } from '@/utils/oss';
|
import { uploadFile, OssUploadType } from '@/utils/oss';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import styles from './BannerFormModal.module.less';
|
import styles from './BannerFormModal.module.less';
|
||||||
@@ -81,9 +82,9 @@ export default defineComponent({
|
|||||||
const formData = reactive(getDefaultForm());
|
const formData = reactive(getDefaultForm());
|
||||||
|
|
||||||
/** 裁剪弹窗状态 */
|
/** 裁剪弹窗状态 */
|
||||||
const cropperVisible = ref(false);
|
const [cropperVisible, setCropperVisible] = useState(false);
|
||||||
/** 待裁剪图片的本地预览 URL */
|
/** 待裁剪图片的本地预览 URL */
|
||||||
const uploadImageUrl = ref('');
|
const [uploadImageUrl, setUploadImageUrl] = useState('');
|
||||||
|
|
||||||
/** 根据 record 初始化表单 */
|
/** 根据 record 初始化表单 */
|
||||||
const initFormFromRecord = (record: any) => {
|
const initFormFromRecord = (record: any) => {
|
||||||
@@ -123,8 +124,8 @@ export default defineComponent({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.visible) {
|
if (props.visible) {
|
||||||
initFormFromRecord(props.record);
|
initFormFromRecord(props.record);
|
||||||
cropperVisible.value = false;
|
setCropperVisible(false);
|
||||||
uploadImageUrl.value = '';
|
setUploadImageUrl('');
|
||||||
setTimeout(() => formRef.value?.clearValidate(), 0);
|
setTimeout(() => formRef.value?.clearValidate(), 0);
|
||||||
}
|
}
|
||||||
}, [() => props.visible]);
|
}, [() => props.visible]);
|
||||||
@@ -166,13 +167,13 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成本地预览 URL,打开裁剪弹窗
|
// 生成本地预览 URL,打开裁剪弹窗
|
||||||
uploadImageUrl.value = URL.createObjectURL(file);
|
setUploadImageUrl(URL.createObjectURL(file));
|
||||||
cropperVisible.value = true;
|
setCropperVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 裁剪确认回调:将裁剪后的图片上传到 OSS */
|
/** 裁剪确认回调:将裁剪后的图片上传到 OSS */
|
||||||
const handleCropConfirm = async (dataUrl: string) => {
|
const handleCropConfirm = async (dataUrl: string) => {
|
||||||
cropperVisible.value = false;
|
setCropperVisible(false);
|
||||||
|
|
||||||
// 释放旧的本地预览
|
// 释放旧的本地预览
|
||||||
if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) {
|
if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) {
|
||||||
@@ -205,16 +206,16 @@ export default defineComponent({
|
|||||||
if (uploadImageUrl.value) {
|
if (uploadImageUrl.value) {
|
||||||
URL.revokeObjectURL(uploadImageUrl.value);
|
URL.revokeObjectURL(uploadImageUrl.value);
|
||||||
}
|
}
|
||||||
uploadImageUrl.value = '';
|
setUploadImageUrl('');
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 裁剪弹窗取消 */
|
/** 裁剪弹窗取消 */
|
||||||
const onCropperCancel = () => {
|
const onCropperCancel = () => {
|
||||||
cropperVisible.value = false;
|
setCropperVisible(false);
|
||||||
if (uploadImageUrl.value) {
|
if (uploadImageUrl.value) {
|
||||||
URL.revokeObjectURL(uploadImageUrl.value);
|
URL.revokeObjectURL(uploadImageUrl.value);
|
||||||
}
|
}
|
||||||
uploadImageUrl.value = '';
|
setUploadImageUrl('');
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 删除已上传图片 */
|
/** 删除已上传图片 */
|
||||||
@@ -317,7 +318,9 @@ export default defineComponent({
|
|||||||
}`}
|
}`}
|
||||||
onClick={triggerFilePicker}
|
onClick={triggerFilePicker}
|
||||||
>
|
>
|
||||||
<span class={styles.uploadIcon}>+</span>
|
<span class={styles.uploadIcon}>
|
||||||
|
<FileImageOutlined />
|
||||||
|
</span>
|
||||||
<span class={styles.uploadHint}>点击上传</span>
|
<span class={styles.uploadHint}>点击上传</span>
|
||||||
<span class={styles.uploadTip}>
|
<span class={styles.uploadTip}>
|
||||||
建议尺寸 1053*351 像素
|
建议尺寸 1053*351 像素
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
.eventBannerMain {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.tableBody {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:global {
|
|
||||||
.ant-spin-nested-loading,
|
|
||||||
.ant-spin-container,
|
|
||||||
.ant-table,
|
|
||||||
.ant-table-container {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import { useBannerModel, BANNER_STATUS_OPTIONS } from './model/useBannerModel';
|
import { useBannerModel, BANNER_STATUS_OPTIONS } from './model/useBannerModel';
|
||||||
import { useContainerSize } from '@/hooks';
|
import { useContainerSize } from '@/hooks';
|
||||||
import BannerFormModal from './components/BannerFormModal';
|
import BannerFormModal from './components/BannerFormModal';
|
||||||
import styles from './index.module.less';
|
|
||||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,7 +65,7 @@ function renderBodyCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转类型列:彩色 Tag
|
// 跳转类型列:StatusTag
|
||||||
if (column.dataIndex === 'jumpType') {
|
if (column.dataIndex === 'jumpType') {
|
||||||
return renderJumpType(text);
|
return renderJumpType(text);
|
||||||
}
|
}
|
||||||
@@ -231,41 +230,39 @@ export default defineComponent({
|
|||||||
|
|
||||||
{/* ===== 表格区 ===== */}
|
{/* ===== 表格区 ===== */}
|
||||||
<div class={pageStyles.table}>
|
<div class={pageStyles.table}>
|
||||||
<div class={styles.eventBannerMain}>
|
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||||
<div ref={containerRef} class={styles.tableBody}>
|
<Table
|
||||||
<Table
|
columns={tableColumns}
|
||||||
columns={tableColumns}
|
dataSource={dataSource.value}
|
||||||
dataSource={dataSource.value}
|
loading={loading.value}
|
||||||
loading={loading.value}
|
scroll={{ x: 'max-content', y: height.value }}
|
||||||
scroll={{ x: 'max-content', y: height.value }}
|
pagination={false}
|
||||||
pagination={false}
|
>
|
||||||
>
|
{{
|
||||||
{{
|
bodyCell: (args: any) =>
|
||||||
bodyCell: (args: any) =>
|
renderBodyCell({
|
||||||
renderBodyCell({
|
...args,
|
||||||
...args,
|
renderJumpType,
|
||||||
renderJumpType,
|
renderStatus,
|
||||||
renderStatus,
|
onEdit: handleEdit,
|
||||||
onEdit: handleEdit,
|
onToggleStatus: confirmToggleStatus,
|
||||||
onToggleStatus: confirmToggleStatus,
|
onDelete: confirmDelete,
|
||||||
onDelete: confirmDelete,
|
}),
|
||||||
}),
|
}}
|
||||||
}}
|
</Table>
|
||||||
</Table>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 独立分页,右下方 */}
|
{/* 独立分页,右下方 */}
|
||||||
<div class={pageStyles.pagination}>
|
<div class={pageStyles.pagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={pagination.value.current}
|
current={pagination.value.current}
|
||||||
pageSize={pagination.value.pageSize}
|
pageSize={pagination.value.pageSize}
|
||||||
total={pagination.value.total}
|
total={pagination.value.total}
|
||||||
showSizeChanger
|
showSizeChanger
|
||||||
showTotal={(total: number) => `共 ${total} 条`}
|
showTotal={(total: number) => `共 ${total} 条`}
|
||||||
onChange={handlePageChange}
|
onChange={handlePageChange}
|
||||||
onShowSizeChange={handlePageChange}
|
onShowSizeChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||||
import { message, Tag } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { StatusTag, type StatusTagTone } from '@/components';
|
||||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -21,18 +22,18 @@ export const JUMP_TYPE_OPTIONS = [
|
|||||||
{ value: 'none', label: '无链接' },
|
{ value: 'none', label: '无链接' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 跳转类型映射(用于表格 Tag 渲染) */
|
/** 跳转类型映射(用于表格 StatusTag 渲染) */
|
||||||
const JUMP_TYPE_MAP: Record<string, { label: string; color: string }> = {
|
const JUMP_TYPE_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
event_detail: { label: '赛事详情页', color: 'blue' },
|
event_detail: { label: '赛事详情页', tone: 'primary' },
|
||||||
miniprogram_page: { label: '小程序页面', color: 'cyan' },
|
miniprogram_page: { label: '小程序页面', tone: 'cyan' },
|
||||||
custom_link: { label: '自定义链接', color: 'purple' },
|
custom_link: { label: '自定义链接', tone: 'purple' },
|
||||||
none: { label: '无链接', color: 'default' },
|
none: { label: '无链接', tone: 'default' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Banner 状态映射 */
|
/** Banner 状态映射 */
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone; dimmed?: boolean }> = {
|
||||||
enabled: { label: '启用中', color: 'green' },
|
enabled: { label: '启用中', tone: 'success' },
|
||||||
disabled: { label: '已禁用', color: 'default' },
|
disabled: { label: '已禁用', tone: 'default', dimmed: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 关联赛事选项(mock) */
|
/** 关联赛事选项(mock) */
|
||||||
@@ -153,7 +154,6 @@ export function useBannerModel() {
|
|||||||
dataIndex: 'jumpType',
|
dataIndex: 'jumpType',
|
||||||
key: 'jumpType',
|
key: 'jumpType',
|
||||||
width: 120,
|
width: 120,
|
||||||
align: 'center' as const,
|
|
||||||
},
|
},
|
||||||
{ title: '关联赛事', dataIndex: 'relatedEvent', key: 'relatedEvent', width: 200 },
|
{ title: '关联赛事', dataIndex: 'relatedEvent', key: 'relatedEvent', width: 200 },
|
||||||
{ title: '所属区域', key: 'region', width: 140 },
|
{ title: '所属区域', key: 'region', width: 140 },
|
||||||
@@ -282,18 +282,17 @@ export function useBannerModel() {
|
|||||||
|
|
||||||
// ===== 供页面 bodyCell 使用的渲染工具 =====
|
// ===== 供页面 bodyCell 使用的渲染工具 =====
|
||||||
const renderJumpType = (type: string) => {
|
const renderJumpType = (type: string) => {
|
||||||
const info = JUMP_TYPE_MAP[type] || { label: type || '-', color: 'default' };
|
const info = JUMP_TYPE_MAP[type] || { label: type || '-', tone: 'default' as const };
|
||||||
return h(Tag, { color: info.color }, () => info.label);
|
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStatus = (status: string) => {
|
const renderStatus = (status: string) => {
|
||||||
const info = STATUS_MAP[status] || { label: status || '-', color: 'default' };
|
const info = STATUS_MAP[status] || { label: status || '-', tone: 'default' as const };
|
||||||
const tagProps: any = { color: info.color };
|
return h(StatusTag, {
|
||||||
// 已禁用状态添加半透明样式
|
label: info.label,
|
||||||
if (status === 'disabled') {
|
tone: info.tone,
|
||||||
tagProps.style = { opacity: 0.5 };
|
dimmed: info.dimmed,
|
||||||
}
|
});
|
||||||
return h(Tag, tagProps, () => info.label);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
.eventListMain {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.tableBody {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:global {
|
|
||||||
.ant-spin-nested-loading,
|
|
||||||
.ant-spin-container,
|
|
||||||
.ant-table,
|
|
||||||
.ant-table-container {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,6 @@ import { useContainerSize, useState } from '@/hooks';
|
|||||||
import EventRegulationModal from './components/EventRegulationModal';
|
import EventRegulationModal from './components/EventRegulationModal';
|
||||||
import EventDetailModal from './components/EventDetailModal';
|
import EventDetailModal from './components/EventDetailModal';
|
||||||
import EventAuditModal from './components/EventAuditModal';
|
import EventAuditModal from './components/EventAuditModal';
|
||||||
import styles from './index.module.less';
|
|
||||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
@@ -245,7 +244,7 @@ export default defineComponent({
|
|||||||
<Select
|
<Select
|
||||||
value={filterForm.status}
|
value={filterForm.status}
|
||||||
options={EVENT_STATUS_OPTIONS as any}
|
options={EVENT_STATUS_OPTIONS as any}
|
||||||
style={{ width: '110px' }}
|
style={{ width: '150px' }}
|
||||||
allowClear
|
allowClear
|
||||||
onUpdate:value={(val: any) => (filterForm.status = val || '')}
|
onUpdate:value={(val: any) => (filterForm.status = val || '')}
|
||||||
/>
|
/>
|
||||||
@@ -301,39 +300,37 @@ export default defineComponent({
|
|||||||
|
|
||||||
{/* ===== 表格区 ===== */}
|
{/* ===== 表格区 ===== */}
|
||||||
<div class={pageStyles.table}>
|
<div class={pageStyles.table}>
|
||||||
<div class={styles.eventListMain}>
|
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||||
<div ref={containerRef} class={styles.tableBody}>
|
<Table
|
||||||
<Table
|
columns={tableColumns}
|
||||||
columns={tableColumns}
|
dataSource={dataSource.value}
|
||||||
dataSource={dataSource.value}
|
loading={loading.value}
|
||||||
loading={loading.value}
|
scroll={{ x: 'max-content', y: height.value }}
|
||||||
scroll={{ x: 'max-content', y: height.value }}
|
pagination={false}
|
||||||
pagination={false}
|
>
|
||||||
>
|
{{
|
||||||
{{
|
bodyCell: (args: any) =>
|
||||||
bodyCell: (args: any) =>
|
renderBodyCell({
|
||||||
renderBodyCell({
|
...args,
|
||||||
...args,
|
onView: handleView,
|
||||||
onView: handleView,
|
onAudit: handleAudit,
|
||||||
onAudit: handleAudit,
|
onToggleShelf: handleToggleShelf,
|
||||||
onToggleShelf: handleToggleShelf,
|
}),
|
||||||
}),
|
}}
|
||||||
}}
|
</Table>
|
||||||
</Table>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 独立分页,右下方 */}
|
{/* 独立分页,右下方 */}
|
||||||
<div class={pageStyles.pagination}>
|
<div class={pageStyles.pagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={pagination.value.current}
|
current={pagination.value.current}
|
||||||
pageSize={pagination.value.pageSize}
|
pageSize={pagination.value.pageSize}
|
||||||
total={pagination.value.total}
|
total={pagination.value.total}
|
||||||
showSizeChanger
|
showSizeChanger
|
||||||
showTotal={(total: number) => `共 ${total} 条`}
|
showTotal={(total: number) => `共 ${total} 条`}
|
||||||
onChange={handlePageChange}
|
onChange={handlePageChange}
|
||||||
onShowSizeChange={handlePageChange}
|
onShowSizeChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||||
import { message, Tag } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { StatusTag, type StatusTagTone } from '@/components';
|
||||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -67,12 +68,12 @@ const MOCK_DATA = Array.from({ length: 12 }, (_, i) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
/** 状态标签映射 */
|
/** 状态标签映射 */
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
pending: { label: '待审核', color: 'gold' },
|
pending: { label: '待审核', tone: 'warning' },
|
||||||
enrolling: { label: '报名中', color: 'blue' },
|
enrolling: { label: '报名中', tone: 'primary' },
|
||||||
deleted: { label: '已删除', color: 'red' },
|
deleted: { label: '已删除', tone: 'neutral' },
|
||||||
removed: { label: '已下架', color: 'orange' },
|
removed: { label: '已下架', tone: 'orange' },
|
||||||
rejected: { label: '审核不通过', color: 'volcano' },
|
rejected: { label: '审核不通过', tone: 'danger' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -169,9 +170,10 @@ export function useEventListModel() {
|
|||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
width: 90,
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
customRender: ({ text }: { text: string }) => {
|
customRender: ({ text }: { text: string }) => {
|
||||||
const info = STATUS_MAP[text] || { label: text, color: 'default' };
|
const info = STATUS_MAP[text] || { label: text, tone: 'default' as const };
|
||||||
return h(Tag, { color: info.color }, () => info.label);
|
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '报名人数', dataIndex: 'enrolledCount', key: 'enrolledCount', width: 90 },
|
{ title: '报名人数', dataIndex: 'enrolledCount', key: 'enrolledCount', width: 90 },
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { defineComponent, ref, computed } from 'vue';
|
import { defineComponent, computed } from 'vue';
|
||||||
import { Modal, Table, Button, Tag, Pagination, Image, Space } from 'ant-design-vue';
|
import { Modal, Table, Button, Pagination, Image, Space } from 'ant-design-vue';
|
||||||
|
import { StatusTag, type StatusTagTone } from '@/components';
|
||||||
|
import { useState } from '@/hooks';
|
||||||
import styles from './OrderDetailModal.module.less';
|
import styles from './OrderDetailModal.module.less';
|
||||||
|
|
||||||
interface OrderDetailModalProps {
|
interface OrderDetailModalProps {
|
||||||
@@ -69,9 +71,9 @@ const PLAYER_COLUMNS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const REFUND_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const REFUND_STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
success: { label: '退款成功', color: 'green' },
|
success: { label: '退款成功', tone: 'success' },
|
||||||
failed: { label: '退款失败', color: 'red' },
|
failed: { label: '退款失败', tone: 'danger' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -83,8 +85,8 @@ export default defineComponent({
|
|||||||
onReRefund: { type: Function, required: true },
|
onReRefund: { type: Function, required: true },
|
||||||
},
|
},
|
||||||
setup(props: OrderDetailModalProps) {
|
setup(props: OrderDetailModalProps) {
|
||||||
const refundPage = ref<number>(1);
|
const [refundPage, setRefundPage] = useState(1);
|
||||||
const refundPageSize = ref<number>(2);
|
const [refundPageSize, setRefundPageSize] = useState(2);
|
||||||
|
|
||||||
/** 退款记录 */
|
/** 退款记录 */
|
||||||
const refundRecords = computed<any[]>(() => props.record?.refundRecords || []);
|
const refundRecords = computed<any[]>(() => props.record?.refundRecords || []);
|
||||||
@@ -104,8 +106,8 @@ export default defineComponent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRefundPageChange = (page: number, pageSize: number) => {
|
const handleRefundPageChange = (page: number, pageSize: number) => {
|
||||||
refundPage.value = page;
|
setRefundPage(page);
|
||||||
refundPageSize.value = pageSize;
|
setRefundPageSize(pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
@@ -203,14 +205,14 @@ export default defineComponent({
|
|||||||
{pagedRefunds.value.map((r, idx) => {
|
{pagedRefunds.value.map((r, idx) => {
|
||||||
const statusInfo = REFUND_STATUS_MAP[r.status] || {
|
const statusInfo = REFUND_STATUS_MAP[r.status] || {
|
||||||
label: '-',
|
label: '-',
|
||||||
color: 'default',
|
tone: 'default' as const,
|
||||||
};
|
};
|
||||||
const realIdx = (refundPage.value - 1) * refundPageSize.value + idx + 1;
|
const realIdx = (refundPage.value - 1) * refundPageSize.value + idx + 1;
|
||||||
return (
|
return (
|
||||||
<div key={r.key} class={styles.refundCard}>
|
<div key={r.key} class={styles.refundCard}>
|
||||||
<div class={styles.refundCardHeader}>
|
<div class={styles.refundCardHeader}>
|
||||||
<span class={styles.refundIndex}>第{realIdx}次退款</span>
|
<span class={styles.refundIndex}>第{realIdx}次退款</span>
|
||||||
<Tag color={statusInfo.color}>{statusInfo.label}</Tag>
|
<StatusTag label={statusInfo.label} tone={statusInfo.tone} />
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.refundCardBody}>
|
<div class={styles.refundCardBody}>
|
||||||
<div class={styles.refundCol}>
|
<div class={styles.refundCol}>
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
.eventOrdersMain {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.summaryBar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #1a1a1a;
|
|
||||||
|
|
||||||
.summaryItem {
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
.summaryLabel {
|
|
||||||
color: #666;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableBody {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:global {
|
|
||||||
.ant-spin-nested-loading,
|
|
||||||
.ant-spin-container,
|
|
||||||
.ant-table,
|
|
||||||
.ant-table-container {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import { useOrderModel, ORDER_STATUS_OPTIONS, REFUND_STATUS_OPTIONS } from './model/useOrderModel';
|
import { useOrderModel, ORDER_STATUS_OPTIONS, REFUND_STATUS_OPTIONS } from './model/useOrderModel';
|
||||||
import { useContainerSize, useState } from '@/hooks';
|
import { useContainerSize, useState } from '@/hooks';
|
||||||
import OrderDetailModal from './components/OrderDetailModal';
|
import OrderDetailModal from './components/OrderDetailModal';
|
||||||
import styles from './index.module.less';
|
|
||||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
@@ -176,7 +175,7 @@ export default defineComponent({
|
|||||||
<Select
|
<Select
|
||||||
value={filterForm.orderStatus}
|
value={filterForm.orderStatus}
|
||||||
options={ORDER_STATUS_OPTIONS as any}
|
options={ORDER_STATUS_OPTIONS as any}
|
||||||
style={{ width: '120px' }}
|
style={{ width: '140px' }}
|
||||||
allowClear
|
allowClear
|
||||||
onUpdate:value={(val: any) => (filterForm.orderStatus = val || '')}
|
onUpdate:value={(val: any) => (filterForm.orderStatus = val || '')}
|
||||||
/>
|
/>
|
||||||
@@ -185,7 +184,7 @@ export default defineComponent({
|
|||||||
<Select
|
<Select
|
||||||
value={filterForm.refundStatus}
|
value={filterForm.refundStatus}
|
||||||
options={REFUND_STATUS_OPTIONS as any}
|
options={REFUND_STATUS_OPTIONS as any}
|
||||||
style={{ width: '120px' }}
|
style={{ width: '140px' }}
|
||||||
allowClear
|
allowClear
|
||||||
onUpdate:value={(val: any) => (filterForm.refundStatus = val || '')}
|
onUpdate:value={(val: any) => (filterForm.refundStatus = val || '')}
|
||||||
/>
|
/>
|
||||||
@@ -203,54 +202,58 @@ export default defineComponent({
|
|||||||
|
|
||||||
{/* ===== 表格区 ===== */}
|
{/* ===== 表格区 ===== */}
|
||||||
<div class={pageStyles.table}>
|
<div class={pageStyles.table}>
|
||||||
<div class={styles.eventOrdersMain}>
|
{/* 金额汇总 */}
|
||||||
{/* 金额汇总 */}
|
<div class={pageStyles.summary}>
|
||||||
<div class={styles.summaryBar}>
|
<span class={pageStyles.summaryItem}>
|
||||||
<span class={styles.summaryItem}>
|
<span class={pageStyles.summaryLabel}>总订单金额(已支付):</span>
|
||||||
<span class={styles.summaryLabel}>总订单金额(已支付):</span>
|
<span class={pageStyles.summaryValue}>
|
||||||
{summary.value.totalOrderAmount.toFixed(2)}元
|
{summary.value.totalOrderAmount.toFixed(2)}元
|
||||||
</span>
|
</span>
|
||||||
<span class={styles.summaryItem}>
|
</span>
|
||||||
<span class={styles.summaryLabel}>总实付金额:</span>
|
<span class={pageStyles.summaryItem}>
|
||||||
|
<span class={pageStyles.summaryLabel}>总实付金额:</span>
|
||||||
|
<span class={pageStyles.summaryValue}>
|
||||||
{summary.value.totalPaidAmount.toFixed(2)}元
|
{summary.value.totalPaidAmount.toFixed(2)}元
|
||||||
</span>
|
</span>
|
||||||
<span class={styles.summaryItem}>
|
</span>
|
||||||
<span class={styles.summaryLabel}>总退款金额:</span>
|
<span class={pageStyles.summaryItem}>
|
||||||
|
<span class={pageStyles.summaryLabel}>总退款金额:</span>
|
||||||
|
<span class={pageStyles.summaryValue}>
|
||||||
{summary.value.totalRefundAmount.toFixed(2)}元
|
{summary.value.totalRefundAmount.toFixed(2)}元
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div ref={containerRef} class={styles.tableBody}>
|
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||||
<Table
|
<Table
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
dataSource={dataSource.value}
|
dataSource={dataSource.value}
|
||||||
loading={loading.value}
|
loading={loading.value}
|
||||||
scroll={{ x: 'max-content', y: height.value }}
|
scroll={{ x: 'max-content', y: height.value }}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
bodyCell: (args: any) =>
|
bodyCell: (args: any) =>
|
||||||
renderBodyCell({
|
renderBodyCell({
|
||||||
...args,
|
...args,
|
||||||
onView: handleView,
|
onView: handleView,
|
||||||
onReRefund: handleReRefund,
|
onReRefund: handleReRefund,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 独立分页,右下方 */}
|
{/* 独立分页,右下方 */}
|
||||||
<div class={pageStyles.pagination}>
|
<div class={pageStyles.pagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={pagination.value.current}
|
current={pagination.value.current}
|
||||||
pageSize={pagination.value.pageSize}
|
pageSize={pagination.value.pageSize}
|
||||||
total={pagination.value.total}
|
total={pagination.value.total}
|
||||||
showSizeChanger
|
showSizeChanger
|
||||||
showTotal={(total: number) => `共 ${total} 条`}
|
showTotal={(total: number) => `共 ${total} 条`}
|
||||||
onChange={handlePageChange}
|
onChange={handlePageChange}
|
||||||
onShowSizeChange={handlePageChange}
|
onShowSizeChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||||
import { message, Tag } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { StatusTag, type StatusTagTone } from '@/components';
|
||||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -22,18 +23,18 @@ export const REFUND_STATUS_OPTIONS = [
|
|||||||
{ value: 'refund_failed', label: '退款失败' },
|
{ value: 'refund_failed', label: '退款失败' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 订单状态 Tag 映射 */
|
/** 订单状态 StatusTag 映射 */
|
||||||
const ORDER_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const ORDER_STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
paid: { label: '已支付', color: 'green' },
|
paid: { label: '已支付', tone: 'success' },
|
||||||
partial_refund: { label: '部分退款', color: 'orange' },
|
partial_refund: { label: '部分退款', tone: 'orange' },
|
||||||
full_refund: { label: '全部退款', color: 'blue' },
|
full_refund: { label: '全部退款', tone: 'primary' },
|
||||||
closed: { label: '已关闭', color: 'default' },
|
closed: { label: '已关闭', tone: 'default' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 退款状态 Tag 映射 */
|
/** 退款状态 StatusTag 映射 */
|
||||||
const REFUND_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const REFUND_STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
refund_success: { label: '退款成功', color: 'green' },
|
refund_success: { label: '退款成功', tone: 'success' },
|
||||||
refund_failed: { label: '退款失败', color: 'red' },
|
refund_failed: { label: '退款失败', tone: 'danger' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -254,8 +255,8 @@ export function useOrderModel() {
|
|||||||
width: 100,
|
width: 100,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
customRender: ({ text }: { text: string }) => {
|
customRender: ({ text }: { text: string }) => {
|
||||||
const info = ORDER_STATUS_MAP[text] || { label: text || '-', color: 'default' };
|
const info = ORDER_STATUS_MAP[text] || { label: text || '-', tone: 'default' as const };
|
||||||
return h(Tag, { color: info.color }, () => info.label);
|
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -266,8 +267,8 @@ export function useOrderModel() {
|
|||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
customRender: ({ text }: { text: string }) => {
|
customRender: ({ text }: { text: string }) => {
|
||||||
if (!text) return h('span', '-');
|
if (!text) return h('span', '-');
|
||||||
const info = REFUND_STATUS_MAP[text] || { label: text, color: 'default' };
|
const info = REFUND_STATUS_MAP[text] || { label: text, tone: 'default' as const };
|
||||||
return h(Tag, { color: info.color }, () => info.label);
|
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
.eventUsersMain {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.tableBody {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:global {
|
|
||||||
.ant-spin-nested-loading,
|
|
||||||
.ant-spin-container,
|
|
||||||
.ant-table,
|
|
||||||
.ant-table-container {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
import { useUserModel, USER_STATUS_OPTIONS } from './model/useUserModel';
|
import { useUserModel, USER_STATUS_OPTIONS } from './model/useUserModel';
|
||||||
import { useContainerSize } from '@/hooks';
|
import { useContainerSize } from '@/hooks';
|
||||||
import styles from './index.module.less';
|
|
||||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,37 +144,35 @@ export default defineComponent({
|
|||||||
|
|
||||||
{/* ===== 表格区 ===== */}
|
{/* ===== 表格区 ===== */}
|
||||||
<div class={pageStyles.table}>
|
<div class={pageStyles.table}>
|
||||||
<div class={styles.eventUsersMain}>
|
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||||
<div ref={containerRef} class={styles.tableBody}>
|
<Table
|
||||||
<Table
|
columns={tableColumns}
|
||||||
columns={tableColumns}
|
dataSource={dataSource.value}
|
||||||
dataSource={dataSource.value}
|
loading={loading.value}
|
||||||
loading={loading.value}
|
scroll={{ x: 'max-content', y: height.value }}
|
||||||
scroll={{ x: 'max-content', y: height.value }}
|
pagination={false}
|
||||||
pagination={false}
|
>
|
||||||
>
|
{{
|
||||||
{{
|
bodyCell: (args: any) =>
|
||||||
bodyCell: (args: any) =>
|
renderBodyCell({
|
||||||
renderBodyCell({
|
...args,
|
||||||
...args,
|
onToggleStatus: handleToggleStatus,
|
||||||
onToggleStatus: handleToggleStatus,
|
}),
|
||||||
}),
|
}}
|
||||||
}}
|
</Table>
|
||||||
</Table>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 独立分页,右下方 */}
|
{/* 独立分页,右下方 */}
|
||||||
<div class={pageStyles.pagination}>
|
<div class={pageStyles.pagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={pagination.value.current}
|
current={pagination.value.current}
|
||||||
pageSize={pagination.value.pageSize}
|
pageSize={pagination.value.pageSize}
|
||||||
total={pagination.value.total}
|
total={pagination.value.total}
|
||||||
showSizeChanger
|
showSizeChanger
|
||||||
showTotal={(total: number) => `共 ${total} 条`}
|
showTotal={(total: number) => `共 ${total} 条`}
|
||||||
onChange={handlePageChange}
|
onChange={handlePageChange}
|
||||||
onShowSizeChange={handlePageChange}
|
onShowSizeChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||||
import { message, Tag } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { StatusTag, type StatusTagTone } from '@/components';
|
||||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -13,10 +14,10 @@ export const USER_STATUS_OPTIONS = [
|
|||||||
{ value: 'disabled', label: '禁用' },
|
{ value: 'disabled', label: '禁用' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 用户状态 Tag 映射 */
|
/** 用户状态 StatusTag 映射 */
|
||||||
const USER_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const USER_STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
active: { label: '正常', color: 'green' },
|
active: { label: '正常', tone: 'success' },
|
||||||
disabled: { label: '禁用', color: 'red' },
|
disabled: { label: '禁用', tone: 'danger' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -94,8 +95,8 @@ export function useUserModel() {
|
|||||||
width: 90,
|
width: 90,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
customRender: ({ text }: { text: string }) => {
|
customRender: ({ text }: { text: string }) => {
|
||||||
const info = USER_STATUS_MAP[text] || { label: text, color: 'default' };
|
const info = USER_STATUS_MAP[text] || { label: text, tone: 'default' as const };
|
||||||
return h(Tag, { color: info.color }, () => info.label);
|
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '登录时间', dataIndex: 'loginTime', key: 'loginTime', width: 170 },
|
{ title: '登录时间', dataIndex: 'loginTime', key: 'loginTime', width: 170 },
|
||||||
|
|||||||
@@ -101,10 +101,10 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 柱状图(echarts) =====
|
// ===== 组合图(echarts) =====
|
||||||
.chartEcharts {
|
.chartEcharts {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 明细表卡(按内容自适应高度,不被压缩) =====
|
// ===== 明细表卡(按内容自适应高度,不被压缩) =====
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import styles from './index.module.less';
|
|||||||
* 页面结构:
|
* 页面结构:
|
||||||
* 1. 顶部标题 + 时间范围选择
|
* 1. 顶部标题 + 时间范围选择
|
||||||
* 2. 三张统计卡(总收入 / 总提现 / 平台余额)
|
* 2. 三张统计卡(总收入 / 总提现 / 平台余额)
|
||||||
* 3. 月度收支趋势(近 6 个月,echarts 柱状图)
|
* 3. 月度收支趋势(近 6 个月,echarts 柱状 + 折线组合图)
|
||||||
* 4. 月度收支明细(表格)
|
* 4. 月度收支明细(表格)
|
||||||
*/
|
*/
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -27,7 +27,7 @@ export default defineComponent({
|
|||||||
RANGE_OPTIONS,
|
RANGE_OPTIONS,
|
||||||
} = useReportsModel();
|
} = useReportsModel();
|
||||||
|
|
||||||
// ===== 柱状图(echarts) =====
|
// ===== 组合图(柱状 + 折线,echarts) =====
|
||||||
const chartRef = ref<HTMLElement>();
|
const chartRef = ref<HTMLElement>();
|
||||||
let chartInstance: any = null;
|
let chartInstance: any = null;
|
||||||
|
|
||||||
@@ -39,48 +39,90 @@ export default defineComponent({
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
type: 'shadow',
|
type: 'cross',
|
||||||
|
crossStyle: { color: 'rgba(0, 0, 0, 0.15)' },
|
||||||
|
label: { backgroundColor: '#6a7985' },
|
||||||
},
|
},
|
||||||
valueFormatter: (v: number) => `¥${formatMoney(v)}`,
|
valueFormatter: (v: number) => `¥${formatMoney(v)}`,
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
data: ['订单金额', '净收入'],
|
data: [
|
||||||
|
{ name: '订单金额', icon: 'roundRect' },
|
||||||
|
{ name: '净收入', icon: 'circle' },
|
||||||
|
],
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
icon: 'roundRect',
|
|
||||||
itemWidth: 14,
|
itemWidth: 14,
|
||||||
itemHeight: 10,
|
itemHeight: 10,
|
||||||
textStyle: { color: 'rgba(0, 0, 0, 0.65)' },
|
textStyle: { color: 'rgba(0, 0, 0, 0.65)' },
|
||||||
},
|
},
|
||||||
grid: { left: 8, right: 16, top: 24, bottom: 48, containLabel: true },
|
grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: months,
|
data: months,
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||||
axisLabel: { color: 'rgba(0, 0, 0, 0.55)' },
|
axisLabel: { color: 'rgba(0, 0, 0, 0.55)' },
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: 'rgba(0, 0, 0, 0.45)',
|
color: 'rgba(0, 0, 0, 0.45)',
|
||||||
formatter: (v: number) => (v >= 1000 ? `${v / 1000}k` : `${v}`),
|
formatter: (v: number) => (v >= 1000 ? `¥${v / 1000}k` : `¥${v}`),
|
||||||
},
|
},
|
||||||
splitLine: { lineStyle: { color: '#f5f5f5' } },
|
splitLine: { lineStyle: { color: '#f5f5f5', type: 'dashed' } },
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '订单金额',
|
name: '订单金额',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: order,
|
data: order,
|
||||||
barWidth: 30,
|
barMaxWidth: 48,
|
||||||
itemStyle: { color: '#1677ff', borderRadius: [3, 3, 0, 0] },
|
barCategoryGap: '35%',
|
||||||
|
itemStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: '#4096ff' },
|
||||||
|
{ offset: 1, color: '#1677ff' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
borderRadius: [4, 4, 0, 0],
|
||||||
|
},
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '净收入',
|
name: '净收入',
|
||||||
type: 'bar',
|
type: 'line',
|
||||||
data: netIncome,
|
data: netIncome,
|
||||||
barWidth: 30,
|
smooth: true,
|
||||||
itemStyle: { color: '#52c41a', borderRadius: [3, 3, 0, 0] },
|
symbol: 'circle',
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3, color: '#52c41a' },
|
||||||
|
itemStyle: {
|
||||||
|
color: '#52c41a',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(82, 196, 26, 0.22)' },
|
||||||
|
{ offset: 1, color: 'rgba(82, 196, 26, 0.02)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
z: 10,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -170,7 +212,7 @@ export default defineComponent({
|
|||||||
value={range.value}
|
value={range.value}
|
||||||
onUpdate:value={(val: string) => handleRangeChange(val)}
|
onUpdate:value={(val: string) => handleRangeChange(val)}
|
||||||
options={[...RANGE_OPTIONS]}
|
options={[...RANGE_OPTIONS]}
|
||||||
style={{ width: 120 }}
|
style={{ width: '120px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,7 +231,7 @@ export default defineComponent({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== 柱状图卡(echarts) ===== */}
|
{/* ===== 组合图卡(echarts) ===== */}
|
||||||
<div class={styles.chartCard}>
|
<div class={styles.chartCard}>
|
||||||
<div class={styles.cardTitle}>
|
<div class={styles.cardTitle}>
|
||||||
<span class={styles.cardTitleIcon}>📊</span>
|
<span class={styles.cardTitleIcon}>📊</span>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const RANGE_OPTIONS = [
|
|||||||
// 假数据(数值自洽:总收入 - 总提现 = 平台余额)
|
// 假数据(数值自洽:总收入 - 总提现 = 平台余额)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/** 6 个月柱状图数据(月份标签 + 订单金额 + 净收入) */
|
/** 6 个月图表数据(月份标签 + 订单金额 + 净收入) */
|
||||||
const CHART_DATA = [
|
const CHART_DATA = [
|
||||||
{ month: '01月', order: 22500, netIncome: 19000, withdraw: 300 },
|
{ month: '01月', order: 22500, netIncome: 19000, withdraw: 300 },
|
||||||
{ month: '02月', order: 24500, netIncome: 21500, withdraw: 1850 },
|
{ month: '02月', order: 24500, netIncome: 21500, withdraw: 1850 },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||||
import { message, Tag } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { StatusTag, type StatusTagTone } from '@/components';
|
||||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -22,11 +23,11 @@ export const TRANSACTION_STATUS_OPTIONS = [
|
|||||||
{ value: '已解冻', label: '已解冻' },
|
{ value: '已解冻', label: '已解冻' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 明细状态映射(用于表格 Tag 渲染) */
|
/** 明细状态映射(用于表格 StatusTag 渲染) */
|
||||||
const TRANSACTION_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const TRANSACTION_STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
正常: { label: '正常', color: 'blue' },
|
正常: { label: '正常', tone: 'primary' },
|
||||||
冻结中: { label: '冻结中', color: 'orange' },
|
冻结中: { label: '冻结中', tone: 'orange' },
|
||||||
已解冻: { label: '已解冻', color: 'green' },
|
已解冻: { label: '已解冻', tone: 'success' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -319,10 +320,13 @@ export function useWalletModel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 渲染交易状态 Tag */
|
/** 渲染交易状态 StatusTag */
|
||||||
const renderTransactionStatus = (status: string) => {
|
const renderTransactionStatus = (status: string) => {
|
||||||
const info = TRANSACTION_STATUS_MAP[status] || { label: status || '-', color: 'default' };
|
const info = TRANSACTION_STATUS_MAP[status] || {
|
||||||
return h(Tag, { color: info.color }, () => info.label);
|
label: status || '-',
|
||||||
|
tone: 'default' as const,
|
||||||
|
};
|
||||||
|
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { defineComponent, reactive, ref } from 'vue';
|
import { defineComponent, reactive } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import { Button, Checkbox, Form, FormItem, Input, message } from 'ant-design-vue';
|
import { Button, Checkbox, Form, FormItem, Input, message } from 'ant-design-vue';
|
||||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
|
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
|
||||||
import { auth } from '@/hooks/useAuth';
|
import { auth } from '@/hooks/useAuth';
|
||||||
import { useEffect } from '@/hooks/useEffect';
|
import { useEffect, useState } from '@/hooks';
|
||||||
import { useUserStore } from '@/stores/userStore';
|
import { useUserStore } from '@/stores/userStore';
|
||||||
import styles from './index.module.less';
|
import styles from './index.module.less';
|
||||||
|
|
||||||
@@ -14,8 +15,9 @@ interface LoginForm {
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'LoginPage',
|
name: 'LoginPage',
|
||||||
setup() {
|
setup() {
|
||||||
const loading = ref(false);
|
const route = useRoute();
|
||||||
const rememberMe = ref(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
|
||||||
const form = reactive<LoginForm>({
|
const form = reactive<LoginForm>({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -29,7 +31,7 @@ export default defineComponent({
|
|||||||
const data = JSON.parse(saved);
|
const data = JSON.parse(saved);
|
||||||
form.username = data.username || '';
|
form.username = data.username || '';
|
||||||
form.password = data.password || '';
|
form.password = data.password || '';
|
||||||
rememberMe.value = true;
|
setRememberMe(true);
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem('cpms_login_remember');
|
localStorage.removeItem('cpms_login_remember');
|
||||||
}
|
}
|
||||||
@@ -47,7 +49,7 @@ export default defineComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true;
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
@@ -57,7 +59,10 @@ export default defineComponent({
|
|||||||
const { setUser } = useUserStore();
|
const { setUser } = useUserStore();
|
||||||
setUser({ phone: form.username, nickname: form.username, token });
|
setUser({ phone: form.username, nickname: form.username, token });
|
||||||
|
|
||||||
await auth.login(token);
|
const redirect = Array.isArray(route.query.redirect)
|
||||||
|
? route.query.redirect[0]
|
||||||
|
: route.query.redirect;
|
||||||
|
await auth.login(token, redirect || undefined);
|
||||||
message.success('登录成功');
|
message.success('登录成功');
|
||||||
|
|
||||||
if (rememberMe.value) {
|
if (rememberMe.value) {
|
||||||
@@ -74,7 +79,7 @@ export default defineComponent({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error('登录失败,请重试');
|
message.error('登录失败,请重试');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
.systemRolesMain {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.tableBody {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:global {
|
|
||||||
.ant-spin-nested-loading,
|
|
||||||
.ant-spin-container,
|
|
||||||
.ant-table,
|
|
||||||
.ant-table-container {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.userCountLink {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmContent {
|
|
||||||
padding: 4px 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(0, 0, 0, 0.85);
|
|
||||||
|
|
||||||
strong {
|
|
||||||
margin: 0 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmTip {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(0, 0, 0, 0.45);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.deleteConfirmModal) {
|
|
||||||
.ant-modal-confirm-body {
|
|
||||||
padding: 24px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import { useRoleModel } from './model/useRoleModel';
|
|||||||
import { useContainerSize } from '@/hooks';
|
import { useContainerSize } from '@/hooks';
|
||||||
import RoleFormModal from './components/RoleFormModal';
|
import RoleFormModal from './components/RoleFormModal';
|
||||||
import UserListModal from './components/UserListModal';
|
import UserListModal from './components/UserListModal';
|
||||||
import styles from './index.module.less';
|
|
||||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +26,7 @@ function renderBodyCell({
|
|||||||
}) {
|
}) {
|
||||||
if (column.dataIndex === 'userCount') {
|
if (column.dataIndex === 'userCount') {
|
||||||
return (
|
return (
|
||||||
<a class={styles.userCountLink} onClick={() => onViewUsers(record)}>
|
<a class={pageStyles.tabularLink} onClick={() => onViewUsers(record)}>
|
||||||
{record.userCount}人
|
{record.userCount}人
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -55,17 +54,13 @@ function confirmDelete({ record, onOk }: { record: any; onOk: (record: any) => v
|
|||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
icon: null,
|
icon: null,
|
||||||
class: styles.deleteConfirmModal,
|
class: 'deleteConfirmModal',
|
||||||
content: (
|
content: (
|
||||||
<div class={styles.systemRolesMain}>
|
<div class={pageStyles.confirmContent}>
|
||||||
<div class={styles.confirmContent}>
|
<span>确定要删除角色</span>
|
||||||
<div>
|
<strong>{record.roleName}</strong>
|
||||||
<span>确定要删除角色</span>
|
<span>吗?</span>
|
||||||
<strong>{record.roleName}</strong>
|
<div class={pageStyles.confirmTip}>删除后该角色下的用户将失去对应的权限,请谨慎操作。</div>
|
||||||
<span>吗?</span>
|
|
||||||
</div>
|
|
||||||
<div class={styles.confirmTip}>删除后该角色下的用户将失去对应的权限,请谨慎操作。</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
okText: '确认',
|
okText: '确认',
|
||||||
@@ -150,39 +145,37 @@ export default defineComponent({
|
|||||||
|
|
||||||
{/* ===== 表格区 ===== */}
|
{/* ===== 表格区 ===== */}
|
||||||
<div class={pageStyles.table}>
|
<div class={pageStyles.table}>
|
||||||
<div class={styles.systemRolesMain}>
|
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||||
<div ref={containerRef} class={styles.tableBody}>
|
<Table
|
||||||
<Table
|
columns={tableColumns}
|
||||||
columns={tableColumns}
|
dataSource={dataSource.value}
|
||||||
dataSource={dataSource.value}
|
loading={loading.value}
|
||||||
loading={loading.value}
|
scroll={{ x: 'max-content', y: height.value }}
|
||||||
scroll={{ x: 'max-content', y: height.value }}
|
pagination={false}
|
||||||
pagination={false}
|
>
|
||||||
>
|
{{
|
||||||
{{
|
bodyCell: (args: any) =>
|
||||||
bodyCell: (args: any) =>
|
renderBodyCell({
|
||||||
renderBodyCell({
|
...args,
|
||||||
...args,
|
onViewUsers: handleViewUsers,
|
||||||
onViewUsers: handleViewUsers,
|
onEdit: handleEdit,
|
||||||
onEdit: handleEdit,
|
onDelete: triggerDelete,
|
||||||
onDelete: triggerDelete,
|
}),
|
||||||
}),
|
}}
|
||||||
}}
|
</Table>
|
||||||
</Table>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
<div class={pageStyles.pagination}>
|
<div class={pageStyles.pagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={pagination.value.current}
|
current={pagination.value.current}
|
||||||
pageSize={pagination.value.pageSize}
|
pageSize={pagination.value.pageSize}
|
||||||
total={pagination.value.total}
|
total={pagination.value.total}
|
||||||
showSizeChanger
|
showSizeChanger
|
||||||
showTotal={(total: number) => `共 ${total} 条`}
|
showTotal={(total: number) => `共 ${total} 条`}
|
||||||
onChange={handlePageChange}
|
onChange={handlePageChange}
|
||||||
onShowSizeChange={handlePageChange}
|
onShowSizeChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||||
import { message, Tag } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { StatusTag, type StatusTagTone } from '@/components';
|
||||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -132,9 +133,9 @@ const MOCK_USERS: Record<string, any[]> = {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// 状态文本/颜色映射
|
// 状态文本/颜色映射
|
||||||
// ============================================================
|
// ============================================================
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
active: { label: '正常', color: 'green' },
|
active: { label: '正常', tone: 'success' },
|
||||||
disabled: { label: '禁用', color: 'red' },
|
disabled: { label: '禁用', tone: 'danger' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -288,10 +289,10 @@ export function useRoleModel() {
|
|||||||
/** 根据 roleId 获取该角色下的用户列表 */
|
/** 根据 roleId 获取该角色下的用户列表 */
|
||||||
const getRoleUsers = (roleId: string) => MOCK_USERS[roleId] || [];
|
const getRoleUsers = (roleId: string) => MOCK_USERS[roleId] || [];
|
||||||
|
|
||||||
/** 状态 Tag 渲染 */
|
/** 状态 StatusTag 渲染 */
|
||||||
const renderStatus = (status: string) => {
|
const renderStatus = (status: string) => {
|
||||||
const info = STATUS_MAP[status] || { label: status || '-', color: 'default' };
|
const info = STATUS_MAP[status] || { label: status || '-', tone: 'default' as const };
|
||||||
return h(Tag, { color: info.color }, () => info.label);
|
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineComponent, ref, reactive } from 'vue';
|
import { defineComponent, ref, reactive } from 'vue';
|
||||||
import { useEffect } from '@/hooks';
|
import { useEffect, useState } from '@/hooks';
|
||||||
import { Modal, Form, Input, Select, Button } from 'ant-design-vue';
|
import { Modal, Form, Input, Select, Button } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
ROLE_FORM_OPTIONS,
|
ROLE_FORM_OPTIONS,
|
||||||
@@ -43,14 +43,14 @@ export default defineComponent({
|
|||||||
const formData = reactive(getDefaultForm());
|
const formData = reactive(getDefaultForm());
|
||||||
|
|
||||||
/** 编辑模式下是否处于"重置密码"状态 */
|
/** 编辑模式下是否处于"重置密码"状态 */
|
||||||
const isResetting = ref(false);
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
/** 根据 record 初始化表单 */
|
/** 根据 record 初始化表单 */
|
||||||
const initFormFromRecord = (record: any) => {
|
const initFormFromRecord = (record: any) => {
|
||||||
const fresh = getDefaultForm();
|
const fresh = getDefaultForm();
|
||||||
if (!record) {
|
if (!record) {
|
||||||
Object.assign(formData, fresh);
|
Object.assign(formData, fresh);
|
||||||
isResetting.value = false;
|
setIsResetting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Object.assign(formData, {
|
Object.assign(formData, {
|
||||||
@@ -59,21 +59,21 @@ export default defineComponent({
|
|||||||
phone: record.phone || '',
|
phone: record.phone || '',
|
||||||
role: record.role || '',
|
role: record.role || '',
|
||||||
});
|
});
|
||||||
isResetting.value = false;
|
setIsResetting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 监听 visible 变化重置表单 */
|
/** 监听 visible 变化重置表单 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.visible) {
|
if (props.visible) {
|
||||||
initFormFromRecord(props.record);
|
initFormFromRecord(props.record);
|
||||||
isResetting.value = false;
|
setIsResetting(false);
|
||||||
setTimeout(() => formRef.value?.clearValidate(), 0);
|
setTimeout(() => formRef.value?.clearValidate(), 0);
|
||||||
}
|
}
|
||||||
}, [() => props.visible]);
|
}, [() => props.visible]);
|
||||||
|
|
||||||
/** 点击"重置密码" */
|
/** 点击"重置密码" */
|
||||||
const handleResetPassword = () => {
|
const handleResetPassword = () => {
|
||||||
isResetting.value = true;
|
setIsResetting(true);
|
||||||
formData.password = '';
|
formData.password = '';
|
||||||
formData.confirmPassword = '';
|
formData.confirmPassword = '';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
.systemUsersMain {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.tableBody {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:global {
|
|
||||||
.ant-spin-nested-loading,
|
|
||||||
.ant-spin-container,
|
|
||||||
.ant-table,
|
|
||||||
.ant-table-container {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmContent {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 4px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(0, 0, 0, 0.85);
|
|
||||||
|
|
||||||
strong {
|
|
||||||
margin: 0 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.toggleConfirmModal) {
|
|
||||||
.ant-modal-confirm-body {
|
|
||||||
padding: 24px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { Button, Input, Table, Form, Space, Select, Modal, Pagination } from 'an
|
|||||||
import { useUserModel, USER_ROLE_OPTIONS, USER_STATUS_OPTIONS } from './model/useUserModel';
|
import { useUserModel, USER_ROLE_OPTIONS, USER_STATUS_OPTIONS } from './model/useUserModel';
|
||||||
import { useContainerSize } from '@/hooks';
|
import { useContainerSize } from '@/hooks';
|
||||||
import UserFormModal from './components/UserFormModal';
|
import UserFormModal from './components/UserFormModal';
|
||||||
import styles from './index.module.less';
|
|
||||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,14 +51,12 @@ function confirmToggleStatus({ record, onOk }: { record: any; onOk: (record: any
|
|||||||
const actionText = isActive ? '禁用' : '启用';
|
const actionText = isActive ? '禁用' : '启用';
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${actionText}`,
|
title: `确认${actionText}`,
|
||||||
class: styles.toggleConfirmModal,
|
class: 'toggleConfirmModal',
|
||||||
content: (
|
content: (
|
||||||
<div class={styles.systemUsersMain}>
|
<div class={pageStyles.confirmContent}>
|
||||||
<div class={styles.confirmContent}>
|
<span>确定要{actionText}用户</span>
|
||||||
<span>确定要{actionText}用户</span>
|
<strong>{record.realName}</strong>
|
||||||
<strong>{record.realName}</strong>
|
<span>吗?</span>
|
||||||
<span>吗?</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
okText: '确认',
|
okText: '确认',
|
||||||
@@ -136,7 +133,7 @@ export default defineComponent({
|
|||||||
<Select
|
<Select
|
||||||
value={filterForm.role}
|
value={filterForm.role}
|
||||||
options={USER_ROLE_OPTIONS as any}
|
options={USER_ROLE_OPTIONS as any}
|
||||||
style={{ width: '120px' }}
|
style={{ width: '140px' }}
|
||||||
allowClear
|
allowClear
|
||||||
onUpdate:value={(val: any) => (filterForm.role = val || '')}
|
onUpdate:value={(val: any) => (filterForm.role = val || '')}
|
||||||
/>
|
/>
|
||||||
@@ -145,7 +142,7 @@ export default defineComponent({
|
|||||||
<Select
|
<Select
|
||||||
value={filterForm.status}
|
value={filterForm.status}
|
||||||
options={USER_STATUS_OPTIONS as any}
|
options={USER_STATUS_OPTIONS as any}
|
||||||
style={{ width: '120px' }}
|
style={{ width: '140px' }}
|
||||||
allowClear
|
allowClear
|
||||||
onUpdate:value={(val: any) => (filterForm.status = val || '')}
|
onUpdate:value={(val: any) => (filterForm.status = val || '')}
|
||||||
/>
|
/>
|
||||||
@@ -166,38 +163,36 @@ export default defineComponent({
|
|||||||
|
|
||||||
{/* ===== 表格区 ===== */}
|
{/* ===== 表格区 ===== */}
|
||||||
<div class={pageStyles.table}>
|
<div class={pageStyles.table}>
|
||||||
<div class={styles.systemUsersMain}>
|
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||||
<div ref={containerRef} class={styles.tableBody}>
|
<Table
|
||||||
<Table
|
columns={tableColumns}
|
||||||
columns={tableColumns}
|
dataSource={dataSource.value}
|
||||||
dataSource={dataSource.value}
|
loading={loading.value}
|
||||||
loading={loading.value}
|
scroll={{ x: 'max-content', y: height.value }}
|
||||||
scroll={{ x: 'max-content', y: height.value }}
|
pagination={false}
|
||||||
pagination={false}
|
>
|
||||||
>
|
{{
|
||||||
{{
|
bodyCell: (args: any) =>
|
||||||
bodyCell: (args: any) =>
|
renderBodyCell({
|
||||||
renderBodyCell({
|
...args,
|
||||||
...args,
|
onEdit: triggerEdit,
|
||||||
onEdit: triggerEdit,
|
onToggleStatus: triggerToggle,
|
||||||
onToggleStatus: triggerToggle,
|
}),
|
||||||
}),
|
}}
|
||||||
}}
|
</Table>
|
||||||
</Table>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 独立分页 */}
|
{/* 独立分页 */}
|
||||||
<div class={pageStyles.pagination}>
|
<div class={pageStyles.pagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={pagination.value.current}
|
current={pagination.value.current}
|
||||||
pageSize={pagination.value.pageSize}
|
pageSize={pagination.value.pageSize}
|
||||||
total={pagination.value.total}
|
total={pagination.value.total}
|
||||||
showSizeChanger
|
showSizeChanger
|
||||||
showTotal={(total: number) => `共 ${total} 条`}
|
showTotal={(total: number) => `共 ${total} 条`}
|
||||||
onChange={handlePageChange}
|
onChange={handlePageChange}
|
||||||
onShowSizeChange={handlePageChange}
|
onShowSizeChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, toRef, Ref, h } from 'vue';
|
import { computed, reactive, toRef, Ref, h } from 'vue';
|
||||||
import { message, Tag } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { StatusTag, type StatusTagTone } from '@/components';
|
||||||
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
import { useState, useDebounce, useThrottleFn } from '@/hooks';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -19,17 +20,17 @@ export const USER_STATUS_OPTIONS = [
|
|||||||
{ value: 'disabled', label: '禁用' },
|
{ value: 'disabled', label: '禁用' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 角色文本/颜色映射(表格渲染) */
|
/** 角色文本/色调映射(表格渲染) */
|
||||||
const ROLE_MAP: Record<string, { label: string; color: string }> = {
|
const ROLE_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
admin: { label: '管理员', color: 'blue' },
|
admin: { label: '管理员', tone: 'primary' },
|
||||||
operator: { label: '运营人员', color: 'cyan' },
|
operator: { label: '运营人员', tone: 'cyan' },
|
||||||
auditor: { label: '审核人员', color: 'purple' },
|
auditor: { label: '审核人员', tone: 'purple' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 状态文本/颜色映射 */
|
/** 状态文本/色调映射 */
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||||
active: { label: '正常', color: 'green' },
|
active: { label: '正常', tone: 'success' },
|
||||||
disabled: { label: '禁用', color: 'red' },
|
disabled: { label: '禁用', tone: 'danger' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -103,10 +104,9 @@ export function useUserModel() {
|
|||||||
dataIndex: 'role',
|
dataIndex: 'role',
|
||||||
key: 'role',
|
key: 'role',
|
||||||
width: 120,
|
width: 120,
|
||||||
align: 'center' as const,
|
|
||||||
customRender: ({ text }: { text: string }) => {
|
customRender: ({ text }: { text: string }) => {
|
||||||
const info = ROLE_MAP[text] || { label: text || '-', color: 'default' };
|
const info = ROLE_MAP[text] || { label: text || '-', tone: 'default' as const };
|
||||||
return h(Tag, { color: info.color }, () => info.label);
|
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,8 +116,8 @@ export function useUserModel() {
|
|||||||
width: 100,
|
width: 100,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
customRender: ({ text }: { text: string }) => {
|
customRender: ({ text }: { text: string }) => {
|
||||||
const info = STATUS_MAP[text] || { label: text || '-', color: 'default' };
|
const info = STATUS_MAP[text] || { label: text || '-', tone: 'default' as const };
|
||||||
return h(Tag, { color: info.color }, () => info.label);
|
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
|
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
|
||||||
|
|||||||
+26
-3
@@ -1,6 +1,7 @@
|
|||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import { auth } from '@/hooks/useAuth';
|
import { auth } from '@/hooks/useAuth';
|
||||||
import { useTabsStore } from '@/stores/tabsStore';
|
import { useTabsStore } from '@/stores/tabsStore';
|
||||||
|
import { useMenuStore } from '@/stores/menuStore';
|
||||||
|
|
||||||
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
|
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
|
||||||
|
|
||||||
@@ -9,6 +10,11 @@ function updateDocumentTitle(title?: string) {
|
|||||||
document.title = title ? `${title} - ${appTitle}` : appTitle;
|
document.title = title ? `${title} - ${appTitle}` : appTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRedirectPath(queryValue: unknown): string | undefined {
|
||||||
|
if (Array.isArray(queryValue)) return queryValue.find((item) => typeof item === 'string');
|
||||||
|
return typeof queryValue === 'string' ? queryValue : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局路由守卫
|
* 全局路由守卫
|
||||||
* 核心流程:
|
* 核心流程:
|
||||||
@@ -26,8 +32,21 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
// ── /login 特殊处理 ──
|
// ── /login 特殊处理 ──
|
||||||
if (to.path === '/login') {
|
if (to.path === '/login') {
|
||||||
if (auth.isLoggedIn()) {
|
if (auth.isLoggedIn()) {
|
||||||
const { homePath } = await import('@/stores/menuStore').then((m) => m.useMenuStore());
|
const { loadMenu, loaded, homePath, isRoutePathAvailable } =
|
||||||
next(homePath.value);
|
await import('@/stores/menuStore').then((m) => m.useMenuStore());
|
||||||
|
if (!loaded.value) {
|
||||||
|
const { loadPermissions } = await import('@/stores/permissionStore').then((m) =>
|
||||||
|
m.usePermissionStore(),
|
||||||
|
);
|
||||||
|
await Promise.all([loadMenu(), loadPermissions()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectPath = getRedirectPath(to.query.redirect);
|
||||||
|
const targetPath =
|
||||||
|
redirectPath && isRoutePathAvailable(redirectPath)
|
||||||
|
? redirectPath
|
||||||
|
: homePath.value || '/404';
|
||||||
|
next(targetPath);
|
||||||
} else {
|
} else {
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
@@ -36,7 +55,7 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
|
|
||||||
// ── 未登录 → 跳转登录页 ──
|
// ── 未登录 → 跳转登录页 ──
|
||||||
if (!auth.isLoggedIn()) {
|
if (!auth.isLoggedIn()) {
|
||||||
next('/login');
|
next({ path: '/login', query: { redirect: to.fullPath } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +87,14 @@ router.afterEach((to) => {
|
|||||||
const componentName = (to.meta.componentName as string) || (to.name as string);
|
const componentName = (to.meta.componentName as string) || (to.name as string);
|
||||||
if (!componentName) return;
|
if (!componentName) return;
|
||||||
|
|
||||||
|
const { isRoutePathAvailable } = useMenuStore();
|
||||||
|
if (!isRoutePathAvailable(to.path)) return;
|
||||||
|
|
||||||
const { addTab } = useTabsStore();
|
const { addTab } = useTabsStore();
|
||||||
addTab({
|
addTab({
|
||||||
path: to.path,
|
path: to.path,
|
||||||
name: componentName,
|
name: componentName,
|
||||||
title: to.meta.title,
|
title: to.meta.title,
|
||||||
|
icon: to.meta.icon as string | undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+38
-6
@@ -54,7 +54,7 @@ const state = reactive<MenuState>({
|
|||||||
menuTree: [],
|
menuTree: [],
|
||||||
menuItems: [],
|
menuItems: [],
|
||||||
loaded: false,
|
loaded: false,
|
||||||
homePath: '/dashboard',
|
homePath: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -69,6 +69,13 @@ function getEnabledChildren(node: MenuNode): MenuNode[] {
|
|||||||
return node.children.filter((child) => !child.disabled && !child.externalLink);
|
return node.children.filter((child) => !child.disabled && !child.externalLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRoutePath(path?: string): string {
|
||||||
|
if (!path) return '';
|
||||||
|
const purePath = path.split(/[?#]/)[0] || '';
|
||||||
|
if (!purePath || purePath === '/') return purePath;
|
||||||
|
return purePath.startsWith('/') ? purePath : `/${purePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将后端 MenuNode 递归转为 antd Menu 的 items 格式
|
* 将后端 MenuNode 递归转为 antd Menu 的 items 格式
|
||||||
*
|
*
|
||||||
@@ -186,15 +193,35 @@ function getFirstMenuPath(nodes: MenuNode[]): string {
|
|||||||
if (node.disabled || node.externalLink) continue;
|
if (node.disabled || node.externalLink) continue;
|
||||||
|
|
||||||
const enabledChildren = getEnabledChildren(node);
|
const enabledChildren = getEnabledChildren(node);
|
||||||
if (!node.hideInMenu && enabledChildren.length === 0) {
|
if (!node.hideInMenu && node.component) {
|
||||||
return '/' + node.path;
|
return normalizeRoutePath(node.path);
|
||||||
}
|
}
|
||||||
if (enabledChildren.length > 0) {
|
if (enabledChildren.length > 0) {
|
||||||
const childPath = getFirstMenuPath(enabledChildren);
|
const childPath = getFirstMenuPath(enabledChildren);
|
||||||
if (childPath) return childPath;
|
if (childPath) return childPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '/dashboard';
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRoutePath(nodes: MenuNode[], targetPath: string): boolean {
|
||||||
|
const normalizedTarget = normalizeRoutePath(targetPath);
|
||||||
|
if (!normalizedTarget) return false;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.disabled || node.externalLink) continue;
|
||||||
|
|
||||||
|
const currentPath = normalizeRoutePath(node.path);
|
||||||
|
if (currentPath === normalizedTarget && node.component) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children?.length && hasRoutePath(node.children, normalizedTarget)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,7 +257,7 @@ function applyMenuTree(menuTree: MenuNode[]) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 默认重定向到首页
|
// 默认重定向到首页
|
||||||
router.addRoute('BasicLayout', { path: '', redirect: state.homePath });
|
router.addRoute('BasicLayout', { path: '', redirect: state.homePath || '/404' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -258,7 +285,11 @@ export function useMenuStore() {
|
|||||||
state.menuTree = [];
|
state.menuTree = [];
|
||||||
state.menuItems = [];
|
state.menuItems = [];
|
||||||
state.loaded = false;
|
state.loaded = false;
|
||||||
state.homePath = '/dashboard';
|
state.homePath = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRoutePathAvailable = (path?: string) => {
|
||||||
|
return hasRoutePath(state.menuTree, path || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -269,5 +300,6 @@ export function useMenuStore() {
|
|||||||
homePath: computed(() => state.homePath),
|
homePath: computed(() => state.homePath),
|
||||||
loadMenu,
|
loadMenu,
|
||||||
clearMenu,
|
clearMenu,
|
||||||
|
isRoutePathAvailable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { reactive, computed, toRefs } from 'vue';
|
import { reactive, computed, toRefs } from 'vue';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
|
import { useMenuStore } from '@/stores/menuStore';
|
||||||
import type { TabView } from '@/types';
|
import type { TabView } from '@/types';
|
||||||
|
|
||||||
export type { TabView };
|
export type { TabView };
|
||||||
@@ -31,6 +32,9 @@ export function useTabsStore() {
|
|||||||
const addTab = (view: TabView): void => {
|
const addTab = (view: TabView): void => {
|
||||||
if (!view || !view.path || !view.name) return;
|
if (!view || !view.path || !view.name) return;
|
||||||
|
|
||||||
|
const { isRoutePathAvailable } = useMenuStore();
|
||||||
|
if (!isRoutePathAvailable(view.path)) return;
|
||||||
|
|
||||||
if (!state.tabs.some((t) => t.path === view.path)) {
|
if (!state.tabs.some((t) => t.path === view.path)) {
|
||||||
state.tabs.push(view);
|
state.tabs.push(view);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -28,6 +28,8 @@ export interface TabView {
|
|||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** vue-icons-plus 图标名(如 AiOutlineTrophy) */
|
||||||
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 菜单 & 权限 ──
|
// ── 菜单 & 权限 ──
|
||||||
@@ -52,7 +54,7 @@ export interface MenuNode {
|
|||||||
* 留空或省略 → 纯布局节点(自动重定向到第一个子路由)
|
* 留空或省略 → 纯布局节点(自动重定向到第一个子路由)
|
||||||
*/
|
*/
|
||||||
component?: string;
|
component?: string;
|
||||||
/** 菜单图标(antd icon 名,如 DashboardOutlined) */
|
/** 菜单图标(vue-icons-plus 导出名,如 AiOutlineTrophy) */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
/** 排序权重(越小越靠前) */
|
/** 排序权重(越小越靠前) */
|
||||||
sort?: number;
|
sort?: number;
|
||||||
@@ -79,8 +81,9 @@ export interface MenuNode {
|
|||||||
*/
|
*/
|
||||||
export interface MenuItemRaw {
|
export interface MenuItemRaw {
|
||||||
key: string;
|
key: string;
|
||||||
|
/** 解析后的图标 VNode(传给 antd Menu) */
|
||||||
icon?: any;
|
icon?: any;
|
||||||
/** antd icon 名称字符串,用于动态解析图标 */
|
/** vue-icons-plus 图标名(如 AiOutlineTrophy) */
|
||||||
iconName?: string;
|
iconName?: string;
|
||||||
label: string;
|
label: string;
|
||||||
children?: MenuItemRaw[];
|
children?: MenuItemRaw[];
|
||||||
@@ -97,6 +100,7 @@ export type PermissionCode = string;
|
|||||||
declare module 'vue-router' {
|
declare module 'vue-router' {
|
||||||
interface RouteMeta {
|
interface RouteMeta {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** vue-icons-plus 图标名(如 AiOutlineTrophy) */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
/** 是否需要登录 */
|
/** 是否需要登录 */
|
||||||
requiresAuth?: boolean;
|
requiresAuth?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { h, type VNode } from 'vue';
|
||||||
|
import type { IconType } from 'vue-icons-plus/lib';
|
||||||
|
import {
|
||||||
|
AiOutlineAudit,
|
||||||
|
AiOutlineBarChart,
|
||||||
|
AiOutlineHistory,
|
||||||
|
AiOutlineMoneyCollect,
|
||||||
|
AiOutlinePayCircle,
|
||||||
|
AiOutlinePicture,
|
||||||
|
AiOutlineSafetyCertificate,
|
||||||
|
AiOutlineSetting,
|
||||||
|
AiOutlineShoppingCart,
|
||||||
|
AiOutlineTeam,
|
||||||
|
AiOutlineTransaction,
|
||||||
|
AiOutlineTrophy,
|
||||||
|
AiOutlineUnorderedList,
|
||||||
|
AiOutlineUser,
|
||||||
|
AiOutlineWallet,
|
||||||
|
} from 'vue-icons-plus/ai';
|
||||||
|
|
||||||
|
/** 路由 / 菜单 / 标签页使用的图标名 → vue-icons-plus 组件 */
|
||||||
|
const ROUTE_ICON_MAP: Record<string, IconType> = {
|
||||||
|
AiOutlineTrophy,
|
||||||
|
AiOutlineUnorderedList,
|
||||||
|
AiOutlineShoppingCart,
|
||||||
|
AiOutlineTeam,
|
||||||
|
AiOutlinePicture,
|
||||||
|
AiOutlineHistory,
|
||||||
|
AiOutlineTransaction,
|
||||||
|
AiOutlineMoneyCollect,
|
||||||
|
AiOutlineWallet,
|
||||||
|
AiOutlinePayCircle,
|
||||||
|
AiOutlineBarChart,
|
||||||
|
AiOutlineSetting,
|
||||||
|
AiOutlineUser,
|
||||||
|
AiOutlineSafetyCertificate,
|
||||||
|
AiOutlineAudit,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteIconName = keyof typeof ROUTE_ICON_MAP;
|
||||||
|
|
||||||
|
export interface RouteIconOptions {
|
||||||
|
size?: number | string;
|
||||||
|
class?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将路由 meta.icon 字符串渲染为图标 VNode */
|
||||||
|
export function renderRouteIcon(iconName?: string, options: RouteIconOptions = {}): VNode | null {
|
||||||
|
if (!iconName) return null;
|
||||||
|
|
||||||
|
const Icon = ROUTE_ICON_MAP[iconName];
|
||||||
|
if (!Icon) {
|
||||||
|
console.warn(`[RouteIcon] 未注册的图标: ${iconName}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { size = 14, class: className, color } = options;
|
||||||
|
return h(Icon, {
|
||||||
|
size,
|
||||||
|
class: className,
|
||||||
|
...(color ? { color } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user