Merge branch 'develop' of https://gitee.com/likeadmin/likeadmin_java into develop

This commit is contained in:
TinyAnts
2023-01-16 09:24:18 +08:00
344 changed files with 21836 additions and 207 deletions

View File

@@ -2,10 +2,10 @@ import request from '@/utils/request'
// 微信开发平台配置保存
export function setWxDevConfig(params: any) {
return request.post({ url: '/channel/wx/save', params })
return request.post({ url: '/channel/op/save', params })
}
// 微信开发平台配置详情
export function getWxDevConfig() {
return request.get({ url: '/channel/wx/detail' })
return request.get({ url: '/channel/op/detail' })
}

View File

@@ -1,7 +1,7 @@
const config = {
terminal: 1, //终端
title: '后台管理系统', //网站默认标题
version: '1.3.3', //版本号
version: '1.4.0', //版本号
baseUrl: `${import.meta.env.VITE_APP_BASE_URL || ''}/`, //请求接口域名
urlPrefix: 'api', //请求默认前缀
timeout: 10 * 1000 //请求超时时长

View File

@@ -6,5 +6,6 @@ import './styles/index.scss'
import 'virtual:svg-icons-register'
const app = createApp(App)
console.log(app)
app.use(install)
app.mount('#app')

View File

@@ -3,14 +3,14 @@
<el-card class="!border-none" shadow="never">
<el-alert
type="warning"
title="温馨提示:填写微信开放平台开发配置,请前往微信开放平台创建应用并完成认证;APP应用配置主要用于APP微信登录和微信支付"
title="温馨提示:填写微信开放平台开发配置,请前往微信开放平台创建应用并完成认证;网站应用配置主要用于网站微信登录和微信支付"
:closable="false"
show-icon
/>
</el-card>
<el-form ref="formRef" :model="formData" label-width="160px">
<el-card class="!border-none mt-4" shadow="never">
<div class="font-medium mb-7">APP应用</div>
<div class="font-medium mb-7">网站应用</div>
<el-form-item label="AppID" prop="appId">
<div class="w-80">
<el-input v-model="formData.appId" placeholder="请输入AppID" />
@@ -23,20 +23,15 @@
</div>
</div>
</el-form-item>
<el-form-item>
<div class="form-tips">
小程序账号登录微信公众平台点击开发>开发设置->开发者ID设置AppID和AppSecret
</div>
</el-form-item>
</el-card>
</el-form>
<footer-btns v-perms="['channel:wx:save']">
<footer-btns v-perms="['channel:op:save']">
<el-button type="primary" @click="handelSave">保存</el-button>
</footer-btns>
</div>
</template>
<script lang="ts" setup name="wxDevConfig">
import { getWxDevConfig, setWxDevConfig } from '@/api/channel/wx_dev'
import { getWxDevConfig, setWxDevConfig } from '@/api/channel/wx_op'
import feedback from '@/utils/feedback'
const formData = reactive({

View File

@@ -11,6 +11,7 @@
:is="widgets[widget?.name]?.attr"
:content="widget?.content"
:styles="widget?.styles"
:type="type"
/>
</keep-alive>
</div>
@@ -23,6 +24,10 @@ const props = defineProps({
widget: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
type: {
type: String as PropType<'mobile' | 'pc'>,
default: 'mobile'
}
})
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="pages-preview">
<div
v-for="(widget, index) in pageData"
:key="widget.id"
class="relative"
:class="{
'cursor-pointer': !widget?.disabled
}"
@click="handleClick(widget, index)"
>
<div
class="absolute w-full h-full z-[100] border-dashed"
:class="{
select: index == modelValue,
'border-[#dcdfe6] border-2': !widget?.disabled
}"
:style="widget.styles"
></div>
<slot>
<component
:is="widgets[widget?.name]?.content"
:content="widget.content"
:styles="widget.styles"
:key="widget.id"
/>
</slot>
</div>
</div>
</template>
<script lang="ts" setup>
import widgets from '../widgets'
import type { PropType } from 'vue'
defineProps({
pageData: {
type: Array as PropType<any[]>,
default: () => []
},
modelValue: {
type: Number,
default: 0
}
})
const emit = defineEmits<{
(event: 'update:modelValue', value: number): void
}>()
const handleClick = (widget: any, index: number) => {
if (widget.disabled) return
emit('update:modelValue', index)
}
</script>
<style lang="scss" scoped>
.pages-preview {
width: 460px;
height: 360px;
background: url(../../image/pc_index.png);
background-size: 100% 100%;
background-repeat: no-repeat;
.select {
@apply border-primary border-solid;
}
}
</style>

View File

@@ -33,7 +33,15 @@
/>
</el-form-item>
<el-form-item class="mt-[18px]" label="图片链接">
<link-picker v-model="item.link" />
<link-picker
v-if="type == 'mobile'"
v-model="item.link"
/>
<el-input
v-if="type == 'pc'"
placeholder="请输入链接"
v-model="item.link.path"
/>
</el-form-item>
</div>
</div>
@@ -63,6 +71,10 @@ const props = defineProps({
styles: {
type: Object as PropType<OptionsType['styles']>,
default: () => ({})
},
type: {
type: String as PropType<'mobile' | 'pc'>,
default: 'mobile'
}
})

View File

@@ -1,7 +1,12 @@
<template>
<div class="banner">
<div class="banner-image">
<decoration-img width="100%" height="170px" :src="getImage" fit="contain" />
<div class="banner" :style="styles">
<div class="banner-image w-full h-full">
<decoration-img
width="100%"
:height="styles.height || height"
:src="getImage"
fit="contain"
/>
</div>
</div>
</template>
@@ -18,6 +23,10 @@ const props = defineProps({
styles: {
type: Object as PropType<OptionsType['styles']>,
default: () => ({})
},
height: {
type: String,
default: '170px'
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

View File

@@ -0,0 +1,90 @@
<template>
<div class="decoration-pages min-w-[1100px]">
<el-card shadow="never" class="!border-none flex-1 flex" :body-style="{ flex: 1 }">
<div class="flex h-full items-start">
<Menu v-model="activeMenu" :menus="menus" />
<preview-pc class="mx-4" v-model="selectWidgetIndex" :pageData="getPageData" />
<attr-setting class="flex-1" :widget="getSelectWidget" type="pc" />
</div>
</el-card>
<footer-btns class="mt-4" :fixed="false" v-perms="['decorate:pages:save']">
<el-button type="primary" @click="setData">保存</el-button>
</footer-btns>
</div>
</template>
<script lang="ts" setup name="decorationPc">
import Menu from './component/pages/menu.vue'
import PreviewPc from './component/pages/preview-pc.vue'
import AttrSetting from './component/pages/attr-setting.vue'
import widgets from './component/widgets'
import { getDecoratePages, setDecoratePages } from '@/api/decoration'
import { getNonDuplicateID } from '@/utils/util'
enum pagesTypeEnum {
HOME = '4'
}
const generatePageData = (widgetNames: string[]) => {
return widgetNames.map((widgetName) => {
const options = {
id: getNonDuplicateID(),
...(widgets[widgetName]?.options() || {})
}
return options
})
}
const menus: Record<
string,
{
id: number
name: string
pageData: any[]
}
> = reactive({
[pagesTypeEnum.HOME]: {
id: 4,
pageType: 4,
name: 'pc首页装修',
pageData: []
}
})
const activeMenu = ref('4')
const selectWidgetIndex = ref(0)
const getPageData = computed(() => {
return menus[activeMenu.value]?.pageData ?? []
})
const getSelectWidget = computed(() => {
return menus[activeMenu.value]?.pageData[selectWidgetIndex.value] ?? ''
})
const getData = async () => {
const data = await getDecoratePages({ id: activeMenu.value })
menus[String(data.id)].pageData = JSON.parse(data.pageData)
selectWidgetIndex.value = getPageData.value.findIndex((item) => !item.disabled)
}
const setData = async () => {
await setDecoratePages({
...menus[activeMenu.value],
pageData: JSON.stringify(menus[activeMenu.value].pageData)
})
getData()
}
watch(
activeMenu,
() => {
selectWidgetIndex.value = getPageData.value.findIndex((item) => !item.disabled)
getData()
},
{
immediate: true
}
)
</script>
<style lang="scss" scoped>
.decoration-pages {
min-height: calc(100vh - var(--navbar-height) - 80px);
@apply flex flex-col;
}
</style>

View File

@@ -218,11 +218,50 @@
</div>
</el-form-item>
<el-form-item label="生成方式" prop="gen.genType">
<el-radio-group v-model="formData.gen.genType">
<el-radio :label="GenType.ZIP">压缩包下载</el-radio>
<el-radio :label="GenType.CUSTOM_PATH">自定义路径</el-radio>
</el-radio-group>
<div>
<el-radio-group v-model="formData.gen.genType">
<el-radio :label="GenType.ZIP">压缩包下载</el-radio>
<el-radio :label="GenType.CUSTOM_PATH">自定义路径</el-radio>
</el-radio-group>
<div class="form-tips">压縮包下载方式暂不支持自动构建菜单权限</div>
</div>
</el-form-item>
<el-form-item label="菜单构建" prop="gen.menuStatus" required>
<div>
<el-radio-group v-model="formData.gen.menuStatus">
<el-radio :label="1">自动构建</el-radio>
<el-radio :label="0">手动添加</el-radio>
</el-radio-group>
<div class="form-tips">
自动构建自动执行生成菜单sql 手动添加自行添加菜单
</div>
</div>
</el-form-item>
<el-form-item label="父级菜单" prop="gen.menuPid">
<el-tree-select
class="w-80"
v-model="formData.gen.menuPid"
:data="optionsData.menu"
clearable
node-key="id"
:props="{
label: 'menuName'
}"
default-expand-all
placeholder="请选择父级菜单"
check-strictly
/>
</el-form-item>
<el-form-item label="菜单名称" prop="gen.menuName">
<div class="w-80">
<el-input
v-model="formData.gen.menuName"
placeholder="请输入菜单名称"
clearable
/>
</div>
</el-form-item>
<el-form-item
v-if="formData.gen.genType == GenType.CUSTOM_PATH"
label="自定义路径"
@@ -367,7 +406,10 @@ const formData = reactive({
subTableFr: '',
treeParent: '',
treePrimary: '',
treeName: ''
treeName: '',
menuName: '',
menuStatus: 0,
menuPid: 0
}
})
@@ -407,9 +449,9 @@ const { optionsData } = useDictOptions<{
menu: {
api: menuLists,
transformData(data: any) {
const menu = { id: 0, name: '顶级', children: [] }
const menu = { id: 0, menuName: '顶级', children: [] }
menu.children = data
return menu
return [menu]
}
},
dataTable: {

View File

@@ -52,6 +52,44 @@
</div>
</el-form-item>
</el-card>
<el-card shadow="never" class="!border-none mt-4">
<div class="text-xl font-medium mb-[20px]">PC端设置</div>
<el-form-item label="PC端LOGO" prop="pcLogo">
<div>
<material-picker v-model="formData.pcLogo" :limit="1" />
<div class="form-tips">建议尺寸120*28px支持jpgjpegpng格式</div>
</div>
</el-form-item>
<el-form-item label="网站标题" prop="pcTitle">
<div class="w-80">
<el-input
v-model.trim="formData.pcTitle"
placeholder="请输入PC端网站标题"
maxlength="30"
show-word-limit
/>
</div>
</el-form-item>
<el-form-item label="网站图标" prop="pcIco">
<div>
<material-picker v-model="formData.pcIco" :limit="1" />
<div class="form-tips">建议尺寸100*100像素支持jpgjpegpng格式</div>
</div>
</el-form-item>
<el-form-item label="网站描述" prop="pcDesc">
<div class="w-80">
<el-input v-model.trim="formData.pcDesc" placeholder="请输入PC端网站描述" />
</div>
</el-form-item>
<el-form-item label="网站关键词" prop="pcKeywords">
<div class="w-80">
<el-input
v-model.trim="formData.pcKeywords"
placeholder="请输入PC端网站关键词"
/>
</div>
</el-form-item>
</el-card>
</el-form>
<footer-btns v-perms="['setting:website:save']">
<el-button type="primary" @click="handleSubmit">保存</el-button>
@@ -73,7 +111,12 @@ const formData = reactive({
logo: '', // 网站logo
backdrop: '', // 登录页广告图
shopName: '',
shopLogo: ''
shopLogo: '',
pcDesc: '',
pcIco: '',
pcKeywords: '',
pcLogo: '',
pcTitle: ''
})
// 表单验证
@@ -119,6 +162,27 @@ const rules = {
message: '请选择商城LOGO',
trigger: ['change']
}
],
pcLogo: [
{
required: true,
message: '请选择PC端LOGO',
trigger: ['change']
}
],
pcTitle: [
{
required: true,
message: '请输入PC端网站标题',
trigger: ['blur']
}
],
pcIco: [
{
required: true,
message: '请选择PC端网站图标',
trigger: ['change']
}
]
}

View File

@@ -0,0 +1,63 @@
<template>
<div>
<el-card header="基础使用" shadow="none" class="!border-none">
<div class="flex flex-wrap">
<div class="flex m-4">
<div class="mr-4">选择图片</div>
<material-picker v-model="state.value1" />
</div>
<div class="flex m-4">
<div class="mr-4">选择视频</div>
<material-picker type="video" v-model="state.value3" />
</div>
<div class="flex flex-1 m-4">
<div class="mr-4">多张图片</div>
<div class="flex-1">
<!-- 外层需要有足够的宽度这样预览图和选择按钮才不会直接换行 -->
<material-picker :limit="4" v-model="state.value2" />
</div>
</div>
</div>
</el-card>
<el-card header="进阶用法" shadow="none" class="!border-none mt-4">
<div class="flex flex-wrap">
<div class="flex m-4">
<div class="mr-4">自定义选择器大小</div>
<material-picker size="60px" v-model="state.value4" />
</div>
<div class="flex m-4">
<div class="mr-4">使用插槽</div>
<material-picker v-model="state.value5">
<template #upload>
<el-button>选择文件</el-button>
</template>
</material-picker>
</div>
<div class="flex m-4">
<div class="mr-4">选出地址不带域名</div>
<material-picker :exclude-domain="true" v-model="state.value6" />
</div>
</div>
<div>
<div class="flex m-4 items-center">
<div class="w-20 flex-none">带域名</div>
<el-input class="w-[500px]" :model-value="state.value5" />
</div>
<div class="flex m-4 items-center">
<div class="w-20 flex-none">不带域名</div>
<el-input class="w-[500px]" :model-value="state.value6" />
</div>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
const state = reactive({
value1: '',
value2: [],
value3: '',
value4: '',
value5: '',
value6: ''
})
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div>
<el-card header="element-plus图标" shadow="none" class="!border-none">
<div class="flex items-center">
<icon class="m-4" :size="24" name="el-icon-Search" />
<icon class="m-4" :size="24" name="el-icon-Plus" />
<icon class="m-4" :size="24" name="el-icon-FullScreen" />
<icon class="m-4" :size="24" name="el-icon-Setting" />
<icon class="m-4" :size="24" name="el-icon-Warning" />
</div>
</el-card>
<el-card header="本地图标" shadow="none" class="!border-none mt-4">
<div class="flex items-center">
<icon class="m-4" :size="24" name="local-icon-baoxian" />
<icon class="m-4" :size="24" name="local-icon-youhui" />
<icon class="m-4" :size="24" name="local-icon-daiyunying" />
<icon class="m-4" :size="24" name="local-icon-diancanshezhi" />
<icon class="m-4" :size="24" name="local-icon-dianzifapiao" />
</div>
</el-card>
<el-card header="图标选择器" shadow="none" class="!border-none mt-4">
<div class="flex items-center">
<icon-picker v-model="state.value" />
</div>
</el-card>
<el-card
header="element-plus图标库大全点击复制图标名称"
shadow="none"
class="!border-none mt-4"
>
<div class="flex items-center">
<div class="flex flex-wrap">
<div v-for="item in getElementPlusIconNames()" :key="item" class="m-1">
<el-button v-copy="item">
<icon :name="item" :size="20" />
</el-button>
</div>
</div>
</div>
</el-card>
<el-card
header="本地图标库大全(点击复制图标名称)"
shadow="none"
class="!border-none mt-4"
>
<div class="flex items-center">
<div class="flex flex-wrap">
<div v-for="item in getLocalIconNames()" :key="item" class="m-1">
<el-button v-copy="item">
<icon :name="item" :size="20" />
</el-button>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import Icon from '@/components/icon/index.vue'
import { getElementPlusIconNames, getLocalIconNames } from '@/components/icon'
const state = reactive({
value: ''
})
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div>
<el-card header="基础使用" shadow="none" class="!border-none">
<link-picker v-model="state.value1" />
</el-card>
</div>
</template>
<script lang="ts" setup>
const state = reactive({
value1: {}
})
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div>
<el-card header="基础使用" shadow="none" class="!border-none">
<overflow-tooltip class="w-20 m-4" content="超出自动打点,悬浮弹窗显示全部内容" />
<overflow-tooltip class="w-60 m-4" content="超出自动打点,悬浮弹窗显示全部内容" />
</el-card>
</div>
</template>
<script lang="ts" setup></script>

View File

@@ -0,0 +1,48 @@
<template>
<div>
<el-card header="基础使用" shadow="none" class="!border-none">
<div class="flex flex-wrap">
<div class="m-4">
<popover-input @confirm="onConfirm">
<template #default>
<el-button> 点击输入 </el-button>
</template>
</popover-input>
</div>
<div class="m-4">
<popover-input type="number" @confirm="onConfirm">
<template #default>
<el-button> 输入数字 </el-button>
</template>
</popover-input>
</div>
<div class="m-4">
<popover-input size="small" @confirm="onConfirm">
<template #default>
<el-button> 调整大小 </el-button>
</template>
</popover-input>
</div>
<div class="m-4">
<popover-input :limit="20" :show-limit="true" @confirm="onConfirm">
<template #default>
<el-button> 限制输入长度 </el-button>
</template>
</popover-input>
</div>
<div class="m-4">
<popover-input value="默认值" @confirm="onConfirm">
<template #default>
<el-button> 默认值 </el-button>
</template>
</popover-input>
</div>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
const onConfirm = (value: string) => {
console.log(value)
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div>
<el-card header="基础使用" shadow="none" class="!border-none">
<editor v-model="state.value1" height="500px" />
</el-card>
<el-card header="简洁模式" shadow="none" class="!border-none mt-4">
<editor v-model="state.value2" height="500px" mode="simple" />
</el-card>
</div>
</template>
<script lang="ts" setup>
const state = reactive({
value1: '',
value2: ''
})
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div>
<el-card header="基础使用" shadow="none" class="!border-none">
<div class="flex flex-wrap">
<div class="m-4">
<upload
@change="onChange"
@success="onSuccess"
@error="onError"
:show-progress="true"
>
<el-button type="primary">上传图片</el-button>
</upload>
</div>
<div class="m-4">
<upload
type="video"
@change="onChange"
@success="onSuccess"
@error="onError"
:show-progress="true"
>
<el-button type="primary">上传视频</el-button>
</upload>
</div>
<div class="m-4">
<upload
:multiple="false"
@change="onChange"
@success="onSuccess"
@error="onError"
:show-progress="true"
>
<el-button type="primary">取消多选</el-button>
</upload>
</div>
<div class="m-4">
<upload
:limit="2"
@change="onChange"
@success="onSuccess"
@error="onError"
:show-progress="true"
>
<el-button type="primary">一次最多上传2张</el-button>
</upload>
</div>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import Upload from '@/components/upload/index.vue'
const onChange = (file: any) => {
console.log('上传文件的状态发生改变', file)
}
const onSuccess = (file: any) => {
console.log('上传文件成功', file)
}
const onError = (file: any) => {
console.log('上传文件失败', file)
}
</script>

View File

@@ -0,0 +1,2 @@
# 请求域名
NUXT_API_URL=

17
pc/.env.example Normal file
View 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

View File

@@ -0,0 +1,3 @@
# 请求域名
NUXT_API_URL=

46
pc/.eslintrc.cjs Normal file
View 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
View 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
View 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.

36
pc/api/account.ts Normal file
View File

@@ -0,0 +1,36 @@
import { getClient } from '~~/utils/env'
// 登录
export function login(params: any) {
return $request.post({
url: '/login/check',
params: { ...params, client: getClient() }
})
}
//注册
export function register(params: any) {
return $request.post({
url: '/login/register',
params: { ...params, client: getClient() }
})
}
//向微信请求code的链接
export function getWxCodeUrl() {
return $request.get({
url: '/login/getScanCode',
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
View File

@@ -0,0 +1,19 @@
//发送短信
export function smsSend(params: any) {
return $request.post({ url: '/sms/send', params })
}
// 获取配置
export function getConfig() {
return $request.get({ url: '/pc/getConfig' })
}
// 获取协议
export function getPolicy(params: any) {
return $request.get({ url: '/policy', params })
}
// 上传图片
export function uploadImage(params: any) {
return $request.uploadFile({ url: '/upload/image' }, params)
}

57
pc/api/news.ts Normal file
View 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/addCollect', params })
}
/**
* @description 取消收藏
* @param { number } id
* @return { Promise }
*/
export function cancelCollect(params) {
return $request.post({ url: '/article/cancelCollect', params })
}
/**
* @description 获取收藏列表
* @return { Promise }
*/
export function getCollect(params) {
return $request.get({ url: '/article/collect', params })
}

4
pc/api/shop.ts Normal file
View File

@@ -0,0 +1,4 @@
//首页数据
export function getIndex() {
return $request.get({ url: '/pc/index' })
}

26
pc/api/user.ts Normal file
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View 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);
}
}

View File

@@ -0,0 +1,3 @@
@use 'element.scss';
@use 'var.css';
@use 'public.scss';

View 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
View 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);
}

View 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>

View 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>

View 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>

View 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>

View 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 }}&nbsp;|&nbsp;
</span>
<span class="mr-5" v-if="showTime">{{ createTime }}</span>
<div v-if="showClick" class="flex items-center">
<ElIcon>
<View />
</ElIcon>
<span>&nbsp;{{ 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
export enum PageEnum {
//登录页面
LOGIN = '/login',
//无权限页面
ERROR_403 = '/403',
INDEX = '/'
}

28
pc/enums/requestEnums.ts Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
<template>
<section class="layout-blank">
<slot />
</section>
</template>
<script lang="ts" setup></script>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,323 @@
<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, login } 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 = {
scene: LoginWayEnum[formData.scene].toLowerCase()
}
if (isAccountLogin.value) {
params.username = formData.account
params.password = formData.password
}
if (isMobileLogin.value) {
params.mobile = formData.account
params.code = formData.code
}
const data = await login(params)
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>

View 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>

View 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>

View 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
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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()
}
})

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

32
pc/package.json Normal file
View 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"
}
}

View 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
View 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>

View 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>

View 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 }}&nbsp;|&nbsp;
</span>
<span class="mr-5">{{ newsDetail.createTime }}</span>
<div class="flex items-center">
<Icon name="el-icon-View" />
<span>&nbsp;{{ 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>

View 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>

View 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>

View 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
View 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>

View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

61
pc/scripts/build.mjs Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
// https://v3.nuxtjs.org/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

32
pc/typings/fetch.d.ts vendored Normal file
View 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
View File

8
pc/typings/router.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import 'vue-router'
declare module 'vue-router' {
// 扩展 RouteMeta
interface RouteMeta {
module?: string
activeMenu?: string
}
}

Some files were not shown because too many files have changed in this diff Show More