initial commit

This commit is contained in:
Mr.April
2026-01-16 22:04:48 +08:00
parent 28cc3ec005
commit c5244e2b75
95 changed files with 27633 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
/**
* 用户登录认证
*/
export function authenticateUser(data: AnyObject) {
return request.post('/auth/login', data)
}
/**
* 手机验证码登录
*/
export function authenticateWithMobile(data: AnyObject) {
return request.post('/auth/login/mobile', data)
}
/**
* 获取登录配置信息
*/
export function getLoginConfig() {
return request.get('/auth/config')
}
/**
* 用户退出登录
*/
export function logoutUser() {
return request.post('/auth/logout')
}
/**
* 用户注册
*/
export function registerUser(data: AnyObject) {
const url = data.pid ? `/auth/register?pid=${data.pid}` : '/auth/register'
return request.post(url, data)
}
/**
* 手机号注册
*/
export function registerWithMobile(data: AnyObject) {
const url = data.pid ? `/auth/register/mobile?pid=${data.pid}` : '/auth/register/mobile'
return request.post(url, data)
}
/**
* 微信授权登录
*/
export function authenticateWithWechat(data: AnyObject) {
return request.post('/auth/wechat', data)
}
/**
* 微信小程序登录
*/
export function authenticateWithWeapp(data: AnyObject) {
return request.post('/auth/weapp', data)
}
/**
* 绑定手机号
*/
export function bindMobile(data: AnyObject) {
const url = data.pid ? `/auth/bind?pid=${data.pid}` : '/auth/bind'
return request.post(url, data)
}
/**
* 生成微信扫码登录二维码
*/
export function generateWechatQrCode() {
return request.post('/auth/wechat/scan')
}
/**
* 检查微信扫码状态
*/
export function checkWechatScanStatus(data: AnyObject) {
return request.get('/auth/wechat/scan/status', data)
}
/**
* 检查微信登录可用性
*/
export function checkWechatAvailability() {
return request.get('/auth/wechat/availability')
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 获取会员基本信息
*/
export function getMemberProfile() {
return request.get('/member/profile')
}
/**
* 更新会员信息
*/
export function updateMemberInfo(data: AnyObject) {
return request.put(`/member/profile/${data.field}`, data)
}
/**
* 获取积分流水记录
*/
export function getPointTransactions(data: AnyObject) {
return request.get('/member/points/transactions', data)
}
/**
* 获取会员积分总额
*/
export function getMemberPointsTotal() {
return request.get('/member/points/total')
}
/**
* 获取余额流水记录
*/
export function getBalanceTransactions(data: AnyObject) {
return request.get('/member/balance/transactions', data)
}
/**
* 获取所有余额流水记录
*/
export function getAllBalanceTransactions(data: AnyObject) {
return request.get('/member/balance/transactions/all', data)
}
/**
* 绑定会员手机号
*/
export function bindMemberMobile(data: AnyObject) {
return request.put('/member/mobile', data)
}
/**
* 获取会员等级信息
*/
export function getMemberLevel() {
return request.get('/member/level')
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 获取验证码图片
*/
export function getCaptchaImage() {
return request.get('/system/captcha', { time: Date.now() })
}
/**
* 获取微信授权码
*/
export function getWechatAuthCode(data: AnyObject) {
return request.get('/system/wechat/auth-code', data)
}
/**
* 同步微信用户信息
*/
export function syncWechatUserInfo(data: AnyObject) {
return request.get('/system/wechat/sync', data)
}
/**
* 获取协议内容
*/
export function getAgreementContent(key: string) {
return request.get(`/system/agreement/${key}`)
}
/**
* 重置用户密码
*/
export function resetUserPassword(data: AnyObject) {
return request.post('/system/password/reset', data)
}
/**
* 发送短信验证码
*/
export function sendSmsVerificationCode(data: AnyObject) {
return request.post(`/system/sms/${data.type}`, data)
}
/**
* 获取微信 JSSDK 配置
*/
export function getWechatJssdkConfig(data: AnyObject) {
return request.get('/system/wechat/jssdk-config', data)
}
/**
* 获取远程图片
*/
export function fetchRemoteImage(data: AnyObject) {
return request.post('/system/image/fetch', data)
}
/**
* 获取 Base64 格式图片
*/
export function fetchBase64Image(data: AnyObject) {
return request.post('/system/image/base64', data)
}
/**
* 获取版权信息
*/
export function getCopyrightInfo() {
return request.get('/system/copyright')
}
/**
* 获取站点信息
*/
export function getSiteInformation() {
return request.get('/system/site')
}
/**
* 获取广告位信息
*/
export function getAdvertisementInfo(params: Record<string, any>) {
return request.get('/system/web/advertisements', params, { showErrorMessage: false })
}
/**
* 获取导航菜单列表
*/
export function getNavigationList() {
return request.get('/system/web/navigation')
}
/**
* 获取友情链接列表
*/
export function getFriendlyLinks() {
return request.get('/system/web/friendly-links')
}
+26
View File
@@ -0,0 +1,26 @@
<template>
<div>
<el-config-provider :value-on-clear="() => null" :locale="locale">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</el-config-provider>
</div>
</template>
<script setup lang="ts">
//@ts-ignore
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
//@ts-ignore
import en from 'element-plus/dist/locale/en.mjs'
import type { Language } from 'element-plus/es/locale'
// modules import mark, Please do not remove.
const systemStore = useSystemStore()
const locale = computed(() => (systemStore.lang === 'zh-cn' ? zhCn : en))
</script>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="200" width="200" viewBox="0 0 24 24" class="dark-icon">
<path d="M11.01 3.05C6.51 3.54 3 7.36 3 12a9 9 0 0 0 9 9c4.63 0 8.45-3.5 8.95-8c.09-.79-.78-1.42-1.54-.95A5.403 5.403 0 0 1 11.1 7.5c0-1.06.31-2.06.84-2.89c.45-.67-.04-1.63-.93-1.56z" fill="currentColor" /></svg>

After

Width:  |  Height:  |  Size: 527 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768551179331" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4278" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M549.12 643.008L440.768 535.936l1.28-1.28A747.52 747.52 0 0 0 600.32 256h125.056V170.688H426.688V85.376H341.376v85.312H42.688v84.928h476.608A670.4 670.4 0 0 1 384 484.288a667.52 667.52 0 0 1-98.56-142.912H200.128a749.312 749.312 0 0 0 127.168 194.56L110.08 750.08l60.608 60.544L384 597.376l132.736 132.672 32.384-87.04z m240.256-216.32H704l-192 512h85.376l47.744-128h202.688l48.192 128h85.376l-192-512z m-111.808 298.688l69.12-184.768 69.12 184.768h-138.24z" p-id="4279"></path></svg>

After

Width:  |  Height:  |  Size: 817 B

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="200" width="200" viewBox="0 0 24 24" class="light-icon">
<path
d="M6.05 4.14l-.39-.39a.993.993 0 0 0-1.4 0l-.01.01a.984.984 0 0 0 0 1.4l.39.39c.39.39 1.01.39 1.4 0l.01-.01a.984.984 0 0 0 0-1.4zM3.01 10.5H1.99c-.55 0-.99.44-.99.99v.01c0 .55.44.99.99.99H3c.56.01 1-.43 1-.98v-.01c0-.56-.44-1-.99-1zm9-9.95H12c-.56 0-1 .44-1 .99v.96c0 .55.44.99.99.99H12c.56.01 1-.43 1-.98v-.97c0-.55-.44-.99-.99-.99zm7.74 3.21c-.39-.39-1.02-.39-1.41-.01l-.39.39a.984.984 0 0 0 0 1.4l.01.01c.39.39 1.02.39 1.4 0l.39-.39a.984.984 0 0 0 0-1.4zm-1.81 15.1l.39.39a.996.996 0 1 0 1.41-1.41l-.39-.39a.993.993 0 0 0-1.4 0c-.4.4-.4 1.02-.01 1.41zM20 11.49v.01c0 .55.44.99.99.99H22c.55 0 .99-.44.99-.99v-.01c0-.55-.44-.99-.99-.99h-1.01c-.55 0-.99.44-.99.99zM12 5.5c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6s-2.69-6-6-6zm-.01 16.95H12c.55 0 .99-.44.99-.99v-.96c0-.55-.44-.99-.99-.99h-.01c-.55 0-.99.44-.99.99v.96c0 .55.44.99.99.99zm-7.74-3.21c.39.39 1.02.39 1.41 0l.39-.39a.993.993 0 0 0 0-1.4l-.01-.01a.996.996 0 0 0-1.41 0l-.39.39c-.38.4-.38 1.02.01 1.41z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768524866566" class="icon" viewBox="0 0 1052 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1221" xmlns:xlink="http://www.w3.org/1999/xlink" width="205.46875" height="200"><path d="M1017.752 1024H983.04l-69.424-69.424h104.136a34.712 34.712 0 0 1 0 69.424zM983.04 798.373H826.837v69.424L687.989 728.949H983.04V69.424H63.176v34.712L0 40.96A69.424 69.424 0 0 1 63.176 0H983.04a69.424 69.424 0 0 1 69.424 69.424v659.525a69.424 69.424 0 0 1-69.424 69.424zM826.837 607.458h-260.34L219.38 260.339v-69.424h607.458v416.543z" p-id="1222"></path></svg>

After

Width:  |  Height:  |  Size: 699 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768524782127" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1075" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M850.6368 850.6368l77.3632 0.128v77.2352h-77.3632v-77.3632z m-154.8032 0l77.3632 0.128v77.2352H723.2l-27.3664-27.3664v-49.9968z m77.568-309.6064v77.3376h77.3632v-77.3376h77.2352v232.1664h-232.1664V695.808H618.496v127.4368l-77.3632-77.3632v-204.8768h232.2944z m-309.632 0v127.5392l-127.5648-127.5648h127.5648zM928 76.8v386.9696H541.0304V76.8H928zM463.7696 76.8v386.9696h-204.8l-77.568-77.568h204.8V154.0352H154.0352v204.8L76.8 281.6V76.8h386.9696z m386.9952 77.2352h-232.192v232.192h232.192v-232.192z m-541.9008 77.568v77.3632H231.6032V231.6032h77.2608z m464.3328 0v77.3632H695.808V231.6032h77.3632z" fill="#000000" p-id="1076"></path></svg>

After

Width:  |  Height:  |  Size: 973 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768534060667" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2308" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M761.61061 291.378506c-9.413135 0-18.204364 3.626784-24.845082 10.262471L459.729123 578.861539 327.677545 446.738513c-6.641725-6.641725-15.432953-10.268509-24.845082-10.268509-9.34068 0-18.132915 3.626784-24.77464 10.268509-6.641725 6.642731-10.268509 15.432953-10.268509 24.77464 0 9.345712 3.626784 18.13694 10.268509 24.778665l156.898673 157.020438c6.641725 6.580339 15.432953 10.268509 24.773634 10.268509 9.35175 0 18.142978-3.68817 24.845082-10.268509l301.809033-302.054575c6.642731-6.576314 10.268509-15.366536 10.268509-24.77464 0-9.346718-3.626784-18.259712-10.268509-24.841057C779.742519 295.00529 770.951291 291.378506 761.61061 291.378506zM773.100794 94.579168l-2.027739 0c-109.742427 0-201.841011-12.483424-259.26476-78.142911-57.366389 65.659488-149.89266 78.142911-260.06378 78.142911C76.277191 94.579168 71.467978 270.042468 71.467978 270.042468c0 98.063054-0.061386 191.573508-0.061386 394.215551 0 113.735512 272.008821 321.109281 420.604332 356.400992 12.726954 3.008903 25.705488 3.008903 38.429422 0.061386 149.70649-35.048181 421.449642-241.31097 421.449642-356.401998L951.889989 270.103854C951.889989 142.22348 889.485952 94.579168 773.100794 94.579168zM888.935494 664.318399c0 65.293187-215.615546 247.458591-353.329697 289.998838-15.800261 4.859529-32.894652 4.859529-48.635539 0C349.131322 911.594845 134.362093 729.422397 134.362093 664.259026l-0.906696-267.93523 0.906696-124.248558c0.366301-11.684404 6.10133-114.54057 117.383429-114.54057 92.832193 0 186.958516-7.930823 260.06378-57.6069 73.039853 49.676076 167.166176 57.6069 261.291492 57.6069 88.778728 0 115.8347 26.3123 115.8347 112.569185L888.935494 664.318399z" p-id="2309"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

+127
View File
@@ -0,0 +1,127 @@
@use 'sass:map';
@use 'mixins' as *;
@use 'var' as *;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
outline: none !important;
}
html,
body,
#app {
width: 100%;
height: 100%;
font-family:
Helvetica Neue,
Helvetica,
PingFang SC,
Hiragino Sans GB,
Microsoft YaHei,
SimSun,
sans-serif;
font-weight: 400;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
background-color: var(--ma-bg-color);
color: var(--el-text-color-primary);
font-size: var(--el-font-size-base);
}
.w100 {
width: 100% !important;
}
.h100 {
height: 100% !important;
}
.-center {
display: flex;
align-items: center;
justify-content: center;
}
/* 鼠标置入浮动效果-s */
.suspension {
transition: all 0.3s ease;
}
.suspension:hover {
-webkit-transform: translateY(-4px) scale(1.02);
-moz-transform: translateY(-4px) scale(1.02);
-ms-transform: translateY(-4px) scale(1.02);
-o-transform: translateY(-4px) scale(1.02);
transform: translateY(-4px) scale(1.02);
-webkit-box-shadow: 0 14px 24px rgba(0, 0, 0, 0.2);
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.2);
z-index: 999;
border-radius: 6px;
}
/* 鼠标置入浮动效果-e */
/* 输入组件-s */
.block-help {
display: block;
width: 100%;
color: #909399;
font-size: 13px;
line-height: 16px;
padding-top: 5px;
}
.img-preview-dialog .el-dialog__body {
display: flex;
align-items: center;
justify-content: center;
img {
max-width: 100%;
}
}
/* 输入组件-e */
/* 布局相关-s */
.layouts-main {
min-height: calc(100vh - 120px);
}
.user-views {
padding: 0 15px;
.user-views-card {
margin-bottom: 15px;
}
}
.fallback-loading {
display: flex;
align-items: center;
justify-content: center;
}
.ma-aside-drawer {
.el-drawer__body {
padding: 0;
}
}
/* 布局相关-e */
/* 暗黑模式公共样式-s */
.ma-icon-dark {
color: var(--el-text-color-primary) !important;
}
/* 暗黑模式公共样式-e */
/* 自适应-s */
@media screen and (max-width: 768px) {
.xs-hidden {
display: none;
}
}
/* 自适应-e */
/* 全局遮罩-s */
.ma-layout-shade {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999990;
}
/* 全局遮罩-e */
+25
View File
@@ -0,0 +1,25 @@
@use 'sass:map';
@use 'mixins' as *;
// Dark theme variables
$dark-bg-color: () !default;
$dark-bg-color: map.merge(
(
'': #141414,
'overlay': #1d1e1f,
),
$dark-bg-color
);
$dark-border-color: () !default;
$dark-border-color: map.merge(
(
'': #58585b,
),
$dark-border-color
);
html.dark {
@include set-component-css-var('bg-color', $dark-bg-color);
@include set-component-css-var('border-color', $dark-border-color);
}
+90
View File
@@ -0,0 +1,90 @@
.el-menu {
user-select: none;
.el-sub-menu__title:hover {
background-color: var(--el-color-primary-light-9) !important;
}
}
.el-table {
--el-table-border-color: var(--ba-border-color);
}
.el-card {
border: none;
.el-card__header {
border-bottom: 1px solid var(--el-border-color-extra-light);
}
}
.el-divider__text.is-center {
transform: translateX(-50%) translateY(-62%);
}
/* 修复 Chrome 浏览器输入框内选中字符行高异常的问题开始 <<< */
.el-input {
.el-input__inner {
line-height: calc(var(--el-input-height, 40px) - 4px);
}
}
/* 修复 Chrome 浏览器输入框内选中字符行高异常的问题结束 >>> */
/* 输入框样式统一开始 <<< */
.el-input-number.is-controls-right {
.el-input__wrapper {
padding-left: 11px;
}
.el-input__inner {
text-align: left;
}
}
.el-textarea__inner {
padding: 5px 11px;
}
.datetime-picker {
height: 32px;
padding-top: 0;
padding-bottom: 0;
}
.w100 .el-input__wrapper {
width: 100%;
}
/* 输入框样式统一结束 >>> */
/* dialog 滚动条样式优化开始 <<< */
.el-overlay-dialog,
.ma-scroll-style {
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #eaeaea;
border-radius: var(--el-border-radius-base);
box-shadow: none;
-webkit-box-shadow: none;
}
&:hover {
&::-webkit-scrollbar-thumb:hover {
background: #c8c9cc;
}
}
}
@supports not (selector(::-webkit-scrollbar)) {
.el-overlay-dialog,
.ba-scroll-style {
scrollbar-width: thin;
scrollbar-color: #c8c9cc #eaeaea;
}
}
/* dialog 滚动条样式优化结束 >>> */
/* 小屏设备 el-radio-group 样式优化开始 <<< */
.ma-input-item-radio {
margin-bottom: 10px;
.el-radio-group {
.el-radio {
margin-bottom: 8px;
}
}
}
/* 小屏设备 el-radio-group 样式优化结束 >>> */
+6
View File
@@ -0,0 +1,6 @@
@forward 'element-plus/theme-chalk/src/dark/var.scss' with (
$bg-color: (
'page': #0a0a0a,
'overlay': #1d1e1f,
)
);
+26
View File
@@ -0,0 +1,26 @@
$-colors: (
'primary': (
'base': rgba(107,33,168, 1),
),
'success': (
'base': green,
),
'warning': (
'base': #f9a23c,
),
'danger': (
'base': #ff3300,
),
'error': (
'base': #f56c6c,
),
'info': (
'base': #909399,
),
);
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: $-colors
);
@use './dark.scss';
+4
View File
@@ -0,0 +1,4 @@
@forward 'app';
@forward 'dark';
@forward 'element';
@forward 'var';
+30
View File
@@ -0,0 +1,30 @@
@mixin set-css-var-value($name, $value) {
#{joinVarName($name)}: #{$value};
}
@function joinVarName($list) {
$name: '--ma';
@each $item in $list {
@if $item != '' {
$name: $name + '-' + $item;
}
}
@return $name;
}
@function getCssVarName($args...) {
@return joinVarName($args);
}
/*
* 通过映射设置所有的CSS变量
*/
@mixin set-component-css-var($name, $variables) {
@each $attribute, $value in $variables {
@if $attribute == 'default' {
#{getCssVarName($name)}: #{$value};
} @else {
#{getCssVarName($name, $attribute)}: #{$value};
}
}
}
+25
View File
@@ -0,0 +1,25 @@
@use 'sass:map';
@use 'mixins' as *;
// Light theme variables
$light-bg-color: () !default;
$light-bg-color: map.merge(
(
'': #f5f5f5,
'overlay': #ffffff,
),
$light-bg-color
);
$light-border-color: () !default;
$light-border-color: map.merge(
(
'': #f6f6f6,
),
$light-border-color
);
:root {
@include set-component-css-var('bg-color', $light-bg-color);
@include set-component-css-var('border-color', $light-border-color);
}
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import type { CSSProperties } from 'vue'
export default defineComponent({
name: 'Icon',
props: {
name: {
type: String,
required: true,
},
size: {
type: [String, Number],
default: '18px',
},
color: {
type: String,
default: '#000000',
},
attr: {
type: Object,
default: () => {},
},
},
setup(props:any) {
// 添加isExternal函数定义
const isExternal = (path: string): boolean => {
return /^(https?:|mailto:|tel:)/.test(path)
}
const iconStyle = computed((): CSSProperties => {
const { size, color } = props
// 修复:添加类型检查,确保size是字符串才能调用replace方法
let fontSize = typeof size === 'string' ? `${size.replace('px', '')}px` : `${size}px`
return {
fontSize: fontSize,
color: color,
}
})
if (props.name.startsWith('el-icon-')) {
// 提取去掉前缀后的组件名
const iconName = props.name.replace('el-icon-', '')
return () =>
h(resolveComponent('el-icon'), { class: [props.name, 'icon'], style: iconStyle.value, ...props.attr }, () =>
h(resolveComponent(iconName))
)
} else if (props.name.startsWith('local-') || isExternal(props.name)) {
const name = props.name.replace('local-', '')
return () => h(resolveComponent('nuxt-icon'), { class: [props.name, 'icon'], name: name, style: iconStyle.value, ...props.attr })
} else {
// 处理fa前缀和其他普通图标
return () => h('i', { class: [props.name, 'icon'], style: iconStyle.value, ...props.attr })
}
},
})
</script>
+80
View File
@@ -0,0 +1,80 @@
<template>
<div class="loginPopup">
<el-dialog
v-model="dialogVisible"
align-center
width="450"
:before-close="beforeClose"
custom-class="login !rounded-[var(--rounded-big)]"
:show-close="false"
append-to-body
>
<login v-if="type === 'login' && dialogVisible" @typeChange="typeChange" />
<register v-if="type === 'register' && dialogVisible" @typeChange="typeChange" />
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from "vue";
import login from "./login.vue";
import register from "./register.vue";
const type = ref("login");
const memberStore = useMemberStore();
//弹框状态
const dialogVisible = computed(() => {
return memberStore.loginPopup;
});
//弹框关闭
const beforeClose = (next: any) => {
memberStore.logClose();
type.value = "login";
next();
};
const handleClose = () => {
memberStore.logClose();
type.value = "login";
};
//判断当前是登录还是注册
const typeChange = (val: any) => {
type.value = val;
};
</script>
<style>
.login .el-dialog__header {
padding: 0 !important;
}
.login .el-dialog__body {
padding: 0 !important;
}
.login .el-dialog__headerbtn {
z-index: 99;
}
</style>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-input__wrapper {
box-shadow: unset !important;
border-radius: 0;
&.is-focus {
border: none;
}
}
&.is-error {
.el-input__wrapper {
border: none;
}
}
}
:deep(.el-form-item__error) {
padding-top: 5px;
}
.text-color {
color: var(--el-color-primary);
}
</style>
+649
View File
@@ -0,0 +1,649 @@
<template>
<div class="login-container" :class="{'dark-mode': isDarkMode}">
<!-- 登录方式切换图标 -->
<div class="login-mode-toggle">
<Icon
v-if="isAccountLoginActive"
@click="toggleLoginMode"
name="local-qr"
:color="isDarkMode ? '#ffffff' : '#000000'"
size="50px"
class="login-toggle-icon"
/>
<Icon
v-else
@click="toggleLoginMode"
name="local-pc"
:color="isDarkMode ? '#ffffff' : '#000000'"
size="50px"
class="login-toggle-icon"
/>
</div>
<!-- 账号密码登录表单 -->
<div v-if="isAccountLoginActive" class="login-form-container">
<div class="login-type-tabs">
<div
v-for="(item, index) in availableLoginTypes"
:key="item.type"
class="login-type-tab"
:class="{
'login-type-tab--active': currentLoginType === item.type,
'login-type-tab--margin': index + 1 !== availableLoginTypes.length,
}"
@click="currentLoginType = item.type"
>
{{ item.title }}
</div>
</div>
<el-form
:model="formData"
ref="formRef"
:rules="formRules"
:validate-on-rule-change="false"
class="login-form"
>
<!-- 用户名密码登录 -->
<div v-show="currentLoginType === 'username'" class="login-form-section">
<el-form-item prop="username" class="form-item">
<div class="input-container">
<el-input
v-model="formData.username"
:placeholder="t('username_placeholder')"
clearable
:inline-message="true"
:readonly="isUsernameInputReadonly"
@click="isUsernameInputReadonly = false"
@blur="isUsernameInputReadonly = true"
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-User"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="password" class="form-item">
<div class="input-container">
<el-input
v-model="formData.password"
:placeholder="t('password_placeholder')"
type="password"
clearable
show-password
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-Lock"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 手机号登录 -->
<div v-show="currentLoginType === 'mobile'" class="login-form-section">
<el-form-item prop="mobile" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobile"
:placeholder="t('mobile_placeholder')"
clearable
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-Phone"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="mobile_code" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobile_code"
:placeholder="t('code_placeholder')"
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-Key"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
<template #suffix>
<sms-code
:mobile="formData.mobile"
type="login"
v-model="formData.mobile_key"
@click="handleSendSmsCode"
ref="smsCodeRef"
></sms-code>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 注册链接 -->
<div class="form-actions">
<el-button type="primary" link @click="switchToRegister" class="register-link">
{{ t("no_account") }},{{ t("to_register") }}
</el-button>
</div>
<!-- 登录按钮 -->
<div class="login-button-container">
<el-button
type="primary"
class="login-button"
size="large"
@click="handleLoginSubmit"
:loading="isLoading"
>
{{ isLoading ? t("logining") : t("login") }}
</el-button>
</div>
<!-- 协议同意 -->
<div v-if="configStore.login.agreement_show" class="agreement-container">
<span
class="iconfont agreement-checkbox"
:class="isAgreeProtocol ? 'icon-xuanze1' : 'icon-checkbox_nol'"
@click="isAgreeProtocol = !isAgreeProtocol"
></span>
{{ t("agree_tips") }}
<NuxtLink :to="protocolUrls.serviceUrl" target="_blank">
<span class="agreement-link">{{ t("user_agreement") }}</span>
</NuxtLink>
{{ t("and") }}
<NuxtLink :to="protocolUrls.privacyUrl" target="_blank">
<span class="agreement-link">{{ t("privacy_agreement") }}</span>
</NuxtLink>
</div>
</el-form>
</div>
<!-- 微信扫码登录 -->
<div v-else class="wechat-login-container">
<div class="wechat-login-title">{{ t("wechat_scan_login") }}</div>
<div class="qrcode-container">
<div class="qrcode-wrapper">
<el-image v-if="wechatQrCode.url" :src="wechatQrCode.url" class="qrcode-image" />
<div v-else class="qrcode-placeholder"></div>
<div v-if="wechatQrCode.isExpired" class="qrcode-overlay">
<span class="qrcode-error-text">{{ wechatQrCode.expiredMessage }}</span>
<span @click="handleGenerateWechatQrCode" class="qrcode-refresh-link">{{ t("click_refresh") }}</span>
</div>
</div>
<div class="wechat-tip">
<span class="iconfont icon-weixin1 wechat-icon"></span>
<span class="wechat-text">{{ t("wechat_scan_tip") }}</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onUnmounted } from "vue";
import { authenticateUser, authenticateWithMobile, authenticateWithWechat,generateWechatQrCode, checkWechatScanStatus } from "@/api/auth";
import useConfigStore from "@/stores/config";
import QRCode from "qrcode";
import type { FormInstance } from "element-plus";
// Store 实例
const memberStore = useMemberStore();
const configStore = useConfigStore();
// 暗黑模式状态
const isDarkMode = computed(() => {
return configStore.isDarkMode;
});
// 协议链接配置
const protocolUrls = computed(() => {
const basePath = location.pathname.includes("web") ? "/web/auth/agreement" : "/auth/agreement";
return {
serviceUrl: `${basePath}?key=service`,
privacyUrl: `${basePath}?key=privacy`,
};
})as any;
// 登录模式状态
const isAccountLoginActive = ref(true);
const currentLoginType = ref("");
let qrCodeTimer: any = null;
// 微信扫码登录状态
const wechatQrCode = ref({
url: "",
key: "",
isExpired: false,
expiredMessage: t("qrcode_generate_failed"),
});
// 表单数据
const isLoading = ref(false);
const isAgreeProtocol = ref(false);
const isUsernameInputReadonly = ref(true);
const formData = reactive({
username: "",
password: "",
mobile: "",
mobile_code: "",
mobile_key: "",
});
// 表单引用
const formRef = ref<FormInstance>();
const smsCodeRef = ref<AnyObject | null>(null);
// 计算属性
const availableLoginTypes = computed(() => {
const types = [];
if (configStore.login.is_username) {
types.push({ type: "username", title: t("username_login") });
}
if (configStore.login.is_mobile) {
types.push({ type: "mobile", title: t("mobile_login") });
}
currentLoginType.value = types[0] ? types[0].type : "";
return types;
});
const formRules = computed(() => ({
username: {
required: currentLoginType.value === "username",
message: t("username_placeholder"),
trigger: ["blur", "change"],
},
password: {
required: currentLoginType.value === "username",
message: t("password_placeholder"),
trigger: ["blur", "change"],
},
mobile: [
{
required: currentLoginType.value === "mobile",
message: t("mobile_placeholder"),
trigger: ["blur", "change"],
},
{
validator(rule: any, value: string, callback: any) {
if (currentLoginType.value !== "mobile") return true;
return validate.mobile(value);
},
message: t("mobile_error"),
trigger: ["blur"],
},
],
mobile_code: {
required: currentLoginType.value === "mobile",
message: t("code_placeholder"),
trigger: ["change"],
},
}));
// 生命周期
onUnmounted(() => {
clearTimeout(qrCodeTimer);
});
// 初始化配置
configStore.getLoginConfig();
// 登录模式切换
const toggleLoginMode = () => {
isAccountLoginActive.value = !isAccountLoginActive.value;
if (!isAccountLoginActive.value) {
handleGenerateWechatQrCode();
} else {
clearTimeout(qrCodeTimer);
}
};
// 微信扫码登录相关方法
const handleGenerateWechatQrCode = async () => {
try {
const response = await generateWechatQrCode() as any;
const data = response.data;
wechatQrCode.value.key = data.key;
if (data.url) {
const qrCodeUrl = await QRCode.toDataURL(data.url, {
errorCorrectionLevel: "L",
margin: 0,
width: 100,
});
wechatQrCode.value.url = qrCodeUrl;
wechatQrCode.value.isExpired = false;
setTimeout(() => {
checkScan(wechatQrCode.value.key);
}, 1000);
}
} catch (error) {
wechatQrCode.value.isExpired = true;
wechatQrCode.value.expiredMessage = t("qrcode_generate_failed");
}
};
/**
* 检查微信扫码登录状态
* @param key - 扫码登录密钥
*/
const checkScan = (key: string) => {
checkWechatScanStatus({ key })
.then((res: any) => {
const data = res.data;
switch (data.status) {
case "wait":
qrCodeTimer = setTimeout(() => {
checkScan(wechatQrCode?.value.key);
}, 1000);
break;
case "success":
handleWechatLoginSuccess(data.login_data);
break;
case "fail":
wechatQrCode.value.expiredMessage = data.fail_reason;
wechatQrCode.value.isExpired = true;
break;
}
})
.catch((error) => {
wechatQrCode.value.isExpired = true;
wechatQrCode.value.expiredMessage = error.msg || t("scan_check_failed");
});
};
const handleWechatLoginSuccess = (loginData: any) => {
if (!loginData.token) {
useCookie("openId").value = loginData.openid;
navigateTo("/auth/bind");
memberStore.logClose();
} else {
memberStore.setToken(loginData.token);
memberStore.logClose();
}
};
// 表单处理方法
const handleLoginSubmit = async () => {
const isValid = await formRef.value?.validate();
if (!isValid) return;
if (configStore.login.agreement_show && !isAgreeProtocol.value) {
ElMessage.error(t("is_agree_tips"));
return;
}
if (isLoading.value) return;
isLoading.value = true;
try {
const loginMethod = currentLoginType.value === "username" ? authenticateUser : authenticateWithMobile;
const response = await loginMethod(formData) as any;
await memberStore.setToken(response?.data?.token || "");
memberStore.logClose();
} catch (error) {
isLoading.value = false;
}
};
const handleSendSmsCode = async () => {
const isValid = await formRef.value?.validateField("mobile");
if (isValid) {
smsCodeRef.value?.send();
}
};
// 组件通信
const emit = defineEmits(["typeChange"]);
const switchToRegister = () => {
emit("typeChange", "register");
};
</script>
<style lang="scss" scoped>
.login-container {
position: relative;
width: 100%;
&.dark-mode {
background: var(--el-bg-color);
color: var(--el-text-color-primary);
}
}
.login-mode-toggle {
position: absolute;
top: 0;
right: 0;
}
.login-toggle-icon {
font-size: 50px;
cursor: pointer;
}
.login-form-container {
background: var(--el-bg-color);
width: 100%;
padding: 60px 30px;
border-radius: var(--rounded-big);
}
.login-type-tabs {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 30px;
}
.login-type-tab {
font-size: 18px;
cursor: pointer;
color: var(--el-text-color-secondary);
line-height: 24px;
font-family: "OppoSans-R";
&--active {
color: var(--el-text-color-primary);
font-weight: 600;
}
&--margin {
margin-right: 70px;
}
}
.login-form {
width: 100%;
}
.form-item {
margin-bottom: 20px;
}
.input-container {
flex: 1;
height: 50px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
display: flex;
align-items: center;
background: var(--el-bg-color);
}
.login-input {
width: 100%;
border: none;
:deep(.el-input__wrapper) {
box-shadow: none;
background: transparent;
.el-input__inner {
color: var(--el-text-color-primary);
&::placeholder {
color: var(--el-text-color-placeholder);
}
}
}
}
.input-prefix-icon {
margin-right: 14px;
}
.form-actions {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.register-link {
font-size: 12px;
}
.login-button-container {
margin-top: 20px;
}
.login-button {
width: 100%;
height: 50px;
border-radius: 8px;
font-family: "OppoSans-M";
}
.agreement-container {
font-size: 12px;
line-height: 24px;
display: flex;
align-items: center;
width: 100%;
margin-top: 20px;
color: var(--el-text-color-secondary);
}
.agreement-checkbox {
color: var(--el-color-primary);
margin-right: 5px;
cursor: pointer;
}
.agreement-link {
color: var(--el-color-primary);
margin: 0 4px;
&:hover {
color: var(--el-color-primary-light-3);
}
}
.wechat-login-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 30px;
background: var(--el-bg-color);
}
.wechat-login-title {
font-size: 18px;
color: var(--el-text-color-primary);
font-weight: 600;
line-height: 24px;
font-family: "OppoSans-R";
}
.qrcode-container {
margin-top: 30px;
}
.qrcode-wrapper {
padding: 20px;
border: 1px solid var(--el-border-color);
border-radius: var(--rounded-small);
position: relative;
background: var(--el-bg-color);
}
.qrcode-image {
width: 200px;
height: 200px;
}
.qrcode-placeholder {
width: 202px;
height: 202px;
background: var(--el-bg-color-page);
}
.qrcode-overlay {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
inset: 0;
background: var(--el-bg-color-overlay);
}
.qrcode-error-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.qrcode-refresh-link {
font-size: 12px;
cursor: pointer;
color: var(--el-color-primary);
margin-top: 8px;
&:hover {
color: var(--el-color-primary-light-3);
}
}
.wechat-tip {
margin-top: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.wechat-icon {
color: #00c22c;
font-size: 16px;
}
.wechat-text {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-left: 4px;
}
</style>
+520
View File
@@ -0,0 +1,520 @@
<template>
<div class="register-container" :class="{'dark-mode': isDarkMode}">
<div class="register-form-container">
<div class="register-type-tabs">
<div
v-for="(item, index) in registerTypes"
:key="item.type"
class="register-type-tab"
:class="{
'register-type-tab--active': currentType === item.type,
'register-type-tab--margin': index + 1 !== registerTypes.length
}"
@click="currentType = item.type"
>
{{ item.title }}
</div>
</div>
<el-form
:model="formData"
ref="formRef"
:rules="formRules"
:validate-on-rule-change="false"
class="register-form"
>
<!-- 用户名注册 -->
<div v-show="currentType === 'username'" class="register-form-section">
<el-form-item prop="username" class="form-item">
<div class="input-container">
<el-input
v-model="formData.username"
:placeholder="t('username_placeholder')"
clearable
:inline-message="true"
:readonly="realNameInput"
@click="realNameInput = false"
@blur="realNameInput = true"
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-User"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="password" class="form-item">
<div class="input-container">
<el-input
v-model="formData.password"
:placeholder="t('password_placeholder')"
type="password"
clearable
show-password
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Lock"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="confirmPassword" class="form-item">
<div class="input-container">
<el-input
v-model="formData.confirmPassword"
:placeholder="t('confirm_password_placeholder')"
type="password"
clearable
show-password
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Lock"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="captchaCode" class="form-item">
<div class="input-container">
<el-input
v-model="formData.captchaCode"
:placeholder="t('captcha_placeholder')"
class="register-input"
>
<template #prefix>
<Icon
name="local-verify"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
<template #suffix>
<div class="captcha-image-container">
<el-image
:src="captcha.image.value"
class="captcha-image"
@click="captcha.refresh()"
></el-image>
</div>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 手机号注册 -->
<div v-show="currentType === 'mobile' || configStore.login.is_bind_mobile" class="register-form-section">
<el-form-item prop="mobile" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobile"
:placeholder="t('mobile_placeholder')"
clearable
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Phone"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="mobileCode" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobileCode"
:placeholder="t('code_placeholder')"
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Key"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
<template #suffix>
<sms-code
:mobile="formData.mobile"
type="login"
v-model="formData.mobileKey"
@click="sendSmsCode"
ref="smsCodeRef"
></sms-code>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 登录链接 -->
<div class="form-actions">
<el-button type="primary" link @click="switchToLogin" class="login-link">
{{ t('have_account') }},{{ t('to_login') }}
</el-button>
</div>
<!-- 注册按钮 -->
<div class="register-button-container">
<el-button
type="primary"
class="register-button"
size="large"
@click="handleRegister"
:loading="isLoading"
>
{{ isLoading ? t('registering') : t('register') }}
</el-button>
</div>
<!-- 协议同意 -->
<div
v-if="configStore.login.agreement_show"
class="agreement-container"
>
<span
class="iconfont agreement-checkbox"
:class="isAgreeChecked ? 'icon-xuanze1' : 'icon-checkbox_nol'"
@click="toggleAgreement"
></span>
{{ t('register_agree_tips') }}
<NuxtLink :to="protocolUrls.service" target="_blank">
<span class="agreement-link">{{ t('user_agreement') }}</span>
</NuxtLink>
{{ t('and') }}
<NuxtLink :to="protocolUrls.privacy" target="_blank">
<span class="agreement-link">{{ t('privacy_agreement') }}</span>
</NuxtLink>
</div>
</el-form>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { registerUser, registerWithMobile } from '@/api/auth'
import useConfigStore from '@/stores/config'
import type { FormInstance } from 'element-plus'
// 状态管理
const memberStore = useMemberStore()
const configStore = useConfigStore()
// 暗黑模式状态
const isDarkMode = computed(() => {
return configStore.isDarkMode
})
// 初始化配置
configStore.getLoginConfig()
// 协议链接配置
const protocolUrls = computed(() => {
const basePath = location.pathname.includes('web') ? '/web' : ''
return {
service: `${basePath}/auth/agreement?key=service`,
privacy: `${basePath}/auth/agreement?key=privacy`
}
})
// 注册类型配置
const currentType = ref('')
const registerTypes = computed(() => {
const types = []
if (configStore.login.is_username) {
types.push({ type: 'username', title: t('username_register') })
}
if (configStore.login.is_mobile && !configStore.login.is_bind_mobile) {
types.push({ type: 'mobile', title: t('mobile_register') })
}
currentType.value = types[0]?.type || ''
return types
})
// 表单数据
const isLoading = ref(false)
const formData = reactive({
username: '',
password: '',
confirmPassword: '',
mobile: '',
mobileCode: '',
mobileKey: '',
captchaKey: '',
captchaCode: ''
})
// 表单验证规则
const formRef = ref<FormInstance>()
const formRules = computed(() => ({
username: {
type: 'string',
required: currentType.value === 'username',
message: t('username_placeholder'),
trigger: ['blur', 'change'],
},
password: {
type: 'string',
required: currentType.value === 'username',
message: t('password_placeholder'),
trigger: ['blur', 'change']
},
confirmPassword: [
{
type: 'string',
required: currentType.value === 'username',
message: t('confirm_password_placeholder'),
trigger: ['blur', 'change']
},
{
validator(rule: any, value: string, callback: any) {
return value === formData.password
},
message: t('confirm_password_error'),
trigger: ['change', 'blur'],
}
],
mobile: [
{
type: 'string',
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
message: t('mobile_placeholder'),
trigger: ['blur', 'change'],
},
{
validator(rule: any, value: string, callback: any) {
if (currentType.value !== 'mobile' && !configStore.login.is_bind_mobile) return true
return validate.mobile(value)
},
message: t('mobile_error'),
trigger: ['change', 'blur'],
}
],
mobileCode: {
type: 'string',
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
message: t('code_placeholder'),
trigger: ['blur', 'change']
},
captchaCode: {
type: 'string',
required: currentType.value === 'username',
message: t('captcha_placeholder'),
trigger: ['blur', 'change'],
}
}))
// 协议同意状态
const isAgreeChecked = ref(false)
const toggleAgreement = () => {
isAgreeChecked.value = !isAgreeChecked.value
}
// 注册处理
const handleRegister = async () => {
await formRef.value?.validate(async (valid) => {
if (!valid) return
// 检查协议同意
if (configStore.login.agreement_show && !isAgreeChecked.value) {
ElMessage.error(t('is_agree_tips'))
return
}
if (isLoading.value) return
isLoading.value = true
try {
const registerApi = currentType.value === 'username' ? registerUser : registerWithMobile
const res = await registerApi(formData)
memberStore.setToken(res.data.token)
memberStore.logClose()
} catch (error) {
isLoading.value = false
captcha.refresh()
}
})
}
// 验证码功能
const captcha = useCaptcha(formData)
captcha.refresh()
// 短信验证码
const smsCodeRef = ref<AnyObject | null>(null)
const sendSmsCode = async () => {
await formRef.value?.validateField('mobile', (valid) => {
if (valid) {
smsCodeRef.value?.send()
}
})
}
// 切换到登录
const emit = defineEmits(['typeChange'])
const switchToLogin = () => {
emit('typeChange', 'login')
}
// 输入框状态
const realNameInput = ref(true)
</script>
<style lang="scss" scoped>
.register-container {
width: 100%;
&.dark-mode {
background: var(--el-bg-color);
color: var(--el-text-color-primary);
}
}
.register-form-container {
background: var(--el-bg-color);
width: 100%;
padding: 60px 30px;
border-radius: var(--rounded-big);
}
.register-type-tabs {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 30px;
}
.register-type-tab {
font-size: 18px;
cursor: pointer;
color: var(--el-text-color-secondary);
line-height: 24px;
font-family: 'OppoSans-R';
&--active {
color: var(--el-text-color-primary);
font-weight: 600;
}
&--margin {
margin-right: 70px;
}
}
.register-form {
width: 100%;
}
.form-item {
margin-bottom: 20px;
}
.input-container {
flex: 1;
height: 50px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
display: flex;
align-items: center;
background: var(--el-bg-color);
}
.register-input {
width: 100%;
border: none;
:deep(.el-input__wrapper) {
box-shadow: none;
background: transparent;
.el-input__inner {
color: var(--el-text-color-primary);
&::placeholder {
color: var(--el-text-color-placeholder);
}
}
}
}
.input-prefix-icon {
margin-right: 14px;
}
.captcha-image-container {
padding: 0;
line-height: 1;
}
.captcha-image {
height: 30px;
cursor: pointer;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.login-link {
font-size: 12px;
}
.register-button-container {
margin-top: 20px;
}
.register-button {
width: 100%;
height: 50px;
border-radius: 8px;
font-family: 'OppoSans-M';
}
.agreement-container {
font-size: 12px;
line-height: 24px;
display: flex;
align-items: center;
width: 100%;
margin-top: 20px;
color: var(--el-text-color-secondary);
}
.agreement-checkbox {
color: var(--el-color-primary);
margin-right: 5px;
cursor: pointer;
}
.agreement-link {
color: var(--el-color-primary);
margin: 0 4px;
&:hover {
color: var(--el-color-primary-light-3);
}
}
</style>
+268
View File
@@ -0,0 +1,268 @@
<template>
<div class="sms-code-container">
<el-button
type="primary"
link
:disabled="!canSendCode"
@click="handleSendCode"
class="sms-code-button"
>
{{ buttonText }}
</el-button>
</div>
<el-dialog
v-model="showCaptchaDialog"
:title="t('captcha_title')"
width="350px"
:append-to-body="true"
:align-center="true"
class="captcha-dialog"
>
<el-form
:model="captchaForm"
ref="captchaFormRef"
:rules="captchaRules"
@submit.native.prevent
class="captcha-form"
>
<el-form-item prop="captcha_code" class="captcha-form-item">
<el-input
v-model="captchaForm.captcha_code"
:placeholder="t('captcha_placeholder')"
class="captcha-input"
>
<template #suffix>
<div class="captcha-image-wrapper">
<el-image
:src="captchaImage"
class="captcha-image"
@click="refreshCaptcha"
></el-image>
</div>
</template>
</el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="closeCaptchaDialog" class="cancel-button">
{{ t('cancel') }}
</el-button>
<el-button
type="primary"
:loading="isLoading"
@click="confirmSendCode"
class="confirm-button"
>
{{ t('confirm') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { sendSmsVerificationCode } from '@/api/system'
import type { FormInstance } from 'element-plus'
interface SmsCodeProps {
mobile: string
type: string
modelValue: string
}
interface CaptchaFormData {
mobile: string
captcha_code: string
captcha_key: string
type: string
}
const props = defineProps<SmsCodeProps>()
const emit = defineEmits(['update:modelValue', 'click'])
// 响应式数据
const isLoading = ref(false)
const showCaptchaDialog = ref(false)
const canSendCode = ref(true)
const buttonText = ref('')
const seconds = ref(90)
let countdownTimer: number | undefined = undefined
// 验证码相关
const captchaForm = reactive<CaptchaFormData>({
mobile: '',
captcha_code: '',
captcha_key: '',
type: props.type
})
const captchaImage = ref('')
const captchaFormRef = ref<FormInstance>()
// 验证规则
const captchaRules = reactive({
captcha_code: {
required: true,
message: '请输入验证码',
trigger: ['blur', 'change']
}
})
// 计算属性
const smsValue = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
}
})
// 初始化按钮文本
buttonText.value = t('get_sms_code')
// 发送短信验证码
const sendSmsCode = async () => {
captchaForm.mobile = props.mobile
if (!canSendCode.value) return
try {
isLoading.value = true
const response = await sendSmsVerificationCode(captchaForm) as any
if (response?.code !== -1) {
smsValue.value = response.data.key
startCountdown()
closeCaptchaDialog()
} else {
refreshCaptcha()
}
} catch (error) {
refreshCaptcha()
} finally {
isLoading.value = false
}
}
// 开始倒计时
const startCountdown = () => {
canSendCode.value = false
countdownTimer = setInterval(() => {
if (seconds.value > 0) {
seconds.value -= 1
buttonText.value = `${seconds.value}${t('sms_code_change_text')}`
} else {
resetCountdown()
}
}, 1000) as any
}
// 重置倒计时
const resetCountdown = () => {
clearInterval(countdownTimer)
seconds.value = 90
canSendCode.value = true
buttonText.value = t('get_sms_code')
}
// 刷新验证码
const refreshCaptcha = async () => {
try {
// 这里需要调用验证码API,暂时使用空字符串
captchaImage.value = ''
captchaForm.captcha_code = ''
} catch (error) {
console.error('刷新验证码失败:', error)
}
}
// 处理发送验证码点击
const handleSendCode = () => {
emit('click')
showCaptchaDialog.value = true
refreshCaptcha()
}
// 确认发送验证码
const confirmSendCode = async () => {
await captchaFormRef.value?.validate(async (valid) => {
if (valid) {
await sendSmsCode()
}
})
}
// 关闭验证码对话框
const closeCaptchaDialog = () => {
showCaptchaDialog.value = false
captchaForm.captcha_code = ''
}
// 暴露方法给父组件
defineExpose({
send: sendSmsCode
})
</script>
<style lang="scss" scoped>
.sms-code-container {
height: 30px;
display: flex;
align-items: center;
}
.sms-code-button {
font-size: 12px;
padding: 0;
height: auto;
}
.captcha-dialog {
:deep(.el-dialog__header) {
padding: 20px 20px 10px;
}
:deep(.el-dialog__body) {
padding: 10px 20px;
}
}
.captcha-form {
width: 100%;
}
.captcha-form-item {
margin-bottom: 0;
}
.captcha-input {
:deep(.el-input__wrapper) {
padding-right: 0;
}
}
.captcha-image-wrapper {
padding: 5px 0;
line-height: 1;
}
.captcha-image {
height: 30px;
cursor: pointer;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cancel-button,
.confirm-button {
min-width: 80px;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import { ref } from 'vue'
import { getCaptchaImage } from '@/api/system'
interface formData {
captcha_code: string,
captcha_key: string
}
export function useCaptcha(formData: formData) {
const image = ref('')
const refresh = async () => {
try {
await getCaptchaImage().then((res: any) => {
if (res.code == 1) {
formData.captcha_key = res.data.captcha_key
formData.captcha_code = ''
image.value = res.data.img.replace(/\r\n/g, '')
}
})
} catch (e) {
}
}
return {
image,
refresh
}
}
+10
View File
@@ -0,0 +1,10 @@
import { useRoute } from 'vue-router'
import Language from '~/utils/language'
export function t(message: string) {
const i18n = useNuxtApp().$getI18n()
const route = useRoute()
const file = new Language(i18n).getFileKey((route?.meta.plugin || 'app'), route?.path || '')
const key = `${file}.${message}`
return i18n.global.t(key) != key ? i18n.global.t(key) : i18n.global.t(message)
}
+29
View File
@@ -0,0 +1,29 @@
import type { LocationQueryRaw } from 'vue-router'
import storage from '@/utils/storage'
export function useLogin() {
/**
* 设置登录返回页
*/
const setLoginBack = (route: LocationQueryRaw) => {
storage.set({
key: 'loginBack',
data: {
path: route.path,
query: route.query
}
})
}
/**
* 执行登录后跳转
*/
const handleLoginBack = (callbak:any) => {
if(callbak) callbak()
}
return {
setLoginBack,
handleLoginBack
}
}
+60
View File
@@ -0,0 +1,60 @@
import { reactive, ref } from 'vue'
import { sendSmsVerificationCode } from '@/api/system'
export function useSendSms() {
const canGetCode = ref(true),
text = ref(t('get_sms_code')),
seconds = ref(90);
let timer: number | undefined = undefined;
const send = async (param: requestMobileParam) => {
if (!canGetCode.value) return
canGetCode.value = false
let result: string | boolean = false
await sendSmsVerificationCode(param).then((res: any) => {
if (res.code == 1) {
start()
result = res.data.key
} else {
reset()
result = false
}
}).catch(() => {
result = false
reset()
})
return result
}
/**
* 开始
*/
const start = () => {
timer = setInterval(() => {
if (seconds.value > 0) {
seconds.value -= 1
text.value = `${seconds.value}${t('sms_code_change_text')}`
} else {
reset()
}
}, 1000) as any
}
/**
* 重置
*/
const reset = () => {
clearInterval(timer)
seconds.value = 90
canGetCode.value = true
text.value = t('getSmsCode')
}
return {
send,
text,
canGetCode
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"language": "语言en"
}
+3
View File
@@ -0,0 +1,3 @@
{
"title": "Home"
}
+8
View File
@@ -0,0 +1,8 @@
{
"pages": {
"index": "Index",
"code": {
"code": "Code"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"logining": "登录中",
"username_placeholder": "请输入账号",
"password_placeholder": "请输入密码",
"resetpwd": "忘记密码",
"no_account": "还没有账号",
"to_register": "去注册",
"and": "和",
"agree_tips": "登录代表您同意",
"username_login": "密码登录",
"mobile_login": "验证码登录",
"mobile_placeholder": "请输入手机号",
"wechat_scan_login": "扫码登录",
"wechat_scan_tips": "请使用微信扫描二维码登录",
"qrcode_generate_failed": "二维码生成失败,请刷新重试",
"click_refresh": "点击刷新",
"wechat_scan_tip": "请使用微信扫描二维码登录"
}
+15
View File
@@ -0,0 +1,15 @@
{
"registering": "注册中",
"username_placeholder": "请输入账号",
"password_placeholder": "请输入密码",
"confirm_password_placeholder": "请再次确认密码",
"confirm_password_error": "两次输入的密码不一致",
"resetpwd": "忘记密码",
"have_account": "已有账号",
"to_login": "去登录",
"and": "和",
"register_agree_tips": "注册代表您同意",
"username_register": "账号注册",
"mobile_register": "手机号注册",
"mobile_placeholder": "请输入手机号"
}
+56
View File
@@ -0,0 +1,56 @@
{
"language": "语言",
"logout_login": "注销登录",
"member_center": "个人中心",
"login": "登录",
"register": "注册",
"getSmsCode": "获取短信验证码",
"smsCodeChangeText": "秒后重新获取",
"captchaTitle": "请先完成安全验证",
"logining": "登录中",
"usernamePlaceholder": "请输入账号",
"passwordPlaceholder": "请输入密码",
"resetpwd": "忘记密码",
"noAccount": "还没有账号",
"toRegister": "去注册",
"registering": "注册中",
"confirmPasswordPlaceholder": "请再次确认密码",
"confirmPasswordError": "两次输入的密码不一致",
"haveAccount": "已有账号",
"toLogin": "去登录",
"and": "和",
"registerAgreeTips": "注册代表您同意",
"usernameRegister": "账号注册",
"mobileRegister": "手机号注册",
"agreeTips": "请阅读并同意",
"isAgreeTips":"请先阅读并同意协议",
"usernameLogin": "密码登录",
"mobileLogin": "验证码登录",
"confirm": "确认",
"cancel": "取消",
"captchaPlaceholder": "请输入验证码",
"mobilePlaceholder": "请输入手机号码",
"mobileError": "请输入正确的手机号",
"codePlaceholder": "请输入手机验证码",
"userAgreement": "用户协议",
"privacyAgreement": "隐私协议",
"protocolNotConfigured": "未配置协议",
"siteClose": "站点已关闭",
"request": {
"unknownError": "未知错误",
"400": "错误的请求",
"401": "请重新登录",
"403": "拒绝访问",
"404": "请求错误",
"405": "请求方法未允许",
"408": "请求超时",
"409": "请求跨域",
"500": "服务器端出错,错误原因:",
"501": "网络未实现",
"502": "网络错误",
"503": "服务不可用",
"504": "网络超时",
"505": "http版本不支持该请求",
"timeout": "网络请求超时!"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"title": "首页"
}
+34
View File
@@ -0,0 +1,34 @@
{
"pages": {
"article": {
"list": "文章资讯",
"detail": "文章详情"
},
"index": {
"index": "首页"
},
"app":{
"index": "首页",
"member":{
"center": "个人中心",
"point": "我的积分",
"balance": "我的余额"
}
},
"auth": {
"agreement": "协议",
"login": "登录",
"register": "注册",
"bind": "手机号绑定"
},
"member": {
"index": "欢迎页",
"center": "个人中心",
"point": "我的积分",
"balance": "我的余额"
},
"site": {
"close": "站点已关闭"
}
}
}
+200
View File
@@ -0,0 +1,200 @@
<template>
<el-aside class="member-sidebar">
<!-- 用户信息区域 -->
<div class="member-sidebar__user-info">
<div @click="routerPush('/member/profile')" class="member-sidebar__avatar-container">
<img class="member-sidebar__avatar" :src="getAvatarUrl(memberStore?.info?.avatar)" alt="" />
</div>
<p class="member-sidebar__username">{{ memberStore?.info?.nickname }}</p>
</div>
<el-menu
class="member-sidebar__menu"
:default-active="activeMenu"
router
unique-opened
>
<template v-for="(item, idx) in personalCenterStore.state.user_menus" :key="idx">
<el-sub-menu
v-if="item.children && item.children.length"
:index="item.id.toString()"
>
<template #title>
<Icon v-if="item.icon" :name="item.icon" size="16" />
<span>{{ item.title }}</span>
</template>
<el-menu-item
v-for="(menu, index) in item.children"
:key="index"
:index="menu.path"
@click="routerPush(menu)"
>
<Icon v-if="menu.icon" :name="menu.icon" size="16" />
<span>{{ menu.title }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item
v-else
:index="item.path"
@click="routerPush(item)"
>
<Icon v-if="item.icon" :name="item.icon" size="16" />
<span>{{ item.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
</template>
<script setup lang="ts">
import type { Menus } from '~/stores/interface'
// 在组件顶部导入图片
import defaultAvatar from '~/assets/images/default_avatar.png'
const route = useRoute()
const memberStore = useMemberStore()
const personalCenterStore = usePersonalCenterStore()
// 当前激活的菜单
const activeMenu = computed(() => {
return route.path
})
/**
* 获取用户头像URL
*/
const getAvatarUrl = (avatarUrl: string | null | undefined): string => {
if (!avatarUrl || avatarUrl.trim() === "") {
return defaultAvatar
}
return fullUrl(avatarUrl)
}
/**
* 菜单跳转
*/
const routerPush = (route: string | Menus) => {
if (typeof route === 'string') {
navigateTo(route)
} else {
onClickMenu(route as any)
}
}
</script>
<style scoped lang="scss">
.member-sidebar {
width: 240px;
background-color: var(--ba-bg-color-overlay);
box-shadow: var(--el-box-shadow-light);
border-radius: 8px;
overflow: hidden;
}
.member-sidebar__user-info {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 20px;
border-bottom: 1px solid var(--el-border-color-light);
}
.member-sidebar__avatar-container {
position: relative;
cursor: pointer;
margin-bottom: 12px;
}
.member-sidebar__avatar {
display: block;
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
background-color: black;
}
.member-sidebar__avatar-gender {
position: absolute;
bottom: 0;
right: 0;
height: 20px;
width: 20px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--ba-bg-color-overlay);
border-radius: 50%;
box-shadow: var(--el-box-shadow);
}
.member-sidebar__username {
text-align: center;
width: 100%;
margin: 8px 0 16px;
font-size: var(--el-font-size-large);
font-weight: 600;
color: var(--el-text-color-primary);
}
.member-sidebar__button-group {
display: flex;
gap: 8px;
}
.member-sidebar__button {
font-size: var(--el-font-size-small);
height: 32px;
min-width: 80px;
}
.member-sidebar__menu {
border: none;
background: transparent;
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
height: 44px;
line-height: 44px;
.icon {
margin-right: 8px;
width: 16px;
height: 16px;
}
}
:deep(.el-menu-item.is-active) {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
/* 响应式设计 */
@media screen and (max-width: 991px) {
.member-sidebar {
width: 100%;
border-radius: 0;
box-shadow: none;
}
.member-sidebar__user-info {
padding: 20px 16px;
}
.member-sidebar__avatar {
width: 60px;
height: 60px;
}
.member-sidebar__button-group {
flex-direction: column;
width: 100%;
}
.member-sidebar__button {
width: 100%;
}
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div class="theme-toggle-content" @click="toggleDark">
<div class="switch">
<div class="switch-action">
<Icon name="local-dark" color="#ffffff" size="13px" class="switch-icon dark-icon" />
<Icon name="local-light" color="#303133" size="13px" class="switch-icon light-icon" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const color = useColorMode()
function toggleDark() {
color.preference = color.value === 'dark' ? 'light' : 'dark'
}
</script>
<style scoped lang="scss">
.theme-toggle-content {
display: flex;
align-items: center;
height: 24px;
padding: 0 12px;
}
.switch {
display: inline-block;
position: relative;
width: 40px;
height: 20px;
border: 1px solid var(--el-border-color);
border-radius: 10px;
box-sizing: border-box;
background-color: var(--ma-bg-color);
cursor: pointer;
transition:
border-color 0.3s,
background-color 0.5s;
}
.switch-action {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 1px;
left: 1px;
border-radius: 50%;
background-color: #ffffff;
transform: translate(0);
color: var(--el-text-color-primary);
transition: all 0.3s;
}
.switch-icon {
position: absolute;
left: 1px;
transition: all 0.3s;
cursor: pointer;
}
.light-icon {
opacity: 1;
}
.dark-icon {
opacity: 0;
}
@at-root html.dark {
.switch {
background-color: #2c2c2c;
}
.switch-action {
transform: translate(20px);
background-color: #141414;
}
.dark-icon {
opacity: 1;
}
.light-icon {
opacity: 0;
}
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<template>
<button class="inline-flex justify-center items-center" @click="toggleDark">
<div class="i-ri-sun-line dark:i-ri-moon-line" />
</button>
</template>
<script setup lang="ts">
const color = useColorMode()
function toggleDark() {
color.preference = color.value === 'dark' ? 'light' : 'dark'
}
</script>
+31
View File
@@ -0,0 +1,31 @@
<template>
<el-footer class="md-footer">
<div>
Copyright @ 2020~2026版权所有
<a href="http://beian.miit.gov.cn/">189456784215</a>
</div>
</el-footer>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.md-footer {
display: flex;
width: 100%;
justify-content: center;
align-items: center;
background-color: var(--el-color-info-light-7);
a {
color: var(--el-text-color-secondary);
}
@media screen and (max-width: 768px) {
a {
display: block;
text-align: center;
}
}
}
</style>
+270
View File
@@ -0,0 +1,270 @@
<template>
<el-menu :default-active="state.activeMenu" @select="onSelect">
<el-sub-menu v-blur index="language-switch" class="language-switch">
<template #title>
<Icon v-if="showIcon" name="local-lang" color="var(--el-text-color-primary)" />
{{ t("language") }}
</template>
<el-menu-item
@click="systemStore.setLanguage(item.name)"
v-for="item in availableLanguages"
:key="item.name"
:index="'language-switch-' + item.value"
class="language-switch"
>
{{ item.value }}
</el-menu-item>
</el-sub-menu>
<!-- 主题切换 -->
<el-menu-item
index="theme-switch"
class="theme-switch"
:class="$attrs.mode + '-theme-switch'"
>
<DarkSwitch />
<!-- <DarkToggle v-if="!isMobile" /> -->
</el-menu-item>
<!-- 已登录用户场景 -->
<el-sub-menu
v-if="memberStore.info"
@click="$attrs.mode == 'vertical' ? '' : navigateTo('/member/profile')"
v-blur
index="user-box"
>
<template #title>
<div class="header-user-box">
<img
class="header-user-avatar"
:class="$attrs.mode == 'vertical' ? 'icon-header-user-avatar' : ''"
:src="getAvatarUrl(memberStore?.info?.avatar || '')"
alt=""
/>
{{ memberStore?.info?.nickname || t("user") }}
</div>
</template>
<el-menu-item @click="navigateTo('/member/profile')" v-blur index="user">
<Icon
v-if="showIcon"
name="fa fa-user-circle"
color="var(--el-text-color-primary)"
/>
{{ t("member_center") }}
</el-menu-item>
<!-- 动态菜单 -->
<MenuSub :menus="personalCenterStore.state.nav_user_menus" :show-icon="showIcon" />
<!-- 会员中心菜单 -->
<MenuSub :menus="personalCenterStore.state.user_menus" :show-icon="showIcon" />
<el-menu-item @click="memberStore.logout()" v-blur index="user-logout">
<Icon
v-if="showIcon"
name="fa fa-sign-out"
color="var(--el-text-color-primary)"
/>
{{ t("logout_login") }}
</el-menu-item>
</el-sub-menu>
<!-- 未登录场景 -->
<el-menu-item v-else @click="handleLogin" v-blur index="user">
<Icon
v-if="showIcon"
name="fa fa-user-circle"
color="var(--el-text-color-primary)"
/>
{{ t("login") }}
</el-menu-item>
<LoadingDialog />
</el-menu>
</template>
<script setup lang="ts">
import type { Menus } from "~/stores/interface";
import defaultAvatar from "~/assets/images/default_avatar.png";
import DarkToggle from "./dark-toggle.vue";
import DarkSwitch from "./dark-switch.vue";
import LoadingDialog from "@/components/login-dialog/index.vue";
import MenuSub from "./menu-sub.vue";
const memberStore = useMemberStore();
const systemStore = useSystemStore();
const configStore = useConfigStore();
const availableLanguages = computed(() => systemStore.getLanguages);
const currentLanguage = computed(() => systemStore.lang);
const personalCenterStore = usePersonalCenterStore();
interface Props {
showIcon?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showIcon: false,
});
const state = reactive({
activeMenu: "",
});
// 判断是否为移动端
const isMobile = ref(false);
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768;
};
/**
* 获取用户头像URL
* @param avatarUrl 用户头像URL,可为空、null或undefined
* @returns 完整的头像URL,如果传入空值则返回默认头像
*/
const getAvatarUrl = (avatarUrl: string | null | undefined): string => {
if (!avatarUrl || avatarUrl.trim() === "") {
// 返回默认头像
return defaultAvatar;
}
return fullUrl(avatarUrl);
};
const handleLogin = () => {
if (
!getToken() &&
!configStore.login.is_username &&
!configStore.login.is_mobile &&
!configStore.login.is_bind_mobile
) {
ElMessage.error("商家未开启普通账号登录注册");
return false;
}
memberStore.logOpen();
};
/**
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
*/
const onSelect = (index: string) => {
if (noNeedActive(systemStore.site.head_nav, index)) {
const oldActiveMenu = state.activeMenu;
state.activeMenu = "";
nextTick(() => {
state.activeMenu = oldActiveMenu;
});
}
};
/**
* 检查一个菜单是否需要激活态
* @param menus
* @param index
*/
const noNeedActive = (menus: Menus[], index: string) => {
if (index.indexOf("language-switch") === 0 || index == "theme-switch") {
return true;
}
return isExternalLink(menus, index);
};
/**
* 检查一个菜单是否是外站链接,如果是,不要激活
* @param menus
* @param index
*/
const isExternalLink = (menus: Menus[], index: string): boolean => {
// 修复:添加空值检查
if (!menus || !Array.isArray(menus)) {
return false;
}
for (const key in menus) {
// 修复:检查 menus[key] 是否存在
if (!menus[key]) continue;
const columnIndex = `column-${menus[key].id}`;
if (columnIndex == index) {
return menus[key].meta?.menu_type == "link";
}
// 修复:添加 children 存在性检查
if (
menus[key].children &&
Array.isArray(menus[key].children) &&
menus[key].children.length
) {
const result = isExternalLink(menus[key].children, index);
if (result) return true;
}
}
return false;
};
const toggleDarkMode = () => {
setDark(!getDark());
};
onMounted(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
});
onUnmounted(() => {
window.removeEventListener("resize", checkMobile);
});
</script>
<style scoped lang="scss">
.header-user-box {
display: flex;
align-items: center;
justify-content: center;
position: relative;
.header-user-avatar {
width: 25px;
height: 25px;
margin-right: 4px;
border-radius: 50%;
background-color: black;
}
.icon-header-user-avatar {
margin-left: 4px;
margin-right: 6px;
}
}
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 2px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active > .icon {
color: var(--el-menu-active-color) !important;
}
.el-menu {
border-bottom: none;
border-right: none;
.theme-switch.is-active,
.language-switch.is-active {
border-bottom: none;
:deep(.el-sub-menu__title) {
border-bottom: none;
}
}
}
.theme-switch {
--el-menu-hover-bg-color: none;
padding-right: 0;
}
.vertical-theme-switch {
.theme-toggle-content {
padding: 0;
}
}
.theme-toggle-content {
padding-right: 0;
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<template>
<div class="ma-header">
<div class="header-container">
<!-- 左侧logo -->
<div class="header-left">
<Logo clickable show-text size="medium" />
</div>
<!-- 中间菜单区域(靠右显示) -->
<div class="header-center">
<client-only>
<el-scrollbar class="hidden-sm-and-down">
<Menu class="frontend-header-menu" :ellipsis="false" mode="horizontal" />
</el-scrollbar>
</client-only>
</div>
<!-- 右侧功能区域 -->
<div class="header-right">
<client-only>
<el-scrollbar class="hidden-sm-and-down" >
<HeaderActions class="frontend-header-menu" :ellipsis="false" mode="horizontal" v-if="!isMobile"/>
<div
v-if="isMobile && !personalCenter.state.menu_expand"
@click="personalCenter.toggleMenuExpand(true)"
class="mobile-menu-toggle mr-2"
>
<Icon name="el-icon-Expand" color="var(--el-color-primary)" size="20" />
</div>
</el-scrollbar>
</client-only>
</div>
</div>
<!-- 移动端抽屉菜单(仅在移动端显示) -->
<MobileDrawer
v-if="isMobile"
v-model="personalCenterStore.state.menu_expand"
@language-changed="handleLanguageChange"
@logged-out="handleLogout"
/>
</div>
</template>
<script setup lang="ts">
import Menu from './menu.vue'
import Logo from './logo.vue'
import MobileDrawer from './mobile-drawer.vue'
import HeaderActions from './header-actions.vue'
const personalCenterStore = usePersonalCenterStore()
// 判断是否为移动端
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
const handleLanguageChange = (languageName: string) => {
if (isMobile.value) {
personalCenter.toggleMenuExpand(false)
}
}
const handleLogout = () => {
// 可以添加额外的登出处理逻辑
}
const toggleDarkMode = () => {
setDark(!getDark())
}
</script>
<style scoped lang="scss">
.ma-header {
background-color: var(--ba-bg-color-overlay);
box-shadow: 0 0 8px rgba(0 0 0 / 8%);
position: relative;
z-index: 1000;
}
.header-container {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1450px;
margin: 0 auto;
padding: 0 20px;
height: var(--el-header-height);
}
.header-left {
flex-shrink: 0;
display: flex;
align-items: center;
}
.header-center {
flex: 1;
display: flex;
justify-content: flex-end;
margin: 0 20px;
.frontend-header-menu {
height: var(--el-header-height);
}
:deep(.el-menu--horizontal) {
border-bottom: none;
}
}
.header-right {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0;
}
.header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.header-action-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
transition: all 0.3s ease;
color: var(--el-text-color-primary);
&:hover {
background-color: var(--el-fill-color-light);
}
}
.mobile-menu-toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
cursor: pointer;
border-radius: 6px;
&:hover {
background-color: var(--el-fill-color-light);
}
}
// 响应式设计
@media screen and (max-width: 768px) {
.header-container {
padding: 0 15px;
}
.header-center {
display: none;
}
}
@media screen and (max-width: 414px) {
.header-container {
padding: 0 10px;
}
}
</style>
+105
View File
@@ -0,0 +1,105 @@
<template>
<div @click="handleClick" class="logo-container" :class="{ 'clickable': clickable, [`size-${size}`]: true }">
<img :src="effectiveLogoUrl" :alt="altText" class="logo-image" />
<span v-if="showText" class="site-name">{{ siteName }}</span>
</div>
</template>
<script setup lang="ts">
interface Props {
clickable?: boolean
showText?: boolean
size?: 'small' | 'medium' | 'large'
logoUrl?: string
altText?: string
}
const props = withDefaults(defineProps<Props>(), {
clickable: true,
showText: true,
size: 'medium',
logoUrl: '',
altText: 'Logo'
})
const systemStore = useSystemStore()
const siteName = computed(() => systemStore?.site?.site_name || '快速开发框架')
// 在组件顶部导入图片
import logoImage from '~/assets/images/logo.png'
const effectiveLogoUrl = computed(() => {
if (props.logoUrl) {
return props.logoUrl
}
return logoImage
})
const handleClick = () => {
if (props.clickable) {
navigateTo('/')
}
}
</script>
<style scoped lang="scss">
.logo-container {
display: flex;
align-items: center;
cursor: default;
&.clickable {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
// 修复:尺寸样式应该设置在父容器上,然后影响子元素
&.size-small {
.logo-image {
height: 24px;
width: 24px;
}
}
&.size-medium {
.logo-image {
height: 34px;
width: 34px;
}
}
&.size-large {
.logo-image {
height: 48px;
width: 48px;
}
}
}
.logo-image {
// 基础样式可以放在这里
object-fit: contain;
}
.site-name {
padding-left: 8px;
font-weight: 600;
white-space: nowrap;
color: var(--el-text-color-primary);
.logo-container.size-small & {
font-size: var(--el-font-size-small);
}
.logo-container.size-medium & {
font-size: var(--el-font-size-large);
}
.logo-container.size-large & {
font-size: var(--el-font-size-extra-large);
}
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<template>
<template v-for="(item, idx) in props.menus" :key="idx">
<template v-if="!isEmpty(item.children)">
<el-sub-menu @click="onClickSubMenu(item)" v-blur :index="`column-${item.id}`">
<template #title>
<Icon v-if="showIcon && item.icon" :name="item.icon" color="var(--el-text-color-primary)" />
{{ item.title }}
</template>
<MenuSub :menus="item.children" :show-icon="showIcon" />
</el-sub-menu>
</template>
<template v-else>
<el-menu-item @click="onClickMenu(item)" v-blur :index="'column-' + item.id" :class="item.name.replace(/[\/]/g, '-')">
<Icon v-if="showIcon && item.icon" :name="item.icon" color="var(--el-text-color-primary)" />
<template #title>{{ item.title }}</template>
</el-menu-item>
</template>
</template>
</template>
<script setup lang="ts">
import { isEmpty } from 'lodash-es'
import type { Menus } from '~/stores/interface'
interface Props {
menus: Menus[]
showIcon?: boolean
}
const props = withDefaults(defineProps<Props>(), {
menus: () => [],
showIcon: false,
})
const onClickSubMenu = (menu: Menus) => {
/**
* 1、'/'表示菜单规则的 path 为空
* 2、会员中心菜单目录不需要跳转
*/
if (menu.path == '/' || menu.type == 'menu_dir') return
onClickMenu(menu)
}
</script>
<style scoped lang="scss">
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 2px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active > .icon {
color: var(--el-menu-active-color) !important;
}
</style>
+206
View File
@@ -0,0 +1,206 @@
<template>
<el-menu :default-active="state.activeMenu" @select="onSelect">
<el-menu-item @click="handleClick('/')" v-blur index="index">
<Icon v-if="props.showIcon" name="fa fa-home" color="var(--el-text-color-primary)" />
<template #title>{{ t('主页') }}</template>
</el-menu-item>
<!-- 动态菜单 -->
<MenuSub :menus="systemStore.site.head_nav" :show-icon="showIcon" />
</el-menu>
</template>
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import type { Menus } from '~/stores/interface'
import MenuSub from './menu-sub.vue'
const route = useRoute()
const systemStore = useSystemStore()
interface Props {
showIcon?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showIcon: false,
})
const state = reactive({
activeMenu: '',
switchingLanguage: false,
})
/**
* 设置激活菜单
*/
const setActiveMenu = (route: RouteLocationNormalizedLoaded) => {
if (route.path == '/') return (state.activeMenu = 'index')
const menuId = findMenus(route)
if (menuId) {
state.activeMenu = 'column-' + menuId
} else if (route.path.startsWith('/user')) {
state.activeMenu = 'user'
}
}
/**
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
*/
const onSelect = (index: string) => {
if (noNeedActive(systemStore.site.head_nav, index)) {
const oldActiveMenu = state.activeMenu
state.activeMenu = ''
nextTick(() => {
state.activeMenu = oldActiveMenu
})
}
}
/**
* 检查一个菜单是否需要激活态
* @param menus
* @param index
*/
const noNeedActive = (menus: Menus[], index: string) => {
if (index.indexOf('language-switch') === 0 || index == 'theme-switch') {
return true
}
return isExternalLink(menus, index)
}
/**
* 检查一个菜单是否是外站链接,如果是,不要激活
* @param menus
* @param index
*/
const isExternalLink = (menus: Menus[], index: string): boolean => {
// 修复:添加空值检查
if (!menus || !Array.isArray(menus)) {
return false
}
for (const key in menus) {
// 修复:检查 menus[key] 是否存在
if (!menus[key]) continue
const columnIndex = `column-${menus[key].id}`
if (columnIndex == index) {
return menus[key].meta?.menu_type == 'link'
}
// 修复:添加 children 存在性检查
if (menus[key].children && Array.isArray(menus[key].children) && menus[key].children.length) {
const result = isExternalLink(menus[key].children, index)
if (result) return true
}
}
return false
}
/**
* 递归的搜索菜单 Index
*/
const searchMenuIndex = (menus: Menus[], route: RouteLocationNormalizedLoaded): number | false => {
// 修复:添加空值检查
if (!menus || !Array.isArray(menus)) {
return false
}
let find: boolean | number = false
for (const key in menus) {
// 修复:检查 menus[key] 是否存在
if (!menus[key]) continue
if (menus[key].id && menus[key].path == route.fullPath) {
return menus[key].id
}
if (menus[key].children && Array.isArray(menus[key].children) && menus[key].children.length) {
find = searchMenuIndex(menus[key].children, route)
if (find !== false) return find
}
}
return find
}
const handleClick = (path: string) => {
navigateTo(path)
}
/**
* 从动态菜单中-搜索一个菜单
*/
const findMenus = (route: RouteLocationNormalizedLoaded) => {
// 修复:添加空值检查
if (!systemStore.site.head_nav || !Array.isArray(systemStore.site.head_nav)) {
return false
}
const headNavIndex = searchMenuIndex(systemStore.site.head_nav, route)
if (headNavIndex !== false) return headNavIndex
}
setActiveMenu(route)
watch(
() => route.fullPath,
() => {
setActiveMenu(route)
}
)
</script>
<style scoped lang="scss">
.header-user-box {
display: flex;
align-items: center;
justify-content: center;
position: relative;
.header-user-avatar {
width: 16px;
height: 16px;
margin-right: 4px;
border-radius: 50%;
}
.icon-header-user-avatar {
margin-left: 4px;
margin-right: 6px;
}
}
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 2px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active > .icon {
color: var(--el-menu-active-color) !important;
}
.el-menu {
border-bottom: none;
border-right: none;
.theme-switch.is-active,
.language-switch.is-active {
border-bottom: none;
:deep(.el-sub-menu__title) {
border-bottom: none;
}
}
}
.theme-switch {
--el-menu-hover-bg-color: none;
padding-right: 0;
}
.vertical-theme-switch {
.theme-toggle-content {
padding: 0;
}
}
.theme-toggle-content {
padding-right: 0;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<template>
<el-drawer
class="ma-aside-drawer"
:append-to-body="true"
v-model="drawerVisible"
:with-header="false"
direction="ltr"
>
<div class="drawer-header">
<Logo clickable show-text size="small" />
<div @click="closeDrawer" class="drawer-close">
<Icon name="el-icon-Close" color="var(--el-color-primary)" size="20" />
</div>
</div>
<!-- 移动端菜单 -->
<div class="mobile-menu-section">
<Menu :show-icon="true" mode="vertical" />
</div>
<!-- 移动端-会员菜单 -->
<div class="mobile-menu-section">
<HeaderActions :show-icon="true" mode="vertical" />
</div>
</el-drawer>
</template>
<script setup lang="ts">
import Menu from './menu.vue'
import Logo from './logo.vue'
import HeaderActions from './header-actions.vue'
interface Props {
modelValue: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
languageChanged: [languageName: string]
loggedOut: []
}>()
const drawerVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const closeDrawer = () => {
drawerVisible.value = false
}
</script>
<style scoped lang="scss">
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
background-color: var(--el-color-info-light-9);
border-bottom: 1px solid var(--el-border-color-light);
.drawer-close {
cursor: pointer;
padding: 8px;
border-radius: 4px;
&:hover {
background-color: var(--el-fill-color-light);
}
}
}
.mobile-menu-section {
flex: 1;
overflow-y: auto;
}
.mobile-actions {
padding: 20px;
border-top: 1px solid var(--el-border-color-light);
background-color: var(--el-fill-color-light);
}
.mobile-action-section {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
.mobile-action-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-weight: 600;
padding: 10px 16px;
color: var(--el-text-color-primary);
}
.theme-toggle-mobile {
padding: 10px 10px;
}
// 暗黑模式适配
@at-root html.dark {
.drawer-header {
background-color: var(--el-bg-color-page);
}
.mobile-actions {
border-top-color: var(--el-border-color);
background-color: var(--el-fill-color-dark);
}
}
</style>
+10
View File
@@ -0,0 +1,10 @@
<template>
<el-container>
<slot />
</el-container>
</template>
<script lang="ts" setup>
</script>
<style lang="scss" scoped></style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<el-container class="layout-container">
<el-header class="layout-header">
<layout-header />
</el-header>
<el-main class="layout-main">
<div class="layout-content">
<slot></slot>
</div>
<layout-footer />
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import layoutHeader from "./components/header.vue";
import layoutFooter from "./components/footer.vue";
</script>
<style scoped lang="scss">
.layout-container {
width: 100%;
min-height: 100vh;
overflow-x: hidden;
.layout-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: var(--el-bg-color);
box-shadow: var(--el-box-shadow-light);
height: 60px;
}
.layout-main {
--el-main-padding: 0 !important;
padding: 0 !important;
min-height: calc(100vh - 60px);
margin-top: 60px;
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
.layout-content {
flex: 1;
min-height: var(--content-min-height, 500px);
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.layout-footer {
flex-shrink: 0;
margin-top: auto;
width: 100%;
max-width: 100%;
}
}
}
/* 全局重置 */
:global(html, body) {
margin: 0;
padding: 0;
width: 100%;
overflow-x: hidden;
}
/* 响应式断点 */
@media screen and (max-width: 768px) {
.layout-container {
.layout-main {
.layout-content {
--content-min-height: 300px;
}
}
}
}
@media screen and (max-width: 1024px) {
.layout-container {
.layout-main {
.layout-content {
--content-min-height: 400px;
}
}
}
}
/* 组件样式适配 */
.layout-header {
background-color: var(--el-bg-color) !important;
box-shadow: var(--el-box-shadow-light) !important;
position: fixed;
width: 100%;
z-index: 1000;
left: 0;
right: 0;
:deep(.header-logo) {
span {
padding-left: 4px;
}
}
:deep(.frontend-header-menu) {
background: transparent;
.el-menu-item,
.el-sub-menu .el-sub-menu__title {
&.is-active {
color: var(--el-color-primary) !important;
}
&:hover {
background-color: transparent !important;
color: var(--el-menu-hover-text-color) !important;
}
}
}
}
.layout-footer {
color: var(--el-text-color-secondary);
background-color: transparent !important;
position: relative;
bottom: auto;
width: 100%;
left: 0;
right: 0;
}
/* 页面特定样式 */
.page-index {
.layout-content {
/* 首页特定样式 */
}
}
.page-member {
.layout-content {
/* 会员页面特定样式 */
}
}
/* 响应式适配 */
@media screen and (max-width: 1024px) {
.page-index {
.layout-main {
.layout-content {
/* 平板端首页样式 */
}
}
}
}
@media screen and (max-width: 375px) {
.page-index {
.layout-main {
.layout-content {
/* 小屏手机首页样式 */
}
}
}
}
@media screen and (max-height: 650px) {
.page-index {
.layout-main {
.layout-content {
/* 矮屏设备样式 */
}
}
}
}
/* 深色模式 */
@at-root html.dark {
.page-index {
background: url(~/assets/images/bg-dark.jpg) repeat;
}
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="member-layout">
<el-container class="is-vertical">
<Header />
<el-scrollbar :style="calcHeight(60)" class="main-scrollbar" ref="mainScrollbarRef">
<el-row class="layouts-main" justify="center">
<el-col class="user-layouts" :span="16" :xs="24">
<Aside class="hidden-sm-and-down" />
<el-main class="layout-main">
<slot />
</el-main>
</el-col>
</el-row>
<Footer />
</el-scrollbar>
</el-container>
</div>
</template>
<script setup lang="ts">
import Header from './components/header.vue'
import Aside from './components/aside.vue'
import Footer from './components/footer.vue'
const route = useRoute()
const mainScrollbarRef = useTemplateRef('mainScrollbarRef')
// 路由切换时滚动条滚动至顶部
watch(
() => route.fullPath,
() => {
if (!route.meta.disableScrollTo) {
mainScrollbarRef.value?.scrollTo(0, 0)
}
}
)
// 将滚动条的 ref provide 给子级组件
provide('mainScrollbarRef', mainScrollbarRef)
</script>
<style scoped lang="scss">
.user-layouts {
display: flex;
padding-top: 15px;
align-items: flex-start;
}
.layout-main {
padding: 0 !important;
overflow-x: hidden;
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<template>
<div class="page-container">
<!-- 轮播图区域 -->
<section class="hero-section">
<el-carousel height="500px" arrow="never" indicator-position="none">
<el-carousel-item>
<div class="hero-background"></div>
</el-carousel-item>
</el-carousel>
</section>
<!-- 主要内容区域 -->
<section class="content-section">
<div class="content-container">
<!-- 标题区域 -->
<div class="section-header">
<h1 class="section-title">文档管理</h1>
<p class="section-subtitle">无论是快速上手,还是深入了解框架,这里都是最好的地点</p>
</div>
<!-- 功能卡片区域 -->
<div class="features-grid">
<div class="feature-card">
<div class="feature-header">
<div class="feature-icon">
<img src="@/assets/images/index/course.jpg" alt="官方教程" />
</div>
<h3 class="feature-title">官方教程</h3>
</div>
<p class="feature-description">详尽细致的逐步官方教程,帮助您系统全面地接触Madong,建议在使用前阅读。</p>
<NuxtLink to="https://doc.niucloud.com/v6" target="_blank" class="feature-link">
<el-button type="primary" class="w-full">
<span>前往教程</span>
<el-icon class="ml-2"><ArrowRight /></el-icon>
</el-button>
</NuxtLink>
</div>
<div class="feature-card">
<div class="feature-header">
<div class="feature-icon">
<img src="@/assets/images/index/api.jpg" alt="API文档" />
</div>
<h3 class="feature-title">API文档</h3>
</div>
<p class="feature-description">通过API文档了解niucloud的正确使用方法,深入理解其运行逻辑和接口规范。</p>
<div class="feature-link">
<el-button type="primary" class="w-full" disabled>
<span>前往API文档</span>
<el-icon class="ml-2"><ArrowRight /></el-icon>
</el-button>
</div>
</div>
<div class="feature-card">
<div class="feature-header">
<div class="feature-icon">
<img src="@/assets/images/index/community.jpg" alt="问答社区" />
</div>
<h3 class="feature-title">问答社区</h3>
</div>
<p class="feature-description">浏览其他用户的问题和解答,获取使用技巧,遇到问题也可以随时提问交流。</p>
<NuxtLink to="https://www.niushop.com/bbs.html" target="_blank" class="feature-link">
<el-button type="primary" class="w-full">
<span>前往问答社区</span>
<el-icon class="ml-2"><ArrowRight /></el-icon>
</el-button>
</NuxtLink>
</div>
<div class="feature-card">
<div class="feature-header">
<div class="feature-icon">
<img src="@/assets/images/index/wx.jpg" alt="关注公众号" />
</div>
<h3 class="feature-title">关注公众号</h3>
</div>
<p class="feature-description">您可以扫描页面底部的二维码来关注我们的官方公众号,获得一手咨询及使用技巧。</p>
<div class="feature-link">
<el-button type="info" class="w-full" plain>
<span>查看二维码</span>
<el-icon class="ml-2"><ChatDotRound /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { ArrowRight, ChatDotRound } from '@element-plus/icons-vue'
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: #ffffff;
}
.hero-section {
width: 100%;
.el-carousel {
border-radius: 0;
}
.hero-background {
height: 500px;
background-image: url('@/assets/images/index/index_carousel.jpeg');
background-position: center center;
background-repeat: no-repeat;
background-size: cover;
}
}
.content-section {
background-image: url('@/assets/images/index/section.png');
background-size: 100%;
background-repeat: no-repeat;
padding: 60px 0 200px;
}
.content-container {
max-width: 1400px; // 增加整体宽度以容纳4个容器
margin: 0 auto;
padding: 0 40px; // 增加边距以适应更宽的内容
}
.section-header {
text-align: center;
margin-bottom: 100px;
.section-title {
font-size: 40px;
font-weight: bold;
color: #666666;
margin-bottom: 20px;
line-height: 1.2;
}
.section-subtitle {
font-size: 16px;
color: #666666;
line-height: 1.5;
}
}
.features-grid {
display: grid;
grid-template-columns: repeat(4, 1fr); // PC端固定4列
gap: 30px; // 调整间距以适应4列布局
justify-content: center;
}
.feature-card {
background: #ffffff;
border-radius: 12px;
padding: 25px; // 调整内边距以适应更小的容器
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
}
.feature-header {
display: flex;
align-items: center;
margin-bottom: 20px; // 调整间距
.feature-icon {
width: 28px; // 稍微缩小图标
height: 28px;
margin-right: 10px;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
}
.feature-title {
font-size: 18px; // 稍微缩小标题字体
font-weight: bold;
color: #666666;
margin: 0;
}
}
.feature-description {
font-size: 13px; // 稍微缩小描述字体
color: #666666;
line-height: 20px;
margin-bottom: 25px;
min-height: 80px; // 调整最小高度
}
.feature-link {
display: block;
.el-button {
border-radius: 6px;
height: 36px; // 调整按钮高度
font-size: 13px; // 调整按钮字体
:deep(.el-icon) {
font-size: 14px; // 调整图标大小
}
}
}
// 平板端适配 (768px - 1024px)
@media (max-width: 1024px) and (min-width: 769px) {
.content-container {
max-width: 100%;
padding: 0 30px;
}
.features-grid {
grid-template-columns: repeat(2, 1fr); // 平板端显示2列
gap: 25px;
}
.section-header {
margin-bottom: 80px;
.section-title {
font-size: 36px;
}
}
}
// 移动端适配 (768px以下)
@media (max-width: 768px) {
.content-container {
max-width: 100%;
padding: 0 20px;
}
.section-header {
margin-bottom: 60px;
.section-title {
font-size: 32px;
}
}
.features-grid {
grid-template-columns: 1fr; // 移动端显示1列
gap: 20px;
}
.feature-card {
padding: 20px;
}
.hero-section .hero-background {
height: 300px;
}
.content-section {
padding: 40px 0 120px;
}
.feature-header {
.feature-icon {
width: 30px;
height: 30px;
}
.feature-title {
font-size: 18px;
}
}
.feature-description {
font-size: 14px;
min-height: auto; // 移动端取消固定高度
}
}
// 超大屏幕适配 (1400px以上)
@media (min-width: 1400px) {
.content-container {
max-width: 1400px;
}
}
// 小屏幕手机适配 (480px以下)
@media (max-width: 480px) {
.content-container {
padding: 0 16px;
}
.section-header {
margin-bottom: 40px;
.section-title {
font-size: 28px;
}
.section-subtitle {
font-size: 14px;
}
}
.features-grid {
gap: 16px;
}
.feature-card {
padding: 16px;
}
}
</style>
+7
View File
@@ -0,0 +1,7 @@
<template>
<div class="user-views">
<el-card class="user-views-card" shadow="hover">
个人资料
</el-card>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
export default [
{
path: "/",
component: () => import('~/pages/index.vue')
},
{
path: "/member/profile",
component: () => import('~/pages/member/profile.vue'),
meta: {
title: '会员中心',
layout: 'member'
}
},
]
+9
View File
@@ -0,0 +1,9 @@
// 引入 Element Plus 图标组件@element-plus/icons-vue
//@ts-ignore
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
export default defineNuxtPlugin((NuxtApp) => {
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
NuxtApp.vueApp.component(key, component)
}
})
+87
View File
@@ -0,0 +1,87 @@
import { createI18n } from 'vue-i18n'
// 定义语言文件模块接口
interface LanguageFileModule {
default: Record<string, any>
}
// 类型保护函数,确保模块符合预期格式
function isLanguageFileModule(module: any): module is LanguageFileModule {
return module && typeof module === 'object' && 'default' in module && typeof module.default === 'object'
}
// 自动扫描加载所有语言文件
const loadAllLanguageFiles = () => {
const messages: Record<string, Record<string, any>> = {}
try {
// 加载所有语言文件
const languageFiles = import.meta.glob('~/lang/**/*.json', {
eager: true
}) as Record<string, LanguageFileModule>
// 处理语言文件
for (const [filePath, module] of Object.entries(languageFiles)) {
if (isLanguageFileModule(module)) {
const pathParts = filePath.split('/')
const langIndex = pathParts.indexOf('lang')
if (langIndex >= 0 && pathParts.length > langIndex + 1) {
const langCode = pathParts[langIndex + 1] ?? ''
const fileName = (pathParts[pathParts.length - 1] ?? '').replace('.json', '')
if (langCode && fileName) {
if (!messages[langCode]) {
messages[langCode] = {}
}
// 直接合并所有文件内容到对应语言下
messages[langCode] = {
...messages[langCode],
...module.default
}
}
}
}
}
// 如果没有任何语言文件,使用默认配置
if (Object.keys(messages).length === 0) {
console.warn('未找到任何语言文件,使用默认语言配置')
messages['zh-cn'] = { common: {} }
messages['en'] = { common: {} }
}
return messages
} catch (error) {
console.error('加载语言文件时出错:', error)
// 返回默认配置
return {
'zh-cn': { common: {} },
'en': { common: {} }
}
}
}
export default defineNuxtPlugin((NuxtApp) => {
// 加载所有语言消息
const messages = loadAllLanguageFiles()
const i18n = createI18n({
legacy: false, // 使用Composition API
locale: 'zh-cn', // 默认语言
fallbackLocale: 'zh-cn', // 回退语言
globalInjection: true, // 全局注入
messages: messages,
silentFallbackWarn: true,
silentTranslationWarn: true
})
NuxtApp.vueApp.use(i18n)
return {
provide: {
i18n: i18n.global, // 提供全局实例
getI18n: () => i18n
}
}
})
+43
View File
@@ -0,0 +1,43 @@
import type { RouterConfig } from '@nuxt/schema'
import routes from '~/pages/routes'
// 定义 pluginRoutes 的类型
interface PluginRouteModule {
default: any[]
}
type PluginRoutes = Record<string, PluginRouteModule>
// import.meta.glob 自动加载模块路由
const pluginRoutes: PluginRoutes = import.meta.glob('@/features/**/pages/routes.ts', {
eager: true
})
// 创建可修改的路由数组副本
const finalRoutes = [...routes]
// 安全地处理 pluginRoutes
if (pluginRoutes && Object.keys(pluginRoutes).length > 0) {
for (const key in pluginRoutes) {
const addonModule = pluginRoutes[key]
// 安全检查:确保模块存在且有 default 属性
if (addonModule?.default && Array.isArray(addonModule.default)) {
const addon = key.split('/')[2]
// 先加载插件路由后加载app路由
const processedRoutes = addonModule.default.map((item: any) => {
item.meta = item.meta ? Object.assign(item.meta, { addon }) : { addon }
return item
})
finalRoutes.unshift(...processedRoutes)
}
}
}
// https://router.vuejs.org/api/interfaces/routeroptions.html
export default <RouterConfig>{
routes: (_routes) => finalRoutes,
strict: false
}
+18
View File
@@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
interface App {
route: string
}
const useAppStore = defineStore('app', {
state: (): App => {
return {
route: ''
}
},
actions: {
}
})
export default useAppStore
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { getLoginConfig } from '@/api/auth'
interface loginConfig {
is_username: number | boolean,
is_mobile: number | boolean,
is_auth_register: number | boolean,
is_bind_mobile: number | boolean,
agreement_show: number | boolean
}
interface Config {
login: loginConfig
}
export const useConfigStore = defineStore('config', {
state: (): Config => {
return {
login: {
is_username: 1,
is_mobile: 0,
is_auth_register: 1,
is_bind_mobile: 0,
agreement_show: 0
},
}
},
actions: {
async getLoginConfig(router: any = null) {
await getLoginConfig().then(({ data }:any) => {
this.login.is_username = parseInt(data.is_username)
this.login.is_mobile = parseInt(data.is_mobile)
this.login.is_auth_register = parseInt(data.is_auth_register)
this.login.is_bind_mobile = parseInt(data.is_bind_mobile)
this.login.agreement_show = parseInt(data.agreement_show)
if(data && router && router.currentRoute.value.path === '/site/close'){
navigateTo('/', { replace: true })
}
}).catch(() => {
})
}
}
})
export default useConfigStore
+12
View File
@@ -0,0 +1,12 @@
// 会员资料
export const MEMBER_KEY = 'member'
// 当前语言
export const LANG = 'lang'
// 是否开启暗黑模式
export const ISDARK = 'is-dark'
// 会员 token 在请求 headers 的字段名
export const MEMBER_TOKEN_KEY = 'member-token'
+10
View File
@@ -0,0 +1,10 @@
/**
* 全局公用的变量
*/
import type { Globals } from './interface'
export const globals: Globals = {
lazy: null,
unique: ref(0),
loadLangHandle: {},
}
+76
View File
@@ -0,0 +1,76 @@
export interface Globals {
// 防抖计时器
lazy: NodeJS.Timeout | null
// 随机数生成时的唯一性自增种子
unique: Ref<number>
// 语言包懒加载句柄
loadLangHandle: Record<string, any>
}
export interface Menus {
id: number
name: string
type: string
path?: string
title: string
url: string
icon: string
meta: {
/**
* 在 meta 中存储多个可能用到的属性
* 兼容 RouteRecordRaw 和 RouteLocationNormalizedLoaded 类型
*/
id: number
type: string
menu_type: 'tab' | 'link' | 'iframe'
}
children: Menus[]
}
export interface SiteConfig {
site_name: string
record_number?: string
version: string
upload: {
mode: string
[key: string]: any
}
head_nav: Menus[]
initialize: boolean
user_initialize: boolean
}
export interface Member {
id: number|string
username: string
nickname: string
email: string
mobile: string
gender: number
birthday: string
money: number
score: number
avatar: string
last_login_time: string
last_login_ip: string
motto: string
token: string
refresh_token: string
}
export interface PersonalCenter {
// 是否开启会员中心
open: boolean
// 从后台加载到的会员中心菜单数据
user_menus: Menus[]
// 是否显示一级菜单标题(当有多个一级菜单分组时显示)
show_headline: boolean
// 权限节点
auth_node: Map<string, string[]>
// 收缩布局(小屏设备)
shrink: boolean
// 菜单展开状态(小屏设备)
menu_expand: boolean
// 顶栏会员菜单下拉项
nav_user_menus: Menus[]
}
+71
View File
@@ -0,0 +1,71 @@
import { defineStore } from 'pinia'
import { MEMBER_KEY } from './constant/keys'
import { getMemberProfile } from '~/api/member'
interface Member {
token: string | null
info: Record<string, any> | null,
loginPopup:boolean
}
const info={
"id": 1001,
"username": "john_doe",
"nickname": "John",
"email": "john.doe@example.com",
"mobile": "13800138000",
"gender": 1,
"birthday": "1990-05-15",
"money": 1500.5,
"score": 2500,
"avatar": "",
"last_login_time": "2024-05-20T14:30:00Z",
"last_login_ip": "192.168.1.100",
"motto": "Stay curious, keep learning",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"refresh_token": "rt_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
}
export const useMemberStore = defineStore('member', {
state: (): Member => {
return {
token: useCookie('token').value||'',
info: info,
loginPopup:false
}
},
actions: {
async setToken(token: string) {
this.token = token
useCookie('token').value = token
await this.getMemberInfo()
},
async getMemberInfo() {
if (!this.token) return
await getMemberProfile()
.then((res: any) => {
this.info = res.data
})
.catch((err) => {
this.logout()
})
},
logout() {
if (!this.token) return
this.token = ''
this.info = null
useCookie('token').value = null
// logout().then().catch()
},
logOpen(){
this.loginPopup = true
},
logClose(){
this.loginPopup = false
}
}
})
+393
View File
@@ -0,0 +1,393 @@
import { defineStore } from 'pinia'
import type { PersonalCenter, Menus } from '~/stores/interface'
// 顶栏会员菜单下拉项(nav_user_menus)- 动态菜单
const navUserMenus: Menus[] = [
{
"id": 101,
"name": "dashboard",
"type": "menu",
"path": "/member/dashboard",
"title": "仪表盘",
"url": "",
"icon": "i-dashboard",
"meta": {
"id": 101,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 102,
"name": "orders",
"type": "menu",
"path": "/member/orders",
"title": "我的订单",
"url": "",
"icon": "i-shopping-cart",
"meta": {
"id": 102,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 103,
"name": "wallet",
"type": "menu",
"path": "/member/wallet",
"title": "我的钱包",
"url": "",
"icon": "i-wallet",
"meta": {
"id": 103,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 104,
"name": "messages",
"type": "menu",
"path": "/member/messages",
"title": "消息中心",
"url": "",
"icon": "i-message",
"meta": {
"id": 104,
"type": "menu",
"menu_type": "tab"
},
"children": []
}
]
// 会员中心菜单(user_menus)- 完整的会员中心菜单结构
const userMenus: Menus[] = [
{
"id": 1,
"name": "member_center",
"type": "directory",
"path": "/member",
"title": "会员中心",
"url": "",
"icon": "i-user",
"meta": {
"id": 1,
"type": "directory",
"menu_type": "tab"
},
"children": [
{
"id": 11,
"name": "profile",
"type": "menu",
"path": "/member/profile",
"title": "个人资料",
"url": "",
"icon": "i-profile",
"meta": {
"id": 11,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 12,
"name": "security",
"type": "menu",
"path": "/member/security",
"title": "安全设置",
"url": "",
"icon": "i-shield",
"meta": {
"id": 12,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 13,
"name": "notification",
"type": "menu",
"path": "/member/notification",
"title": "通知设置",
"url": "",
"icon": "i-bell",
"meta": {
"id": 13,
"type": "menu",
"menu_type": "tab"
},
"children": []
}
]
},
{
"id": 2,
"name": "order_management",
"type": "directory",
"path": "/member/orders",
"title": "订单管理",
"url": "",
"icon": "i-shopping-cart",
"meta": {
"id": 2,
"type": "directory",
"menu_type": "tab"
},
"children": [
{
"id": 21,
"name": "order_list",
"type": "menu",
"path": "/member/orders/list",
"title": "订单列表",
"url": "",
"icon": "i-list",
"meta": {
"id": 21,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 22,
"name": "order_history",
"type": "menu",
"path": "/member/orders/history",
"title": "历史订单",
"url": "",
"icon": "i-history",
"meta": {
"id": 22,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 23,
"name": "refund_orders",
"type": "menu",
"path": "/member/orders/refund",
"title": "退款订单",
"url": "",
"icon": "i-refresh",
"meta": {
"id": 23,
"type": "menu",
"menu_type": "tab"
},
"children": []
}
]
},
{
"id": 3,
"name": "finance",
"type": "directory",
"path": "/member/finance",
"title": "财务管理",
"url": "",
"icon": "i-wallet",
"meta": {
"id": 3,
"type": "directory",
"menu_type": "tab"
},
"children": [
{
"id": 31,
"name": "balance",
"type": "menu",
"path": "/member/finance/balance",
"title": "账户余额",
"url": "",
"icon": "i-money",
"meta": {
"id": 31,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 32,
"name": "recharge",
"type": "menu",
"path": "/member/finance/recharge",
"title": "在线充值",
"url": "",
"icon": "i-credit-card",
"meta": {
"id": 32,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 33,
"name": "transaction",
"type": "menu",
"path": "/member/finance/transaction",
"title": "交易记录",
"url": "",
"icon": "i-file-text",
"meta": {
"id": 33,
"type": "menu",
"menu_type": "tab"
},
"children": []
}
]
},
{
"id": 4,
"name": "support",
"type": "directory",
"path": "/member/support",
"title": "客服支持",
"url": "",
"icon": "i-headphones",
"meta": {
"id": 4,
"type": "directory",
"menu_type": "tab"
},
"children": [
{
"id": 41,
"name": "help_center",
"type": "menu",
"path": "/member/support/help",
"title": "帮助中心",
"url": "",
"icon": "i-help-circle",
"meta": {
"id": 41,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 42,
"name": "contact_us",
"type": "menu",
"path": "/member/support/contact",
"title": "联系我们",
"url": "",
"icon": "i-phone",
"meta": {
"id": 42,
"type": "menu",
"menu_type": "tab"
},
"children": []
},
{
"id": 43,
"name": "feedback",
"type": "menu",
"path": "/member/support/feedback",
"title": "意见反馈",
"url": "",
"icon": "i-message-square",
"meta": {
"id": 43,
"type": "menu",
"menu_type": "tab"
},
"children": []
}
]
}
]
export const usePersonalCenterStore = defineStore('personal-center', () => {
const state: PersonalCenter = reactive({
open: true,
user_menus: userMenus,
show_headline: true,
auth_node: new Map(),
shrink: false,
menu_expand: false,
nav_user_menus: navUserMenus,
})
const setNavUserMenus = (menus: Menus[]) => {
state.nav_user_menus = menus
}
const mergeNavUserMenus = (menus: Menus[]) => {
state.nav_user_menus = [...state.nav_user_menus, ...menus]
}
const setAuthNode = (key: string, data: string[]) => {
state.auth_node.set(key, data)
}
const mergeAuthNode = (authNode: Map<string, string[]>) => {
state.auth_node = new Map([...state.auth_node, ...authNode])
}
const setUserMenus = (menus: Menus[]): void => {
state.user_menus = encodeRoutesURI(menus)
}
const setShowHeadline = (show: boolean): void => {
state.show_headline = show
}
const setShrink = (shrink: boolean) => {
state.shrink = shrink
}
const setStatus = (status: boolean) => {
state.open = status
}
const toggleMenuExpand = (expand = !state.menu_expand) => {
state.menu_expand = expand
}
return {
state,
setAuthNode,
setNavUserMenus,
mergeNavUserMenus,
mergeAuthNode,
setUserMenus,
setShowHeadline,
setShrink,
setStatus,
toggleMenuExpand,
}
})
function encodeRoutesURI(data: Menus[]) {
data.forEach((item) => {
if (item.meta.menu_type == 'iframe') {
item.path = '/user/iframe/' + encodeURIComponent(item.path)
}
if (item.children && item.children.length) {
item.children = encodeRoutesURI(item.children)
}
})
return data
}
+239
View File
@@ -0,0 +1,239 @@
import { defineStore } from 'pinia'
import storage from '@/utils/storage'
import type { Menus } from './interface'
interface System {
lang: string
site: Record<string, any>
}
// 支持的语言列表
const availableLanguages = [
{ name: 'zh-cn', value: '中文' },
{ name: 'en', value: 'English' }
]
const menus = [
{
"id": 1,
"name": "community",
"type": "menu",
"path": "https://madong.tech",
"title": "社区",
"url": "https://www.baidu.com",
"icon": "i-dashboard",
"meta": {
"id": 1,
"type": "menu",
"menu_type": "tab"
}
},
{
"id": 2,
"name": "community",
"type": "menu",
"path": "/",
"title": "模块市场",
"url": "",
"icon": "i-dashboard",
"meta": {
"id": 2,
"type": "menu",
"menu_type": "tab"
}
},
{
"id": 3,
"name": "docs",
"type": "menu",
"path": "/",
"title": "文档",
"url": "",
"icon": "i-dashboard",
"meta": {
"id": 3,
"type": "menu",
"menu_type": "tab"
}
},
{
"id": 4,
"name": "demo",
"type": "directory",
"title": "演示站",
"url": "",
"icon": "i-settings",
"meta": {
"id": 4,
"type": "directory",
"menu_type": "tab"
},
"children": [
{
"id": 201,
"name": "demo1",
"type": "page",
"path": "/demo/test1",
"title": "演示站1",
"url": "",
"icon": "i-users",
"meta": {
"id": 201,
"type": "page",
"menu_type": "tab"
},
"children": []
},
{
"id": 202,
"name": "demo2",
"type": "page",
"path": "/demo/test2",
"title": "演示站2",
"url": "",
"icon": "i-users",
"meta": {
"id": 202,
"type": "page",
"menu_type": "tab"
},
"children": []
}
]
},
{
"id": 5,
"name": "code",
"type": "directory",
"title": "代码仓库",
"url": "",
"icon": "i-link",
"meta": {
"id": 5,
"type": "directory",
"menu_type": "tab"
},
"children": [
{
"id": 501,
"name": "gitee",
"type": "page",
"path": "/code/gitee",
"title": "Gitee",
"url": "https://gitee.com",
"icon": "i-book",
"meta": {
"id": 501,
"type": "page",
"menu_type": "link"
},
"children": []
},
{
"id": 502,
"name": "github",
"type": "page",
"path": "/external/support",
"title": "GitHub",
"url": "https://github.com",
"icon": "i-help-circle",
"meta": {
"id": 502,
"type": "page",
"menu_type": "link"
},
"children": []
}
]
}
] as Menus[]
export const useSystemStore = defineStore('system', {
state: (): System => {
return {
lang: storage.get('lang') ?? 'zh-cn',
site: {
site_name: '通用多应用管理系统后台框架',
record_number: 'test',
version: 'v1.0.1',
upload: {
mode: 'local',
},
head_nav: menus,
initialize: false,
user_initialize: false,
}
}
},
getters: {
/**
* 获取支持的语言列表
*/
getLanguages: () => availableLanguages,
/**
* 获取当前语言信息
*/
getCurrentLanguage: (state) => {
return availableLanguages.find(lang => lang.code === state.lang) || availableLanguages[0]
}
},
actions: {
/**
* 切换语言
* @param name 语言名称
*/
setLanguage(name: string) {
// 验证语言代码是否支持
if (!availableLanguages.some(lang => lang.name === name)) {
console.warn(`不支持的语言代码: ${name}`)
return false
}
this.lang = name
// 保存到本地存储
storage.set({ key: 'lang', data: name })
// 触发 i18n 语言切换
this.updateI18nLocale(name)
// 触发语言切换事件,让组件重新渲染
this.triggerLanguageChange()
return true
},
/**
* 更新 i18n 语言设置
* @param locale 语言代码
*/
updateI18nLocale(locale: string) {
// 获取 i18n 实例并切换语言
const nuxtApp = useNuxtApp()
const i18n = (nuxtApp as any).$i18n
if (i18n && i18n.locale !== locale) {
i18n.locale = locale
}
},
/**
* 触发语言切换事件
*/
triggerLanguageChange() {
// 触发自定义事件,通知组件重新渲染
if (process.client) {
window.dispatchEvent(new CustomEvent('languageChanged', {
detail: { locale: this.lang }
}))
}
},
async getSiteInfoFn() {
// 原有逻辑保持不变
}
}
})
+18
View File
@@ -0,0 +1,18 @@
declare interface AnyObject {
[key: string]: any;
}
declare interface responseResult {
code: number,
data: any,
msg: string
}
interface requestMobileParam {
mobile: string,
captcha_code: string,
captcha_key: string,
type: string
}
+220
View File
@@ -0,0 +1,220 @@
import type { FormInstance } from 'element-plus'
import { isNull, trim } from 'lodash-es'
import type { CSSProperties } from 'vue'
import type { TranslateOptions } from 'vue-i18n'
type anyObj = { [key: string]: any }
/**
* 获取token
* @returns
*/
export function getToken(): null | string {
return useMemberStore().token
}
/**
* 获取资源完整地址
* @param relativeUrl 资源相对地址
* @param domain 指定域名
*/
export const fullUrl = (relativeUrl: string, domain = '') => {
const configStore = useConfigStore()
if (!domain) {
domain = configStore?.cdn_url ? configStore?.cdn_url : import.meta.env.VITE_API_BASE_URL
}
if (!relativeUrl) return domain
const regUrl = new RegExp(/^http(s)?:\/\//)
const regexImg = new RegExp(/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i)
if (!domain || regUrl.test(relativeUrl) || regexImg.test(relativeUrl)) {
return relativeUrl
}
return domain + relativeUrl
}
/**
* 获取一组资源的完整地址
* @param relativeUrls 资源相对地址
* @param domain 指定域名
*/
export const arrayFullUrl = (relativeUrls: string | string[], domain = '') => {
if (typeof relativeUrls === 'string') {
relativeUrls = relativeUrls == '' ? [] : relativeUrls.split(',')
}
for (const key in relativeUrls) {
relativeUrls[key] = fullUrl(relativeUrls[key], domain)
}
return relativeUrls
}
/**
* 从一个文件路径中获取文件名
* @param path 文件路径
*/
export const getFileNameFromPath = (path: string) => {
const paths = path.split('/')
return paths[paths.length - 1]
}
/**
* 是否是外部链接
* @param path
*/
export function isExternal(path: string): boolean {
return /^(https?|ftp|mailto|tel):/.test(path)
}
/**
* 是否为手机设备
*/
export const isMobile = () => {
const event = useRequestEvent()
const userAgent = import.meta.client ? navigator.userAgent : event?.node.req.headers['user-agent']
if (!userAgent) return false
return !!userAgent.match(
/android|webos|ip(hone|ad|od)|opera (mini|mobi|tablet)|iemobile|windows.+(phone|touch)|mobile|fennec|kindle (Fire)|Silk|maemo|blackberry|playbook|bb10\; (touch|kbd)|Symbian(OS)|Ubuntu Touch/i
)
}
/**
* 防抖
* @param fn 执行函数
* @param ms 间隔毫秒数
*/
export const debounce = (fn: Function, ms: number) => {
return (...args: any[]) => {
if (globals.lazy) {
clearTimeout(globals.lazy)
}
globals.lazy = setTimeout(() => {
fn(...args)
}, ms)
}
}
/**
* 表单重置
* @param formEl
*/
export function onResetForm(formEl?: FormInstance | null) {
typeof formEl?.resetFields == 'function' && formEl?.resetFields()
}
/**
* 根据pk字段的值从数组中获取key
* @param arr
* @param pk
* @param value
*/
export const getArrayKey = (arr: any, pk: string, value: any): any => {
for (const key in arr) {
if (arr[key][pk] == value) {
return key
}
}
return false
}
/**
* 获取简洁的路由 path
*/
export const getCurrentRoutePath = () => {
const router = useRouter()
let path = router.currentRoute.value.path
if (path.indexOf('?') !== -1) path = path.replace(/\?.*/, '')
return path
}
export function auth(node: string): boolean
export function auth(node: { name: string; subNodeName?: string }): boolean
/**
* 鉴权
* 提供 string 将根据当前路由 path 自动拼接和鉴权,还可以提供路由的 name 对象进行鉴权
* @param node
*/
export function auth(node: string | { name: string; subNodeName?: string }) {
const store = usePersonalCenterStore()
if (typeof node === 'string') {
const path = getCurrentRoutePath()
if (store.state.authNode.has(path)) {
const subNodeName = path + (path == '/' ? '' : '/') + node
if (store.state.authNode.get(path)!.some((v: string) => v == subNodeName)) {
return true
}
}
} else {
// 节点列表中没有找到 name
if (!node.name || !store.state.authNode.has(node.name)) return false
// 无需继续检查子节点或未找到子节点
if (!node.subNodeName || store.state.authNode.get(node.name)?.includes(node.subNodeName)) return true
}
return false
}
/*
* 格式化时间戳
*/
export const timeFormat = (dateTime: string | number | null = null, fmt = 'yyyy-mm-dd hh:MM:ss') => {
if (dateTime == 'none') {
return i18n.global.t('None')
}
if (isNull(dateTime)) {
dateTime = Number(new Date())
}
if (dateTime.toString().length === 10) {
dateTime = +dateTime * 1000
}
const date = new Date(Number(dateTime))
let ret
const opt: anyObj = {
'y+': date.getFullYear().toString(), // 年
'm+': (date.getMonth() + 1).toString(), // 月
'd+': date.getDate().toString(), // 日
'h+': date.getHours().toString(), // 时
'M+': date.getMinutes().toString(), // 分
's+': date.getSeconds().toString(), // 秒
}
for (const k in opt) {
ret = new RegExp('(' + k + ')').exec(fmt)
if (ret) {
fmt = fmt.replace(ret[1], ret[1].length == 1 ? opt[k] : padStart(opt[k], ret[1].length, '0'))
}
}
return fmt
}
/*
* 字符串补位
*/
const padStart = (str: string, maxLength: number, fillString = ' ') => {
if (str.length >= maxLength) return str
const fillLength = maxLength - str.length
let times = Math.ceil(fillLength / fillString.length)
while ((times >>= 1)) {
fillString += fillString
if (times === 1) {
fillString += fillString
}
}
return fillString.slice(0, fillLength) + str
}
/**
* calc(100vh - minusHeight)
* @param minusHeight 需要减去的高度
* @returns CSSProperties
*/
export function calcHeight(minusHeight = 60): CSSProperties {
return {
height: 'calc(100vh - ' + minusHeight.toString() + 'px)',
}
}
+24
View File
@@ -0,0 +1,24 @@
import { ISDARK } from '~/stores/constant/keys'
export function getDark() {
const dark = useCookie(ISDARK)
return dark.value && parseInt(dark.value) ? true : false
}
export function setDark(val: boolean) {
const dark = useCookie(ISDARK)
dark.value = val ? '1' : '0'
updateHtmlDarkClass(val)
}
export function updateHtmlDarkClass(val: boolean) {
if (import.meta.server) return
const htmlEl = document.getElementsByTagName('html')[0]
htmlEl.setAttribute('class', val ? 'dark' : '')
}
export function initDark() {
updateHtmlDarkClass(getDark())
}
if (import.meta.client) initDark()
+76
View File
@@ -0,0 +1,76 @@
import { nextTick } from 'vue'
class Language {
private i18n: any;
private loadLocale: Array<string> = []; //已加载的语言
public file: string = '';
constructor(i18n: any) {
this.i18n = i18n
}
/**
*
* @param locale 设置语言
*/
public setI18nLanguage(locale: string) {
if (this.i18n.mode === 'legacy') {
this.i18n.global.locale = locale
} else {
this.i18n.global.locale = locale
}
let html = document.querySelector('html')
html && html.setAttribute('lang', locale)
}
/**
* 加载语言包
* @param path
* @param locale
* @returns
*/
public async loadLocaleMessages(app: string, path: string, locale: string) {
try {
const file = path == '/' ? 'index' : path.replace('/', '').replaceAll('/', '.')
if (this.loadLocale.includes(`${app}/${locale}/${file}`)) {
return nextTick()
}
this.loadLocale.push(`${app}/${locale}/${file}`)
if (app != 'app' && !this.loadLocale.includes(`${app}/${locale}/pages`)) {
const pagesMessages = await import(`~/addon/${app}/lang/${locale}/pages.json`)
this.i18n.global.mergeLocaleMessage(locale, pagesMessages)
this.loadLocale.push(`${app}/${locale}/pages`)
}
// 引入语言包文件
const messages = await import(app == 'app' ? `~/app/lang/${locale}/${file}.json` : `~/addon/${app}/lang/${locale}/${file}.json`)
const prefix = this.getFileKey(app, path)
let data: Record<string, string> = {}
Object.keys(messages.default).forEach(key => {
data[`${prefix}.${key}`] = messages.default[key]
})
this.i18n.global.mergeLocaleMessage(locale, data)
this.setI18nLanguage(locale)
return nextTick()
} catch {
this.setI18nLanguage(locale)
return nextTick()
}
}
/**
* @param app
* @param path
*/
public getFileKey = (app: string, path: string) => {
const file = path == '/' ? 'index' : path.replace('/', '').replaceAll('/', '.')
return `${app}.${file}`
}
}
export default Language
+184
View File
@@ -0,0 +1,184 @@
import { ElMessage } from 'element-plus'
import qs from 'qs'
interface ConfigOption {
showErrorMessage?: boolean
showSuccessMessage?: boolean,
headers?: Headers
}
interface FetchOptions {
baseURL: string
headers: Record<string, any>
onRequest?: (data: any) => void
onResponse?: (data: any) => void
onResponseError?: (data: any) => void
showErrorMessage?: boolean
showSuccessMessage?: boolean
watch: boolean
}
class Http {
private options: FetchOptions = {
baseURL: '',
headers: {},
watch: false
}
public constructor() {
/**
* 全局请求拦截器
*/
this.options.onRequest = (data) => {
const runtimeConfig = useRuntimeConfig()
this.options.baseURL = runtimeConfig.public.VITE_APP_BASE_URL || `${location.origin}/api/`
this.options.headers[runtimeConfig.public.VITE_REQUEST_HEADER_CHANNEL_KEY] = 'pc'
if (getToken()) this.options.headers[runtimeConfig.public.VITE_REQUEST_HEADER_TOKEN_KEY] = getToken()
}
/**
* 全局响应拦截器
*/
this.options.onResponse = ({ response, options }) => {
const { _data: data } = response
this.handleNetworkError(response)
if (data.code != undefined) {
if (data.code == 1) {
if (options.showSuccessMessage) ElMessage({ message: data.msg, type: 'success' })
} else {
if (options.showErrorMessage === false) return;
if (data.code == 0 || data.code == 400) {
ElMessage({ message: data.msg, type: 'error' })
} else {
this.handleAuthError(data.code)
}
}
}
}
}
public get(url: string, query = {}, config: ConfigOption = {}) {
url += '?' + qs.stringify(query)
return this.request(url, 'GET', {}, config)
}
public post(url: string, body = {}, config: ConfigOption = {}) {
return this.request(url, 'POST', { body }, config)
}
public put(url: string, body = {}, config: ConfigOption = {}) {
return this.request(url, 'PUT', { body }, config)
}
public delete(url: string, config: ConfigOption = {}) {
return this.request(url, 'DELETE', {}, config)
}
/**
* 发送请求
* @param url
* @param method
* @param param
* @param config
*/
private request(url: string, method: string, param: AnyObject = {}, config: ConfigOption = {}) {
return new Promise((resolve, reject) => {
setTimeout(() => {
// 处理首次请求baseurl空的问题
const runtimeConfig = useRuntimeConfig()
!this.options.baseURL && (this.options.baseURL = runtimeConfig.public.VITE_APP_BASE_URL || `${location.origin}/api/`)
this.options.baseURL.substr(-1) != '/' && (this.options.baseURL += '/')
// 处理数组格式
for (const key in param.query) {
if (param.query[key] instanceof Array) {
param.query[key].forEach((item, index) => {
param.query[`${key}[${index}]`] = item
});
delete param.query[key]
}
}
useFetch(url, { ...this.options, method, ...config, ...param }).then((response) => {
const { data: { value }, error } = response
if (value) {
if (value.code && value.code == 1) {
resolve(value)
} else {
if (value.type && value.type == 'application/zip') {
resolve(value)
} else {
reject(value)
}
}
} else {
reject(error)
}
}).catch(err => {
reject(err)
})
}, this.options.baseURL ? 0 : 500);
})
}
private handleAuthError(code: number) {
switch (code) {
case 401:
useMemberStore().logout()
break;
case 402:
navigateTo('/site/close', { replace: true })
break;
}
}
private handleNetworkError(err: any) {
if (err.status && err.status != 200) {
let errMessage = ''
switch (err.status) {
case 400:
errMessage = t('request.400')
break
case 401:
errMessage = t('request.401')
break
case 403:
errMessage = t('request.403')
break
case 404:
errMessage = err.url + t('request.404')
break
case 405:
errMessage = t('request.405')
break
case 408:
errMessage = t('request.408')
break
case 409:
errMessage = t('request.409')
break
case 500:
errMessage = t('request.500')
break
case 501:
errMessage = t('request.501')
break
case 502:
errMessage = t('request.502')
break
case 503:
errMessage = t('request.503')
break
case 504:
errMessage = t('request.504')
break
case 505:
errMessage = t('request.505')
break
}
ElMessage({ message: errMessage, type: 'error' })
}
}
}
const request = new Http()
export default request
+129
View File
@@ -0,0 +1,129 @@
import type { RouteRecordRaw } from 'vue-router'
import type { Menus } from '~/stores/interface'
/**
* 获取第一个菜单
*/
export const getFirstRoute = (routes: RouteRecordRaw[]): false | RouteRecordRaw => {
const routerPaths: string[] = []
const router = useRouter()
const routers = router.getRoutes()
routers.forEach((item) => {
if (item.path) routerPaths.push(item.path)
})
let find: boolean | RouteRecordRaw = false
for (const key in routes) {
if (routes[key].meta?.type != 'menu_dir' && routerPaths.indexOf(routes[key].path) !== -1) {
return routes[key]
} else if (routes[key].children && routes[key].children?.length) {
find = getFirstRoute(routes[key].children!)
if (find) return find
}
}
return find
}
/**
* 打开菜单
* @param menu 菜单数据
*/
export const onClickMenu = (menu: RouteRecordRaw) => {
switch (menu.meta?.menu_type) {
case 'iframe':
case 'tab':
navigateTo(menu.path)
break
case 'link':
window.open(menu.path, '_blank')
break
default:
alert('菜单类型未识别')
// ElNotification({
// message: $t('utils.Navigation failed, the menu type is unrecognized!'),
// type: 'error',
// })
break
}
}
/**
* 处理权限节点
* @param routes 路由数据
* @param prefix 节点前缀
* @returns 组装好的权限节点
*/
export const handleAuthNode = (routes: any, prefix = '/') => {
const authNode: Map<string, string[]> = new Map([])
assembleAuthNode(routes, authNode, prefix, prefix)
return authNode
}
const assembleAuthNode = (routes: any, authNode: Map<string, string[]>, prefix = '/', parent = '/') => {
const authNodeTemp = []
for (const key in routes) {
if (routes[key].type == 'button') authNodeTemp.push(prefix + routes[key].name)
if (routes[key].children && routes[key].children.length > 0) {
assembleAuthNode(routes[key].children, authNode, prefix, prefix + routes[key].name)
}
}
if (authNodeTemp && authNodeTemp.length > 0) {
authNode.set(parent, authNodeTemp)
}
}
export const registerMenus = (rules: any, menus: any) => {
const systemConfig = useSystemStore()
// if (rules.length) {
// personalCenter.mergeAuthNode(handleAuthNode(rules, '/'))
// systemConfig.setHeadNav(handleMenus(rules, '/', ['nav']))
// personalCenter.mergeNavUserMenus(handleMenus(rules, '/', ['nav_user_menu']))
// }
// if (menus.length) {
// const menupersonalCenterBaseRoute = '/user/'
// personalCenter.mergeAuthNode(handleAuthNode(menus, menupersonalCenterBaseRoute))
// personalCenter.mergeNavUserMenus(handleMenus(menus, '/', ['nav_user_menu']))
// personalCenter.setShowHeadline(menus.length > 1)
// personalCenter.setUserMenus(handleMenus(menus, menupersonalCenterBaseRoute, ['menu', 'menu_dir']))
// }
}
export const handleMenus = (rules: any, prefix = '/', type = ['nav']) => {
const menus: Menus[] = []
for (const key in rules) {
if (rules[key].extend == 'add_rules_only') {
continue
}
if (rules[key].type == 'menu_dir' && rules[key].children && !rules[key].children.length) {
continue
}
let children: Menus[] = []
if (rules[key].children && rules[key].children.length > 0) {
children = handleMenus(rules[key].children, prefix, type)
}
if (type.includes(rules[key].type)) {
let path = ''
if (['link', 'iframe'].includes(rules[key].menu_type)) {
path = rules[key].url
} else {
path = prefix + rules[key].path
}
menus.push({
...rules[key],
meta: {
/**
* 在 meta 中存储多个可能用到的属性
* 兼容 RouteRecordRaw 和 RouteLocationNormalizedLoaded 类型
*/
id: rules[key].id,
type: rules[key].type,
menu_type: rules[key].menu_type,
},
path: path,
children: children,
})
}
}
return menus
}
+55
View File
@@ -0,0 +1,55 @@
interface setParam {
key: string,
data: any,
success?: () => {},
fail?: (err: any) => {}
}
class Storage {
/**
* 设置缓存
* @param param
*/
public set(param: setParam) {
try {
window.localStorage.setItem(param.key, JSON.stringify(param.data))
typeof param.success == 'function' && param.success()
} catch (error) {
typeof param.fail == 'function' && param.fail(error)
}
}
/**
* 获取缓存
* @param key
* @returns
*/
public get(key: string) {
try {
const json: any = window.localStorage.getItem(key)
return JSON.parse(json)
} catch (error) {
return null
}
}
/**
* 移除指定缓存
* @param key
*/
public remove(key: string | string[]) {
if (typeof key == 'string') window.localStorage.removeItem(key)
else key.forEach(item => { window.localStorage.removeItem(item) })
}
/**
* 清理缓存
*/
public clear() {
window.localStorage.clear()
}
}
const storage = new Storage()
export default storage
+232
View File
@@ -0,0 +1,232 @@
const validate = {
/**
* 验证电子邮箱格式
*/
email(value: string) {
return /^\w+((-\w+)|(\.\w+))*\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$/.test(value)
},
/**
* 验证手机格式
*/
mobile(value: string) {
return /^1[23456789]\d{9}$/.test(value)
},
/**
* 验证URL格式
*/
url(value: string) {
return /^((https|http|ftp|rtsp|mms):\/\/)(([0-9a-zA-Z_!~*'().&=+$%-]+: )?[0-9a-zA-Z_!~*'().&=+$%-]+@)?(([0-9]{1,3}.){3}[0-9]{1,3}|([0-9a-zA-Z_!~*'()-]+.)*([0-9a-zA-Z][0-9a-zA-Z-]{0,61})?[0-9a-zA-Z].[a-zA-Z]{2,6})(:[0-9]{1,4})?((\/?)|(\/[0-9a-zA-Z_!~*'().;?:@&=+$,%#-]+)+\/?)$/
.test(value)
},
/**
* 验证日期格式
*/
date(value: string) {
if (!value) return false
// 判断是否数值或者字符串数值(意味着为时间戳),转为数值,否则new Date无法识别字符串时间戳
if (this.number(value)) value = +value
return !/Invalid|NaN/.test(new Date(value).toString())
},
/**
* 验证ISO类型的日期格式
*/
dateISO(value: string) {
return /^\d{4}[\/\-](0?[1-9]|1[012])[\/\-](0?[1-9]|[12][0-9]|3[01])$/.test(value)
},
/**
* 验证十进制数字
*/
number(value: string) {
return /^[\+-]?(\d+\.?\d*|\.\d+|\d\.\d+e\+\d+)$/.test(value)
},
/**
* 验证字符串
*/
string(value: string) {
return typeof value === 'string'
},
/**
* 验证整数
*/
digits(value: string) {
return /^\d+$/.test(value)
},
/**
* 验证身份证号码
*/
idCard(value: string) {
return /^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X)$/.test(
value
)
},
/**
* 是否车牌号
*/
carNo(value: string) {
// 新能源车牌
const xreg = /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}(([0-9]{5}[DF]$)|([DF][A-HJ-NP-Z0-9][0-9]{4}$))/
// 旧车牌
const creg = /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]{1}$/
if (value.length === 7) {
return creg.test(value)
} if (value.length === 8) {
return xreg.test(value)
}
return false
},
/**
* 金额,只允许2位小数
*/
amount(value: string) {
// 金额,只允许保留两位小数
return /^[1-9]\d*(,\d{3})*(\.\d{1,2})?$|^0\.\d{1,2}$/.test(value)
},
/**
* 中文
*/
chinese(value: string) {
const reg = /^[\u4e00-\u9fa5]+$/gi
return reg.test(value)
},
/**
* 只能输入字母
*/
letter(value: string) {
return /^[a-zA-Z]*$/.test(value)
},
/**
* 只能是字母或者数字
*/
enOrNum(value: string) {
// 英文或者数字
const reg = /^[0-9a-zA-Z]*$/g
return reg.test(value)
},
/**
* 验证是否包含某个值
*/
contains(value: string, param: string) {
return value.indexOf(param) >= 0
},
/**
* 验证一个值范围[min, max]
*/
range(value: number, param: number[]) {
return value >= param[0] && value <= param[1]
},
/**
* 验证一个长度范围[min, max]
*/
rangeLength(value: string, param: number[]) {
return value.length >= param[0] && value.length <= param[1]
},
/**
* 是否固定电话
*/
landline(value: string) {
const reg = /^\d{3,4}-\d{7,8}(-\d{3,4})?$/
return reg.test(value)
},
/**
* 判断是否为空
*/
empty(value: any) {
switch (typeof value) {
case 'undefined':
return true
case 'string':
if (value.replace(/(^[ \t\n\r]*)|([ \t\n\r]*$)/g, '').length == 0) return true
break
case 'boolean':
if (!value) return true
break
case 'number':
if (value === 0 || isNaN(value)) return true
break
case 'object':
if (value === null || value.length === 0) return true
for (const i in value) {
return false
}
return true
}
return false
},
/**
* 是否json字符串
*/
jsonString(value: object) {
if (typeof value === 'string') {
try {
const obj = JSON.parse(value)
if (typeof obj === 'object' && obj) {
return true
}
return false
} catch (e) {
return false
}
}
return false
},
/**
* 是否数组
*/
array(value: []) {
if (typeof Array.isArray === 'function') {
return Array.isArray(value)
}
return Object.prototype.toString.call(value) === '[object Array]'
},
/**
* 是否对象
*/
object(value: object) {
return Object.prototype.toString.call(value) === '[object Object]'
},
/**
* 是否短信验证码
*/
code(value: string, len = 6) {
return new RegExp(`^\\d{${len}}$`).test(value)
},
/**
* 是否函数方法
* @param {Object} value
*/
func(value: string) {
return typeof value === 'function'
},
/**
* 是否promise对象
* @param {Object} value
*/
promise(value: Promise<any>) {
return this.object(value) && this.func(value.then) && this.func(value.catch)
},
/** 是否图片格式
* @param {Object} value
*/
image(value: string) {
const newValue = value.split('?')[0]
const IMAGE_REGEXP = /\.(jpeg|jpg|gif|png|svg|webp|jfif|bmp|dpg)/i
return IMAGE_REGEXP.test(newValue)
},
/**
* 是否视频格式
* @param {Object} value
*/
video(value: string) {
const VIDEO_REGEXP = /\.(mp4|mpg|mpeg|dat|asf|avi|rm|rmvb|mov|wmv|flv|mkv|m3u8)/i
return VIDEO_REGEXP.test(value)
},
/**
* 是否为正则对象
* @param o
*/
regExp(o) {
return o && Object.prototype.toString.call(o) === '[object RegExp]'
}
}
export default validate