11 Commits

Author SHA1 Message Date
chenzhen 715666b5b6 Merge pull request 'feature/0723/ZR' (#4) from feature/0723/ZR into release/0.01
Reviewed-on: #4
2026-07-29 09:48:58 +08:00
ZhuRui 8439f64c40 fix: ref写法优化 路由跳转逻辑优化 部分样式优化 2026-07-29 09:32:14 +08:00
ZhuRui a4dab891cf feat: 改进页面标签组件 2026-07-28 18:04:53 +08:00
ZhuRui da90d7c356 feat: 添加图标库,更新菜单与页面标签中的icon相关功能与样式 2026-07-28 17:40:35 +08:00
ZhuRui 202f4bbd44 feat: 柱形图优化 2026-07-28 16:53:20 +08:00
ZhuRui 3d6aa6026c feat: tag样式优化 2026-07-28 16:42:10 +08:00
ZhuRui 28e339a048 fix: 处理样式冗余问题 2026-07-28 16:14:20 +08:00
ZhuRui 49dbde11e0 feat: 页面切换动画 2026-07-28 15:24:42 +08:00
ZhuRui a7399c997d Merge remote-tracking branch 'origin/release/0.01' into feature/0723/ZR 2026-07-28 14:52:53 +08:00
ZhuRui aaa3710314 feat: 页面标签组件优化 2026-07-28 14:44:11 +08:00
ZhuRui 880f986b8a feat: 页面标签组件样式更新 2026-07-28 14:20:04 +08:00
46 changed files with 1257 additions and 822 deletions
+1
View File
@@ -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": {
+35 -3
View File
@@ -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
+29
View File
@@ -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;
}
+35
View File
@@ -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;
}
}
+37
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
export { default as StatusTag } from './StatusTag';
export type { StatusTagTone } from './StatusTag';
+3 -1
View File
@@ -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';
+203 -138
View File
@@ -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;
} }
} }
+164 -46
View File
@@ -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>
); );
}, },
+19 -8
View File
@@ -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
View File
@@ -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();
+29 -16
View File
@@ -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
View File
@@ -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
-21
View File
@@ -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%;
}
}
}
}
+33 -36
View File
@@ -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>
+19 -20
View File
@@ -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 {
-21
View File
@@ -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%;
}
}
}
}
+31 -34
View File
@@ -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}>
-40
View File
@@ -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%;
}
}
}
}
+46 -43
View File
@@ -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>
+16 -15
View File
@@ -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 });
}, },
}, },
]; ];
-21
View File
@@ -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%;
}
}
}
}
+28 -31
View File
@@ -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>
+8 -7
View File
@@ -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 },
+2 -2
View File
@@ -101,10 +101,10 @@
font-size: 14px; font-size: 14px;
} }
// ===== 柱状图(echarts ===== // ===== 组合图(echarts =====
.chartEcharts { .chartEcharts {
width: 100%; width: 100%;
height: 300px; height: 320px;
} }
// ===== 明细表卡(按内容自适应高度,不被压缩) ===== // ===== 明细表卡(按内容自适应高度,不被压缩) =====
+57 -15
View File
@@ -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 {
+13 -8
View File
@@ -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);
} }
}; };
-49
View File
@@ -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;
}
}
+37 -44
View File
@@ -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>
+8 -7
View File
@@ -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 = '';
}; };
-41
View File
@@ -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;
}
}
+36 -41
View File
@@ -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>
+15 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}; };
} }
+4
View File
@@ -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
View File
@@ -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;
+64
View File
@@ -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 } : {}),
});
}