init
This commit is contained in:
2
pc/.env.development.example
Normal file
2
pc/.env.development.example
Normal file
@ -0,0 +1,2 @@
|
||||
# 请求域名
|
||||
NUXT_API_URL=
|
17
pc/.env.example
Normal file
17
pc/.env.example
Normal file
@ -0,0 +1,17 @@
|
||||
# 版本号
|
||||
NUXT_VERSION=1.0
|
||||
|
||||
# 接口默认前缀
|
||||
NUXT_API_PREFIX=/api
|
||||
|
||||
# 客户端类型
|
||||
NUXT_CLIENT=4
|
||||
|
||||
# 基础路径
|
||||
NUXT_BASE_URL=/pc/
|
||||
|
||||
# 是否开启ssr,填些任意值开启
|
||||
NUXT_SSR=
|
||||
|
||||
# 端口号
|
||||
NITRO_PORT=3000
|
3
pc/.env.production.example
Normal file
3
pc/.env.production.example
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
# 请求域名
|
||||
NUXT_API_URL=
|
46
pc/.eslintrc.cjs
Normal file
46
pc/.eslintrc.cjs
Normal file
@ -0,0 +1,46 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'plugin:nuxt/recommended',
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript/recommended',
|
||||
'@vue/eslint-config-prettier'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
parser: '@typescript-eslint/parser',
|
||||
sourceType: 'module'
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
'prettier/prettier': [
|
||||
'warn',
|
||||
{
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
printWidth: 80,
|
||||
proseWrap: 'preserve',
|
||||
bracketSameLine: false,
|
||||
endOfLine: 'auto',
|
||||
tabWidth: 4,
|
||||
useTabs: false,
|
||||
trailingComma: 'none'
|
||||
}
|
||||
],
|
||||
'no-useless-escape': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'no-undef': 'off',
|
||||
'vue/prefer-import-from-vue': 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
'prefer-spread': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off'
|
||||
},
|
||||
globals: {
|
||||
module: 'readonly'
|
||||
}
|
||||
}
|
11
pc/.gitignore
vendored
Normal file
11
pc/.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
*.log*
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.output
|
||||
.vite
|
||||
.env
|
||||
.env.development
|
||||
.env.production
|
||||
dist
|
42
pc/README.md
Normal file
42
pc/README.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Nuxt 3 Minimal Starter
|
||||
|
||||
Look at the [nuxt 3 documentation](https://v3.nuxtjs.org) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install the dependencies:
|
||||
|
||||
```bash
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install --shamefully-hoist
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on http://localhost:3000
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Checkout the [deployment documentation](https://v3.nuxtjs.org/guide/deploy/presets) for more information.
|
41
pc/api/account.ts
Normal file
41
pc/api/account.ts
Normal file
@ -0,0 +1,41 @@
|
||||
// 登录
|
||||
export function mobileLogin(params: any) {
|
||||
return $request.post({
|
||||
url: '/login/mobileLogin',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function accountLogin(params: any) {
|
||||
return $request.post({
|
||||
url: '/login/accountLogin',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
//注册
|
||||
export function register(params: any) {
|
||||
return $request.post({
|
||||
url: '/login/register',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
//向微信请求code的链接
|
||||
export function getWxCodeUrl() {
|
||||
return $request.get({
|
||||
url: '/login/scanCodeUrl',
|
||||
params: {
|
||||
url: location.href
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function wxLogin(params: any) {
|
||||
return $request.post({ url: '/login/scanLogin', params })
|
||||
}
|
||||
|
||||
//忘记密码
|
||||
export function forgotPassword(params: Record<string, any>) {
|
||||
return $request.post({ url: '/login/forgotPassword', params })
|
||||
}
|
19
pc/api/app.ts
Normal file
19
pc/api/app.ts
Normal file
@ -0,0 +1,19 @@
|
||||
//发送短信
|
||||
export function smsSend(params: any) {
|
||||
return $request.post({ url: '/index/sendSms', params })
|
||||
}
|
||||
|
||||
// 获取配置
|
||||
export function getConfig() {
|
||||
return $request.get({ url: '/pc/getConfig' })
|
||||
}
|
||||
|
||||
// 获取协议
|
||||
export function getPolicy(params: any) {
|
||||
return $request.get({ url: '/index/policy', params })
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
export function uploadImage(params: any) {
|
||||
return $request.uploadFile({ url: '/upload/image' }, params)
|
||||
}
|
57
pc/api/news.ts
Normal file
57
pc/api/news.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @description 获取文章分类
|
||||
* @return { Promise }
|
||||
*/
|
||||
export function getArticleCate() {
|
||||
return $request.get({ url: '/article/category' })
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取文章列表
|
||||
* @return { Promise }
|
||||
*/
|
||||
export function getArticleList(params) {
|
||||
return $request.get({ url: '/article/list', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取资讯中心
|
||||
* @return { Promise }
|
||||
*/
|
||||
export function getArticleCenter() {
|
||||
return $request.get({ url: '/pc/articleCenter' })
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 文章详情
|
||||
* @return { Promise }
|
||||
*/
|
||||
export function getArticleDetail(params) {
|
||||
return $request.get({ url: '/pc/articleDetail', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 加入收藏
|
||||
* @param { number } id
|
||||
* @return { Promise }
|
||||
*/
|
||||
export function addCollect(params) {
|
||||
return $request.post({ url: '/article/collectAdd', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 取消收藏
|
||||
* @param { number } id
|
||||
* @return { Promise }
|
||||
*/
|
||||
export function cancelCollect(params) {
|
||||
return $request.post({ url: '/article/collectCancel', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取收藏列表
|
||||
* @return { Promise }
|
||||
*/
|
||||
export function getCollect(params) {
|
||||
return $request.get({ url: '/article/collectList', params })
|
||||
}
|
4
pc/api/shop.ts
Normal file
4
pc/api/shop.ts
Normal file
@ -0,0 +1,4 @@
|
||||
//首页数据
|
||||
export function getIndex() {
|
||||
return $request.get({ url: '/pc/index' })
|
||||
}
|
26
pc/api/user.ts
Normal file
26
pc/api/user.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export function getUserCenter(headers?: any) {
|
||||
return $request.get({ url: '/user/center', headers })
|
||||
}
|
||||
|
||||
// 个人信息
|
||||
export function getUserInfo() {
|
||||
return $request.get({ url: '/user/info' })
|
||||
}
|
||||
|
||||
// 个人编辑
|
||||
export function userEdit(params: any) {
|
||||
return $request.post({ url: '/user/edit', params })
|
||||
}
|
||||
|
||||
// 绑定手机
|
||||
export function userBindMobile(params: any, headers?: any) {
|
||||
return $request.post(
|
||||
{ url: '/user/bindMobile', params, headers },
|
||||
{ withToken: !headers?.token }
|
||||
)
|
||||
}
|
||||
|
||||
// 更改密码
|
||||
export function userChangePwd(params: any) {
|
||||
return $request.post({ url: '/user/changePwd', params })
|
||||
}
|
35
pc/app.vue
Normal file
35
pc/app.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import { ID_INJECTION_KEY, ElConfigProvider } from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { useAppStore } from './stores/app'
|
||||
provide(ID_INJECTION_KEY, {
|
||||
prefix: 100,
|
||||
current: 0
|
||||
})
|
||||
const config = {
|
||||
locale: zhCn
|
||||
}
|
||||
const appStore = useAppStore()
|
||||
const { pcTitle, pcIco, pcKeywords, pcDesc } = appStore.getWebsiteConfig
|
||||
useHead({
|
||||
title: pcTitle,
|
||||
meta: [
|
||||
{ name: 'description', content: pcDesc },
|
||||
{ name: 'keywords', content: pcKeywords }
|
||||
],
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
href: pcIco
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ElConfigProvider v-bind="config">
|
||||
<NuxtLayout>
|
||||
<NuxtLoadingIndicator color="#4a5dff" :height="2" />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</ElConfigProvider>
|
||||
</template>
|
BIN
pc/assets/images/empty_news.png
Normal file
BIN
pc/assets/images/empty_news.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
BIN
pc/assets/images/icon/icon_wx.png
Normal file
BIN
pc/assets/images/icon/icon_wx.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
BIN
pc/assets/images/placeholder.png
Normal file
BIN
pc/assets/images/placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.0 KiB |
126
pc/assets/styles/element.scss
Normal file
126
pc/assets/styles/element.scss
Normal file
@ -0,0 +1,126 @@
|
||||
@import 'element-plus/theme-chalk/index.css';
|
||||
|
||||
:root {
|
||||
// 弹窗居中
|
||||
.el-overlay-dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100%;
|
||||
position: static;
|
||||
|
||||
.el-dialog {
|
||||
--el-dialog-content-font-size: var(--el-font-size-base);
|
||||
--el-dialog-margin-top: 50px;
|
||||
max-width: calc(100vw - 30px);
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 5px;
|
||||
|
||||
&.body-padding .el-dialog__body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
flex: 1;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
.el-dialog__header {
|
||||
font-size: var(--el-font-size-large);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-drawer {
|
||||
--el-drawer-padding-primary: 16px;
|
||||
&__header {
|
||||
margin-bottom: 0;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
&__title {
|
||||
@apply text-tx-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-header-text-color: var(--el-text-color-primary);
|
||||
--el-table-header-bg-color: var(--table-header-bg-color);
|
||||
font-size: var(--el-font-size-base);
|
||||
|
||||
thead {
|
||||
th {
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-input-group__prepend {
|
||||
background-color: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.el-checkbox {
|
||||
--el-checkbox-font-size: var(--el-font-size-base);
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
--el-messagebox-width: 350px;
|
||||
}
|
||||
.el-date-editor {
|
||||
--el-date-editor-datetimerange-width: 380px;
|
||||
.el-range-input {
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
--el-button-hover-link-text-color: var(--el-color-primary-light-3);
|
||||
}
|
||||
.el-button--success {
|
||||
--el-button-hover-link-text-color: var(--el-color-success-light-3);
|
||||
}
|
||||
.el-button--info {
|
||||
--el-button-hover-link-text-color: var(--el-color-info-light-3);
|
||||
}
|
||||
.el-button--warning {
|
||||
--el-button-hover-link-text-color: var(--el-color-warning-light-3);
|
||||
}
|
||||
.el-button--danger {
|
||||
--el-button-hover-link-text-color: var(--el-color-danger-light-3);
|
||||
}
|
||||
.el-image__error {
|
||||
font-size: 12px;
|
||||
}
|
||||
.el-tabs__nav-wrap::after {
|
||||
height: 1px;
|
||||
}
|
||||
.el-page-header {
|
||||
&__breadcrumb {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.el-card {
|
||||
--el-card-border-radius: 8px;
|
||||
}
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.el-button {
|
||||
// 防止被tailwindcss默认样式覆盖
|
||||
background-color: var(--el-button-bg-color, var(--el-color-white));
|
||||
|
||||
//覆盖el-button的点击样式
|
||||
&:focus {
|
||||
color: var(--el-button-text-color);
|
||||
border-color: var(--el-button-border-color);
|
||||
background-color: var(--el-button-bg-color);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--el-button-hover-text-color);
|
||||
border-color: var(--el-button-hover-border-color);
|
||||
background-color: var(--el-button-hover-bg-color);
|
||||
}
|
||||
}
|
3
pc/assets/styles/index.scss
Normal file
3
pc/assets/styles/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
@use 'element.scss';
|
||||
@use 'var.css';
|
||||
@use 'public.scss';
|
56
pc/assets/styles/public.scss
Normal file
56
pc/assets/styles/public.scss
Normal file
@ -0,0 +1,56 @@
|
||||
body {
|
||||
@apply text-base text-tx-primary bg-page;
|
||||
min-width: 1200px;
|
||||
}
|
||||
body,
|
||||
html {
|
||||
// width: 100vw;
|
||||
}
|
||||
.form-tips {
|
||||
@apply text-tx-secondary text-xs leading-6 mt-1;
|
||||
}
|
||||
.el-button {
|
||||
background-color: var(--el-button-bg-color, var(--el-color-white));
|
||||
}
|
||||
.clearfix:after {
|
||||
content: '';
|
||||
display: block;
|
||||
clear: both;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.render-html {
|
||||
ul {
|
||||
list-style: disc;
|
||||
}
|
||||
ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.17em;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
h5 {
|
||||
font-size: 0.83em;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* NProgress */
|
||||
#nprogress .bar {
|
||||
@apply bg-primary #{!important};
|
||||
}
|
56
pc/assets/styles/var.css
Normal file
56
pc/assets/styles/var.css
Normal file
@ -0,0 +1,56 @@
|
||||
:root {
|
||||
--el-font-family: theme(fontFamily.sans);
|
||||
--el-color-primary: #4a5dff;
|
||||
--el-color-primary-dark-2: rgb(59, 74, 204);
|
||||
--el-color-primary-light-3: rgb(128, 142, 255);
|
||||
--el-color-primary-light-5: rgb(165, 174, 255);
|
||||
--el-color-primary-light-7: rgb(201, 206, 255);
|
||||
--el-color-primary-light-8: rgb(219, 223, 255);
|
||||
--el-color-primary-light-9: rgb(237, 239, 255);
|
||||
--el-font-weight-primary: 400;
|
||||
--el-menu-item-height: 46px;
|
||||
--el-menu-sub-item-height: var(--el-menu-item-height);
|
||||
--el-menu-icon-width: 18px;
|
||||
--aside-width: 200px;
|
||||
--header-height: 60px;
|
||||
--color-white: #ffffff;
|
||||
--table-header-bg-color: #f8f8f8;
|
||||
--el-font-size-extra-large: 18px;
|
||||
--el-menu-base-level-padding: 16px;
|
||||
--el-menu-level-padding: 26px;
|
||||
--el-font-size-large: 16px;
|
||||
--el-font-size-medium: 15px;
|
||||
--el-font-size-base: 14px;
|
||||
--el-font-size-small: 13px;
|
||||
--el-font-size-extra-small: 12px;
|
||||
|
||||
--el-bg-color: var(--color-white);
|
||||
--el-bg-color-page: #f7f7f7;
|
||||
--el-bg-color-overlay: #ffffff;
|
||||
--el-text-color-primary: #333333;
|
||||
--el-text-color-regular: #666666;
|
||||
--el-text-color-secondary: #999999;
|
||||
--el-text-color-placeholder: #a8abb2;
|
||||
--el-text-color-disabled: #c0c4cc;
|
||||
--el-border-color: #dcdfe6;
|
||||
--el-border-color-light: #e4e7ed;
|
||||
--el-border-color-lighter: #ebeef5;
|
||||
--el-border-color-extra-light: #f2f2f2;
|
||||
--el-border-color-dark: #d4d7de;
|
||||
--el-border-color-darker: #cdd0d6;
|
||||
--el-fill-color: #f0f2f5;
|
||||
--el-fill-color-light: #f5f7fa;
|
||||
--el-fill-color-lighter: #fafafa;
|
||||
--el-fill-color-extra-light: #fafcff;
|
||||
--el-fill-color-dark: #ebedf0;
|
||||
--el-fill-color-darker: #e6e8eb;
|
||||
--el-fill-color-blank: #ffffff;
|
||||
--el-mask-color: rgba(255, 255, 255, 0.9);
|
||||
--el-mask-color-extra-light: rgba(255, 255, 255, 0.3);
|
||||
-el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04),
|
||||
0px 8px 20px rgba(0, 0, 0, 0.08);
|
||||
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12);
|
||||
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12);
|
||||
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08),
|
||||
0px 12px 32px rgba(0, 0, 0, 0.12), 0px 8px 16px -8px rgba(0, 0, 0, 0.16);
|
||||
}
|
73
pc/components/cropper-upload/index.vue
Normal file
73
pc/components/cropper-upload/index.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<div>
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
:show-file-list="false"
|
||||
:limit="1"
|
||||
:on-change="handleChange"
|
||||
:auto-upload="false"
|
||||
>
|
||||
<slot />
|
||||
</ElUpload>
|
||||
<ElDialog
|
||||
v-model="state.cropperVisible"
|
||||
:append-to-body="true"
|
||||
:close-on-click-modal="false"
|
||||
:width="600"
|
||||
@close="state.cropperVisible = false"
|
||||
>
|
||||
<div class="h-[400px]">
|
||||
<VueCropper
|
||||
ref="vueCropperRef"
|
||||
:img="state.imagePath"
|
||||
:autoCrop="true"
|
||||
:auto-crop-height="200"
|
||||
:auto-crop-width="200"
|
||||
output-type="png"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<ElButton @click="handleConfirmCropper">
|
||||
确认裁剪
|
||||
</ElButton>
|
||||
</span>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElUpload, ElDialog, ElButton } from 'element-plus'
|
||||
import 'vue-cropper/dist/index.css'
|
||||
import { VueCropper } from 'vue-cropper'
|
||||
import { uploadImage } from '~~/api/app'
|
||||
const emit = defineEmits(['change'])
|
||||
const vueCropperRef = shallowRef()
|
||||
const uploadRef = shallowRef<InstanceType<typeof ElUpload>>()
|
||||
|
||||
const state = reactive({
|
||||
cropperVisible: false,
|
||||
imagePath: ''
|
||||
})
|
||||
|
||||
const handleChange = (rawFile) => {
|
||||
const URL = window.URL || window.webkitURL
|
||||
state.imagePath = URL.createObjectURL(rawFile.raw)
|
||||
state.cropperVisible = true
|
||||
}
|
||||
const handleConfirmCropper = () => {
|
||||
vueCropperRef.value?.getCropBlob(async (file) => {
|
||||
const fileName = `file.${file.type.split('/')[1]}`
|
||||
const imgFile = new window.File([file], fileName, {
|
||||
type: file.type
|
||||
})
|
||||
const data = await uploadImage({ file: imgFile })
|
||||
state.cropperVisible = false
|
||||
emit('change', data.path)
|
||||
uploadRef.value?.clearFiles()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
28
pc/components/icon/index.vue
Normal file
28
pc/components/icon/index.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<ElIcon v-bind="props" v-if="name.includes(EL_ICON_PREFIX)">
|
||||
<component :is="name" />
|
||||
</ElIcon>
|
||||
<span v-if="name.includes(LOCAL_ICON_PREFIX)" class="local-icon">
|
||||
<SvgIcon v-bind="props" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ElIcon } from 'element-plus'
|
||||
import { EL_ICON_PREFIX, LOCAL_ICON_PREFIX } from '~~/plugins/icons'
|
||||
import SvgIcon from './svg-icon.vue'
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: '14px'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'inherit'
|
||||
}
|
||||
})
|
||||
</script>
|
38
pc/components/icon/svg-icon.vue
Normal file
38
pc/components/icon/svg-icon.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<svg aria-hidden="true" :style="styles">
|
||||
<use :xlink:href="symbolId" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { addUnit } from '@/utils/util'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: 16
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'inherit'
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const symbolId = computed(() => `#${props.name}`)
|
||||
const styles = computed<CSSProperties>(() => {
|
||||
return {
|
||||
width: addUnit(props.size),
|
||||
height: addUnit(props.size),
|
||||
color: props.color
|
||||
}
|
||||
})
|
||||
return { symbolId, styles }
|
||||
}
|
||||
})
|
||||
</script>
|
115
pc/components/information/card.vue
Normal file
115
pc/components/information/card.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-[8px]">
|
||||
<div class="flex items-center h-[60px] border-b border-br ml-5 pr-5">
|
||||
<div class="flex-1 flex min-w-0 mr-4 h-full">
|
||||
<span
|
||||
class="text-2xl truncate font-medium h-full border-b-2 border-tx-primary mt-[1px] flex items-center"
|
||||
>
|
||||
{{ header }}
|
||||
</span>
|
||||
</div>
|
||||
<ElButton class="button" link v-if="link">
|
||||
<NuxtLink :to="link" class="flex">
|
||||
更多
|
||||
<ElIcon><ArrowRight /></ElIcon>
|
||||
</NuxtLink>
|
||||
</ElButton>
|
||||
</div>
|
||||
<slot name="content" :data="data" v-if="data.length">
|
||||
<div class="px-5 pb-5">
|
||||
<template v-for="(item, index) in data" :key="item.id">
|
||||
<slot name="item" :item="item" :index="index">
|
||||
<InformationItems
|
||||
:index="index"
|
||||
:show-sort="showSort"
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:desc="item.intro"
|
||||
:click="item.visit"
|
||||
:author="item.author"
|
||||
:create-time="item.createTime"
|
||||
:image="item.image"
|
||||
:only-title="onlyTitle"
|
||||
:image-size="imageSize"
|
||||
:show-author="showAuthor"
|
||||
:show-desc="showDesc"
|
||||
:show-click="showClick"
|
||||
:border="border"
|
||||
:title-line="titleLine"
|
||||
:show-time="showTime"
|
||||
:source="source"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</div>
|
||||
</slot>
|
||||
<div v-else>
|
||||
<el-empty
|
||||
:image="empty_news"
|
||||
description="暂无资讯"
|
||||
:image-size="250"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElButton, ElIcon, ElEmpty } from 'element-plus'
|
||||
import empty_news from '@/assets/images/empty_news.png'
|
||||
import { ArrowRight } from '@element-plus/icons-vue'
|
||||
import { PropType } from 'vue'
|
||||
defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
data: {
|
||||
type: Array as PropType<any[]>,
|
||||
default: () => []
|
||||
},
|
||||
source: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
onlyTitle: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
titleLine: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
border: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
imageSize: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
showAuthor: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showDesc: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showClick: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showTime: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showSort: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
168
pc/components/information/items.vue
Normal file
168
pc/components/information/items.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<NuxtLink :to="`/information/detail/${id}`">
|
||||
<div
|
||||
v-if="onlyTitle"
|
||||
class="before:w-[6px] mt-4 before:h-[6px] before:bg-primary before:block flex items-center before:rounded-[6px] before:mr-2.5 before:flex-none"
|
||||
>
|
||||
<slot name="title" :title="title">
|
||||
<span class="line-clamp-1 flex-1 font-medium">{{ title }}</span>
|
||||
</slot>
|
||||
<span class="text-tx-secondary ml-4" v-if="showTime">
|
||||
{{ createTime }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="{
|
||||
'border-b border-br pb-4': border,
|
||||
'flex pt-4 items-center': !isHorizontal
|
||||
}"
|
||||
>
|
||||
<div class="flex relative">
|
||||
<ElImage
|
||||
v-if="image"
|
||||
class="flex-none"
|
||||
:class="{
|
||||
'mr-4': !isHorizontal
|
||||
}"
|
||||
:src="image"
|
||||
fit="cover"
|
||||
:style="getImageStyle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1"
|
||||
:class="{
|
||||
'p-2': isHorizontal
|
||||
}"
|
||||
>
|
||||
<slot name="title" :title="title">
|
||||
<div
|
||||
class="text-lg font-medium"
|
||||
:class="`line-clamp-${titleLine}`"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
v-if="showDesc && desc"
|
||||
class="text-tx-regular line-clamp-2 mt-4"
|
||||
>
|
||||
{{ desc }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showAuthor || showTime || showClick"
|
||||
class="mt-5 text-tx-secondary flex items-center flex-wrap"
|
||||
>
|
||||
<span v-if="showAuthor && author">
|
||||
{{ author }} |
|
||||
</span>
|
||||
<span class="mr-5" v-if="showTime">{{ createTime }}</span>
|
||||
<div v-if="showClick" class="flex items-center">
|
||||
<ElIcon>
|
||||
<View />
|
||||
</ElIcon>
|
||||
<span> {{ click }}人浏览</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElImage, ElIcon } from 'element-plus'
|
||||
import { View } from '@element-plus/icons-vue'
|
||||
const props = defineProps({
|
||||
index: {
|
||||
type: Number
|
||||
},
|
||||
id: {
|
||||
type: Number
|
||||
},
|
||||
title: {
|
||||
type: String
|
||||
},
|
||||
desc: {
|
||||
type: String
|
||||
},
|
||||
image: {
|
||||
type: String
|
||||
},
|
||||
author: {
|
||||
type: String
|
||||
},
|
||||
click: {
|
||||
type: Number
|
||||
},
|
||||
createTime: {
|
||||
type: String
|
||||
},
|
||||
onlyTitle: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isHorizontal: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
titleLine: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
border: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
source: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
imageSize: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
showAuthor: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showDesc: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showClick: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showTime: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showSort: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const getImageStyle = computed(() => {
|
||||
switch (props.imageSize) {
|
||||
case 'default':
|
||||
return {
|
||||
width: '180px',
|
||||
height: '135px'
|
||||
}
|
||||
case 'mini':
|
||||
return {
|
||||
width: '120px',
|
||||
height: '90px'
|
||||
}
|
||||
case 'large':
|
||||
return {
|
||||
width: '260px',
|
||||
height: '195px'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
137
pc/components/popover-input/index.vue
Normal file
137
pc/components/popover-input/index.vue
Normal file
@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div @mouseenter="inPopover = true" @mouseleave="inPopover = false">
|
||||
<el-popover
|
||||
placement="top"
|
||||
v-model:visible="visible"
|
||||
:width="width"
|
||||
trigger="contextmenu"
|
||||
class="popover-input"
|
||||
:teleported="teleported"
|
||||
:persistent="false"
|
||||
popper-class="!p-0"
|
||||
>
|
||||
<div class="flex p-3" @click.stop="">
|
||||
<div class="popover-input__input mr-[10px] flex-1">
|
||||
<el-select
|
||||
class="flex-1"
|
||||
:size="size"
|
||||
v-if="type == 'select'"
|
||||
v-model="inputValue"
|
||||
:teleported="teleported"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
></el-option>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-else
|
||||
v-model.trim="inputValue"
|
||||
:maxlength="limit"
|
||||
:show-word-limit="showLimit"
|
||||
:type="type"
|
||||
:size="size"
|
||||
clearable
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
</div>
|
||||
<div class="popover-input__btns flex-none">
|
||||
<el-button link @click="close">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:size="size"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template #reference>
|
||||
<div class="inline" @click.stop="handleOpen">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { ElPopover, ElButton, ElSelect, ElOption, ElInput } from 'element-plus'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
width: {
|
||||
type: [Number, String],
|
||||
default: '300px'
|
||||
},
|
||||
placeholder: String,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
options: {
|
||||
type: Array as PropType<any[]>,
|
||||
default: () => []
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<'default' | 'small' | 'large'>,
|
||||
default: 'default'
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 200
|
||||
},
|
||||
showLimit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
teleported: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['confirm'])
|
||||
const visible = ref(false)
|
||||
const inPopover = ref(false)
|
||||
const inputValue = ref()
|
||||
const handleConfirm = () => {
|
||||
close()
|
||||
emit('confirm', inputValue.value)
|
||||
}
|
||||
const handleOpen = () => {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
const close = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(value) => {
|
||||
inputValue.value = value
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
useEventListener(document.documentElement, 'click', () => {
|
||||
if (inPopover.value) return
|
||||
close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
79
pc/components/verification-code/index.vue
Normal file
79
pc/components/verification-code/index.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<ElButton v-if="!isStart" @click="handlStart" link>
|
||||
{{ isRetry ? endText : startText }}
|
||||
</ElButton>
|
||||
<VueCountdown
|
||||
v-else
|
||||
ref="vueCountdownRef"
|
||||
:time="seconds * 1000"
|
||||
v-slot="{ totalSeconds }"
|
||||
@end="handleEnd"
|
||||
>
|
||||
{{ getChangeText(totalSeconds) }}
|
||||
</VueCountdown>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import VueCountdown from '@chenfengyuan/vue-countdown'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { ElButton } from 'element-plus'
|
||||
export default defineComponent({
|
||||
components: {
|
||||
VueCountdown,
|
||||
ElButton
|
||||
},
|
||||
props: {
|
||||
// 倒计时总秒数
|
||||
seconds: {
|
||||
type: Number,
|
||||
default: 60
|
||||
},
|
||||
// 尚未开始时提示
|
||||
startText: {
|
||||
type: String,
|
||||
default: '获取验证码'
|
||||
},
|
||||
// 正在倒计时中的提示
|
||||
changeText: {
|
||||
type: String,
|
||||
default: 'x秒重新获取'
|
||||
},
|
||||
// 倒计时结束时的提示
|
||||
endText: {
|
||||
type: String,
|
||||
default: '重新获取'
|
||||
}
|
||||
},
|
||||
emits: ['click-get'],
|
||||
setup(props, { emit }) {
|
||||
const isStart = ref(false)
|
||||
const isRetry = ref(false)
|
||||
const start = async () => {
|
||||
isStart.value = true
|
||||
}
|
||||
|
||||
const getChangeText = (second) => {
|
||||
return props.changeText.replace('x', second)
|
||||
}
|
||||
const handleEnd = () => {
|
||||
isStart.value = false
|
||||
isRetry.value = true
|
||||
}
|
||||
const handlStart = useThrottleFn(
|
||||
() => {
|
||||
emit('click-get')
|
||||
},
|
||||
1000,
|
||||
false
|
||||
)
|
||||
return {
|
||||
getChangeText,
|
||||
isStart,
|
||||
start,
|
||||
isRetry,
|
||||
handleEnd,
|
||||
handlStart
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
19
pc/composables/useLockFn.ts
Normal file
19
pc/composables/useLockFn.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export function useLockFn(fn: (...args: any[]) => Promise<any>) {
|
||||
const isLock = ref(false)
|
||||
const lockFn = async (...args: any[]) => {
|
||||
if (isLock.value) return
|
||||
isLock.value = true
|
||||
try {
|
||||
const res = await fn(...args)
|
||||
isLock.value = false
|
||||
return res
|
||||
} catch (e) {
|
||||
isLock.value = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return {
|
||||
isLock,
|
||||
lockFn
|
||||
}
|
||||
}
|
26
pc/composables/useMenu.ts
Normal file
26
pc/composables/useMenu.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { NAVBAR, SIDEBAR } from '@/constants/menu'
|
||||
export default function useMenu() {
|
||||
const menu = useState(() => NAVBAR)
|
||||
const route = useRoute()
|
||||
const sidebar = computed(() => getSidebar(route.meta.module))
|
||||
const hasSidebar = computed(() => sidebar.value.length)
|
||||
return {
|
||||
menu,
|
||||
sidebar,
|
||||
hasSidebar
|
||||
}
|
||||
}
|
||||
|
||||
function getSidebar(module?: string): any[] {
|
||||
const queue: any[] = []
|
||||
SIDEBAR.forEach((item) => queue.push(item))
|
||||
while (queue.length) {
|
||||
const item = queue.shift()
|
||||
if (item.module && item.module == module) {
|
||||
return item.children
|
||||
}
|
||||
item.children &&
|
||||
item.children.forEach((child: any) => queue.push(child))
|
||||
}
|
||||
return []
|
||||
}
|
55
pc/constants/menu.ts
Normal file
55
pc/constants/menu.ts
Normal file
@ -0,0 +1,55 @@
|
||||
export const NAVBAR = [
|
||||
{
|
||||
name: '首页',
|
||||
path: '/'
|
||||
},
|
||||
{
|
||||
name: '资讯中心',
|
||||
path: '/information',
|
||||
component: 'information'
|
||||
},
|
||||
{
|
||||
name: '移动端',
|
||||
path: '/mobile',
|
||||
component: 'mobile'
|
||||
},
|
||||
{
|
||||
name: '管理后台',
|
||||
path: '/admin',
|
||||
component: 'admin'
|
||||
}
|
||||
]
|
||||
|
||||
export const SIDEBAR = [
|
||||
{
|
||||
module: 'personal',
|
||||
hidden: true,
|
||||
children: [
|
||||
{
|
||||
name: '个人中心',
|
||||
path: '/user',
|
||||
children: [
|
||||
{
|
||||
name: '个人信息',
|
||||
path: 'info'
|
||||
},
|
||||
{
|
||||
name: '我的收藏',
|
||||
path: 'collection'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
name: '账户设置',
|
||||
path: '/account',
|
||||
children: [
|
||||
{
|
||||
name: '账户安全',
|
||||
path: 'security'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
33
pc/enums/appEnums.ts
Normal file
33
pc/enums/appEnums.ts
Normal file
@ -0,0 +1,33 @@
|
||||
//菜单主题类型
|
||||
export enum ThemeEnum {
|
||||
LIGHT = 'light',
|
||||
DARK = 'dark'
|
||||
}
|
||||
|
||||
// 菜单类型
|
||||
export enum MenuEnum {
|
||||
CATALOGUE = 'M',
|
||||
MENU = 'C',
|
||||
BUTTON = 'A'
|
||||
}
|
||||
|
||||
// 屏幕
|
||||
export enum ScreenEnum {
|
||||
SM = 640,
|
||||
MD = 768,
|
||||
LG = 1024,
|
||||
XL = 1280,
|
||||
'2XL' = 1536
|
||||
}
|
||||
|
||||
export enum SMSEnum {
|
||||
LOGIN = 101,
|
||||
BIND_MOBILE = 102,
|
||||
CHANGE_MOBILE = 103,
|
||||
FIND_PASSWORD = 104
|
||||
}
|
||||
|
||||
export enum PolicyAgreementEnum {
|
||||
SERVICE = 'service',
|
||||
PRIVACY = 'privacy'
|
||||
}
|
8
pc/enums/cacheEnums.ts
Normal file
8
pc/enums/cacheEnums.ts
Normal file
@ -0,0 +1,8 @@
|
||||
// 本地缓冲key
|
||||
|
||||
//token
|
||||
export const TOKEN_KEY = 'token'
|
||||
//账号
|
||||
export const ACCOUNT_KEY = 'account'
|
||||
//设置
|
||||
export const SETTING_KEY = 'setting'
|
7
pc/enums/pageEnum.ts
Normal file
7
pc/enums/pageEnum.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export enum PageEnum {
|
||||
//登录页面
|
||||
LOGIN = '/login',
|
||||
//无权限页面
|
||||
ERROR_403 = '/403',
|
||||
INDEX = '/'
|
||||
}
|
28
pc/enums/requestEnums.ts
Normal file
28
pc/enums/requestEnums.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export enum ContentTypeEnum {
|
||||
// json
|
||||
JSON = 'application/json;charset=UTF-8',
|
||||
// form-data 上传资源(图片,视频)
|
||||
FORM_DATA = 'multipart/form-data'
|
||||
}
|
||||
|
||||
export enum RequestMethodsEnum {
|
||||
GET = 'GET',
|
||||
POST = 'POST'
|
||||
}
|
||||
|
||||
export enum RequestCodeEnum {
|
||||
SUCCESS = 200, //成功
|
||||
FAILED = 300, // 失败
|
||||
PARAMS_VALID_ERROR = 310, //参数校验错误
|
||||
PARAMS_TYPE_ERROR = 311, //参数类型错误
|
||||
REQUEST_METHOD_ERROR = 312, //请求方法错误
|
||||
ASSERT_ARGUMENT_ERROR = 313, //断言参数错误
|
||||
ASSERT_MYBATIS_ERROR = 314, //断言mybatis错误
|
||||
LOGIN_ACCOUNT_ERROR = 330, //登陆账号或密码错误
|
||||
LOGIN_DISABLE_ERROR = 331, //登陆账号已被禁用
|
||||
TOKEN_EMPTY = 332, // TOKEN参数为空
|
||||
TOKEN_INVALID = 333, // TOKEN参数无效
|
||||
NO_PERMISSTION = 403, //无相关权限
|
||||
REQUEST_404_ERROR = 404, //请求接口不存在
|
||||
SYSTEM_ERROR = 500 //系统错误
|
||||
}
|
5
pc/global.d.ts
vendored
Normal file
5
pc/global.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { Request } from '@/utils/http/request'
|
||||
declare global {
|
||||
const $request: Request
|
||||
}
|
6
pc/layouts/blank.vue
Normal file
6
pc/layouts/blank.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<section class="layout-blank">
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts" setup></script>
|
113
pc/layouts/components/account/bind-mobile.vue
Normal file
113
pc/layouts/components/account/bind-mobile.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-4xl">
|
||||
{{ hasMobile ? '更换手机号' : '绑定手机号' }}
|
||||
</span>
|
||||
</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="mt-[35px]"
|
||||
size="large"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
>
|
||||
<ElFormItem prop="mobile">
|
||||
<ElInput
|
||||
v-model="formData.mobile"
|
||||
placeholder="请输入手机号码"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="code">
|
||||
<ElInput v-model="formData.code" placeholder="请输入验证码">
|
||||
<template #suffix>
|
||||
<div
|
||||
class="flex justify-center leading-5 w-[90px] pl-2.5 border-l border-br"
|
||||
>
|
||||
<VerificationCode
|
||||
ref="verificationCodeRef"
|
||||
@click-get="sendSms"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElFormItem class="mt-[60px]">
|
||||
<ElButton
|
||||
class="w-full"
|
||||
type="primary"
|
||||
@click="handleConfirmLock"
|
||||
:loading="isLock"
|
||||
>
|
||||
确认
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElButton,
|
||||
FormInstance,
|
||||
FormRules
|
||||
} from 'element-plus'
|
||||
import { smsSend } from '~~/api/app'
|
||||
import { userBindMobile } from '~~/api/user'
|
||||
import { SMSEnum } from '~~/enums/appEnums'
|
||||
import { useAccount } from './useAccount'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
const { toggleShowPopup } = useAccount()
|
||||
const userStore = useUserStore()
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const verificationCodeRef = shallowRef()
|
||||
const formRules: FormRules = {
|
||||
mobile: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入手机号码',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
code: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入验证码',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
]
|
||||
}
|
||||
const hasMobile = computed(() => !!userStore.userInfo.mobile)
|
||||
|
||||
const formData = reactive({
|
||||
type: hasMobile.value ? 'change' : 'bind',
|
||||
mobile: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
const sendSms = async () => {
|
||||
await formRef.value?.validateField(['mobile'])
|
||||
await smsSend({
|
||||
scene: hasMobile.value ? SMSEnum.CHANGE_MOBILE : SMSEnum.BIND_MOBILE,
|
||||
mobile: formData.mobile
|
||||
})
|
||||
verificationCodeRef.value?.start()
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await formRef.value?.validate()
|
||||
if (userStore.isLogin) {
|
||||
await userBindMobile(formData)
|
||||
} else {
|
||||
await userBindMobile(formData, { token: userStore.temToken })
|
||||
userStore.login(userStore.temToken)
|
||||
await userStore.getUser()
|
||||
}
|
||||
toggleShowPopup(false)
|
||||
}
|
||||
const { lockFn: handleConfirmLock, isLock } = useLockFn(handleConfirm)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
164
pc/layouts/components/account/forgot-pwd.vue
Normal file
164
pc/layouts/components/account/forgot-pwd.vue
Normal file
@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-4xl">忘记登录密码</span>
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
@click="setPopupType(PopupTypeEnum.LOGIN)"
|
||||
v-if="!userStore.isLogin"
|
||||
>
|
||||
返回登录
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="mt-[35px]"
|
||||
size="large"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
>
|
||||
<ElFormItem prop="mobile">
|
||||
<ElInput
|
||||
v-model="formData.mobile"
|
||||
placeholder="请输入手机号码"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="code">
|
||||
<ElInput v-model="formData.code" placeholder="请输入验证码">
|
||||
<template #suffix>
|
||||
<div
|
||||
class="flex justify-center leading-5 w-[90px] pl-2.5 border-l border-br"
|
||||
>
|
||||
<VerificationCode
|
||||
ref="verificationCodeRef"
|
||||
@click-get="sendSms"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="formData.password"
|
||||
placeholder="请输入6-20位数字+字母或符号组合"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="passwordConfirm">
|
||||
<ElInput
|
||||
v-model="formData.passwordConfirm"
|
||||
placeholder="请再次输入密码"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem class="mt-[60px]">
|
||||
<ElButton
|
||||
class="w-full"
|
||||
type="primary"
|
||||
@click="handleConfirmLock"
|
||||
:loading="isLock"
|
||||
>
|
||||
确认
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElButton,
|
||||
FormInstance,
|
||||
FormRules
|
||||
} from 'element-plus'
|
||||
import { smsSend } from '~~/api/app'
|
||||
import { forgotPassword } from '~~/api/account'
|
||||
import { SMSEnum } from '~~/enums/appEnums'
|
||||
import { useUserStore } from '~~/stores/user'
|
||||
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||
import feedback from '~~/utils/feedback'
|
||||
const userStore = useUserStore()
|
||||
const { setPopupType, toggleShowPopup } = useAccount()
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const verificationCodeRef = shallowRef()
|
||||
const formRules: FormRules = {
|
||||
mobile: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入手机号码',
|
||||
trigger: ['change', 'blur']
|
||||
},
|
||||
{
|
||||
min: 3,
|
||||
max: 12,
|
||||
message: '账号长度应为3-12',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
code: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入验证码',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入6-20位数字+字母或符号组合',
|
||||
trigger: ['change', 'blur']
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
max: 20,
|
||||
message: '密码长度应为6-20',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
passwordConfirm: [
|
||||
{
|
||||
validator(rule: any, value: any, callback: any) {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== formData.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
]
|
||||
}
|
||||
const formData = reactive({
|
||||
mobile: '',
|
||||
password: '',
|
||||
code: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
|
||||
const sendSms = async () => {
|
||||
await formRef.value?.validateField(['mobile'])
|
||||
await smsSend({
|
||||
scene: SMSEnum.FIND_PASSWORD,
|
||||
mobile: formData.mobile
|
||||
})
|
||||
verificationCodeRef.value?.start()
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await formRef.value?.validate()
|
||||
await forgotPassword(formData)
|
||||
feedback.msgSuccess('操作成功')
|
||||
userStore.logout()
|
||||
setPopupType(PopupTypeEnum.LOGIN)
|
||||
}
|
||||
const { lockFn: handleConfirmLock, isLock } = useLockFn(handleConfirm)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
36
pc/layouts/components/account/index.vue
Normal file
36
pc/layouts/components/account/index.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="account" v-if="showPopup">
|
||||
<ClientOnly>
|
||||
<ElDialog
|
||||
v-model="showPopup"
|
||||
:width="400"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="px-5 text-tx-primary">
|
||||
<Login v-show="popupType == PopupTypeEnum.LOGIN" />
|
||||
<Register v-show="popupType == PopupTypeEnum.REGISTER" />
|
||||
<ForgotPwd v-show="popupType == PopupTypeEnum.FORGOT_PWD" />
|
||||
<BindMobile
|
||||
v-show="popupType == PopupTypeEnum.BIND_MOBILE"
|
||||
/>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElDialog } from 'element-plus'
|
||||
import Login from './login.vue'
|
||||
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||
import Register from './register.vue'
|
||||
import ForgotPwd from './forgot-pwd.vue'
|
||||
import BindMobile from './bind-mobile.vue'
|
||||
import { useUserStore } from '~~/stores/user'
|
||||
const { popupType, showPopup } = useAccount()
|
||||
const userStore = useUserStore()
|
||||
watch(showPopup, (value) => {
|
||||
if (!value) userStore.temToken = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
331
pc/layouts/components/account/login.vue
Normal file
331
pc/layouts/components/account/login.vue
Normal file
@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<div class="text-4xl">欢迎登录</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="mt-[35px]"
|
||||
size="large"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
>
|
||||
<template
|
||||
v-if="isAccountLogin && includeLoginWay(LoginWayEnum.ACCOUNT)"
|
||||
>
|
||||
<ElFormItem prop="account">
|
||||
<ElInput
|
||||
v-model="formData.account"
|
||||
placeholder="请输入账号/手机号"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
<template
|
||||
v-if="isMobileLogin && includeLoginWay(LoginWayEnum.MOBILE)"
|
||||
>
|
||||
<ElFormItem prop="account">
|
||||
<ElInput
|
||||
v-model="formData.account"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="code">
|
||||
<ElInput v-model="formData.code" placeholder="请输入验证码">
|
||||
<template #suffix>
|
||||
<div
|
||||
class="flex justify-center leading-5 w-[90px] pl-2.5 border-l border-br"
|
||||
>
|
||||
<VerificationCode
|
||||
ref="verificationCodeRef"
|
||||
@click-get="sendSms"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
<div class="flex">
|
||||
<div class="flex-1">
|
||||
<ElButton
|
||||
v-if="
|
||||
isAccountLogin &&
|
||||
includeLoginWay(LoginWayEnum.MOBILE)
|
||||
"
|
||||
type="primary"
|
||||
link
|
||||
@click="changeLoginWay"
|
||||
>
|
||||
手机验证码登录
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="
|
||||
isMobileLogin &&
|
||||
includeLoginWay(LoginWayEnum.ACCOUNT)
|
||||
"
|
||||
type="primary"
|
||||
link
|
||||
@click="changeLoginWay"
|
||||
>
|
||||
账号密码登录
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElButton
|
||||
v-if="isAccountLogin"
|
||||
link
|
||||
@click="setPopupType(PopupTypeEnum.FORGOT_PWD)"
|
||||
>
|
||||
忘记密码?
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElFormItem class="mt-[30px]">
|
||||
<ElButton
|
||||
class="w-full"
|
||||
type="primary"
|
||||
:loading="isLock"
|
||||
@click="loginLock"
|
||||
>
|
||||
登录
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
<div class="mt-[40px]" v-if="isOpenOtherAuth">
|
||||
<ElDivider>
|
||||
<span class="text-tx-secondary font-normal">
|
||||
第三方登录
|
||||
</span>
|
||||
</ElDivider>
|
||||
<div class="flex justify-center">
|
||||
<ElButton link @click="getWxCodeLock" v-if="inWxAuth">
|
||||
<img
|
||||
class="w-[48px] h-[48px]"
|
||||
src="@/assets/images/icon/icon_wx.png"
|
||||
/>
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mb-[-15px] mx-[-40px] mt-[30px] bg-primary-light-9 rounded-b-md px-[15px] flex leading-10"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<ElCheckbox v-if="isOpenAgreement" v-model="isAgreement">
|
||||
<span class="text-tx-secondary text-sm">
|
||||
已阅读并同意
|
||||
<NuxtLink
|
||||
:to="`/policy/${PolicyAgreementEnum.SERVICE}`"
|
||||
custom
|
||||
v-slot="{ href }"
|
||||
>
|
||||
<a
|
||||
class="text-tx-primary"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
>
|
||||
《服务协议》
|
||||
</a>
|
||||
</NuxtLink>
|
||||
和
|
||||
<NuxtLink
|
||||
class="text-tx-primary"
|
||||
:to="`/policy/${PolicyAgreementEnum.PRIVACY}`"
|
||||
custom
|
||||
v-slot="{ href }"
|
||||
>
|
||||
<a
|
||||
class="text-tx-primary"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
>
|
||||
《隐私政策》
|
||||
</a>
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</ElCheckbox>
|
||||
</div>
|
||||
<div>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
@click="setPopupType(PopupTypeEnum.REGISTER)"
|
||||
>
|
||||
<span class="text-sm">注册账号</span>
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElButton,
|
||||
ElDivider,
|
||||
ElCheckbox,
|
||||
FormInstance,
|
||||
FormRules
|
||||
} from 'element-plus'
|
||||
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||
import { getWxCodeUrl, mobileLogin, accountLogin } from '@/api/account'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { smsSend } from '~~/api/app'
|
||||
import { PolicyAgreementEnum, SMSEnum } from '~~/enums/appEnums'
|
||||
import feedback from '~~/utils/feedback'
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const { setPopupType, toggleShowPopup } = useAccount()
|
||||
enum LoginWayEnum {
|
||||
ACCOUNT = 1,
|
||||
MOBILE = 2
|
||||
}
|
||||
const isAgreement = ref(false)
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const formRules: FormRules = {
|
||||
account: [
|
||||
{
|
||||
required: true,
|
||||
validator(rule: any, value: any, callback: any) {
|
||||
if (value === '') {
|
||||
callback(
|
||||
new Error(
|
||||
formData.scene == LoginWayEnum.ACCOUNT
|
||||
? '请输入账号/手机号'
|
||||
: '请输入手机号'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
code: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入验证码',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
]
|
||||
}
|
||||
const formData = reactive({
|
||||
code: '',
|
||||
account: '',
|
||||
password: '',
|
||||
scene: 0
|
||||
})
|
||||
const isAccountLogin = computed(() => formData.scene == LoginWayEnum.ACCOUNT)
|
||||
const isMobileLogin = computed(() => formData.scene == LoginWayEnum.MOBILE)
|
||||
const includeLoginWay = (way: LoginWayEnum) =>
|
||||
appStore.getLoginConfig.loginWay?.includes(way)
|
||||
|
||||
const inWxAuth = computed(() => {
|
||||
return appStore.getLoginConfig.autoLoginAuth.includes(2)
|
||||
})
|
||||
|
||||
const isOpenAgreement = computed(
|
||||
() => appStore.getLoginConfig.openAgreement == 1
|
||||
)
|
||||
const isOpenOtherAuth = computed(
|
||||
() => appStore.getLoginConfig.openOtherAuth == 1
|
||||
)
|
||||
const isForceBindMobile = computed(
|
||||
() => appStore.getLoginConfig.forceBindMobile == 1
|
||||
)
|
||||
const changeLoginWay = () => {
|
||||
if (formData.scene == LoginWayEnum.ACCOUNT) {
|
||||
formData.scene = LoginWayEnum.MOBILE
|
||||
} else {
|
||||
formData.scene = LoginWayEnum.ACCOUNT
|
||||
}
|
||||
}
|
||||
const verificationCodeRef = shallowRef()
|
||||
const sendSms = async () => {
|
||||
await formRef.value?.validateField(['account'])
|
||||
await smsSend({
|
||||
scene: SMSEnum.LOGIN,
|
||||
mobile: formData.account
|
||||
})
|
||||
|
||||
verificationCodeRef.value?.start()
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
await formRef.value?.validate()
|
||||
const params: any = {}
|
||||
if (isAccountLogin.value) {
|
||||
params.username = formData.account
|
||||
params.password = formData.password
|
||||
}
|
||||
if (isMobileLogin.value) {
|
||||
params.mobile = formData.account
|
||||
params.code = formData.code
|
||||
}
|
||||
let data
|
||||
switch (formData.scene) {
|
||||
case LoginWayEnum.ACCOUNT:
|
||||
data = await accountLogin(params)
|
||||
break
|
||||
case LoginWayEnum.MOBILE:
|
||||
data = await mobileLogin(params)
|
||||
|
||||
break
|
||||
}
|
||||
if (!data) return
|
||||
if (isForceBindMobile.value && !data.isBindMobile) {
|
||||
userStore.temToken = data.token
|
||||
setPopupType(PopupTypeEnum.BIND_MOBILE)
|
||||
return
|
||||
}
|
||||
userStore.login(data.token)
|
||||
await userStore.getUser()
|
||||
toggleShowPopup(false)
|
||||
}
|
||||
const { lockFn: handleLoginLock, isLock } = useLockFn(handleLogin)
|
||||
const agreementConfirm = async () => {
|
||||
if (isAgreement.value) {
|
||||
return
|
||||
}
|
||||
await feedback.confirm('确认已阅读并同意《服务协议》和《隐私政策》')
|
||||
isAgreement.value = true
|
||||
}
|
||||
const loginLock = async () => {
|
||||
await agreementConfirm()
|
||||
await handleLoginLock()
|
||||
}
|
||||
|
||||
const getWxCode = async () => {
|
||||
await agreementConfirm()
|
||||
const { url } = await getWxCodeUrl()
|
||||
window.location.href = url
|
||||
}
|
||||
const { lockFn: getWxCodeLock } = useLockFn(getWxCode)
|
||||
watch(
|
||||
() => appStore.getLoginConfig,
|
||||
(value) => {
|
||||
const { loginWay } = value
|
||||
if (loginWay && loginWay.length) {
|
||||
formData.scene = loginWay.at(0)
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
126
pc/layouts/components/account/register.vue
Normal file
126
pc/layouts/components/account/register.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-4xl">注册账号</span>
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
@click="setPopupType(PopupTypeEnum.LOGIN)"
|
||||
>
|
||||
返回登录
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="mt-[35px]"
|
||||
size="large"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
>
|
||||
<ElFormItem prop="username">
|
||||
<ElInput
|
||||
v-model="formData.username"
|
||||
placeholder="请输入创建的账号"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入6-20位数字+字母或符号组合"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="passwordConfirm">
|
||||
<ElInput
|
||||
v-model="formData.passwordConfirm"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请再次输入密码"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem class="mt-[60px]">
|
||||
<ElButton
|
||||
class="w-full"
|
||||
type="primary"
|
||||
:loading="isLock"
|
||||
@click="handleConfirmLock"
|
||||
>
|
||||
注册
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElButton,
|
||||
FormInstance,
|
||||
FormRules
|
||||
} from 'element-plus'
|
||||
import { register } from '~~/api/account'
|
||||
import feedback from '~~/utils/feedback'
|
||||
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||
const { setPopupType } = useAccount()
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const formRules: FormRules = {
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入创建的账号',
|
||||
trigger: ['change', 'blur']
|
||||
},
|
||||
{
|
||||
min: 3,
|
||||
max: 12,
|
||||
message: '账号长度应为3-12',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入6-20位数字+字母或符号组合',
|
||||
trigger: ['change', 'blur']
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
max: 20,
|
||||
message: '密码长度应为6-20',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
passwordConfirm: [
|
||||
{
|
||||
validator(rule: any, value: any, callback: any) {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== formData.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
]
|
||||
}
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await formRef.value?.validate()
|
||||
await register(formData)
|
||||
feedback.msgSuccess('注册成功')
|
||||
setPopupType(PopupTypeEnum.LOGIN)
|
||||
}
|
||||
const { lockFn: handleConfirmLock, isLock } = useLockFn(handleConfirm)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
17
pc/layouts/components/account/to-login.vue
Normal file
17
pc/layouts/components/account/to-login.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<div class="text-tx-regular mb-4">您还未登录,请先登录</div>
|
||||
<ElButton @click="toLogin">登录</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||
import { ElButton } from 'element-plus'
|
||||
const { setPopupType, toggleShowPopup } = useAccount()
|
||||
const toLogin = () => {
|
||||
setPopupType(PopupTypeEnum.LOGIN)
|
||||
toggleShowPopup(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
23
pc/layouts/components/account/useAccount.ts
Normal file
23
pc/layouts/components/account/useAccount.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export enum PopupTypeEnum {
|
||||
LOGIN,
|
||||
FORGOT_PWD,
|
||||
REGISTER,
|
||||
BIND_MOBILE
|
||||
}
|
||||
|
||||
export const useAccount = () => {
|
||||
const popupType = useState<PopupTypeEnum>(() => PopupTypeEnum.LOGIN)
|
||||
const setPopupType = (type: PopupTypeEnum = PopupTypeEnum.LOGIN) => {
|
||||
popupType.value = type
|
||||
}
|
||||
const showPopup = useState(() => false)
|
||||
const toggleShowPopup = (toggle: boolean) => {
|
||||
showPopup.value = toggle ?? !showPopup.value
|
||||
}
|
||||
return {
|
||||
popupType,
|
||||
setPopupType,
|
||||
showPopup,
|
||||
toggleShowPopup
|
||||
}
|
||||
}
|
35
pc/layouts/components/footer/index.vue
Normal file
35
pc/layouts/components/footer/index.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<footer class="layout-footer text-center bg-[#222222] py-[30px]">
|
||||
<div class="text-[#bebebe]">
|
||||
<!-- <NuxtLink> 关于我们 </NuxtLink>
|
||||
| -->
|
||||
<NuxtLink :to="`/policy/${PolicyAgreementEnum.SERVICE}`">
|
||||
用户协议
|
||||
</NuxtLink>
|
||||
|
|
||||
<NuxtLink :to="`/policy/${PolicyAgreementEnum.PRIVACY}`">
|
||||
隐私政策
|
||||
</NuxtLink>
|
||||
|
|
||||
<NuxtLink to="/user/info"> 会员中心 </NuxtLink>
|
||||
</div>
|
||||
<div class="mt-4 text-tx-secondary">
|
||||
<a
|
||||
class="mx-1 hover:underline"
|
||||
:href="item.link"
|
||||
target="_blank"
|
||||
v-for="item in appStore.getCopyrightConfig"
|
||||
:key="item.link"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { PolicyAgreementEnum } from '@/enums/appEnums'
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
24
pc/layouts/components/header/admin.vue
Normal file
24
pc/layouts/components/header/admin.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<NuxtLink :to="appStore.getAdminUrl" target="_blank">
|
||||
<ElMenuItem :index="menuItem.path">
|
||||
<template #title>
|
||||
<span>
|
||||
{{ menuItem.name }}
|
||||
</span>
|
||||
</template>
|
||||
</ElMenuItem>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElMenuItem } from 'element-plus'
|
||||
import { useAppStore } from '~~/stores/app'
|
||||
defineProps({
|
||||
menuItem: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
44
pc/layouts/components/header/index.vue
Normal file
44
pc/layouts/components/header/index.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<header class="layout-header text-white bg-primary">
|
||||
<div class="header-contain">
|
||||
<Logo class="flex-none mr-4" />
|
||||
<Navbar class="w-[600px]" />
|
||||
<div class="flex-1"></div>
|
||||
<Search class="mr-[40px] flex-none" />
|
||||
<User class="flex-none" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import User from './user.vue'
|
||||
import Search from './search.vue'
|
||||
import Logo from './logo.vue'
|
||||
import Navbar from './navbar.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-header {
|
||||
height: var(--header-height);
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 1999;
|
||||
.header-contain {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
.navbar {
|
||||
--el-menu-item-font-size: var(--el-font-size-large);
|
||||
--el-menu-bg-color: var(--el-color-primary);
|
||||
--el-menu-active-color: var(--color-white);
|
||||
--el-menu-text-color: var(--color-white);
|
||||
--el-menu-item-hover-fill: var(--el-color-primary);
|
||||
--el-menu-hover-text-color: var(--color-white);
|
||||
--el-menu-hover-bg-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
47
pc/layouts/components/header/information.vue
Normal file
47
pc/layouts/components/header/information.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<el-dropdown :max-height="200" :disabled="!hasData">
|
||||
<span class="flex items-center text-white">
|
||||
<MenuItem :menu-item="menuItem" :route-path="menuItem.path" />
|
||||
<span class="ml-[-10px]" v-if="hasData">
|
||||
<Icon name="el-icon-ArrowDown" />
|
||||
</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<NuxtLink
|
||||
:to="{
|
||||
path: '/information/search',
|
||||
query: {
|
||||
cid: item.id,
|
||||
name: item.name
|
||||
}
|
||||
}"
|
||||
v-for="item in data"
|
||||
:key="item.id"
|
||||
>
|
||||
<el-dropdown-item> {{ item.name }} </el-dropdown-item>
|
||||
</NuxtLink>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus'
|
||||
import { getArticleCate } from '~~/api/news'
|
||||
import MenuItem from '../menu/menu-item.vue'
|
||||
defineProps({
|
||||
menuItem: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const { data } = await useAsyncData(() => getArticleCate())
|
||||
const hasData = computed(() => {
|
||||
return data.value && data.value.length
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
11
pc/layouts/components/header/logo.vue
Normal file
11
pc/layouts/components/header/logo.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<NuxtLink v-if="appStore.getWebsiteConfig.pcLogo" class="flex" to="/">
|
||||
<img :src="appStore.getWebsiteConfig.pcLogo" class="h-[26px]" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useAppStore } from '~~/stores/app'
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
58
pc/layouts/components/header/mobile.vue
Normal file
58
pc/layouts/components/header/mobile.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<ElMenuItem :index="menuItem.path" @click="showMobilePopup = true">
|
||||
<template #title>
|
||||
<span>
|
||||
{{ menuItem.name }}
|
||||
</span>
|
||||
</template>
|
||||
</ElMenuItem>
|
||||
<ClientOnly>
|
||||
<ElDialog
|
||||
v-model="showMobilePopup"
|
||||
@close="showMobilePopup = false"
|
||||
:width="700"
|
||||
>
|
||||
<div class="text-center text-tx-primary">
|
||||
<div class="text-4xl font-medium">移动端演示</div>
|
||||
<div class="flex my-[40px] justify-around">
|
||||
<div v-if="oa">
|
||||
<img :src="oa" class="w-[180px] h-[180px]" alt="" />
|
||||
<div class="mt-2.5">微信公众号演示</div>
|
||||
</div>
|
||||
<div v-if="mnp">
|
||||
<img
|
||||
:src="mnp"
|
||||
class="w-[180px] h-[180px]"
|
||||
alt=""
|
||||
/>
|
||||
<div class="mt-2.5">微信小程序演示</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!mnp && !oa"
|
||||
class="w-[180px] h-[180px] flex items-center justify-center"
|
||||
>
|
||||
暂无演示
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElMenuItem, ElDialog } from 'element-plus'
|
||||
import { useAppStore } from '~~/stores/app'
|
||||
defineProps({
|
||||
menuItem: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
const appStore = useAppStore()
|
||||
const mnp = computed(() => appStore.getQrcodeConfig.mnp)
|
||||
const oa = computed(() => appStore.getQrcodeConfig.oa)
|
||||
const showMobilePopup = ref(false)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
55
pc/layouts/components/header/navbar.vue
Normal file
55
pc/layouts/components/header/navbar.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<nav>
|
||||
<Menu
|
||||
class="navbar"
|
||||
:menu="menu"
|
||||
:default-active="activeMenu"
|
||||
mode="horizontal"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MenuItem
|
||||
v-if="!item.component"
|
||||
:menu-item="item"
|
||||
:route-path="item.path"
|
||||
/>
|
||||
<div v-else>
|
||||
<template v-if="item.component == 'information'">
|
||||
<Information :menu-item="item" />
|
||||
</template>
|
||||
<template v-if="item.component == 'mobile'">
|
||||
<Mobile :menu-item="item" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</nav>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import Menu from '../menu/index.vue'
|
||||
import MenuItem from '../menu/menu-item.vue'
|
||||
import Admin from './admin.vue'
|
||||
import Information from './information.vue'
|
||||
import Mobile from './mobile.vue'
|
||||
const route = useRoute()
|
||||
const activeMenu = computed<string>(() => route.path)
|
||||
const { menu } = useMenu()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navbar {
|
||||
--el-menu-item-font-size: var(--el-font-size-large);
|
||||
--el-menu-bg-color: var(--el-color-primary);
|
||||
--el-menu-active-color: var(--color-white);
|
||||
--el-menu-text-color: var(--color-white);
|
||||
--el-menu-item-hover-fill: var(--el-color-primary);
|
||||
--el-menu-hover-text-color: var(--color-white);
|
||||
--el-menu-hover-bg-color: var(--el-color-primary);
|
||||
:deep() {
|
||||
& > .el-sub-menu {
|
||||
.el-sub-menu__title:hover {
|
||||
background-color: var(--el-menu-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
50
pc/layouts/components/header/search.vue
Normal file
50
pc/layouts/components/header/search.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="w-[250px] search">
|
||||
<ElInput
|
||||
v-model.trim="searchKeyword"
|
||||
placeholder="请输入关键词"
|
||||
:suffix-icon="Search"
|
||||
@keyup.enter="handleToSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElInput } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import feedback from '~~/utils/feedback'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const searchKeyword = ref()
|
||||
const handleToSearch = () => {
|
||||
if (!searchKeyword.value) return feedback.msgError('请输入关键词')
|
||||
router.push({
|
||||
path: '/information/search',
|
||||
query: {
|
||||
keywords: searchKeyword.value
|
||||
}
|
||||
})
|
||||
}
|
||||
watch(
|
||||
route,
|
||||
(routeNew) => {
|
||||
if (routeNew.path == '/information/search') {
|
||||
searchKeyword.value = routeNew.query.keywords
|
||||
} else {
|
||||
searchKeyword.value = ''
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search {
|
||||
:deep(.el-input) {
|
||||
.el-input__wrapper {
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
65
pc/layouts/components/header/user.vue
Normal file
65
pc/layouts/components/header/user.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div>
|
||||
<ElDropdown v-if="userStore.isLogin" @command="handleCommand">
|
||||
<div class="flex items-center">
|
||||
<ElAvatar :size="25" :src="userStore.userInfo.avatar" />
|
||||
<div class="ml-1 text-white text-lg flex">
|
||||
<span class="mr-2">个人中心</span>
|
||||
<ElIcon><ArrowDown /></ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<NuxtLink to="/user/info">
|
||||
<ElDropdownItem command="user">个人信息</ElDropdownItem>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/user/collection">
|
||||
<ElDropdownItem command="collect">
|
||||
我的收藏
|
||||
</ElDropdownItem>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/account/security">
|
||||
<ElDropdownItem command="account">
|
||||
账号安全
|
||||
</ElDropdownItem>
|
||||
</NuxtLink>
|
||||
<ElDropdownItem command="logout">退出登录</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<div v-else class="cursor-pointer text-lg" @click="handleToLogin">
|
||||
登录/注册
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElAvatar,
|
||||
ElDropdown,
|
||||
ElDropdownMenu,
|
||||
ElDropdownItem,
|
||||
ElIcon
|
||||
} from 'element-plus'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { PopupTypeEnum, useAccount } from '../account/useAccount'
|
||||
import feedback from '~~/utils/feedback'
|
||||
const { setPopupType, toggleShowPopup } = useAccount()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const handleToLogin = () => {
|
||||
setPopupType(PopupTypeEnum.LOGIN)
|
||||
toggleShowPopup(true)
|
||||
}
|
||||
|
||||
const handleCommand = async (command: string) => {
|
||||
switch (command) {
|
||||
case 'logout':
|
||||
await feedback.confirm('确定退出登录吗?')
|
||||
userStore.logout()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
30
pc/layouts/components/main/index.vue
Normal file
30
pc/layouts/components/main/index.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<main class="mx-auto w-[1200px] py-4">
|
||||
<div
|
||||
v-if="sidebar.length"
|
||||
class="mr-4 bg-white rounded-[8px] overflow-hidden"
|
||||
>
|
||||
<Menu
|
||||
:menu="sidebar"
|
||||
:default-active="activeMenu"
|
||||
mode="vertical"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'layout-page flex-1 min-w-0 rounded-[8px]',
|
||||
{
|
||||
'bg-body': hasSidebar
|
||||
}
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import Menu from '../menu/index.vue'
|
||||
const route = useRoute()
|
||||
const activeMenu = computed<string>(() => route.meta.activeMenu ?? route.path)
|
||||
const { sidebar, hasSidebar } = useMenu()
|
||||
</script>
|
42
pc/layouts/components/menu/index.vue
Normal file
42
pc/layouts/components/menu/index.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<ElMenu class="menu" v-bind="$props" :ellipsis="true">
|
||||
<div v-for="item in menu" :key="item.path">
|
||||
<slot name="item" :item="item">
|
||||
<MenuItem :menu-item="item" :route-path="item.path" />
|
||||
</slot>
|
||||
</div>
|
||||
</ElMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ElMenu, menuProps } from 'element-plus'
|
||||
import { PropType } from 'vue'
|
||||
import MenuItem from './menu-item.vue'
|
||||
defineProps({
|
||||
menu: {
|
||||
type: Array as PropType<any[]>,
|
||||
default: () => []
|
||||
},
|
||||
...menuProps
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu {
|
||||
&.el-menu--horizontal {
|
||||
--el-menu-item-height: 40px;
|
||||
border-bottom: none;
|
||||
:deep(.el-menu-item) {
|
||||
span {
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
&.is-active > span {
|
||||
border-color: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.el-menu--vertical:not(.el-menu--collapse) {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
65
pc/layouts/components/menu/menu-item.vue
Normal file
65
pc/layouts/components/menu/menu-item.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<template v-if="!menuItem?.hidden">
|
||||
<NuxtLink
|
||||
v-if="!hasShowChild"
|
||||
:to="routePath"
|
||||
class="flex items-center w-full"
|
||||
:custom="menuItem.type == 'custom'"
|
||||
:external="isExternal(routePath)"
|
||||
:target="isExternal(routePath) ? '_blank' : ''"
|
||||
>
|
||||
<ElMenuItem class="w-full" :index="routePath">
|
||||
<template #title>
|
||||
<span>
|
||||
{{ menuItem.name }}
|
||||
</span>
|
||||
</template>
|
||||
</ElMenuItem>
|
||||
</NuxtLink>
|
||||
<ElSubMenu v-else :index="routePath" :popper-offset="12">
|
||||
<template #title>
|
||||
<!-- <Icon
|
||||
v-if="menuItem.icon"
|
||||
class="menu-item-icon"
|
||||
:size="16"
|
||||
:name="menuItem.icon"
|
||||
/> -->
|
||||
<span>{{ menuItem.name }}</span>
|
||||
</template>
|
||||
<MenuItem
|
||||
v-for="item in menuItem.children"
|
||||
:key="resolvePath(item.path)"
|
||||
:menu-item="item"
|
||||
:route-path="resolvePath(item.path)"
|
||||
/>
|
||||
</ElSubMenu>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ElMenuItem, ElSubMenu } from 'element-plus'
|
||||
import { getNormalPath } from '@/utils/util'
|
||||
import { isExternal } from '@/utils/validate'
|
||||
const props = defineProps({
|
||||
menuItem: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
routePath: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
const hasShowChild = computed(() => {
|
||||
const children = props.menuItem.children ?? []
|
||||
return !!children.filter((item: any) => !item?.hidden).length
|
||||
})
|
||||
const resolvePath = (path: string) => {
|
||||
if (isExternal(path)) {
|
||||
return path
|
||||
}
|
||||
const newPath = getNormalPath(`${props.routePath}/${path}`)
|
||||
return newPath
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
28
pc/layouts/default.vue
Normal file
28
pc/layouts/default.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<section class="layout-default min-w-[1200px]">
|
||||
<LayoutHeader />
|
||||
<div class="main-contain">
|
||||
<LayoutMain class="flex-1 min-h-0 flex">
|
||||
<slot v-if="userStore.isLogin || !$route.meta.auth" />
|
||||
<ToLogin class="h-full" v-else />
|
||||
</LayoutMain>
|
||||
<LayoutFooter />
|
||||
</div>
|
||||
<Account />
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import LayoutHeader from './components/header/index.vue'
|
||||
import LayoutMain from './components/main/index.vue'
|
||||
import LayoutFooter from './components/footer/index.vue'
|
||||
import Account from './components/account/index.vue'
|
||||
import { useUserStore } from '~~/stores/user'
|
||||
import ToLogin from './components/account/to-login.vue'
|
||||
const userStore = useUserStore()
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.main-contain {
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
@apply flex flex-col;
|
||||
}
|
||||
</style>
|
18
pc/middleware/route.global.ts
Normal file
18
pc/middleware/route.global.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useAppStore } from '~~/stores/app'
|
||||
import { useUserStore } from '~~/stores/user'
|
||||
import { isEmptyObject } from '~~/utils/validate'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
try {
|
||||
if (isEmptyObject(appStore.config)) {
|
||||
await appStore.getConfig()
|
||||
}
|
||||
if (userStore.isLogin && isEmptyObject(userStore.userInfo)) {
|
||||
await userStore.getUser()
|
||||
}
|
||||
} catch (error) {
|
||||
userStore.$reset()
|
||||
}
|
||||
})
|
33
pc/middleware/wxlogin.global.ts
Normal file
33
pc/middleware/wxlogin.global.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { wxLogin } from '~~/api/account'
|
||||
import {
|
||||
PopupTypeEnum,
|
||||
useAccount
|
||||
} from '~~/layouts/components/account/useAccount'
|
||||
import { useAppStore } from '~~/stores/app'
|
||||
import { useUserStore } from '~~/stores/user'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const { setPopupType, toggleShowPopup } = useAccount()
|
||||
const isForceBindMobile = appStore.getLoginConfig.forceBindMobile
|
||||
const { code, state } = to.query
|
||||
delete to.query.code
|
||||
delete to.query.state
|
||||
try {
|
||||
if (code && state) {
|
||||
const data = await wxLogin({ code, state })
|
||||
if (isForceBindMobile && !data.isBindMobile) {
|
||||
userStore.temToken = data.token
|
||||
setPopupType(PopupTypeEnum.BIND_MOBILE)
|
||||
toggleShowPopup(true)
|
||||
return
|
||||
}
|
||||
userStore.login(data.token)
|
||||
await userStore.getUser()
|
||||
return navigateTo(to)
|
||||
}
|
||||
} catch (error) {
|
||||
return navigateTo(to)
|
||||
}
|
||||
})
|
17
pc/nuxt.config.ts
Normal file
17
pc/nuxt.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// https://v3.nuxtjs.org/api/configuration/nuxt.config
|
||||
|
||||
import { getEnvConfig } from './nuxt/env'
|
||||
const envConfig = getEnvConfig()
|
||||
export default defineNuxtConfig({
|
||||
css: ['@/assets/styles/index.scss'],
|
||||
modules: ['@pinia/nuxt', '@nuxtjs/tailwindcss'],
|
||||
app: {
|
||||
baseURL: envConfig.baseUrl
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
...envConfig
|
||||
}
|
||||
},
|
||||
ssr: !!envConfig.ssr
|
||||
})
|
18
pc/nuxt/env.ts
Normal file
18
pc/nuxt/env.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import dotenv from 'dotenv'
|
||||
dotenv.config({ path: `.env.${process.env.NODE_ENV}` })
|
||||
const ENV_PREFIX = 'NUXT_'
|
||||
export const getEnvConfig = () => {
|
||||
const config: Record<string, any> = {}
|
||||
Object.keys(process.env).forEach((evnKey) => {
|
||||
if (evnKey.includes(ENV_PREFIX)) {
|
||||
const key = evnKey
|
||||
.replace(ENV_PREFIX, '')
|
||||
.toLowerCase()
|
||||
.replace(/\_([A-Za-z])/g, function (all, $1) {
|
||||
return $1.toUpperCase()
|
||||
})
|
||||
config[key] = process.env[evnKey]
|
||||
}
|
||||
})
|
||||
return config
|
||||
}
|
9721
pc/package-lock.json
generated
Normal file
9721
pc/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
pc/package.json
Normal file
32
pc/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build && node scripts/build.mjs",
|
||||
"dev": "nuxt dev",
|
||||
"start": "nuxt start",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/webpack-builder": "^3.0.0-rc.11",
|
||||
"@nuxtjs/tailwindcss": "^5.3.5",
|
||||
"@pinia/nuxt": "^0.4.3",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.2",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-plugin-nuxt": "^4.0.0",
|
||||
"nuxt": "^3.0.0-rc.11",
|
||||
"prettier": "^2.7.1",
|
||||
"sass": "^1.55.0",
|
||||
"sass-loader": "^13.1.0",
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenfengyuan/vue-countdown": "2",
|
||||
"element-plus": "^2.2.18",
|
||||
"pinia": "^2.0.23",
|
||||
"vue-cropper": "^1.0.5"
|
||||
}
|
||||
}
|
202
pc/pages/account/security.vue
Normal file
202
pc/pages/account/security.vue
Normal file
@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="px-[30px] py-5 user-info">
|
||||
<div class="border-b border-br pb-5">
|
||||
<span class="text-2xl font-medium">账号安全</span>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="info-item leading-10 flex justify-between">
|
||||
<div class="item-name">登录密码</div>
|
||||
<div>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
@click="showMobilePopup = true"
|
||||
>
|
||||
{{ userInfo.isPassword ? '点击修改' : '点击设置' }}
|
||||
<Icon name="el-icon-ArrowRight" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item leading-10 flex justify-between">
|
||||
<div class="item-name">绑定微信</div>
|
||||
<div>
|
||||
{{ userInfo.isBindMnp ? '已绑定' : '未绑定' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<ElDialog
|
||||
v-model="showMobilePopup"
|
||||
:width="400"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="px-5">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-4xl">
|
||||
{{
|
||||
userInfo.isPassword
|
||||
? '修改登录密码'
|
||||
: '设置登录密码'
|
||||
}}
|
||||
</span>
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
@click="toForgetPwd"
|
||||
v-if="userInfo.isPassword"
|
||||
>
|
||||
忘记原密码
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="mt-[35px]"
|
||||
size="large"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
>
|
||||
<ElFormItem
|
||||
prop="oldPassword"
|
||||
v-if="userInfo.isPassword"
|
||||
>
|
||||
<ElInput
|
||||
v-model="formData.oldPassword"
|
||||
placeholder="请输入原密码"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="formData.password"
|
||||
placeholder="请输入6-20位数字+字母或符号组合"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="passwordConfirm">
|
||||
<ElInput
|
||||
v-model="formData.passwordConfirm"
|
||||
placeholder="请再次输入密码"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem class="mt-[60px]">
|
||||
<ElButton
|
||||
class="w-full"
|
||||
type="primary"
|
||||
@click="handleConfirmLock"
|
||||
:loading="isLock"
|
||||
>
|
||||
确认
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { getUserInfo, userChangePwd } from '@/api/user'
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElButton,
|
||||
FormInstance,
|
||||
FormRules,
|
||||
ElDialog
|
||||
} from 'element-plus'
|
||||
import {
|
||||
PopupTypeEnum,
|
||||
useAccount
|
||||
} from '~~/layouts/components/account/useAccount'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import feedback from '~~/utils/feedback'
|
||||
const { data: userInfo, refresh } = await useAsyncData(() => getUserInfo(), {
|
||||
default: () => ({}),
|
||||
initialCache: false
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const showMobilePopup = ref(false)
|
||||
const { setPopupType, toggleShowPopup } = useAccount()
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const formRules: FormRules = {
|
||||
oldPassword: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入原密码',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入6-20位数字+字母或符号组合',
|
||||
trigger: ['change', 'blur']
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
max: 20,
|
||||
message: '密码长度应为6-20',
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
],
|
||||
passwordConfirm: [
|
||||
{
|
||||
validator(rule: any, value: any, callback: any) {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== formData.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: ['change', 'blur']
|
||||
}
|
||||
]
|
||||
}
|
||||
const formData = reactive({
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
|
||||
const toForgetPwd = () => {
|
||||
showMobilePopup.value = false
|
||||
setPopupType(PopupTypeEnum.FORGOT_PWD)
|
||||
toggleShowPopup(true)
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await formRef.value?.validate()
|
||||
await userChangePwd(formData)
|
||||
feedback.msgSuccess('修改成功')
|
||||
userStore.logout()
|
||||
showMobilePopup.value = false
|
||||
refresh()
|
||||
}
|
||||
const { lockFn: handleConfirmLock, isLock } = useLockFn(handleConfirm)
|
||||
definePageMeta({
|
||||
module: 'personal',
|
||||
auth: true
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.user-info {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
padding: 10px 0;
|
||||
.item-name {
|
||||
width: 80px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
75
pc/pages/index.vue
Normal file
75
pc/pages/index.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="index">
|
||||
<div class="flex">
|
||||
<ElCarousel
|
||||
v-if="getSwiperData.enabled"
|
||||
class="w-[750px] flex-none mr-5"
|
||||
trigger="click"
|
||||
height="340px"
|
||||
>
|
||||
<ElCarouselItem v-for="item in getSwiperData.data" :key="item">
|
||||
<NuxtLink :to="item.link.path" target="_blank">
|
||||
<ElImage
|
||||
class="w-full h-full rounded-[8px] bg-white overflow-hidden"
|
||||
:src="appStore.getImageUrl(item.image)"
|
||||
fit="contain"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</ElCarouselItem>
|
||||
</ElCarousel>
|
||||
<InformationCard
|
||||
link="/information/new"
|
||||
class="flex-1 min-w-0"
|
||||
header="最新资讯"
|
||||
:data="pageData.new"
|
||||
:show-time="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5 flex">
|
||||
<InformationCard
|
||||
link="/information"
|
||||
class="w-[750px] flex-none mr-5"
|
||||
header="全部资讯"
|
||||
:data="pageData.all"
|
||||
:only-title="false"
|
||||
/>
|
||||
<InformationCard
|
||||
link="/information/hot"
|
||||
class="flex-1"
|
||||
header="热门资讯"
|
||||
:data="pageData.hot"
|
||||
:only-title="false"
|
||||
image-size="mini"
|
||||
:show-author="false"
|
||||
:show-desc="false"
|
||||
:show-click="false"
|
||||
:border="false"
|
||||
:title-line="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElCarousel, ElCarouselItem, ElImage } from 'element-plus'
|
||||
import { getIndex } from '@/api/shop'
|
||||
import { useAppStore } from '~~/stores/app'
|
||||
const appStore = useAppStore()
|
||||
const { data: pageData } = await useAsyncData(() => getIndex(), {
|
||||
default: () => ({
|
||||
all: [],
|
||||
hot: [],
|
||||
new: [],
|
||||
pages: []
|
||||
})
|
||||
})
|
||||
|
||||
const getSwiperData = computed(() => {
|
||||
try {
|
||||
const data = JSON.parse(pageData.value.pages)
|
||||
return data.find((item) => item.name === 'banner')?.content
|
||||
} catch (error) {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
88
pc/pages/information/[source]/index.vue
Normal file
88
pc/pages/information/[source]/index.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="min-h-full flex flex-col">
|
||||
<div class="text-4xl mb-5">
|
||||
<span v-if="route.query.keywords">
|
||||
查找"{{ route.query.keywords }}"
|
||||
</span>
|
||||
<span v-else>{{ route.query.name || getSourceText }}</span>
|
||||
</div>
|
||||
<div v-loading="pending">
|
||||
<div
|
||||
class="bg-white px-5 rounded overflow-hidden"
|
||||
v-if="data.lists.length"
|
||||
>
|
||||
<div class="pt-5 text-tx-secondary" v-if="route.query.keywords">
|
||||
为您找到相关结果 {{ data.count }}个
|
||||
</div>
|
||||
<InformationItems
|
||||
v-for="item in data.lists"
|
||||
:key="item.id"
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:desc="item.intro"
|
||||
:click="item.visit"
|
||||
:author="item.author"
|
||||
:create-time="item.createTime"
|
||||
:image="item.image"
|
||||
:only-title="false"
|
||||
/>
|
||||
<div class="py-4 flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="params.pageNo"
|
||||
:total="data.count"
|
||||
:page-size="params.pageSize"
|
||||
hide-on-single-page
|
||||
@current-change="refresh()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex-1 flex justify-center items-center">
|
||||
<el-empty
|
||||
:image="empty_news"
|
||||
description="暂无资讯"
|
||||
:image-size="250"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElPagination, ElEmpty } from 'element-plus'
|
||||
import empty_news from '@/assets/images/empty_news.png'
|
||||
import { getArticleList } from '~~/api/news'
|
||||
const route = useRoute()
|
||||
const sort = computed(() =>
|
||||
route.params.source == 'search' ? '' : route.params.source
|
||||
)
|
||||
const keyword = computed(() => route.query.keywords || '')
|
||||
const cid = computed(() => route.query.cid || '')
|
||||
const params = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 15,
|
||||
keyword,
|
||||
cid,
|
||||
sort
|
||||
})
|
||||
const { data, refresh, pending } = await useAsyncData(
|
||||
() => getArticleList(params),
|
||||
{
|
||||
initialCache: false
|
||||
}
|
||||
)
|
||||
|
||||
const getSourceText = computed(() => {
|
||||
switch (route.params.source) {
|
||||
case 'hot':
|
||||
return '热门资讯'
|
||||
case 'new':
|
||||
return ' 最新资讯'
|
||||
default:
|
||||
return '全部资讯'
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => route.query.keywords, () => route.query.cid], () => {
|
||||
refresh()
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
132
pc/pages/information/detail/[id].vue
Normal file
132
pc/pages/information/detail/[id].vue
Normal file
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
当前位置:
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/information' }">
|
||||
资讯中心
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item
|
||||
:to="{
|
||||
path: `/information/search`,
|
||||
query: {
|
||||
cid: newsDetail.cid,
|
||||
name: newsDetail.category
|
||||
}
|
||||
}"
|
||||
>
|
||||
{{ newsDetail.category }}
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>文章详情</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-5">
|
||||
<div class="w-[750px] bg-body rounded-[8px] flex-none p-5">
|
||||
<div class="border-b border-br pb-4">
|
||||
<span class="font-medium text-[22px]">
|
||||
{{ newsDetail.title }}
|
||||
</span>
|
||||
<div
|
||||
class="mt-3 text-tx-secondary flex items-center flex-wrap"
|
||||
>
|
||||
<span v-if="newsDetail.author">
|
||||
{{ newsDetail.author }} |
|
||||
</span>
|
||||
<span class="mr-5">{{ newsDetail.createTime }}</span>
|
||||
<div class="flex items-center">
|
||||
<Icon name="el-icon-View" />
|
||||
<span> {{ newsDetail.visit }}人浏览</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="newsDetail.summary"
|
||||
class="bg-page mt-4 p-3 rounded-lg"
|
||||
>
|
||||
摘要:{{ newsDetail.summary }}
|
||||
</div>
|
||||
<div class="py-4" v-html="newsDetail.content"></div>
|
||||
<div class="flex justify-center mt-[40px]">
|
||||
<ElButton size="large" round @click="handelCollectLock">
|
||||
<Icon
|
||||
:name="`el-icon-${
|
||||
newsDetail.isCollect ? 'StarFilled' : 'Star'
|
||||
}`"
|
||||
:size="newsDetail.isCollect ? 22 : 18"
|
||||
:color="
|
||||
newsDetail.isCollect ? '#FF2C2F' : 'inherit'
|
||||
"
|
||||
/>
|
||||
{{ newsDetail.isCollect ? '取消收藏' : '点击收藏' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="border-t border-br mt-[30px]">
|
||||
<div class="mt-5 flex">
|
||||
<span class="text-tx-regular">上一篇:</span>
|
||||
<NuxtLink
|
||||
v-if="newsDetail.prev"
|
||||
class="flex-1 hover:underline"
|
||||
:to="`/information/detail/${newsDetail.prev?.id}`"
|
||||
>
|
||||
{{ newsDetail.prev?.title }}
|
||||
</NuxtLink>
|
||||
<span v-else> 暂无相关文章 </span>
|
||||
</div>
|
||||
<div class="mt-5 flex">
|
||||
<span class="text-tx-regular">下一篇:</span>
|
||||
<NuxtLink
|
||||
v-if="newsDetail.next"
|
||||
class="flex-1 hover:underline"
|
||||
:to="`/information/detail/${newsDetail.next?.id}`"
|
||||
>
|
||||
{{ newsDetail.next?.title }}
|
||||
</NuxtLink>
|
||||
<span v-else> 暂无相关文章 </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InformationCard
|
||||
class="flex-1"
|
||||
header="相关资讯"
|
||||
:data="newsDetail.news"
|
||||
:only-title="false"
|
||||
image-size="mini"
|
||||
:show-author="false"
|
||||
:show-desc="false"
|
||||
:show-click="false"
|
||||
:border="false"
|
||||
:title-line="2"
|
||||
source="new"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElBreadcrumb, ElBreadcrumbItem, ElButton } from 'element-plus'
|
||||
import { addCollect, cancelCollect, getArticleDetail } from '~~/api/news'
|
||||
import feedback from '~~/utils/feedback'
|
||||
const route = useRoute()
|
||||
const { data: newsDetail, refresh } = await useAsyncData(
|
||||
() =>
|
||||
getArticleDetail({
|
||||
id: route.params.id
|
||||
}),
|
||||
{
|
||||
initialCache: false
|
||||
}
|
||||
)
|
||||
|
||||
const handelCollect = async () => {
|
||||
const articleId = route.params.id
|
||||
if (newsDetail.value.isCollect) {
|
||||
await cancelCollect({ articleId })
|
||||
feedback.msgSuccess('已取消收藏')
|
||||
} else {
|
||||
await addCollect({ articleId })
|
||||
feedback.msgSuccess('收藏成功')
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
const { lockFn: handelCollectLock } = useLockFn(handelCollect)
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
83
pc/pages/information/index.vue
Normal file
83
pc/pages/information/index.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-4xl mb-5">资讯中心</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<InformationCard
|
||||
v-for="item in newsLists"
|
||||
style="width: calc(50% - 8px)"
|
||||
:key="item.id"
|
||||
:header="item.name"
|
||||
:data="item.article"
|
||||
:link="`/information/search?cid=${item.id}&name=${item.name}`"
|
||||
>
|
||||
<template #content="{ data }">
|
||||
<div class="px-4 py-5">
|
||||
<div class="flex gap-2.5">
|
||||
<div
|
||||
class="w-[180px] bg-page rounded overflow-hidden"
|
||||
v-for="(item, index) in splitData(data)
|
||||
.topThree"
|
||||
:key="item.id"
|
||||
>
|
||||
<InformationItems
|
||||
:index="index"
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:author="item.author"
|
||||
:create-time="item.createTime"
|
||||
:image="item.image || placeholder"
|
||||
:only-title="false"
|
||||
:border="false"
|
||||
:show-author="false"
|
||||
:show-desc="false"
|
||||
:show-time="false"
|
||||
:show-click="false"
|
||||
:is-horizontal="true"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="line-clamp-2">{{
|
||||
title
|
||||
}}</span>
|
||||
</template>
|
||||
</InformationItems>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in splitData(data).remain"
|
||||
:key="item.id"
|
||||
>
|
||||
<InformationItems
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:author="item.author"
|
||||
:create-time="item.createTime"
|
||||
:image="item.image"
|
||||
:only-title="true"
|
||||
:show-time="false"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="line-clamp-1">{{
|
||||
title
|
||||
}}</span>
|
||||
</template>
|
||||
</InformationItems>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</InformationCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { getArticleCenter } from '~~/api/news'
|
||||
import placeholder from '@/assets/images/placeholder.png'
|
||||
const { data: newsLists } = await useAsyncData(() => getArticleCenter())
|
||||
const splitData = (data) => {
|
||||
const size = 3
|
||||
return {
|
||||
topThree: data.slice(0, size),
|
||||
remain: data.slice(size)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
25
pc/pages/policy/[type].vue
Normal file
25
pc/pages/policy/[type].vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="bg-white render-html p-[30px] w-[1200px] mx-auto min-h-screen">
|
||||
<h1 class="text-center">{{ data.name }}</h1>
|
||||
<div class="mx-auto" v-html="data.content"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { getPolicy } from '~~/api/app'
|
||||
|
||||
const route = useRoute()
|
||||
const { data } = await useAsyncData(
|
||||
() =>
|
||||
getPolicy({
|
||||
type: route.params.type
|
||||
}),
|
||||
{
|
||||
initialCache: false
|
||||
}
|
||||
)
|
||||
|
||||
definePageMeta({
|
||||
layout: 'blank'
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
85
pc/pages/user/collection.vue
Normal file
85
pc/pages/user/collection.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="px-[30px] py-5 user-info min-h-full flex flex-col">
|
||||
<div class="border-b border-br pb-5">
|
||||
<span class="text-2xl font-medium">我的收藏</span>
|
||||
</div>
|
||||
<div v-if="data.lists.length">
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
v-for="item in data.lists"
|
||||
:key="item.id"
|
||||
@click="$router.push(`/information/detail/${item.articleId}`)"
|
||||
>
|
||||
<div class="border-b border-br py-4 flex items-center">
|
||||
<ElImage
|
||||
v-if="item.image"
|
||||
class="flex-none w-[180px] h-[135px] mr-4"
|
||||
:src="item.image"
|
||||
fit="cover"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="text-lg font-medium line-clamp-1">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
|
||||
<div class="text-tx-regular line-clamp-2 mt-4">
|
||||
{{ item.intro }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-5 text-tx-secondary flex justify-between"
|
||||
>
|
||||
<div>收藏于{{ item.createTime }}</div>
|
||||
<ElButton
|
||||
link
|
||||
@click.stop="handelCollect(item.articleId)"
|
||||
>
|
||||
取消收藏
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-4 flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="params.pageNo"
|
||||
:total="data.count"
|
||||
:page-size="params.pageSize"
|
||||
hide-on-single-page
|
||||
layout="total, prev, pager, next, jumper"
|
||||
@current-change="refresh()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 justify-center items-center" v-else>
|
||||
<el-empty
|
||||
:image="empty_news"
|
||||
description="暂无收藏"
|
||||
:image-size="250"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { cancelCollect, getCollect } from '~~/api/news'
|
||||
import empty_news from '@/assets/images/empty_news.png'
|
||||
import { ElImage, ElButton, ElPagination, ElEmpty } from 'element-plus'
|
||||
import feedback from '~~/utils/feedback'
|
||||
const params = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 15
|
||||
})
|
||||
const { data, refresh } = await useAsyncData(() => getCollect(params), {
|
||||
initialCache: false
|
||||
})
|
||||
const handelCollect = async (articleId) => {
|
||||
await cancelCollect({ articleId })
|
||||
feedback.msgSuccess('已取消收藏')
|
||||
refresh()
|
||||
}
|
||||
definePageMeta({
|
||||
module: 'personal',
|
||||
auth: true
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
201
pc/pages/user/info.vue
Normal file
201
pc/pages/user/info.vue
Normal file
@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="px-[30px] py-5 user-info">
|
||||
<div class="border-b border-br pb-5">
|
||||
<span class="text-2xl font-medium">个人信息</span>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="info-item">
|
||||
<div class="item-name">头像</div>
|
||||
<div class="avatar">
|
||||
<ElAvatar :size="60" :src="userInfo.avatar"></ElAvatar>
|
||||
<div class="change-btn">
|
||||
<CropperUpload
|
||||
@change="setUserInfo($event, UserFieldEnum.AVATAR)"
|
||||
>
|
||||
<span class="text-xs text-white">修改</span>
|
||||
</CropperUpload>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item leading-10">
|
||||
<div class="item-name">账号</div>
|
||||
<div>
|
||||
{{ userInfo.username }}
|
||||
<ClientOnly>
|
||||
<PopoverInput
|
||||
class="inline-block"
|
||||
@confirm="
|
||||
setUserInfo($event, UserFieldEnum.USERNAME)
|
||||
"
|
||||
:limit="30"
|
||||
show-limit
|
||||
>
|
||||
<ElButton link>
|
||||
<Icon name="el-icon-Edit" :size="16" />
|
||||
</ElButton>
|
||||
</PopoverInput>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item leading-10">
|
||||
<div class="item-name">昵称</div>
|
||||
<div>
|
||||
{{ userInfo.nickname }}
|
||||
<ClientOnly>
|
||||
<PopoverInput
|
||||
class="inline-block"
|
||||
@confirm="
|
||||
setUserInfo($event, UserFieldEnum.NICKNAME)
|
||||
"
|
||||
:limit="30"
|
||||
show-limit
|
||||
>
|
||||
<ElButton link>
|
||||
<Icon name="el-icon-Edit" :size="16" />
|
||||
</ElButton>
|
||||
</PopoverInput>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item leading-10">
|
||||
<div class="item-name">性别</div>
|
||||
<div>
|
||||
<span>
|
||||
{{ userInfo.sex }}
|
||||
</span>
|
||||
<ClientOnly>
|
||||
<PopoverInput
|
||||
class="inline-block"
|
||||
type="select"
|
||||
:teleported="false"
|
||||
:options="[
|
||||
{
|
||||
label: '未知',
|
||||
value: 0
|
||||
},
|
||||
{
|
||||
label: '男',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
label: '女',
|
||||
value: 2
|
||||
}
|
||||
]"
|
||||
@confirm="setUserInfo($event, UserFieldEnum.SEX)"
|
||||
>
|
||||
<ElButton link>
|
||||
<Icon name="el-icon-Edit" :size="16" />
|
||||
</ElButton>
|
||||
</PopoverInput>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item leading-10">
|
||||
<div class="item-name">手机号</div>
|
||||
<div v-if="userInfo.mobile">
|
||||
{{ userInfo.mobile }}
|
||||
</div>
|
||||
|
||||
<ElButton link type="primary" @click="changeMobile">
|
||||
{{ userInfo.mobile ? '更换手机号' : '绑定手机号' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="info-item leading-10">
|
||||
<div class="item-name">注册时间</div>
|
||||
<div>
|
||||
{{ userInfo.createTime }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-[60px] flex justify-center">
|
||||
<ElButton type="primary" @click="handleLogout">退出登录</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElAvatar, ElButton } from 'element-plus'
|
||||
import { getUserInfo, userEdit } from '@/api/user'
|
||||
import CropperUpload from '@/components/cropper-upload/index.vue'
|
||||
import PopoverInput from '@/components/popover-input/index.vue'
|
||||
import {
|
||||
useAccount,
|
||||
PopupTypeEnum
|
||||
} from '@/layouts/components/account/useAccount'
|
||||
import feedback from '~~/utils/feedback'
|
||||
import { useUserStore } from '~~/stores/user'
|
||||
const { setPopupType, toggleShowPopup, showPopup } = useAccount()
|
||||
const userStore = useUserStore()
|
||||
// 用户资料
|
||||
enum UserFieldEnum {
|
||||
NONE = '',
|
||||
AVATAR = 'avatar',
|
||||
USERNAME = 'username',
|
||||
NICKNAME = 'nickname',
|
||||
SEX = 'sex'
|
||||
}
|
||||
|
||||
const { data: userInfo, refresh } = await useAsyncData(() => getUserInfo(), {
|
||||
initialCache: false
|
||||
})
|
||||
const setUserInfo = async (
|
||||
value: string,
|
||||
type: UserFieldEnum
|
||||
): Promise<void> => {
|
||||
await userEdit({
|
||||
field: type,
|
||||
value: value
|
||||
})
|
||||
feedback.msgSuccess('操作成功')
|
||||
refresh()
|
||||
}
|
||||
|
||||
const changeMobile = () => {
|
||||
setPopupType(PopupTypeEnum.BIND_MOBILE)
|
||||
toggleShowPopup(true)
|
||||
}
|
||||
|
||||
watch(showPopup, (value) => {
|
||||
if (!value) {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogout = async () => {
|
||||
await feedback.confirm('确定退出登录吗?')
|
||||
userStore.logout()
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
module: 'personal',
|
||||
auth: true
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.user-info {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
padding: 10px 0;
|
||||
.item-name {
|
||||
width: 80px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
.avatar {
|
||||
@apply relative flex cursor-pointer;
|
||||
.change-btn {
|
||||
display: none;
|
||||
height: 50%;
|
||||
line-height: 30px;
|
||||
@apply absolute bg-[rgba(0,0,0,0.5)] w-full text-center bottom-0 rounded-b-full;
|
||||
}
|
||||
&:hover {
|
||||
.change-btn {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
8
pc/plugins/element-plus.ts
Normal file
8
pc/plugins/element-plus.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ElLoading } from 'element-plus'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const plugins = [ElLoading]
|
||||
for (const plugin of plugins) {
|
||||
nuxtApp.vueApp.use(plugin)
|
||||
}
|
||||
})
|
18
pc/plugins/fetch.ts
Normal file
18
pc/plugins/fetch.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { createRequest } from '~~/utils/http'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const request = createRequest()
|
||||
//@ts-ignore 添加
|
||||
globalThis.$request = request
|
||||
|
||||
const $fetchOriginal = globalThis.$fetch
|
||||
const $fetch: any = (url: string, opts?: any) => {
|
||||
opts = opts ?? {}
|
||||
opts.url = url
|
||||
return request.request(opts, opts.requestOptions)
|
||||
}
|
||||
$fetch.raw = $fetchOriginal.raw
|
||||
$fetch.create = $fetchOriginal.create
|
||||
//@ts-ignore 重写$fetch
|
||||
globalThis.$fetch = $fetch
|
||||
})
|
23
pc/plugins/icons.ts
Normal file
23
pc/plugins/icons.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import * as ElementPlusIcons from '@element-plus/icons-vue'
|
||||
//@ts-ignore
|
||||
const localIconsName: string[] = []
|
||||
|
||||
export const LOCAL_ICON_PREFIX = 'local-icon-'
|
||||
export const EL_ICON_PREFIX = 'el-icon-'
|
||||
|
||||
const elIconsName: string[] = []
|
||||
|
||||
export function getElementPlusIconNames() {
|
||||
return elIconsName
|
||||
}
|
||||
export function getLocalIconNames() {
|
||||
return localIconsName
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
for (const [iconName, component] of Object.entries(ElementPlusIcons)) {
|
||||
const componentName = `${EL_ICON_PREFIX}${iconName}`
|
||||
elIconsName.push(componentName)
|
||||
nuxtApp.vueApp.component(componentName, component)
|
||||
}
|
||||
})
|
2
pc/public/robots.txt
Normal file
2
pc/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
61
pc/scripts/build.mjs
Normal file
61
pc/scripts/build.mjs
Normal file
@ -0,0 +1,61 @@
|
||||
import path from 'path'
|
||||
import fsExtra from 'fs-extra'
|
||||
import dotenv from 'dotenv'
|
||||
const { existsSync, remove, copy } = fsExtra
|
||||
const cwd = process.cwd()
|
||||
dotenv.config()
|
||||
|
||||
const isSSR = Boolean(process.env.NUXT_SSR)
|
||||
//打包发布路径,可能会覆盖重要文件,请谨慎改动
|
||||
const releaseRelativePath = '../public/pc'
|
||||
const distRelativePath = isSSR ? '' : './.output/public'
|
||||
const distPath = path.resolve(cwd, distRelativePath)
|
||||
const distSSRFilter = ['.output', 'public', 'static', 'package.json']
|
||||
const distNotSSRFilter = ['']
|
||||
const distFilter = joinPath(isSSR ? distSSRFilter : distNotSSRFilter)
|
||||
|
||||
function joinPath(target, res = []) {
|
||||
target.forEach((src) => {
|
||||
res.push(path.join(distPath, src))
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
const releasePath = path.resolve(cwd, releaseRelativePath)
|
||||
|
||||
async function build() {
|
||||
if (existsSync(releasePath)) {
|
||||
await remove(releasePath)
|
||||
}
|
||||
console.log(`文件正在复制 ==> ${releaseRelativePath}`)
|
||||
try {
|
||||
await copyFile(distPath, releasePath)
|
||||
} catch (error) {
|
||||
console.log(`\n ${error}`)
|
||||
}
|
||||
console.log(`文件已复制 ==> ${releaseRelativePath}`)
|
||||
}
|
||||
|
||||
function copyFile(sourceDir, targetDir) {
|
||||
return new Promise((resolve, reject) => {
|
||||
copy(
|
||||
sourceDir,
|
||||
targetDir,
|
||||
{
|
||||
filter(src) {
|
||||
if (src === distPath) return true
|
||||
return distFilter.some((item) => src.includes(item))
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
build()
|
27
pc/stores/app.ts
Normal file
27
pc/stores/app.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getConfig } from '~~/api/app'
|
||||
|
||||
interface AppSate {
|
||||
config: Record<string, any>
|
||||
}
|
||||
export const useAppStore = defineStore({
|
||||
id: 'appStore',
|
||||
state: (): AppSate => ({
|
||||
config: {}
|
||||
}),
|
||||
getters: {
|
||||
getImageUrl: (state) => (url: string) =>
|
||||
url ? `${state.config.domain}${url}` : '',
|
||||
getWebsiteConfig: (state) => state.config.website || {},
|
||||
getLoginConfig: (state) => state.config.login || {},
|
||||
getCopyrightConfig: (state) => state.config.copyright || [],
|
||||
getQrcodeConfig: (state) => state.config.qrcode || {},
|
||||
getAdminUrl: (state) => state.config.admin_url
|
||||
},
|
||||
actions: {
|
||||
async getConfig() {
|
||||
const config = await getConfig()
|
||||
this.config = config
|
||||
}
|
||||
}
|
||||
})
|
43
pc/stores/user.ts
Normal file
43
pc/stores/user.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { getUserCenter } from '@/api/user'
|
||||
import { TOKEN_KEY } from '@/enums/cacheEnums'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
interface UserSate {
|
||||
userInfo: Record<string, any>
|
||||
token: string | null
|
||||
temToken: string | null
|
||||
}
|
||||
export const useUserStore = defineStore({
|
||||
id: 'userStore',
|
||||
state: (): UserSate => {
|
||||
const TOKEN = useCookie(TOKEN_KEY)
|
||||
return {
|
||||
userInfo: {},
|
||||
token: TOKEN.value || null,
|
||||
temToken: null
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
isLogin: (state) => !!state.token
|
||||
},
|
||||
actions: {
|
||||
async getUser() {
|
||||
const data = await getUserCenter()
|
||||
this.userInfo = data
|
||||
},
|
||||
setUser(userInfo) {
|
||||
this.userInfo = userInfo
|
||||
},
|
||||
login(token: string) {
|
||||
const TOKEN = useCookie(TOKEN_KEY)
|
||||
this.token = token
|
||||
TOKEN.value = token
|
||||
},
|
||||
logout() {
|
||||
const TOKEN = useCookie(TOKEN_KEY)
|
||||
this.token = null
|
||||
this.userInfo = {}
|
||||
TOKEN.value = null
|
||||
}
|
||||
}
|
||||
})
|
76
pc/tailwind.config.js
Normal file
76
pc/tailwind.config.js
Normal file
@ -0,0 +1,76 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./components/**/*.{vue,js}',
|
||||
'./layouts/**/*.vue',
|
||||
'./pages/**/*.vue',
|
||||
'./plugins/**/*.{js,ts}'
|
||||
],
|
||||
theme: {
|
||||
colors: {
|
||||
white: 'var(--color-white)',
|
||||
black: 'var(--el-color-black)',
|
||||
primary: {
|
||||
DEFAULT: 'var(--el-color-primary)',
|
||||
'light-3': 'var(--el-color-primary-light-3)',
|
||||
'light-5': 'var(--el-color-primary-light-5)',
|
||||
'light-7': 'var(--el-color-primary-light-7)',
|
||||
'light-8': 'var(--el-color-primary-light-8)',
|
||||
'light-9': 'var(--el-color-primary-light-9)',
|
||||
'dark-2': 'var(--el-color-primary-dark-2)'
|
||||
},
|
||||
success: 'var(--el-color-success)',
|
||||
warning: 'var(--el-color-warning)',
|
||||
danger: 'var(--el-color-danger)',
|
||||
error: 'var(--el-color-error)',
|
||||
info: 'var(--el-color-info)',
|
||||
body: 'var(--el-bg-color)',
|
||||
page: 'var(--el-bg-color-page)',
|
||||
'tx-primary': 'var(--el-text-color-primary)',
|
||||
'tx-regular': 'var(--el-text-color-regular)',
|
||||
'tx-secondary': 'var(--el-text-color-secondary)',
|
||||
'tx-placeholder': 'var(--el-text-color-placeholder)',
|
||||
'tx-disabled': 'var(--el-text-color-disabled)',
|
||||
br: 'var(--el-border-color)',
|
||||
'br-light': 'var(--el-border-color-light)',
|
||||
'br-extra-light': 'var(--el-border-color-extra-light)',
|
||||
'br-dark': 'var( --el-border-color-dark)',
|
||||
fill: 'var(--el-fill-color)',
|
||||
mask: 'var(--el-mask-color)',
|
||||
overlay: 'var(--el-overlay-color-light)'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'PingFang SC',
|
||||
'Arial',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
'sans-serif'
|
||||
]
|
||||
},
|
||||
boxShadow: {
|
||||
DEFAULT: 'var(--el-box-shadow)',
|
||||
light: 'var(--el-box-shadow-light)',
|
||||
lighter: 'var(--el-box-shadow-lighter)',
|
||||
dark: 'var(--el-box-shadow-dark)'
|
||||
},
|
||||
fontSize: {
|
||||
xs: 'var(--el-font-size-extra-small)',
|
||||
sm: 'var( --el-font-size-small)',
|
||||
base: 'var( --el-font-size-base)',
|
||||
lg: 'var( --el-font-size-medium)',
|
||||
xl: 'var( --el-font-size-large)',
|
||||
'2xl': 'var( --el-font-size-extra-large)',
|
||||
'3xl': '20px',
|
||||
'4xl': '24px',
|
||||
'5xl': '28px',
|
||||
'6xl': '30px',
|
||||
'7xl': '36px',
|
||||
'8xl': '48px',
|
||||
'9xl': '60px'
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/line-clamp') // 引入插件
|
||||
]
|
||||
}
|
4
pc/tsconfig.json
Normal file
4
pc/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
// https://v3.nuxtjs.org/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
32
pc/typings/fetch.d.ts
vendored
Normal file
32
pc/typings/fetch.d.ts
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
import 'ohmyfetch'
|
||||
import { FetchResponse, FetchOptions } from 'ohmyfetch'
|
||||
|
||||
declare module 'ohmyfetch' {
|
||||
interface FetchOptions {
|
||||
url?: string
|
||||
requestOptions?: RequestOptions
|
||||
}
|
||||
interface RequestOptions {
|
||||
// 请求接口前缀
|
||||
apiPrefix?: string
|
||||
// 需要对返回数据进行处理
|
||||
isTransformResponse?: boolean
|
||||
// 是否返回默认数据
|
||||
isReturnDefaultResponse?: boolean
|
||||
//POST请求下如果无data,则将params视为data
|
||||
isParamsToData?: boolean
|
||||
// 是否自动携带token
|
||||
withToken?: boolean
|
||||
requestInterceptorsHook?(options: FetchOptions): FetchOptions
|
||||
responseInterceptorsHook?(
|
||||
response: FetchResponse<any>,
|
||||
options: FetchOptions
|
||||
): any
|
||||
responseInterceptorsCatchHook?: (error: any) => void
|
||||
}
|
||||
interface FileParams {
|
||||
name?: string
|
||||
file: File
|
||||
data?: any
|
||||
}
|
||||
}
|
0
pc/typings/modules.d.ts
vendored
Normal file
0
pc/typings/modules.d.ts
vendored
Normal file
8
pc/typings/router.d.ts
vendored
Normal file
8
pc/typings/router.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
import 'vue-router'
|
||||
declare module 'vue-router' {
|
||||
// 扩展 RouteMeta
|
||||
interface RouteMeta {
|
||||
module?: string
|
||||
activeMenu?: string
|
||||
}
|
||||
}
|
27
pc/utils/env.ts
Normal file
27
pc/utils/env.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @description 获取客户端类型
|
||||
*/
|
||||
export function getClient() {
|
||||
return useRuntimeConfig().public.client
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取版本号
|
||||
*/
|
||||
export function getVersion() {
|
||||
return useRuntimeConfig().public.version
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取请求域名
|
||||
*/
|
||||
export function getApiUrl() {
|
||||
return useRuntimeConfig().public.apiUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取请求前缀
|
||||
*/
|
||||
export function getApiPrefix() {
|
||||
return useRuntimeConfig().public.apiPrefix
|
||||
}
|
95
pc/utils/feedback.ts
Normal file
95
pc/utils/feedback.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElNotification,
|
||||
ElLoading,
|
||||
type ElMessageBoxOptions
|
||||
} from 'element-plus'
|
||||
import type { LoadingInstance } from 'element-plus/es/components/loading/src/loading'
|
||||
|
||||
export class Feedback {
|
||||
private loadingInstance: LoadingInstance | null = null
|
||||
static instance: Feedback | null = null
|
||||
static getInstance() {
|
||||
return this.instance ?? (this.instance = new Feedback())
|
||||
}
|
||||
// 消息提示
|
||||
msg(msg: string) {
|
||||
ElMessage.info(msg)
|
||||
}
|
||||
// 错误消息
|
||||
msgError(msg: string) {
|
||||
ElMessage.error(msg)
|
||||
}
|
||||
// 成功消息
|
||||
msgSuccess(msg: string) {
|
||||
ElMessage.success(msg)
|
||||
}
|
||||
// 警告消息
|
||||
msgWarning(msg: string) {
|
||||
ElMessage.warning(msg)
|
||||
}
|
||||
// 弹出提示
|
||||
alert(msg: string) {
|
||||
ElMessageBox.alert(msg, '系统提示')
|
||||
}
|
||||
// 错误提示
|
||||
alertError(msg: string) {
|
||||
ElMessageBox.alert(msg, '系统提示', { type: 'error' })
|
||||
}
|
||||
// 成功提示
|
||||
alertSuccess(msg: string) {
|
||||
ElMessageBox.alert(msg, '系统提示', { type: 'success' })
|
||||
}
|
||||
// 警告提示
|
||||
alertWarning(msg: string) {
|
||||
ElMessageBox.alert(msg, '系统提示', { type: 'warning' })
|
||||
}
|
||||
// 通知提示
|
||||
notify(msg: string) {
|
||||
ElNotification.info(msg)
|
||||
}
|
||||
// 错误通知
|
||||
notifyError(msg: string) {
|
||||
ElNotification.error(msg)
|
||||
}
|
||||
// 成功通知
|
||||
notifySuccess(msg: string) {
|
||||
ElNotification.success(msg)
|
||||
}
|
||||
// 警告通知
|
||||
notifyWarning(msg: string) {
|
||||
ElNotification.warning(msg)
|
||||
}
|
||||
// 确认窗体
|
||||
confirm(msg: string) {
|
||||
return ElMessageBox.confirm(msg, '温馨提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
}
|
||||
// 提交内容
|
||||
prompt(content: string, title: string, options?: ElMessageBoxOptions) {
|
||||
return ElMessageBox.prompt(content, title, {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
...options
|
||||
})
|
||||
}
|
||||
// 打开全局loading
|
||||
loading(msg: string) {
|
||||
this.loadingInstance = ElLoading.service({
|
||||
lock: true,
|
||||
text: msg
|
||||
})
|
||||
}
|
||||
// 关闭全局loading
|
||||
closeLoading() {
|
||||
this.loadingInstance?.close()
|
||||
}
|
||||
}
|
||||
|
||||
const feedback = Feedback.getInstance()
|
||||
|
||||
export default feedback
|
111
pc/utils/http/index.ts
Normal file
111
pc/utils/http/index.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { getClient } from '@/utils/env'
|
||||
import { FetchOptions } from 'ohmyfetch'
|
||||
import { RequestCodeEnum, RequestMethodsEnum } from '@/enums/requestEnums'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { merge } from 'lodash-es'
|
||||
import { Request } from './request'
|
||||
import { getApiPrefix, getApiUrl, getVersion } from '../env'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import {
|
||||
PopupTypeEnum,
|
||||
useAccount
|
||||
} from '~~/layouts/components/account/useAccount'
|
||||
|
||||
export function createRequest(opt?: Partial<FetchOptions>) {
|
||||
const userStore = useUserStore()
|
||||
const { setPopupType, toggleShowPopup } = useAccount()
|
||||
const defaultOptions: FetchOptions = {
|
||||
// 基础接口地址
|
||||
baseURL: getApiUrl(),
|
||||
//请求头
|
||||
headers: {
|
||||
version: getVersion()
|
||||
},
|
||||
retry: 2,
|
||||
async onRequest({ options }) {
|
||||
const { withToken } = options.requestOptions
|
||||
const headers = options.headers || {}
|
||||
// 添加token
|
||||
if (withToken) {
|
||||
const token = userStore.token
|
||||
headers['like-token'] = token
|
||||
}
|
||||
options.headers['terminal'] = getClient()
|
||||
options.headers = headers
|
||||
},
|
||||
requestOptions: {
|
||||
apiPrefix: getApiPrefix(),
|
||||
isTransformResponse: true,
|
||||
isReturnDefaultResponse: false,
|
||||
withToken: true,
|
||||
isParamsToData: true,
|
||||
requestInterceptorsHook(options) {
|
||||
console.log(options)
|
||||
const { apiPrefix, isParamsToData } = options.requestOptions
|
||||
// 拼接请求前缀
|
||||
if (apiPrefix) {
|
||||
options.url = `${apiPrefix}${options.url}`
|
||||
}
|
||||
const params = options.params || {}
|
||||
// POST请求下如果无data,则将params视为data
|
||||
if (
|
||||
isParamsToData &&
|
||||
!Reflect.has(options, 'body') &&
|
||||
options.method?.toUpperCase() === RequestMethodsEnum.POST
|
||||
) {
|
||||
options.body = params
|
||||
options.params = {}
|
||||
}
|
||||
return options
|
||||
},
|
||||
async responseInterceptorsHook(response, options) {
|
||||
const { isTransformResponse, isReturnDefaultResponse } =
|
||||
options.requestOptions
|
||||
//返回默认响应,当需要获取响应头及其他数据时可使用
|
||||
if (isReturnDefaultResponse) {
|
||||
return response
|
||||
}
|
||||
// 是否需要对数据进行处理
|
||||
if (!isTransformResponse) {
|
||||
return response._data
|
||||
}
|
||||
const { code, data, msg } = response._data
|
||||
switch (code) {
|
||||
case RequestCodeEnum.SUCCESS:
|
||||
return data
|
||||
case RequestCodeEnum.PARAMS_TYPE_ERROR:
|
||||
case RequestCodeEnum.PARAMS_VALID_ERROR:
|
||||
case RequestCodeEnum.REQUEST_METHOD_ERROR:
|
||||
case RequestCodeEnum.ASSERT_ARGUMENT_ERROR:
|
||||
case RequestCodeEnum.ASSERT_MYBATIS_ERROR:
|
||||
case RequestCodeEnum.LOGIN_ACCOUNT_ERROR:
|
||||
case RequestCodeEnum.LOGIN_DISABLE_ERROR:
|
||||
case RequestCodeEnum.NO_PERMISSTION:
|
||||
case RequestCodeEnum.FAILED:
|
||||
case RequestCodeEnum.SYSTEM_ERROR:
|
||||
if (msg) {
|
||||
msg && feedback.msgError(msg)
|
||||
}
|
||||
return Promise.reject(msg)
|
||||
|
||||
case RequestCodeEnum.TOKEN_INVALID:
|
||||
case RequestCodeEnum.TOKEN_EMPTY:
|
||||
userStore.logout()
|
||||
setPopupType(PopupTypeEnum.LOGIN)
|
||||
toggleShowPopup(true)
|
||||
return Promise.reject()
|
||||
|
||||
default:
|
||||
return data
|
||||
}
|
||||
},
|
||||
responseInterceptorsCatchHook(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Request(
|
||||
// 深度合并
|
||||
merge(defaultOptions, opt || {})
|
||||
)
|
||||
}
|
124
pc/utils/http/request.ts
Normal file
124
pc/utils/http/request.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import {
|
||||
FetchOptions,
|
||||
$fetch,
|
||||
$Fetch,
|
||||
FetchResponse,
|
||||
RequestOptions,
|
||||
FileParams
|
||||
} from 'ohmyfetch'
|
||||
import { merge } from 'lodash-es'
|
||||
import { isFunction } from '../validate'
|
||||
import { RequestMethodsEnum } from '@/enums/requestEnums'
|
||||
|
||||
export class Request {
|
||||
private requestOptions: RequestOptions
|
||||
private fetchInstance: $Fetch
|
||||
constructor(private fetchOptions: FetchOptions) {
|
||||
this.fetchInstance = $fetch.create(fetchOptions)
|
||||
this.requestOptions = fetchOptions.requestOptions
|
||||
}
|
||||
|
||||
getInstance() {
|
||||
return this.fetchInstance
|
||||
}
|
||||
/**
|
||||
* @description get请求
|
||||
*/
|
||||
get(fetchOptions: FetchOptions, requestOptions?: Partial<RequestOptions>) {
|
||||
return this.request(
|
||||
{ ...fetchOptions, method: RequestMethodsEnum.GET },
|
||||
requestOptions
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description post请求
|
||||
*/
|
||||
post(fetchOptions: FetchOptions, requestOptions?: Partial<RequestOptions>) {
|
||||
return this.request(
|
||||
{ ...fetchOptions, method: RequestMethodsEnum.POST },
|
||||
requestOptions
|
||||
)
|
||||
}
|
||||
/**
|
||||
* @description: 文件上传
|
||||
*/
|
||||
uploadFile(options: FetchOptions, params: FileParams) {
|
||||
const formData = new FormData()
|
||||
const customFilename = params.name || 'file'
|
||||
formData.append(customFilename, params.file)
|
||||
if (params.data) {
|
||||
Object.keys(params.data).forEach((key) => {
|
||||
const value = params.data![key]
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => {
|
||||
formData.append(`${key}[]`, item)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
formData.append(key, params.data![key])
|
||||
})
|
||||
}
|
||||
return this.request({
|
||||
...options,
|
||||
method: RequestMethodsEnum.POST,
|
||||
body: formData
|
||||
})
|
||||
}
|
||||
/**
|
||||
* @description 请求函数
|
||||
*/
|
||||
request(
|
||||
fetchOptions: FetchOptions,
|
||||
requestOptions?: Partial<RequestOptions>
|
||||
): Promise<any> {
|
||||
let mergeOptions = merge({}, this.fetchOptions, fetchOptions)
|
||||
mergeOptions.requestOptions = merge(
|
||||
{},
|
||||
this.requestOptions,
|
||||
requestOptions
|
||||
)
|
||||
|
||||
const {
|
||||
requestInterceptorsHook,
|
||||
responseInterceptorsHook,
|
||||
responseInterceptorsCatchHook
|
||||
} = this.requestOptions
|
||||
if (requestInterceptorsHook && isFunction(requestInterceptorsHook)) {
|
||||
mergeOptions = requestInterceptorsHook(mergeOptions)
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
return this.fetchInstance
|
||||
.raw(mergeOptions.url, mergeOptions)
|
||||
.then(async (response: FetchResponse<any>) => {
|
||||
if (
|
||||
responseInterceptorsHook &&
|
||||
isFunction(responseInterceptorsHook)
|
||||
) {
|
||||
try {
|
||||
response = await responseInterceptorsHook(
|
||||
response,
|
||||
mergeOptions
|
||||
)
|
||||
resolve(response)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (
|
||||
responseInterceptorsCatchHook &&
|
||||
isFunction(responseInterceptorsCatchHook)
|
||||
) {
|
||||
reject(responseInterceptorsCatchHook(err))
|
||||
return
|
||||
}
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
47
pc/utils/util.ts
Normal file
47
pc/utils/util.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @description 添加单位
|
||||
* @param {String | Number} value 值 100
|
||||
* @param {String} unit 单位 px em rem
|
||||
*/
|
||||
export const addUnit = (value: string | number, unit = 'px') => {
|
||||
return !Object.is(Number(value), NaN) ? `${value}${unit}` : value
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 树转数组,队列实现广度优先遍历
|
||||
* @param {Array} data 数据
|
||||
* @param {Object} props `{ children: 'children' }`
|
||||
*/
|
||||
|
||||
export const treeToArray = (data: any[], props = { children: 'children' }) => {
|
||||
data = JSON.parse(JSON.stringify(data))
|
||||
const { children } = props
|
||||
const newData = []
|
||||
const queue: any[] = []
|
||||
data.forEach((child: any) => queue.push(child))
|
||||
while (queue.length) {
|
||||
const item: any = queue.shift()
|
||||
if (item[children]) {
|
||||
item[children].forEach((child: any) => queue.push(child))
|
||||
delete item[children]
|
||||
}
|
||||
newData.push(item)
|
||||
}
|
||||
return newData
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取正确的路经
|
||||
* @param {String} path 数据
|
||||
*/
|
||||
export function getNormalPath(path: string) {
|
||||
if (path.length === 0 || !path || path == 'undefined') {
|
||||
return path
|
||||
}
|
||||
const newPath = path.replace('//', '/')
|
||||
const length = newPath.length
|
||||
if (newPath[length - 1] === '/') {
|
||||
return newPath.slice(0, length - 1)
|
||||
}
|
||||
return newPath
|
||||
}
|
50
pc/utils/validate.ts
Normal file
50
pc/utils/validate.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export {
|
||||
isArray,
|
||||
isBoolean,
|
||||
isDate,
|
||||
isObject,
|
||||
isFunction,
|
||||
isString,
|
||||
isNumber,
|
||||
isNull
|
||||
} from 'lodash-es'
|
||||
import { isObject } from 'lodash-es'
|
||||
/**
|
||||
* @description 是否是http,邮件,电话号码
|
||||
*/
|
||||
export function isExternal(path: string) {
|
||||
return /^(https?:|mailto:|tel:)/.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 是否是http
|
||||
*/
|
||||
export const isLinkHttp = (link: string): boolean =>
|
||||
/^(https?:)?\/\//.test(link)
|
||||
|
||||
/**
|
||||
* @description 是否是电话号码
|
||||
*/
|
||||
export const isLinkTel = (link: string): boolean => /^tel:/.test(link)
|
||||
|
||||
/**
|
||||
* @description 是否是邮件
|
||||
*/
|
||||
export const isLinkMailto = (link: string): boolean => /^mailto:/.test(link)
|
||||
|
||||
/**
|
||||
* @description 是否为空
|
||||
* @param {unknown} value
|
||||
* @return {Boolean}
|
||||
*/
|
||||
export const isEmpty = (value: unknown) => {
|
||||
return value !== null && value !== '' && typeof value !== 'undefined'
|
||||
}
|
||||
/**
|
||||
* @description 是否为空对象
|
||||
* @param {Object} value
|
||||
* @return {Boolean}
|
||||
*/
|
||||
export const isEmptyObject = (target: object) => {
|
||||
return isObject(target) && !Object.keys(target).length
|
||||
}
|
6416
pc/yarn.lock
Normal file
6416
pc/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user