This commit is contained in:
yovinchen 2023-06-15 17:58:57 +08:00
commit 560d986995
68 changed files with 36853 additions and 0 deletions

3
.browserslistrc Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

1
.env Normal file
View File

@ -0,0 +1 @@
VUE_APP_SERVER_URL = 'http://127.0.0.1:8080'

5
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/

5
babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

12584
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "doubao_community_frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"axios": "^0.21.1",
"buefy": "^0.9.4",
"core-js": "^3.6.5",
"darkreader": "^4.9.27",
"date-fns": "^2.17.0",
"dayjs": "^1.10.4",
"element-ui": "^2.15.0",
"js-cookie": "^2.2.1",
"nprogress": "^0.2.0",
"vditor": "^3.8.1",
"vue": "^2.6.11",
"vue-router": "^3.2.0",
"vuex": "^3.4.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"vue-template-compiler": "^2.6.11"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

17
public/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

31
src/App.vue Normal file
View File

@ -0,0 +1,31 @@
<template>
<div>
<div class="mb-5">
<Header></Header>
</div>
<div class="container context">
<router-view :key="this.$route.fullPath"></router-view>
</div>
<div>
<Footer></Footer>
</div>
</div>
</template>
<script>
import Header from "@/components/Layout/Header";
import Footer from "@/components/Layout/Footer";
export default {
name: "App",
components: {Header, Footer},
};
</script>
<style scoped>
.container {
min-height: 500px;
}
</style>

32
src/api/auth/auth.js Normal file
View File

@ -0,0 +1,32 @@
import request from '@/utils/request'
// 注册
export function userRegister(userDTO) {
return request({
url: '/ums/user/register',
method: 'post',
data: userDTO
})
}
// 前台用户登录
export function login(data) {
return request({
url: '/ums/user/login',
method: 'post',
data
})
}
// 登录后获取前台用户信息
export function getUserInfo() {
return request({
url: '/ums/user/info',
method: 'get'
})
}
// 前台用户注销
export function logout() {
return request({
url: '/ums/user/logout'
})
}

8
src/api/billboard.js Normal file
View File

@ -0,0 +1,8 @@
import request from '@/utils/request'
export function getBillboard() {
return request({
url: '/billboard/show',
method: 'get'
})
}

20
src/api/comment.js Normal file
View File

@ -0,0 +1,20 @@
import request from '@/utils/request'
export function fetchCommentsByTopicId(topic_Id) {
return request({
url: '/comment/get_comments',
method: 'get',
params: {
topicid: topic_Id
}
})
}
export function pushComment(data) {
return request({
url: '/comment/add_comment',
method: 'post',
data: data
})
}

25
src/api/follow.js Normal file
View File

@ -0,0 +1,25 @@
import request from '@/utils/request'
// 关注
export function follow(id) {
return request(({
url: `/relationship/subscribe/${id}`,
method: 'get'
}))
}
// 关注
export function unFollow(id) {
return request(({
url: `/relationship/unsubscribe/${id}`,
method: 'get'
}))
}
// 验证是否关注
export function hasFollow(topicUserId) {
return request(({
url: `/relationship/validate/${topicUserId}`,
method: 'get'
}))
}

56
src/api/post.js Normal file
View File

@ -0,0 +1,56 @@
import request from '@/utils/request'
// 列表
export function getList(pageNo, size, tab) {
return request(({
url: '/post/list',
method: 'get',
params: { pageNo: pageNo, size: size, tab: tab }
}))
}
// 发布
export function post(topic) {
return request({
url: '/post/create',
method: 'post',
data: topic
})
}
// 浏览
export function getTopic(id) {
return request({
url: `/post`,
method: 'get',
params: {
id: id
}
})
}
// 获取详情页推荐
export function getRecommendTopics(id) {
return request({
url: '/post/recommend',
method: 'get',
params: {
topicId: id
}
})
}
export function update(topic) {
return request({
url: '/post/update',
method: 'post',
data: topic
})
}
export function deleteTopic(id) {
return request({
url: `/post/delete/${id}`,
method: 'delete'
})
}

9
src/api/promote.js Normal file
View File

@ -0,0 +1,9 @@
import request from '@/utils/request'
// 获取推广
export function getList() {
return request(({
url: '/promotion/all',
method: 'get'
}))
}

14
src/api/search.js Normal file
View File

@ -0,0 +1,14 @@
import request from '@/utils/request'
// 关键词检索
export function searchByKeyword(query) {
return request({
url: `/search`,
method: 'get',
params: {
keyword: query.keyword,
pageNum: query.pageNum,
pageSize: query.pageSize
}
})
}

12
src/api/tag.js Normal file
View File

@ -0,0 +1,12 @@
import request from '@/utils/request'
export function getTopicsByTag(paramMap) {
return request({
url: '/tag/' + paramMap.name,
method: 'get',
params: {
page: paramMap.page,
size: paramMap.size
}
})
}

8
src/api/tip.js Normal file
View File

@ -0,0 +1,8 @@
import request from '@/utils/request'
export function getTodayTip() {
return request({
url: '/tip/today',
method: 'get'
})
}

30
src/api/user.js Normal file
View File

@ -0,0 +1,30 @@
import request from '@/utils/request'
// 用户主页
export function getInfoByName(username, page, size) {
return request({
url: '/ums/user/' + username,
method: 'get',
params: {
pageNo: page,
size: size
}
})
}
// 用户主页
export function getInfo() {
return request({
url: '/ums/user/info',
method: 'get'
})
}
// 更新
export function update(user) {
return request({
url: '/ums/user/update',
method: 'post',
data: user
})
}

150
src/assets/app.css Normal file
View File

@ -0,0 +1,150 @@
* {
margin: 0;
padding: 0;
}
body,
html {
background-color: #f6f6f6;
color: black;
width: 100%;
font-size: 14px;
letter-spacing: 0.03em;
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, PingFang SC,
Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei,
sans-serif, Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji,
Segoe UI Symbol, Android Emoji, EmojiSymbols;
}
/*背景图*/
/*body {*/
/* background-image: url('https://api.mz-moe.cn/img.php');*/
/* background-repeat: round;*/
/*}*/
@media (min-width: 768px) {
.container {
width: 760px;
}
}
@media (min-width: 992px) {
.container {
width: 980px;
}
}
@media (min-width: 1200px) {
.container {
width: 1080px;
}
}
/*滚动条*/
::-webkit-scrollbar {
width: 10px;
height: 10px;
/**/
}
::-webkit-scrollbar-track {
background: rgb(239, 239, 239);
border-radius: 2px;
}
::-webkit-scrollbar-thumb {
background: #bfbfbf;
border-radius: 10px;
}
::-webkit-scrollbar-corner {
background: #179a16;
}
.header {
position: fixed;
z-index: 89;
top: 0;
width: 100%;
min-width: 1032px;
background: #fff;
box-shadow: 0 1px 0px rgba(26, 26, 26, 0.1);
height: 53px;
font-size: 16px;
}
a {
color: #1d1d1d;
text-decoration: none;
}
a:hover {
color: #f60;
text-decoration: none !important;
}
.shadow-1 {
box-shadow: 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1),
0 0 0 1px rgba(10, 10, 10, 0.02);
}
.navbar-dropdown {
font-size: 15px;
}
/*统一卡片样式*/
.el-card {
/*border-radius: 3px !important;*/
margin-bottom: 16px;
/*border: none;*/
}
.my-card {
cursor: pointer;
transition: all 0.1s ease-in-out;
position: relative;
overflow: hidden;
}
.my-card:hover {
transform: scale(1.03);
}
::selection {
text-shadow: none;
background: rgba(67, 135, 244, 0.56);
}
/* 搜索框 */
.search-bar input {
border: none;
box-shadow: none;
}
/*按钮居中*/
.button-center {
display: block;
margin: 0 auto;
}
.ellipsis {
display: block;
display: -webkit-box;
margin: 0 auto;
line-height: 1.4;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.is-ellipsis-1 {
-webkit-line-clamp: 1;
}
.is-ellipsis-2 {
-webkit-line-clamp: 2;
}
.is-ellipsis-3 {
-webkit-line-clamp: 3;
}

BIN
src/assets/image/doubao.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 434 KiB

View File

@ -0,0 +1,28 @@
<template>
<el-backtop :bottom="60" :right="60">
<div title="回到顶部"
style="{
height: 100%;
width: 100%;
background-color: #f2f5f6;
box-shadow: 0 1px 0 0;
border-radius: 12px;
text-align: center;
line-height: 40px;
color: #167df0;
}"
>
<i class="fa fa-arrow-up"></i>
</div>
</el-backtop>
</template>
<script>
export default {
name: "BackTop"
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,57 @@
<template>
<section class="box comments">
<hr />
<h3 class="title is-5">Comments</h3>
<lv-comments-form :slug="slug" v-if="token" @loadComments="fetchComments"/>
<lv-comments-item
v-for="comment in comments"
:key="`comment-${comment.id}`"
:comment="comment"
/>
</section>
</template>
<script>
import { mapGetters } from 'vuex'
import { fetchCommentsByTopicId } from '@/api/comment'
import LvCommentsForm from './CommentsForm'
import LvCommentsItem from './CommentsItem'
export default {
name: 'LvComments',
components: {
LvCommentsForm,
LvCommentsItem
},
data() {
return {
comments: []
}
},
props: {
slug: {
type: String,
default: null
}
},
computed: {
...mapGetters([
'token'
])
},
async mounted() {
await this.fetchComments(this.slug)
},
methods: {
//
async fetchComments(topic_id) {
console.log(topic_id)
fetchCommentsByTopicId(topic_id).then(response => {
const { data } = response
this.comments = data
})
}
}
}
</script>

View File

@ -0,0 +1,70 @@
<template>
<article class="media">
<div class="media-content">
<form @submit.prevent="onSubmit">
<b-field>
<b-input
v-model.lazy="commentText"
type="textarea"
maxlength="400"
placeholder="Add a comment..."
:disabled="isLoading"
></b-input>
</b-field>
<nav class="level">
<div class="level-left">
<b-button
type="is-primary"
native-type="submit"
class="level-item"
:disabled="isLoading"
>
Comment
</b-button>
</div>
</nav>
</form>
</div>
</article>
</template>
<script>
import { pushComment } from '@/api/comment'
export default {
name: 'LvCommentsForm',
data() {
return {
commentText: '',
isLoading: false
}
},
props: {
slug: {
type: String,
default: null
}
},
methods: {
async onSubmit() {
this.isLoading = true
try {
let postData = {}
console.log(this.commentText)
postData['content'] = this.commentText
postData['topic_id'] = this.slug
await pushComment(postData)
this.$emit('loadComments', this.slug)
this.$message.success('留言成功')
} catch (e) {
this.$buefy.toast.open({
message: `Cannot comment this story. ${e}`,
type: 'is-danger'
})
} finally {
this.isLoading = false
}
}
}
}
</script>

View File

@ -0,0 +1,32 @@
<template>
<article class="media">
<figure class="media-left image is-48x48">
<img :src="`https://cn.gravatar.com/avatar/${comment.userId}?s=164&d=monsterid`">
</figure>
<div class="media-content">
<div class="content">
<p>
<strong>{{ comment.username }}</strong>
<small class="ml-2">{{ comment.createTime | date }}</small>
<br />
{{ comment.content }}
</p>
</div>
</div>
</article>
</template>
<script>
export default {
name: 'LvCommentsItem',
props: {
comment: {
type: Object,
required: true
}
}
}
</script>

View File

@ -0,0 +1,57 @@
<template>
<footer class="footer has-text-grey-light has-background-grey-darker">
<div class="container">
<div class="">
<span>简洁实用美观</span>
<span style="float: right">
<a href="/?lang=zh_CN">中文</a> |
<a href="/?lang=en_US">English</a>
</span>
</div>
<div>
<span>{{ title }} ALL RIGHTS RESERVED</span>
<div style="float: right">
<template>
<b-taglist attached>
<b-tag size="is-normal" type="is-dark">Design</b-tag>
<b-tag size="is-normal" type="is-info">{{ author }}</b-tag>
</b-taglist>
</template>
</div>
</div>
</div>
<back-top></back-top>
</footer>
</template>
<script>
import BackTop from "@/components/Backtop/BackTop";
export default {
name: "Footer",
components: {
BackTop
},
data() {
return {
title: "© " + new Date().getFullYear() + ' yovinchen',
author: 'yovinchen',
};
},
};
</script>
<style scoped>
footer {
margin-top: 120px;
height: 150px;
}
footer a {
color: #bfbfbf;
}
</style>

View File

@ -0,0 +1,183 @@
<template>
<header class="header has-background-white has-text-black">
<b-navbar
:fixed-top="true"
class="container is-white"
>
<template slot="brand">
<b-navbar-item
:to="{ path: '/' }"
tag="router-link">
<img :src="doubaoImg" alt="logo">
</b-navbar-item>
<b-navbar-item
:to="{ path: '/' }"
class="is-hidden-desktop"
tag="router-link"
>
主页
</b-navbar-item>
</template>
<template slot="start">
<b-navbar-item
:to="{ path: '/' }"
tag="router-link"
>
🌐 主页
</b-navbar-item>
</template>
<template slot="end">
<b-navbar-item tag="div">
<b-field position="is-centered">
<b-input
v-model="searchKey"
class="s_input"
clearable
placeholder="搜索帖子、标签和用户"
rounded
width="80%"
@keyup.enter.native="search()"
/>
<p class="control">
<b-button
class="is-info"
@click="search()"
>检索
</b-button>
</p>
</b-field>
</b-navbar-item>
<b-navbar-item tag="div">
<b-switch
v-model="darkMode"
passive-type="is-warning"
type="is-dark"
>
{{ darkMode ? "夜" : "日" }}
</b-switch>
</b-navbar-item>
<b-navbar-item
v-if="token == null || token === ''"
tag="div"
>
<div class="buttons">
<b-button
:to="{ path: '/register' }"
class="is-light"
tag="router-link"
>
注册
</b-button>
<b-button
:to="{ path: '/login' }"
class="is-light"
tag="router-link"
>
登录
</b-button>
</div>
</b-navbar-item>
<b-navbar-dropdown
v-else
:label="user.alias"
>
<b-navbar-item
:to="{ path: `/member/${user.username}/home` }"
tag="router-link"
>
🧘 个人中心
</b-navbar-item>
<hr class="dropdown-divider">
<b-navbar-item
:to="{ path: `/member/${user.username}/setting` }"
tag="router-link"
>
设置中心
</b-navbar-item>
<hr class="dropdown-divider">
<b-navbar-item
tag="a"
@click="logout"
> 👋 退出登录
</b-navbar-item>
</b-navbar-dropdown>
</template>
</b-navbar>
</header>
</template>
<script>
import {disable as disableDarkMode, enable as enableDarkMode} from 'darkreader'
import {getDarkMode, setDarkMode} from '@/utils/auth'
import {mapGetters} from 'vuex'
export default {
name: 'Header',
data() {
return {
logoUrl: require('@/assets/logo.png'),
doubaoImg: require('@/assets/image/doubao.png'),
searchKey: '',
darkMode: false
}
},
computed: {
...mapGetters(['token', 'user'])
},
watch: {
// Theme
darkMode(val) {
if (val) {
enableDarkMode({})
} else {
disableDarkMode()
}
setDarkMode(this.darkMode)
}
},
created() {
// cookie
this.darkMode = getDarkMode()
if (this.darkMode) {
enableDarkMode({})
} else {
disableDarkMode()
}
},
methods: {
async logout() {
this.$store.dispatch('user/logout').then(() => {
this.$message.info('退出登录成功')
setTimeout(() => {
this.$router.push({path: this.redirect || '/'})
}, 500)
})
},
search() {
console.log(this.token)
if (this.searchKey.trim() === null || this.searchKey.trim() === '') {
this.$message.info({
showClose: true,
message: '请输入关键字搜索!',
type: 'warning'
})
return false
}
this.$router.push({path: '/search?key=' + this.searchKey})
}
}
}
</script>
<style scoped>
input {
width: 80%;
height: 86%;
}
</style>

View File

@ -0,0 +1,103 @@
<template>
<div :class="{ hidden: hidden }" class="pagination-container">
<el-pagination
:background="background"
:current-page.sync="currentPage"
:page-size.sync="pageSize"
:layout="layout"
:page-sizes="pageSizes"
:total="total"
v-bind="$attrs"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script>
import {scrollTo} from "@/utils/scroll-to";
export default {
name: "Pagination",
props: {
total: {
required: true,
type: Number,
},
page: {
type: Number,
default: 1,
},
limit: {
type: Number,
default: 10,
},
pageSizes: {
type: Array,
default() {
return [5, 10, 20, 30, 50];
},
},
layout: {
type: String,
default: "total, sizes, prev, pager, next, jumper",
// default: 'sizes, prev, pager, next, jumper'
},
background: {
type: Boolean,
default: true,
},
autoScroll: {
type: Boolean,
default: true,
},
hidden: {
type: Boolean,
default: false,
},
},
computed: {
currentPage: {
get() {
return this.page;
},
set(val) {
this.$emit("update:page", val);
},
},
pageSize: {
get() {
return this.limit;
},
set(val) {
this.$emit("update:limit", val);
},
},
},
methods: {
handleSizeChange(val) {
this.$emit("pagination", { page: this.currentPage, limit: val });
if (this.autoScroll) {
scrollTo(0, 800);
}
},
handleCurrentChange(val) {
this.$emit("pagination", { page: val, limit: this.pageSize });
if (this.autoScroll) {
scrollTo(0, 800);
}
},
},
};
</script>
<style scoped>
.pagination-container {
/* background: #fff; */
padding: 5px 0px;
}
.pagination-container.hidden {
display: none;
}
</style>

42
src/main.js Normal file
View File

@ -0,0 +1,42 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
// Buefy
import Buefy from 'buefy'
import 'buefy/dist/buefy.css'
// ElementUI
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import '@/assets/app.css'
import './assets/plugins/font-awesome-4.7.0/css/font-awesome.min.css'
import format from 'date-fns/format'
import '@/permission'
import relativeTime from 'dayjs/plugin/relativeTime';
// 国际化
import 'dayjs/locale/zh-cn'
const dayjs = require('dayjs');
// 相对时间插件
dayjs.extend(relativeTime)
dayjs.locale('zh-cn') // use locale globally
dayjs().locale('zh-cn').format() // use locale in a specific instance
Vue.prototype.dayjs = dayjs;//可以全局使用dayjs
Vue.filter('date', (date) => {
return format(new Date(date), 'yyyy-MM-dd')
})
Vue.use(Buefy)
Vue.use(ElementUI);
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

41
src/permission.js Normal file
View File

@ -0,0 +1,41 @@
import router from './router'
import store from './store'
import getPageTitle from '@/utils/get-page-title'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css'
import {getToken} from "@/utils/auth"; // progress bar style
NProgress.configure({showSpinner: false}) // NProgress Configuration
router.beforeEach(async (to, from, next) => {
// start progress bar
NProgress.start()
// set page title
document.title = getPageTitle(to.meta.title)
// determine whether the user has logged in
const hasToken = getToken();
if (hasToken) {
if (to.path === '/login') {
// 登录,跳转首页
next({path: '/'})
NProgress.done()
} else {
// 获取用户信息
await store.dispatch('user/getInfo')
next()
}
} else if (!to.meta.requireAuth)
{
next()
}
else {
next('/login')
}
})
router.afterEach(() => {
// finish progress bar
NProgress.done()
})

98
src/router/index.js Normal file
View File

@ -0,0 +1,98 @@
import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);
const routes = [
{
path: "/",
name: "Home",
component: () => import("@/views/Home"),
},
{
path: "/register",
name: "register",
component: () => import("@/views/auth/Register"),
meta: { title: "注册" },
},
// 登录
{
name: "login",
path: "/login",
component: () => import("@/views/auth/Login"),
meta: { title: "登录" },
},
// 发布
{
name: "post-create",
path: "/post/create",
component: () => import("@/views/post/Create"),
meta: { title: "信息发布", requireAuth: true },
},
// 编辑
{
name: 'topic-edit',
path: '/topic/edit/:id',
component: () => import('@/views/post/Edit'),
meta: {
title: '编辑',
requireAuth: true
}
},
// 详情
{
name: "post-detail",
path: "/post/:id",
component: () => import("@/views/post/Detail"),
meta: { title: "详情" },
},
{
name: 'tag',
path: '/tag/:name',
component: () => import('@/views/tag/Tag'),
meta: { title: '主题列表' }
},
// 检索
{
name: 'search',
path: '/search',
component: () => import('@/views/Search'),
meta: { title: '检索' }
},
// 用户主页
{
name: 'user',
path: '/member/:username/home',
component: () => import('@/views/user/Profile'),
meta: { title: '用户主页' }
},
// 用户设置
{
name: 'user-setting',
path: '/member/:username/setting',
component: () => import('@/views/user/Setting'),
meta: { title: '设置', requireAuth: true }
},
{
path: "/404",
name: "404",
component: () => import("@/views/error/404"),
meta: { title: "404-NotFound" },
},
{
path: "*",
redirect: "/404",
hidden: true,
},
];
const originalPush = VueRouter.prototype.push;
VueRouter.prototype.push = function push(location) {
return originalPush.call(this, location).catch((err) => err);
};
const router = new VueRouter({
routes,
});
export default router;

5
src/store/getters.js Normal file
View File

@ -0,0 +1,5 @@
const getters = {
token: state => state.user.token, // token
user: state => state.user.user, // 用户对象
}
export default getters

15
src/store/index.js Normal file
View File

@ -0,0 +1,15 @@
import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
import user from './modules/user'
Vue.use(Vuex)
const store = new Vuex.Store({
modules: {
user
},
getters
})
export default store

80
src/store/modules/user.js Normal file
View File

@ -0,0 +1,80 @@
import { getUserInfo, login, logout } from "@/api/auth/auth";
import { getToken, setToken, removeToken } from "@/utils/auth";
const state = {
token: getToken(), // token
user: "", // 用户对象
};
const mutations = {
SET_TOKEN_STATE: (state, token) => {
state.token = token;
},
SET_USER_STATE: (state, user) => {
state.user = user;
},
};
const actions = {
// 用户登录
login({ commit }, userInfo) {
console.log(userInfo);
const { name, pass, rememberMe } = userInfo;
return new Promise((resolve, reject) => {
login({ username: name.trim(), password: pass, rememberMe: rememberMe })
.then((response) => {
const { data } = response;
commit("SET_TOKEN_STATE", data.token);
setToken(data.token);
resolve();
})
.catch((error) => {
reject(error);
});
});
},
// 获取用户信息
getInfo({ commit, state }) {
return new Promise((resolve, reject) => {
getUserInfo()
.then((response) => {
const { data } = response;
if (!data) {
commit("SET_TOKEN_STATE", "");
commit("SET_USER_STATE", "");
removeToken();
resolve();
reject("Verification failed, please Login again.");
}
commit("SET_USER_STATE", data);
resolve(data);
})
.catch((error) => {
reject(error);
});
});
},
// 注销
logout({ commit, state }) {
return new Promise((resolve, reject) => {
logout(state.token)
.then((response) => {
console.log(response);
commit("SET_TOKEN_STATE", "");
commit("SET_USER_STATE", "");
removeToken();
resolve();
})
.catch((error) => {
reject(error);
});
});
},
};
export default {
namespaced: true,
state,
mutations,
actions,
};

30
src/user.js Normal file
View File

@ -0,0 +1,30 @@
import request from '@/utils/request'
// 用户主页
export function getInfoByName(username, page, size) {
return request({
url: '/ums/user/' + username,
method: 'get',
params: {
pageNo: page,
size: size
}
})
}
// 用户主页
export function getInfo() {
return request({
url: '/ums/user/info',
method: 'get'
})
}
// 更新
export function update(user) {
return request({
url: '/ums/user/update',
method: 'post',
data: user
})
}

31
src/utils/auth.js Normal file
View File

@ -0,0 +1,31 @@
import Cookies from 'js-cookie'
const uToken = 'u_token'
const darkMode = 'dark_mode';
// 获取Token
export function getToken() {
return Cookies.get(uToken);
}
// 设置Token1天,与后端同步
export function setToken(token) {
return Cookies.set(uToken, token, {expires: 1})
}
// 删除Token
export function removeToken() {
return Cookies.remove(uToken)
}
export function removeAll() {
return Cookies.Cookies.removeAll()
}
export function setDarkMode(mode) {
return Cookies.set(darkMode, mode, {expires: 365})
}
export function getDarkMode() {
return !(undefined === Cookies.get(darkMode) || 'false' === Cookies.get(darkMode));
}

View File

@ -0,0 +1,8 @@
const title = '小而美的智慧社区系统'
export default function getPageTitle(pageTitle) {
if (pageTitle) {
return `${pageTitle} - ${title}`
}
return `${title}`
}

81
src/utils/request.js Normal file
View File

@ -0,0 +1,81 @@
import axios from 'axios'
import { Message, MessageBox } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
// 1.创建axios实例
const service = axios.create({
// 公共接口--这里注意后面会讲,url = base url + request url
baseURL: process.env.VUE_APP_SERVER_URL,
// baseURL: 'https://api.example.com',
// 超时时间 单位是ms这里设置了5s的超时时间
timeout: 5 * 1000
})
// 2.请求拦截器request interceptor
service.interceptors.request.use(
config => {
// 发请求前做的一些处理数据转化配置请求头设置token,设置loading等根据需求去添加
// 注意使用token的时候需要引入cookie方法或者用本地localStorage等方法推荐js-cookie
if (store.getters.token) {
// config.params = {'token': token} // 如果要求携带在参数中
// config.headers.token = token; // 如果要求携带在请求头中
// bearerw3c规范
config.headers['Authorization'] = 'Bearer ' + getToken()
}
return config
},
error => {
// do something with request error
// console.log(error) // for debug
return Promise.reject(error)
}
)
// 设置cross跨域 并设置访问权限 允许跨域携带cookie信息,使用JWT可关闭
service.defaults.withCredentials = false
service.interceptors.response.use(
// 接收到响应数据并成功后的一些共有的处理关闭loading等
response => {
const res = response.data
// 如果自定义代码不是200则将其判断为错误。
if (res.code !== 200) {
// 50008: 非法Token; 50012: 异地登录; 50014: Token失效;
if (res.code === 401 || res.code === 50012 || res.code === 50014) {
// 重新登录
MessageBox.confirm('会话失效,您可以留在当前页面,或重新登录', '权限不足', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
center: true
}).then(() => {
window.location.href = '#/login'
})
} else { // 其他异常直接提示
Message({
showClose: true,
message: '⚠' + res.message || 'Error',
type: 'error',
duration: 3 * 1000
})
}
return Promise.reject(new Error(res.message || 'Error'))
} else {
return res
}
},
error => {
/** *** 接收到异常响应的处理开始 *****/
// console.log('err' + error) // for debug
Message({
showClose: true,
message: error.message,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
}
)
export default service

58
src/utils/scroll-to.js Normal file
View File

@ -0,0 +1,58 @@
Math.easeInOutQuad = function(t, b, c, d) {
t /= d / 2
if (t < 1) {
return c / 2 * t * t + b
}
t--
return -c / 2 * (t * (t - 2) - 1) + b
}
// requestAnimationFrame for Smart Animating http://goo.gl/sx5sts
var requestAnimFrame = (function() {
return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) }
})()
/**
* Because it's so fucking difficult to detect the scrolling element, just move them all
* @param {number} amount
*/
function move(amount) {
document.documentElement.scrollTop = amount
document.body.parentNode.scrollTop = amount
document.body.scrollTop = amount
}
function position() {
return document.documentElement.scrollTop || document.body.parentNode.scrollTop || document.body.scrollTop
}
/**
* @param {number} to
* @param {number} duration
* @param {Function} callback
*/
export function scrollTo(to, duration, callback) {
const start = position()
const change = to - start
const increment = 20
let currentTime = 0
duration = (typeof (duration) === 'undefined') ? 500 : duration
var animateScroll = function() {
// increment the time
currentTime += increment
// find the value with the quadratic in-out easing function
var val = Math.easeInOutQuad(currentTime, start, change, duration)
// move the document.body
move(val)
// do the animation unless its over
if (currentTime < duration) {
requestAnimFrame(animateScroll)
} else {
if (callback && typeof (callback) === 'function') {
// the animation is done so lets callback
callback()
}
}
}
animateScroll()
}

42
src/views/Home.vue Normal file
View File

@ -0,0 +1,42 @@
<template>
<div>
<div class="box">🔔 {{ billboard.content }}</div>
<div class="columns">
<div class="column is-three-quarters">
<TopicList></TopicList>
</div>
<div class="column">
<CardBar></CardBar>
</div>
</div>
</div>
</template>
<script>
import { getBillboard } from "@/api/billboard";
import CardBar from "@/views/card/CardBar"
import PostList from '@/views/post/Index'
export default {
name: "Home",
components: {CardBar, TopicList: PostList},
data() {
return {
billboard: {
content: "",
},
};
},
created() {
this.fetchBillboard();
},
methods: {
async fetchBillboard() {
getBillboard().then((value) => {
const { data } = value;
this.billboard = data;
});
},
},
};
</script>

104
src/views/Search.vue Normal file
View File

@ -0,0 +1,104 @@
<template>
<div>
<el-card shadow="never">
<div slot="header" class="clearfix">
检索到 <code>{{ list.length }}</code>
条关于 <code class="has-text-info">{{ query.keyword }}</code> 的记录
</div>
<div>
<article v-for="(item, index) in list" :key="index" class="media">
<div class="media-left">
<figure class="image is-48x48">
<img :src="`https://cn.gravatar.com/avatar/${item.userId}?s=164&d=monsterid`">
</figure>
</div>
<div class="media-content">
<div class="">
<p class="ellipsis is-ellipsis-1">
<el-tooltip class="item" effect="dark" :content="item.title" placement="top">
<router-link :to="{name:'post-detail',params:{id:item.id}}">
<span class="is-size-6">{{ item.title }}</span>
</router-link>
</el-tooltip>
</p>
</div>
<nav class="level has-text-grey is-mobile is-size-7 mt-2">
<div class="level-left">
<div class="level-left">
<router-link class="level-item" :to="{ path: `/member/${item.username}/home` }">
{{ item.alias }}
</router-link>
<span class="mr-1">
发布于:{{ dayjs(item.createTime).format("YYYY/MM/DD") }}
</span>
<span
v-for="(tag, index) in item.tags"
:key="index"
class="tag is-hidden-mobile is-success is-light mr-1"
>
<router-link :to="{ name: 'tag', params: { name: tag.name } }">
{{ "#" + tag.name }}
</router-link>
</span>
<span class="is-hidden-mobile">浏览:{{ item.view }}</span>
</div>
</div>
</nav>
</div>
<div class="media-right" />
</article>
</div>
<!--分页-->
<pagination
v-show="query.total > 0"
:total="query.total"
:page.sync="query.pageNum"
:limit.sync="query.pageSize"
@pagination="fetchList"
/>
</el-card>
</div>
</template>
<script>
import { searchByKeyword } from '@/api/search'
import Pagination from '@/components/Pagination'
export default {
name: 'Search',
components: { Pagination },
data() {
return {
list: [],
query: {
keyword: this.$route.query.key,
pageNum: 1,
pageSize: 10,
total: 0
}
}
},
created() {
this.fetchList()
},
methods: {
fetchList() {
searchByKeyword(this.query).then(value => {
const { data } = value
this.list = data.records
this.query.total = data.total
this.query.pageSize = data.size
this.query.pageNum = data.current
})
}
}
}
</script>
<style scoped>
</style>

116
src/views/auth/Login.vue Normal file
View File

@ -0,0 +1,116 @@
<template>
<div class="columns py-6">
<div class="column is-half is-offset-one-quarter">
<el-card shadow="never">
<div slot="header" class="has-text-centered has-text-weight-bold">
用户登录
</div>
<div>
<el-form
v-loading="loading"
:model="ruleForm"
status-icon
:rules="rules"
ref="ruleForm"
label-width="100px"
class="demo-ruleForm"
>
<el-form-item label="账号" prop="name">
<el-input v-model="ruleForm.name"></el-input>
</el-form-item>
<el-form-item label="密码" prop="pass">
<el-input
type="password"
v-model="ruleForm.pass"
autocomplete="off"
></el-input>
</el-form-item>
<el-form-item label="记住" prop="delivery">
<el-switch v-model="ruleForm.rememberMe"></el-switch>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')"
>提交</el-button
>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</div>
</template>
<script>
export default {
name: "Login",
data() {
return {
redirect: undefined,
loading: false,
ruleForm: {
name: "",
pass: "",
rememberMe: true,
},
rules: {
name: [
{ required: true, message: "请输入账号", trigger: "blur" },
{
min: 2,
max: 15,
message: "长度在 2 到 15 个字符",
trigger: "blur",
},
],
pass: [
{ required: true, message: "请输入密码", trigger: "blur" },
{
min: 6,
max: 20,
message: "长度在 6 到 20 个字符",
trigger: "blur",
},
],
},
};
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
this.loading = true;
this.$store
.dispatch("user/login", this.ruleForm)
.then(() => {
this.$message({
message: "恭喜你,登录成功",
type: "success",
duration: 2000,
});
setTimeout(() => {
this.loading = false;
this.$router.push({ path: this.redirect || "/" });
}, 0.1 * 1000);
})
.catch(() => {
this.loading = false;
});
} else {
return false;
}
});
},
resetForm(formName) {
this.$refs[formName].resetFields();
},
},
};
</script>
<style scoped>
</style>

150
src/views/auth/Register.vue Normal file
View File

@ -0,0 +1,150 @@
<template>
<div class="columns py-6">
<div class="column is-half is-offset-one-quarter">
<el-card shadow="never">
<div slot="header" class="has-text-centered has-text-weight-bold">
新用户入驻
</div>
<div>
<el-form
ref="ruleForm"
v-loading="loading"
:model="ruleForm"
status-icon
:rules="rules"
label-width="100px"
class="demo-ruleForm"
>
<el-form-item label="账号" prop="name">
<el-input v-model="ruleForm.name" />
</el-form-item>
<el-form-item label="密码" prop="pass">
<el-input
v-model="ruleForm.pass"
type="password"
autocomplete="off"
/>
</el-form-item>
<el-form-item label="确认密码" prop="checkPass">
<el-input
v-model="ruleForm.checkPass"
type="password"
autocomplete="off"
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="ruleForm.email" autocomplete="off" />
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="submitForm('ruleForm')"
>立即注册</el-button>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</div>
</template>
<script>
import { userRegister } from '@/api/auth/auth'
export default {
name: 'Register',
data() {
const validatePass = (rule, value, callback) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== this.ruleForm.pass) {
callback(new Error('两次输入密码不一致!'))
} else {
callback()
}
}
return {
loading: false,
ruleForm: {
name: '',
pass: '',
checkPass: '',
email: ''
},
rules: {
name: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{
min: 2,
max: 10,
message: '长度在 2 到 10 个字符',
trigger: 'blur'
}
],
pass: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{
min: 6,
max: 20,
message: '长度在 6 到 20 个字符',
trigger: 'blur'
}
],
checkPass: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validatePass, trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{
type: 'email',
message: '请输入正确的邮箱地址',
trigger: ['blur', 'change']
}
]
}
}
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
this.loading = true
userRegister(this.ruleForm)
.then((value) => {
const { code, message } = value
if (code === 200) {
this.$message({
message: '账号注册成功',
type: 'success'
})
setTimeout(() => {
this.loading = false
this.$router.push({ path: this.redirect || '/login' })
}, 0.1 * 1000)
} else {
this.$message.error('注册失败,' + message)
}
})
.catch(() => {
this.loading = false
})
} else {
return false
}
})
},
resetForm(formName) {
this.$refs[formName].resetFields()
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,26 @@
<template>
<section>
<!--是否登录-->
<login-welcome />
<!--今日赠言-->
<tip-card />
<!--资源推介-->
<PromotionCard />
</section>
</template>
<script>
import TipCard from '@/views/card/Tip'
import PromotionCard from '@/views/card/Promotion'
import LoginWelcome from '@/views/card/LoginWelcome'
export default {
name: 'CardBar',
components: { LoginWelcome, PromotionCard, TipCard }
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,32 @@
<template>
<el-card class="box-card" shadow="never">
<div slot="header">
<span>💐 发帖</span>
</div>
<div v-if="token != null && token !== ''" class="has-text-centered">
<b-button type="is-danger" tag="router-link" :to="{path:'/post/create'}" outlined> 发表想法</b-button>
</div>
<div v-else class="has-text-centered">
<b-button type="is-primary" tag="router-link" :to="{path:'/register'}" outlined>马上入驻</b-button>
<b-button type="is-danger" tag="router-link" :to="{path:'/login'}" outlined class="ml-2"> 社区登入</b-button>
</div>
</el-card>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'LoginWelcome',
computed: {
...mapGetters([
'token'
])
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,39 @@
<template>
<el-card class="box-card" shadow="never">
<div slot="header">
<span>🥂 推广</span>
</div>
<div>
<p v-for="(item, index) in list" :key="index" class="block">
<a :href="item.link" target="_blank">{{ item.title }}</a>
</p>
</div>
</el-card>
</template>
<script>
import { getList } from '@/api/promote'
export default {
name: 'Promotion',
data() {
return {
list: []
}
},
created() {
this.fetchList()
},
methods: {
fetchList() {
getList().then((response) => {
const { data } = response
this.list = data
})
}
}
}
</script>
<style scoped>
</style>

43
src/views/card/Tip.vue Normal file
View File

@ -0,0 +1,43 @@
<template>
<el-card class="box-card" shadow="never">
<div slot="header">
<span>🥳 每日一句</span>
</div>
<div>
<div class="has-text-left block">
{{ tip.content }}
</div>
<div class="has-text-right mt-5 block">
{{ tip.author }}
</div>
</div>
</el-card>
</template>
<script>
import {getTodayTip} from '@/api/tip'
export default {
name: 'Tip',
data() {
return {
tip: {}
}
},
created() {
this.fetchTodayTip()
},
methods: {
fetchTodayTip() {
getTodayTip().then(response => {
const { data } = response
this.tip = data
})
}
}
}
</script>
<style scoped>
</style>

40
src/views/error/404.vue Normal file
View File

@ -0,0 +1,40 @@
<template>
<div class="columns mt-6">
<div class="column mt-6">
<div class="mt-6">
<p class="content">UH OH! 页面丢失</p>
<p class="content subtitle mt-6">
您所寻找的页面不存在 {{ times }} 秒后将返回首页!
</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: "404",
data() {
return {
times: 10
}
},
created() {
this.goHome();
},
methods: {
goHome: function () {
this.timer = setInterval(() => {
this.times--
if (this.times === 0) {
clearInterval(this.timer)
this.$router.push({path: '/'});
}
}, 1000)
}
}
}
</script>
<style scoped>
</style>

103
src/views/post/Author.vue Normal file
View File

@ -0,0 +1,103 @@
<template>
<section id="author">
<el-card class="" shadow="never">
<div slot="header">
<span class="has-text-weight-bold">👨💻 关于作者</span>
</div>
<div class="has-text-centered">
<p class="is-size-5 mb-5">
<router-link :to="{ path: `/member/${user.username}/home` }">
{{ user.alias }} <span class="is-size-7 has-text-grey">{{ '@' + user.username }}</span>
</router-link>
</p>
<div class="columns is-mobile">
<div class="column is-half">
<code>{{ user.topicCount }}</code>
<p>文章</p>
</div>
<div class="column is-half">
<code>{{ user.followerCount }}</code>
<p>粉丝</p>
</div>
</div>
<div>
<button
v-if="hasFollow"
class="button is-success button-center is-fullwidth"
@click="handleUnFollow(user.id)"
>
已关注
</button>
<button v-else class="button is-link button-center is-fullwidth" @click="handleFollow(user.id)">
关注
</button>
</div>
</div>
</el-card>
</section>
</template>
<script>
import { follow, hasFollow, unFollow } from '@/api/follow'
import { mapGetters } from 'vuex'
export default {
name: 'Author',
props: {
user: {
type: Object,
default: null
}
},
data() {
return {
hasFollow: false
}
},
mounted() {
this.fetchInfo()
},
computed: {
...mapGetters([
'token'
])
},
methods: {
fetchInfo() {
if(this.token != null && this.token !== '')
{
hasFollow(this.user.id).then(value => {
const { data } = value
this.hasFollow = data.hasFollow
})
}
},
handleFollow: function(id) {
if(this.token != null && this.token !== '')
{
follow(id).then(response => {
const { message } = response
this.$message.success(message)
this.hasFollow = !this.hasFollow
this.user.followerCount = parseInt(this.user.followerCount) + 1
})
}
else{
this.$message.success('请先登录')
}
},
handleUnFollow: function(id) {
unFollow(id).then(response => {
const { message } = response
this.$message.success(message)
this.hasFollow = !this.hasFollow
this.user.followerCount = parseInt(this.user.followerCount) - 1
})
}
}
}
</script>
<style scoped>
</style>

153
src/views/post/Create.vue Normal file
View File

@ -0,0 +1,153 @@
<template>
<div class="columns">
<div class="column is-full">
<el-card
class="box-card"
shadow="never"
>
<div
slot="header"
class="clearfix"
>
<span><i class="fa fa fa-book"> 主题 / 发布主题</i></span>
</div>
<div>
<el-form
ref="ruleForm"
:model="ruleForm"
:rules="rules"
class="demo-ruleForm"
>
<el-form-item prop="title">
<el-input
v-model="ruleForm.title"
placeholder="输入主题名称"
/>
</el-form-item>
<!--Markdown-->
<div id="vditor" />
<b-taginput
v-model="ruleForm.tags"
class="my-3"
maxlength="15"
maxtags="3"
ellipsis
placeholder="请输入主题标签,限制为 15 个字符和 3 个标签"
/>
<el-form-item>
<el-button
type="primary"
@click="submitForm('ruleForm')"
>立即创建
</el-button>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</div>
</template>
<script>
import { post } from '@/api/post'
import Vditor from 'vditor'
import 'vditor/dist/index.css'
export default {
name: 'TopicPost',
data() {
return {
contentEditor: {},
ruleForm: {
title: '', //
tags: [], //
content: '' //
},
rules: {
title: [
{ required: true, message: '请输入话题名称', trigger: 'blur' },
{
min: 1,
max: 25,
message: '长度在 1 到 25 个字符',
trigger: 'blur'
}
]
}
}
},
mounted() {
this.contentEditor = new Vditor('vditor', {
height: 500,
placeholder: '此处为话题内容……',
theme: 'classic',
counter: {
enable: true,
type: 'markdown'
},
preview: {
delay: 0,
hljs: {
style: 'monokai',
lineNumber: true
}
},
tab: '\t',
typewriterMode: true,
toolbarConfig: {
pin: true
},
cache: {
enable: false
},
mode: 'sv'
})
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
if (
this.contentEditor.getValue().length === 1 ||
this.contentEditor.getValue() == null ||
this.contentEditor.getValue() === ''
) {
alert('话题内容不可为空')
return false
}
if (this.ruleForm.tags == null || this.ruleForm.tags.length === 0) {
alert('标签不可以为空')
return false
}
this.ruleForm.content = this.contentEditor.getValue()
post(this.ruleForm).then((response) => {
const { data } = response
setTimeout(() => {
this.$router.push({
name: 'post-detail',
params: { id: data.id }
})
}, 800)
})
} else {
console.log('error submit!!')
return false
}
})
},
resetForm(formName) {
this.$refs[formName].resetFields()
this.contentEditor.setValue('')
this.ruleForm.tags = ''
}
}
}
</script>
<style>
</style>

153
src/views/post/Detail.vue Normal file
View File

@ -0,0 +1,153 @@
<template>
<div class="columns">
<!--文章详情-->
<div class="column is-three-quarters">
<!--主题-->
<el-card
class="box-card"
shadow="never"
>
<div
slot="header"
class="has-text-centered"
>
<p class="is-size-5 has-text-weight-bold">{{ topic.title }}</p>
<div class="has-text-grey is-size-7 mt-3">
<span>{{ dayjs(topic.createTime).format('YYYY/MM/DD HH:mm:ss') }}</span>
<el-divider direction="vertical" />
<span>发布者{{ topicUser.alias }}</span>
<el-divider direction="vertical" />
<span>查看{{ topic.view }}</span>
</div>
</div>
<!--Markdown-->
<div id="preview" />
<!--标签-->
<nav class="level has-text-grey is-size-7 mt-6">
<div class="level-left">
<p class="level-item">
<b-taglist>
<router-link
v-for="(tag, index) in tags"
:key="index"
:to="{ name: 'tag', params: { name: tag.name } }"
>
<b-tag type="is-info is-light mr-1">
{{ "#" + tag.name }}
</b-tag>
</router-link>
</b-taglist>
</p>
</div>
<div
v-if="token && user.id === topicUser.id"
class="level-right"
>
<router-link
class="level-item"
:to="{name:'topic-edit',params: {id:topic.id}}"
>
<span class="tag">编辑</span>
</router-link>
<a class="level-item">
<span
class="tag"
@click="handleDelete(topic.id)"
>删除</span>
</a>
</div>
</nav>
</el-card>
<lv-comments :slug="topic.id" />
</div>
<div class="column">
<!--作者-->
<Author
v-if="flag"
:user="topicUser"
/>
<!--推荐-->
<recommend
v-if="flag"
:topic-id="topic.id"
/>
</div>
</div>
</template>
<script>
import { deleteTopic, getTopic } from '@/api/post'
import { mapGetters } from 'vuex'
import Author from '@/views/post/Author'
import Recommend from '@/views/post/Recommend'
import LvComments from '@/components/Comment/Comments'
import Vditor from 'vditor'
import 'vditor/dist/index.css'
export default {
name: 'TopicDetail',
components: { Author, Recommend, LvComments },
computed: {
...mapGetters([
'token','user'
])
},
data() {
return {
flag: false,
topic: {
content: '',
id: this.$route.params.id
},
tags: [],
topicUser: {}
}
},
mounted() {
this.fetchTopic()
},
methods: {
renderMarkdown(md) {
Vditor.preview(document.getElementById('preview'), md, {
hljs: { style: 'github' }
})
},
//
async fetchTopic() {
getTopic(this.$route.params.id).then(response => {
const { data } = response
document.title = data.topic.title
this.topic = data.topic
this.tags = data.tags
this.topicUser = data.user
// this.comments = data.comments
this.renderMarkdown(this.topic.content)
this.flag = true
})
},
handleDelete(id) {
deleteTopic(id).then(value => {
const { code, message } = value
alert(message)
if (code === 200) {
setTimeout(() => {
this.$router.push({ path: '/' })
}, 500)
}
})
}
}
}
</script>
<style>
#preview {
min-height: 300px;
}
</style>

108
src/views/post/Edit.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<section>
<div class="columns">
<div class="column is-full">
<el-card class="box-card" shadow="never">
<div slot="header" class="clearfix">
<span><i class="fa fa fa-book"> 主题 / 更新主题</i></span>
</div>
<div>
<el-form :model="topic" ref="topic" class="demo-topic">
<el-form-item prop="title">
<el-input
v-model="topic.title"
placeholder="输入新的主题名称"
></el-input>
</el-form-item>
<!--Markdown-->
<div id="vditor"></div>
<b-taginput
v-model="tags"
class="my-3"
maxlength="15"
maxtags="3"
ellipsis
placeholder="请输入主题标签,限制为 15 个字符和 3 个标签"
/>
<el-form-item class="mt-3">
<el-button type="primary" @click="handleUpdate()"
>更新
</el-button>
<el-button @click="resetForm('topic')">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</div>
</section>
</template>
<script>
import { getTopic, update } from "@/api/post";
import Vditor from "vditor";
import "vditor/dist/index.css";
export default {
name: "TopicEdit",
components: {},
data() {
return {
topic: {},
tags: [],
};
},
created() {
this.fetchTopic();
},
methods: {
renderMarkdown(md) {
this.contentEditor = new Vditor("vditor", {
height: 460,
placeholder: "输入要更新的内容",
preview: {
hljs: { style: "monokai" },
},
mode: "sv",
after: () => {
this.contentEditor.setValue(md);
},
});
},
fetchTopic() {
getTopic(this.$route.params.id).then((value) => {
this.topic = value.data.topic;
this.tags = value.data.tags.map(tag => tag.name);
this.renderMarkdown(this.topic.content);
});
},
handleUpdate: function () {
this.topic.content = this.contentEditor.getValue();
update(this.topic).then((response) => {
const { data } = response;
console.log(data);
setTimeout(() => {
this.$router.push({
name: "post-detail",
params: { id: data.id },
});
}, 800);
});
},
resetForm(formName) {
this.$refs[formName].resetFields();
this.contentEditor.setValue("");
this.tags = "";
},
},
};
</script>
<style>
.vditor-reset pre > code {
font-size: 100%;
}
</style>

157
src/views/post/Index.vue Normal file
View File

@ -0,0 +1,157 @@
<template>
<div>
<el-card shadow="never">
<div slot="header" class="clearfix">
<el-tabs v-model="activeName" @tab-click="handleClick">
<el-tab-pane label="最新主题" name="latest">
<article v-for="(item, index) in articleList" :key="index" class="media">
<div class="media-left">
<figure class="image is-48x48">
<img :src="`https://cn.gravatar.com/avatar/${item.userId}?s=164&d=monsterid`"
style="border-radius: 5px;">
</figure>
</div>
<div class="media-content">
<div class="">
<p class="ellipsis is-ellipsis-1">
<el-tooltip :content="item.title" class="item" effect="dark" placement="top">
<router-link :to="{name:'post-detail',params:{id:item.id}}">
<span class="is-size-6">{{ item.title }}</span>
</router-link>
</el-tooltip>
</p>
</div>
<nav class="level has-text-grey is-mobile is-size-7 mt-2">
<div class="level-left">
<div class="level-left">
<router-link :to="{ path: `/member/${item.username}/home` }" class="level-item">
{{ item.alias }}
</router-link>
<span class="mr-1">
发布于:{{ dayjs(item.createTime).format("YYYY/MM/DD") }}
</span>
<span
v-for="(tag, index) in item.tags"
:key="index"
class="tag is-hidden-mobile is-success is-light mr-1"
>
<router-link :to="{ name: 'tag', params: { name: tag.name } }">
{{ "#" + tag.name }}
</router-link>
</span>
<span class="is-hidden-mobile">浏览:{{ item.view }}</span>
</div>
</div>
</nav>
</div>
<div class="media-right"/>
</article>
</el-tab-pane>
<el-tab-pane label="热门主题" name="hot">
<article v-for="(item, index) in articleList" :key="index" class="media">
<div class="media-left">
<figure class="image is-48x48">
<img :src="`https://cn.gravatar.com/avatar/${item.userId}?s=164&d=monsterid`"
style="border-radius: 5px;">
</figure>
</div>
<div class="media-content">
<div class="">
<p class="ellipsis is-ellipsis-1">
<el-tooltip :content="item.title" class="item" effect="dark" placement="top">
<router-link :to="{name:'post-detail',params:{id:item.id}}">
<span class="is-size-6">{{ item.title }}</span>
</router-link>
</el-tooltip>
</p>
</div>
<nav class="level has-text-grey is-mobile is-size-7 mt-2">
<div class="level-left">
<div class="level-left">
<router-link :to="{ path: `/member/${item.username}/home` }" class="level-item">
{{ item.alias }}
</router-link>
<span class="mr-1">
发布于:{{ dayjs(item.createTime).format("YYYY/MM/DD") }}
</span>
<span
v-for="(tag, index) in item.tags"
:key="index"
class="tag is-hidden-mobile is-success is-light mr-1"
>
<router-link :to="{ name: 'tag', params: { name: tag.name } }">
{{ "#" + tag.name }}
</router-link>
</span>
<span class="is-hidden-mobile">浏览:{{ item.view }}</span>
</div>
</div>
</nav>
</div>
<div class="media-right"/>
</article>
</el-tab-pane>
</el-tabs>
</div>
<!--分页-->
<pagination
v-show="page.total > 0"
:limit.sync="page.size"
:page.sync="page.current"
:total="page.total"
@pagination="init"
/>
</el-card>
</div>
</template>
<script>
import {getList} from '@/api/post'
import Pagination from '@/components/Pagination'
export default {
name: 'TopicList',
components: {Pagination},
data() {
return {
activeName: 'latest',
articleList: [],
page: {
current: 1,
size: 10,
total: 0,
tab: 'latest'
}
}
},
created() {
this.init(this.tab)
},
methods: {
init(tab) {
getList(this.page.current, this.page.size, tab).then((response) => {
const {data} = response
this.page.current = data.current
this.page.total = data.total
this.page.size = data.size
this.articleList = data.records
})
},
handleClick(tab) {
this.page.current = 1
this.init(tab.name)
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,54 @@
<template>
<el-card class="" shadow="never">
<div slot="header">
<span class="has-text-weight-bold">🧐 随便看看</span>
</div>
<div>
<p v-for="(item,index) in recommend" :key="index" :title="item.title" class="block ellipsis is-ellipsis-1">
<router-link :to="{name:'post-detail',params: { id: item.id }}">
<span v-if="index<9" class="tag">
0{{ parseInt(index) + 1 }}
</span>
<span v-else class="tag">
{{ parseInt(index) + 1 }}
</span>
{{ item.title }}
</router-link>
</p>
</div>
</el-card>
</template>
<script>
import { getRecommendTopics } from '@/api/post'
export default {
name: 'Recommend',
props: {
topicId: {
type: String,
default: null
}
},
data() {
return {
recommend: []
}
},
created() {
this.fetchRecommendTopics()
},
methods: {
fetchRecommendTopics() {
getRecommendTopics(this.topicId).then(value => {
const { data } = value
this.recommend = data
})
}
}
}
</script>
<style scoped>
</style>

109
src/views/tag/Tag.vue Normal file
View File

@ -0,0 +1,109 @@
<template>
<div id="tag" class="columns">
<div class="column is-three-quarters">
<el-card class="box-card" shadow="never">
<div slot="header" class="">
🔍 检索到 <span class="has-text-info">{{ topics.length }}</span> 篇有关
<span class="has-text-info">{{ this.$route.params.name }}</span>
的话题
</div>
<div class="text item">
<article v-for="(item, index) in topics" :key="index" class="media mt-3">
<div class="media-content">
<div class="content">
<el-tooltip class="item" effect="dark" :content="item.title" placement="top">
<router-link :to="{ name: 'post-detail',params:{id: item.id } }">
{{ item.title }}
</router-link>
</el-tooltip>
</div>
<nav class="level has-text-grey is-size-7">
<div class="level-left">
<span>发布于:{{ dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss') }}</span>
<span class="mx-3">浏览:{{ item.view }}</span>
<span>评论:{{ item.comments }}</span>
</div>
</nav>
</div>
</article>
</div>
</el-card>
</div>
<div class="column">
<el-card class="box-card" shadow="hover">
<div slot="header" class="clearfix">
🤙 关于标签
</div>
<div>
<ul>
<li class="content">标签由平台用户发布使用</li>
<li class="content">系统每周会定时清理无用标签</li>
</ul>
</div>
</el-card>
<el-card shadow="hover">
<div slot="header">
🏷 热门标签
</div>
<div>
<ul>
<li v-for="(tag,index) in tags" :key="index" style="padding: 6px 0">
<router-link :to="{name:'tag',params:{name:tag.name}}">
<span v-if="index<9" class="tag">
0{{ parseInt(index) + 1 }}
</span>
<span v-else class="tag">
{{ parseInt(index) + 1 }}
</span>
{{ tag.name }}
</router-link>
</li>
</ul>
</div>
</el-card>
</div>
</div>
</template>
<script>
import { getTopicsByTag } from '@/api/tag'
export default {
name: 'Tag',
data() {
return {
topics: [],
tags: [],
paramMap: {
name: this.$route.params.name,
page: 1,
size: 10
}
}
},
created() {
this.fetchList()
},
methods: {
fetchList: function() {
getTopicsByTag(this.paramMap).then(response => {
console.log(response)
this.topics = response.data.topics.records
this.tags = response.data.hotTags.records
})
}
}
}
</script>
<style scoped>
#tag {
min-height: 500px;
}
</style>

134
src/views/user/Profile.vue Normal file
View File

@ -0,0 +1,134 @@
<template>
<div class="member">
<div class="columns">
<div class="column is-one-quarter">
<el-card shadow="never">
<div slot="header" class="has-text-centered">
<el-avatar :size="64" :src="`https://cn.gravatar.com/avatar/${topicUser.id}?s=164&d=monsterid`" />
<p class="mt-3">{{ topicUser.alias || topicUser.username }}</p>
</div>
<div>
<p class="content">积分<code>{{ topicUser.score }}</code></p>
<p class="content">入驻{{ dayjs(topicUser.createTime).format("YYYY/MM/DD HH:MM:ss") }}</p>
<p class="content">简介{{ topicUser.bio }}</p>
</div>
</el-card>
</div>
<div class="column">
<!--用户发布的话题-->
<el-card class="box-card content" shadow="never">
<div slot="header" class="has-text-weight-bold">
<span>话题</span>
</div>
<div v-if="topics.length===0">
暂无话题
</div>
<div v-else class="topicUser-info">
<article v-for="(item, index) in topics" :key="index" class="media">
<div class="media-content">
<div class="content ellipsis is-ellipsis-1">
<el-tooltip class="item" effect="dark" :content="item.title" placement="top">
<router-link :to="{ name: 'post-detail', params: { id: item.id } }">
{{ item.title }}
</router-link>
</el-tooltip>
</div>
<nav class="level has-text-grey is-size-7">
<div class="level-left">
<span class="mr-1">
发布于:{{ dayjs(item.createTime).format("YYYY/MM/DD HH:mm:ss") }}
</span>
</div>
</nav>
</div>
<div v-if="token" class="media-right">
<div v-if="topicUser.username === user.username" class="level">
<div class="level-item mr-1">
<router-link :to="{name:'topic-edit',params: {id:item.id}}">
<span class="tag is-warning">编辑</span>
</router-link>
</div>
<div class="level-item">
<a @click="handleDelete(item.id)">
<span class="tag is-danger">删除</span>
</a>
</div>
</div>
</div>
</article>
</div>
<!--分页-->
<pagination
v-show="page.total > 0"
class="mt-5"
:total="page.total"
:page.sync="page.current"
:limit.sync="page.size"
@pagination="fetchUserById"
/>
</el-card>
</div>
</div>
</div>
</template>
<script>
import { getInfoByName } from '@/api/user'
import pagination from '@/components/Pagination/index'
import { mapGetters } from 'vuex'
import { deleteTopic } from '@/api/post'
export default {
name: 'Profile',
components: { pagination },
data() {
return {
topicUser: {},
topics: {},
page: {
current: 1,
size: 5,
total: 0
}
}
},
computed: {
...mapGetters(['token', 'user'])
},
created() {
this.fetchUserById()
},
methods: {
fetchUserById() {
getInfoByName(this.$route.params.username, this.page.current, this.page.size).then((res) => {
const { data } = res
this.topicUser = data.user
this.page.current = data.topics.current
this.page.size = data.topics.size
this.page.total = data.topics.total
this.topics = data.topics.records
})
},
handleDelete(id) {
deleteTopic(id).then(value => {
const { code, message } = value
alert(message)
if (code === 200) {
setTimeout(() => {
this.$router.push({ path: '/' })
}, 500)
}
})
}
}
}
</script>
<style scoped>
</style>

120
src/views/user/Setting.vue Normal file
View File

@ -0,0 +1,120 @@
<template>
<section>
<el-card shadow="never">
<div slot="header">
个人设置
</div>
<div class="columns">
<div class="column is-full">
<el-tabs v-model="activeName" @tab-click="handleClick">
<el-tab-pane label="基础信息" name="first">
<el-form :label-position="labelPosition" label-width="100px" :model="user">
<el-form-item label="账号">
<el-input v-model="user.username" disabled />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="user.alias" />
</el-form-item>
<el-form-item label="简介">
<el-input v-model="user.bio" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')">提交</el-button>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="头像" name="second">
<figure class="image is-48x48">
<img :src="`https://cn.gravatar.com/avatar/${this.user.id}?s=164&d=monsterid`">
</figure>
</el-tab-pane>
<el-tab-pane label="电子邮箱" name="third">
<el-form ref="dynamicValidateForm" :model="user" label-width="100px" class="demo-dynamic">
<el-form-item
prop="email"
label="邮箱"
:rules="[
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
]"
>
<el-input v-model="user.email" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('dynamicValidateForm')">提交</el-button>
<el-button @click="resetForm('dynamicValidateForm')">重置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="手机号" name="fourth">
<el-form ref="dynamicValidateForm" :model="user" label-width="100px" class="demo-dynamic">
<el-form-item>
<el-input v-model="user.mobile" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('dynamicValidateForm')">提交</el-button>
<el-button @click="resetForm('dynamicValidateForm')">重置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</div>
</el-card>
</section>
</template>
<script>
import {getInfo, update} from '@/api/user'
export default {
name: 'Setting',
data() {
return {
activeName: 'first',
labelPosition: 'right',
user: {
id: '',
username: '',
alias: '',
bio: '',
email: '',
mobile: '',
avatar: ''
}
}
},
created() {
this.fetchInfo()
},
methods: {
fetchInfo() {
getInfo(this.$route.params.username).then(res => {
console.log(res)
const { data } = res
this.user = data
})
},
handleClick(tab, event) {
console.log(tab, event)
},
submitForm(formName) {
console.log(this.user)
update(this.user).then(res => {
this.$message.success('信息修改成功')
this.fetchInfo()
})
},
resetForm(formName) {
this.$refs[formName].resetFields()
}
}
}
</script>
<style scoped>
</style>

8079
yarn-error.log Normal file

File diff suppressed because it is too large Load Diff

8019
yarn.lock Normal file

File diff suppressed because it is too large Load Diff