mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-11 21:27:31 +08:00
Merge pull request #426 from nagisa77/codex/migrate-frontend-to-nuxt4-with-ssr
feat: add initial Nuxt frontend with SSR
This commit is contained in:
4
frontend_nuxt/.gitignore
vendored
Normal file
4
frontend_nuxt/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
.output
|
||||||
93
frontend_nuxt/app.vue
Normal file
93
frontend_nuxt/app.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<div class="header-container">
|
||||||
|
<HeaderComponent @toggle-menu="menuVisible = !menuVisible" :show-menu-btn="!hideMenu" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="menu-container">
|
||||||
|
<MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" />
|
||||||
|
</div>
|
||||||
|
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||||
|
<NuxtPage />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GlobalPopups />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||||
|
import MenuComponent from '~/components/MenuComponent.vue'
|
||||||
|
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'App',
|
||||||
|
components: { HeaderComponent, MenuComponent, GlobalPopups },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
menuVisible: process.client ? window.innerWidth > 768 : false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hideMenu() {
|
||||||
|
return [
|
||||||
|
'/login',
|
||||||
|
'/signup',
|
||||||
|
'/404',
|
||||||
|
'/signup-reason',
|
||||||
|
'/github-callback',
|
||||||
|
'/twitter-callback',
|
||||||
|
'/discord-callback',
|
||||||
|
'/forgot-password',
|
||||||
|
'/google-callback'
|
||||||
|
].includes(this.$route.path)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
// placeholder for future global initializations
|
||||||
|
},
|
||||||
|
methods: {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.header-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-container {}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
/* height: calc(100vh - var(--header-height)); */
|
||||||
|
padding-top: var(--header-height);
|
||||||
|
flex: 1;
|
||||||
|
max-width: 100%;
|
||||||
|
transition: max-width 0.3s ease;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
min-height: calc(100vh - var(--header-height));
|
||||||
|
}
|
||||||
|
|
||||||
|
.content.menu-open {
|
||||||
|
max-width: calc(100% - var(--menu-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
max-width: var(--page-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
.content,
|
||||||
|
.content.menu-open {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
276
frontend_nuxt/assets/global.css
Normal file
276
frontend_nuxt/assets/global.css
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color-hover: rgb(9, 95, 105);
|
||||||
|
--primary-color: rgb(10, 110, 120);
|
||||||
|
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||||
|
--header-height: 60px;
|
||||||
|
--header-background-color: white;
|
||||||
|
--header-border-color: lightgray;
|
||||||
|
--header-text-color: black;
|
||||||
|
--menu-background-color: white;
|
||||||
|
--background-color: white;
|
||||||
|
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
|
||||||
|
--background-color-blur: var(--background-color);
|
||||||
|
--menu-border-color: lightgray;
|
||||||
|
--normal-border-color: lightgray;
|
||||||
|
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||||
|
--menu-text-color: black;
|
||||||
|
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
||||||
|
--normal-background-color: rgb(241, 241, 241);
|
||||||
|
--login-background-color: rgb(248, 248, 248);
|
||||||
|
--login-background-color-hover: #e0e0e0;
|
||||||
|
--text-color: black;
|
||||||
|
--blockquote-text-color: #6a737d;
|
||||||
|
--menu-width: 200px;
|
||||||
|
--page-max-width: 1200px;
|
||||||
|
--page-max-width-mobile: 900px;
|
||||||
|
--article-info-background-color: #f0f0f0;
|
||||||
|
--activity-card-background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--header-background-color: #2b2b2b;
|
||||||
|
--header-border-color: #555;
|
||||||
|
--primary-color: rgb(17, 182, 197);
|
||||||
|
--primary-color-hover: rgb(13, 137, 151);
|
||||||
|
--header-text-color: white;
|
||||||
|
--menu-background-color: #333;
|
||||||
|
--background-color: #333;
|
||||||
|
/* --background-color-blur: #333333a4; */
|
||||||
|
--background-color-blur: var(--background-color);
|
||||||
|
--menu-border-color: #555;
|
||||||
|
--normal-border-color: #555;
|
||||||
|
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
--menu-text-color: white;
|
||||||
|
--normal-background-color: #000000;
|
||||||
|
--login-background-color: #575757;
|
||||||
|
--login-background-color-hover: #717171;
|
||||||
|
--text-color: #eee;
|
||||||
|
--blockquote-text-color: #999;
|
||||||
|
--article-info-background-color: #747373;
|
||||||
|
--activity-card-background-color: #585858;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
background-color: var(--normal-background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
/* 禁止滚动 */
|
||||||
|
/* overflow: hidden; */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*************************
|
||||||
|
* Vditor 自定义皮肤覆写
|
||||||
|
*************************/
|
||||||
|
.vditor {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vditor-toolbar--pin {
|
||||||
|
top: var(--header-height) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vditor-panel {
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .vditor {
|
||||||
|
--textarea-background-color: transparent;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vditor-reset {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vditor-toolbar {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* .vditor-toolbar {
|
||||||
|
position: relative !important;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/*************************
|
||||||
|
* Markdown 渲染样式
|
||||||
|
*************************/
|
||||||
|
.info-content-text ul,
|
||||||
|
.info-content-text ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text h1,
|
||||||
|
.info-content-text h2 {
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text {
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text blockquote {
|
||||||
|
margin: 1em 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 4px solid #d0d7de;
|
||||||
|
color: var(--blockquote-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text pre {
|
||||||
|
background-color: var(--normal-background-color);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1.5;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
opacity: 0.8;
|
||||||
|
color: black;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text code {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background-color: var(--normal-background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text table {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.2em 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-x: auto; /* 小屏可横向滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text thead th {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .info-content-text thead th {
|
||||||
|
background-color: var(--primary-color-hover); /* 暗色稍暗一点 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text tbody tr:nth-child(even) {
|
||||||
|
background-color: rgba(208, 250, 255, 0.25); /* 斑马纹 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .info-content-text tbody tr:nth-child(even) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text th,
|
||||||
|
.info-content-text td {
|
||||||
|
border: 1px solid var(--menu-border-color);
|
||||||
|
padding: 8px 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text tbody td {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 首列加粗,便于阅读 */
|
||||||
|
.info-content-text tbody td:first-child {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 鼠标悬停行高亮 */
|
||||||
|
.info-content-text tbody tr:hover {
|
||||||
|
background-color: var(--menu-selected-background-color);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.vditor {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vditor-toolbar {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content h1,
|
||||||
|
.info-content-text h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content h2,
|
||||||
|
.info-content-text h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content p,
|
||||||
|
.info-content-text p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 3px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.vditor-toolbar--pin {
|
||||||
|
top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content li,
|
||||||
|
.info-content-text li {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text pre {
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vditor-panel {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend_nuxt/assets/icons/discord.svg
Normal file
1
frontend_nuxt/assets/icons/discord.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
28
frontend_nuxt/assets/icons/github.svg
Normal file
28
frontend_nuxt/assets/icons/github.svg
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>GitHub icon</title>
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12
|
||||||
|
0 5.303 3.438 9.8 8.205 11.387
|
||||||
|
.6 .113 .82-.258 .82-.577
|
||||||
|
0-.285-.01-1.04-.015-2.04
|
||||||
|
-3.338 .724-4.042-1.61-4.042-1.61
|
||||||
|
C4.422 16.07 3.633 15.7 3.633 15.7
|
||||||
|
c-1.087-.744 .084-.729 .084-.729
|
||||||
|
1.205 .084 1.84 1.236 1.84 1.236
|
||||||
|
1.07 1.835 2.809 1.305 3.495 .998
|
||||||
|
.108-.775 .418-1.305 .762-1.605
|
||||||
|
-2.665-.3-5.467-1.335-5.467-5.93
|
||||||
|
0-1.31 .468-2.38 1.236-3.22
|
||||||
|
-.123-.303-.536-1.523 .117-3.176
|
||||||
|
0 0 1.008-.322 3.302 1.23
|
||||||
|
.957-.266 1.983-.399 3.003-.404
|
||||||
|
1.02 .005 2.047 .138 3.006 .404
|
||||||
|
2.292-1.552 3.298-1.23 3.298-1.23
|
||||||
|
.654 1.653 .242 2.873 .119 3.176
|
||||||
|
.77 .84 1.235 1.91 1.235 3.22
|
||||||
|
0 4.61-2.807 5.625-5.479 5.921
|
||||||
|
.43 .37 .823 1.102 .823 2.222
|
||||||
|
0 1.606-.014 2.896-.014 3.286
|
||||||
|
0 .321 .216 .694 .825 .576
|
||||||
|
C20.565 22.092 24 17.592 24 12.297
|
||||||
|
c0-6.627 -5.373-12 -12-12z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
28
frontend_nuxt/assets/icons/google.svg
Normal file
28
frontend_nuxt/assets/icons/google.svg
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 48 48">
|
||||||
|
<defs>
|
||||||
|
<!-- 定义整块 “G” 轮廓,用作剪裁路径 -->
|
||||||
|
<path id="a"
|
||||||
|
d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37
|
||||||
|
c-7.2 0-13-5.8-13-13s5.8-13 13-13
|
||||||
|
c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2
|
||||||
|
11.8 2 2 11.8 2 24s9.8 22 22 22
|
||||||
|
c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 应用剪裁后依次绘制四段色块 -->
|
||||||
|
<clipPath id="b">
|
||||||
|
<use xlink:href="#a" overflow="visible"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
<!-- 黄色 (#FBBC05) -->
|
||||||
|
<path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/>
|
||||||
|
<!-- 红色 (#EA4335) -->
|
||||||
|
<path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/>
|
||||||
|
<!-- 绿色 (#34A853) -->
|
||||||
|
<path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/>
|
||||||
|
<!-- 蓝色 (#4285F4) -->
|
||||||
|
<path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
4
frontend_nuxt/assets/icons/twitter.svg
Normal file
4
frontend_nuxt/assets/icons/twitter.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Twitter icon</title>
|
||||||
|
<path d="M23.954 4.569c-.885.389-1.83.654-2.825.775 1.014-.611 1.794-1.574 2.163-2.723-.949.555-2.005.959-3.127 1.184-.897-.959-2.173-1.559-3.591-1.559-2.717 0-4.92 2.203-4.92 4.917 0 .39.045.765.127 1.124-4.083-.205-7.697-2.159-10.126-5.134-.422.722-.666 1.561-.666 2.475 0 1.709.87 3.214 2.188 4.096-.807-.026-1.566-.248-2.229-.616v.061c0 2.385 1.693 4.374 3.946 4.827-.413.111-.849.171-1.296.171-.314 0-.615-.03-.916-.086.631 1.953 2.445 3.376 4.6 3.416-1.68 1.318-3.808 2.105-6.102 2.105-.39 0-.779-.023-1.17-.069 2.189 1.394 4.768 2.209 7.548 2.209 9.051 0 14.001-7.496 14.001-13.986 0-.21 0-.42-.015-.63.961-.689 1.8-1.56 2.46-2.548l-.047-.02z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 764 B |
35
frontend_nuxt/assets/toast.css
Normal file
35
frontend_nuxt/assets/toast.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
.Vue-Toastification__toast {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Vue-Toastification__toast--info {
|
||||||
|
background-color: #d0e9ff;
|
||||||
|
color: #1b6ec2;
|
||||||
|
}
|
||||||
|
.Vue-Toastification__toast--success {
|
||||||
|
background-color: #dff6dd;
|
||||||
|
color: #2b7a2b;
|
||||||
|
}
|
||||||
|
.Vue-Toastification__toast--error {
|
||||||
|
background-color: #f99a9a;
|
||||||
|
color: #b73737;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.Vue-Toastification__container.open-isle-toast-style-v1 {
|
||||||
|
width: auto;
|
||||||
|
max-width: 90vw;
|
||||||
|
right: 0.5em;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Vue-Toastification__toast-body {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Vue-Toastification__close-button {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
frontend_nuxt/components/ActivityPopup.vue
Normal file
79
frontend_nuxt/components/ActivityPopup.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<BasePopup :visible="visible" @close="close">
|
||||||
|
<div class="activity-popup">
|
||||||
|
<img v-if="icon" :src="icon" class="activity-popup-icon" alt="activity icon" />
|
||||||
|
<div class="activity-popup-text">{{ text }}</div>
|
||||||
|
<div class="activity-popup-actions">
|
||||||
|
<div class="activity-popup-button" @click="gotoActivity">立即前往</div>
|
||||||
|
<div class="activity-popup-close" @click="close">稍后再说</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BasePopup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ActivityPopup',
|
||||||
|
components: { BasePopup },
|
||||||
|
props: {
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
icon: String,
|
||||||
|
text: String
|
||||||
|
},
|
||||||
|
emits: ['close'],
|
||||||
|
setup (props, { emit }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const gotoActivity = () => {
|
||||||
|
emit('close')
|
||||||
|
router.push('/activities')
|
||||||
|
}
|
||||||
|
const close = () => emit('close')
|
||||||
|
return { gotoActivity, close }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.activity-popup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-popup-icon {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.activity-popup-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.activity-popup-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.activity-popup-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
.activity-popup-close {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.activity-popup-close:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
frontend_nuxt/components/ArticleCategory.vue
Normal file
71
frontend_nuxt/components/ArticleCategory.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="article-category-container" v-if="category">
|
||||||
|
<div class="article-info-item" @click="gotoCategory">
|
||||||
|
<img
|
||||||
|
v-if="category.smallIcon"
|
||||||
|
class="article-info-item-img"
|
||||||
|
:src="category.smallIcon"
|
||||||
|
:alt="category.name"
|
||||||
|
/>
|
||||||
|
<div class="article-info-item-text">{{ category.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ArticleCategory',
|
||||||
|
props: {
|
||||||
|
category: { type: Object, default: null }
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const gotoCategory = () => {
|
||||||
|
if (!props.category) return
|
||||||
|
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
||||||
|
router.push({ path: '/', query: { category: value } }).then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { gotoCategory }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.article-category-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background-color: var(--article-info-background-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-info-item-img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.article-info-item-img {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-info-item {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
frontend_nuxt/components/ArticleTags.vue
Normal file
79
frontend_nuxt/components/ArticleTags.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="article-tags-container">
|
||||||
|
<div
|
||||||
|
class="article-info-item"
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag.id || tag.name"
|
||||||
|
@click="gotoTag(tag)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="tag.smallIcon"
|
||||||
|
class="article-info-item-img"
|
||||||
|
:src="tag.smallIcon"
|
||||||
|
:alt="tag.name"
|
||||||
|
/>
|
||||||
|
<div class="article-info-item-text">{{ tag.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ArticleTags',
|
||||||
|
props: {
|
||||||
|
tags: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const router = useRouter()
|
||||||
|
const gotoTag = tag => {
|
||||||
|
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||||
|
router.push({ path: '/', query: { tags: value } }).then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { gotoTag }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.article-tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.article-info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background-color: var(--article-info-background-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-info-item-img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.article-info-item-img {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-info-item {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
142
frontend_nuxt/components/AvatarCropper.vue
Normal file
142
frontend_nuxt/components/AvatarCropper.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="show" class="cropper-modal">
|
||||||
|
<div class="cropper-body">
|
||||||
|
<div class="cropper-wrapper">
|
||||||
|
<img ref="image" :src="src" alt="to crop" />
|
||||||
|
</div>
|
||||||
|
<div class="cropper-actions">
|
||||||
|
<button class="cropper-btn" @click="$emit('close')">取消</button>
|
||||||
|
<button class="cropper-btn primary" @click="onConfirm">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Cropper from 'cropperjs'
|
||||||
|
import 'cropperjs/dist/cropper.css'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AvatarCropper',
|
||||||
|
props: {
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['close', 'crop'],
|
||||||
|
data() {
|
||||||
|
return { cropper: null }
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(val) {
|
||||||
|
if (val) {
|
||||||
|
this.$nextTick(() => this.init())
|
||||||
|
} else {
|
||||||
|
this.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.show) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
const image = this.$refs.image
|
||||||
|
this.cropper = new Cropper(image, {
|
||||||
|
aspectRatio: 1,
|
||||||
|
viewMode: 1,
|
||||||
|
autoCropArea: 1,
|
||||||
|
responsive: true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (this.cropper) {
|
||||||
|
this.cropper.destroy()
|
||||||
|
this.cropper = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfirm() {
|
||||||
|
if (!this.cropper) return
|
||||||
|
this.cropper.getCroppedCanvas({ width: 256, height: 256 }).toBlob(blob => {
|
||||||
|
const file = new File([blob], 'avatar.png', { type: 'image/png' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
this.$emit('crop', { file, url })
|
||||||
|
this.$emit('close')
|
||||||
|
this.destroy()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cropper-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
opacity: 1.0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-body {
|
||||||
|
background: var(--background-color);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-wrapper {
|
||||||
|
width: 80vw;
|
||||||
|
height: 80vw;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-wrapper img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-btn.primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.cropper-wrapper {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
82
frontend_nuxt/components/BaseInput.vue
Normal file
82
frontend_nuxt/components/BaseInput.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="base-input">
|
||||||
|
<i v-if="icon" :class="['base-input-icon', icon]" />
|
||||||
|
|
||||||
|
<!-- 普通输入框 -->
|
||||||
|
<input
|
||||||
|
v-if="!textarea"
|
||||||
|
class="base-input-text"
|
||||||
|
:type="type"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:value="modelValue"
|
||||||
|
@input="$emit('update:modelValue', $event.target.value)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 多行输入框 -->
|
||||||
|
<textarea
|
||||||
|
v-else
|
||||||
|
class="base-input-text"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:value="modelValue"
|
||||||
|
@input="$emit('update:modelValue', $event.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'BaseInput',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number], default: '' },
|
||||||
|
icon: { type: String, default: '' },
|
||||||
|
type: { type: String, default: 'text' },
|
||||||
|
textarea: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
computed: {
|
||||||
|
innerValue: {
|
||||||
|
get() {
|
||||||
|
return this.modelValue
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:modelValue', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-input:focus-within {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-input-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-input-text {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
resize: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
34
frontend_nuxt/components/BasePlaceholder.vue
Normal file
34
frontend_nuxt/components/BasePlaceholder.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="base-placeholder">
|
||||||
|
<i :class="['base-placeholder-icon', icon]" />
|
||||||
|
<div class="base-placeholder-text">
|
||||||
|
<slot>{{ text }}</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'BasePlaceholder',
|
||||||
|
props: {
|
||||||
|
text: { type: String, default: '' },
|
||||||
|
icon: { type: String, default: 'fas fa-inbox' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 300px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.base-placeholder-text {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
58
frontend_nuxt/components/BasePopup.vue
Normal file
58
frontend_nuxt/components/BasePopup.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="popup">
|
||||||
|
<div class="popup-overlay" @click="onOverlayClick"></div>
|
||||||
|
<div class="popup-content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'BasePopup',
|
||||||
|
props: {
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
closeOnOverlay: { type: Boolean, default: true }
|
||||||
|
},
|
||||||
|
emits: ['close'],
|
||||||
|
methods: {
|
||||||
|
onOverlayClick () {
|
||||||
|
if (this.closeOnOverlay) this.$emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.popup {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1100;
|
||||||
|
}
|
||||||
|
.popup-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.popup-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
max-width: 80%;
|
||||||
|
max-height: 80%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
frontend_nuxt/components/BaseTimeline.vue
Normal file
103
frontend_nuxt/components/BaseTimeline.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="timeline-item" v-for="(item, idx) in items" :key="idx">
|
||||||
|
<div
|
||||||
|
class="timeline-icon"
|
||||||
|
:class="{ clickable: !!item.iconClick }"
|
||||||
|
@click="item.iconClick && item.iconClick()"
|
||||||
|
>
|
||||||
|
<img v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
|
||||||
|
<i v-else-if="item.icon" :class="item.icon"></i>
|
||||||
|
<span v-else-if="item.emoji" class="timeline-emoji">{{ item.emoji }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<slot name="item" :item="item">{{ item.content }}</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'BaseTimeline',
|
||||||
|
props: {
|
||||||
|
items: { type: Array, default: () => [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-icon {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-icon.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-emoji {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 32px;
|
||||||
|
left: 15px;
|
||||||
|
width: 2px;
|
||||||
|
bottom: -20px;
|
||||||
|
background: var(--text-color);
|
||||||
|
opacity: 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:last-child::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
flex: 1;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
.timeline-icon {
|
||||||
|
margin-right: 2px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
30
frontend_nuxt/components/CallbackPage.vue
Normal file
30
frontend_nuxt/components/CallbackPage.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="callback-page">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
<div class="callback-page-text">Magic is happening...</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'CallbackPage'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.callback-page {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callback-page-text {
|
||||||
|
margin-top: 25px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
frontend_nuxt/components/CategorySelect.vue
Normal file
86
frontend_nuxt/components/CategorySelect.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<Dropdown v-model="selected" :fetch-options="fetchCategories" placeholder="选择分类" :initial-options="providedOptions">
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="option-container">
|
||||||
|
<div class="option-main">
|
||||||
|
<template v-if="option.icon">
|
||||||
|
<img v-if="isImageIcon(option.icon)" :src="option.icon" class="option-icon" :alt="option.name" />
|
||||||
|
<i v-else :class="['option-icon', option.icon]"></i>
|
||||||
|
</template>
|
||||||
|
<span>{{ option.name }}</span>
|
||||||
|
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="option.description" class="option-desc">{{ option.description }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { API_BASE_URL } from '~/main'
|
||||||
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CategorySelect',
|
||||||
|
components: { Dropdown },
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number], default: '' },
|
||||||
|
options: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
val => {
|
||||||
|
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return [{ id: '', name: '无分类' }, ...data]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImageIcon = icon => {
|
||||||
|
if (!icon) return false
|
||||||
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: v => emit('update:modelValue', v)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { fetchCategories, selected, isImageIcon, providedOptions }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.option-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-count {
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
182
frontend_nuxt/components/CommentEditor.vue
Normal file
182
frontend_nuxt/components/CommentEditor.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div class="comment-editor-container">
|
||||||
|
<div class="comment-editor-wrapper">
|
||||||
|
<div :id="editorId" ref="vditorElement"></div>
|
||||||
|
<LoginOverlay v-if="showLoginOverlay" />
|
||||||
|
</div>
|
||||||
|
<div class="comment-bottom-container">
|
||||||
|
<div class="comment-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||||
|
<template v-if="!loading">
|
||||||
|
发布评论
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<i class="fa-solid fa-spinner fa-spin"></i> 发布中...
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted, computed, watch, onUnmounted, useId } from 'vue'
|
||||||
|
import { themeState } from '../utils/theme'
|
||||||
|
import {
|
||||||
|
createVditor,
|
||||||
|
getEditorTheme as getEditorThemeUtil,
|
||||||
|
getPreviewTheme as getPreviewThemeUtil
|
||||||
|
} from '../utils/vditor'
|
||||||
|
import LoginOverlay from './LoginOverlay.vue'
|
||||||
|
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CommentEditor',
|
||||||
|
emits: ['submit'],
|
||||||
|
props: {
|
||||||
|
editorId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showLoginOverlay: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: { LoginOverlay },
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const vditorInstance = ref(null)
|
||||||
|
const text = ref('')
|
||||||
|
const editorId = ref(props.editorId)
|
||||||
|
if (!editorId.value) {
|
||||||
|
editorId.value = 'editor-' + useId()
|
||||||
|
}
|
||||||
|
const getEditorTheme = getEditorThemeUtil
|
||||||
|
const getPreviewTheme = getPreviewThemeUtil
|
||||||
|
const applyTheme = () => {
|
||||||
|
if (vditorInstance.value) {
|
||||||
|
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = computed(() => props.loading || props.disabled || !text.value.trim())
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!vditorInstance.value || isDisabled.value) return
|
||||||
|
const value = vditorInstance.value.getValue()
|
||||||
|
console.debug('CommentEditor submit', value)
|
||||||
|
emit('submit', value, () => {
|
||||||
|
if (!vditorInstance.value) return
|
||||||
|
vditorInstance.value.setValue('')
|
||||||
|
text.value = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
vditorInstance.value = createVditor(editorId.value, {
|
||||||
|
placeholder: '说点什么...',
|
||||||
|
preview: {
|
||||||
|
actions: [],
|
||||||
|
markdown: { toc: false }
|
||||||
|
},
|
||||||
|
input(value) {
|
||||||
|
text.value = value
|
||||||
|
},
|
||||||
|
after() {
|
||||||
|
if (props.loading || props.disabled) {
|
||||||
|
vditorInstance.value.disabled()
|
||||||
|
}
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// applyTheme()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearVditorStorage()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.loading,
|
||||||
|
val => {
|
||||||
|
if (!vditorInstance.value) return
|
||||||
|
if (val) {
|
||||||
|
vditorInstance.value.disabled()
|
||||||
|
} else if (!props.disabled) {
|
||||||
|
vditorInstance.value.enable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
val => {
|
||||||
|
if (!vditorInstance.value) return
|
||||||
|
if (val) {
|
||||||
|
vditorInstance.value.disabled()
|
||||||
|
} else if (!props.loading) {
|
||||||
|
vditorInstance.value.enable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => themeState.mode,
|
||||||
|
() => {
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return { submit, isDisabled, editorId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comment-editor-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-bottom-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-submit {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-submit.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-submit.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-submit:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.comment-editor-container {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
302
frontend_nuxt/components/CommentItem.vue
Normal file
302
frontend_nuxt/components/CommentItem.vue
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<div class="info-content-container" :id="'comment-' + comment.id" :style="{
|
||||||
|
...(level > 0 ? { /*borderLeft: '1px solid #e0e0e0', */borderBottom: 'none' } : {})
|
||||||
|
}">
|
||||||
|
<!-- <div class="user-avatar-container">
|
||||||
|
<div class="user-avatar-item">
|
||||||
|
<img class="user-avatar-item-img" :src="comment.avatar" alt="avatar" />
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
<div class="info-content">
|
||||||
|
<div class="common-info-content-header">
|
||||||
|
<div class="info-content-header-left">
|
||||||
|
<span class="user-name">{{ comment.userName }}</span>
|
||||||
|
<span v-if="level >= 2">
|
||||||
|
<i class="fas fa-reply reply-icon"></i>
|
||||||
|
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="post-time">{{ comment.time }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-content-header-right">
|
||||||
|
<DropdownMenu v-if="commentMenuItems.length > 0" :items="commentMenuItems">
|
||||||
|
<template #trigger>
|
||||||
|
<i class="fas fa-ellipsis-vertical action-menu-icon"></i>
|
||||||
|
</template>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-content-text" v-html="renderMarkdown(comment.text)" @click="handleContentClick"></div>
|
||||||
|
<div class="article-footer-container">
|
||||||
|
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
||||||
|
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
||||||
|
<i class="far fa-comment"></i>
|
||||||
|
</div>
|
||||||
|
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
</div>
|
||||||
|
</ReactionsGroup>
|
||||||
|
</div>
|
||||||
|
<div class="comment-editor-wrapper">
|
||||||
|
<CommentEditor v-if="showEditor" @submit="submitReply" :loading="isWaitingForReply" :disabled="!loggedIn"
|
||||||
|
:show-login-overlay="!loggedIn" />
|
||||||
|
</div>
|
||||||
|
<div v-if="replyCount && level < 2" class="reply-toggle" @click="toggleReplies">
|
||||||
|
<i v-if="showReplies" class="fas fa-chevron-up reply-toggle-icon"></i>
|
||||||
|
<i v-else class="fas fa-chevron-down reply-toggle-icon"></i>
|
||||||
|
{{ replyCount }}条回复
|
||||||
|
</div>
|
||||||
|
<div v-if="showReplies && level < 2" class="reply-list">
|
||||||
|
<BaseTimeline :items="replyList">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<CommentItem :key="item.id" :comment="item" :level="level + 1" :default-show-replies="item.openReplies" />
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
|
</div>
|
||||||
|
<vue-easy-lightbox :visible="lightboxVisible" :imgs="lightboxImgs" :index="lightboxIndex"
|
||||||
|
@hide="lightboxVisible = false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import CommentEditor from './CommentEditor.vue'
|
||||||
|
import { renderMarkdown, handleMarkdownClick } from '../utils/markdown'
|
||||||
|
import TimeManager from '../utils/time'
|
||||||
|
import BaseTimeline from './BaseTimeline.vue'
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import { getToken, authState } from '../utils/auth'
|
||||||
|
import ReactionsGroup from './ReactionsGroup.vue'
|
||||||
|
import DropdownMenu from './DropdownMenu.vue'
|
||||||
|
import LoginOverlay from './LoginOverlay.vue'
|
||||||
|
|
||||||
|
const CommentItem = {
|
||||||
|
name: 'CommentItem',
|
||||||
|
emits: ['deleted'],
|
||||||
|
props: {
|
||||||
|
comment: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
defaultShowReplies: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
|
||||||
|
watch(
|
||||||
|
() => props.defaultShowReplies,
|
||||||
|
(val) => {
|
||||||
|
showReplies.value = props.level === 0 ? true : val
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const showEditor = ref(false)
|
||||||
|
const isWaitingForReply = ref(false)
|
||||||
|
const lightboxVisible = ref(false)
|
||||||
|
const lightboxIndex = ref(0)
|
||||||
|
const lightboxImgs = ref([])
|
||||||
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
|
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||||
|
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||||
|
const toggleReplies = () => {
|
||||||
|
showReplies.value = !showReplies.value
|
||||||
|
}
|
||||||
|
const toggleEditor = () => {
|
||||||
|
showEditor.value = !showEditor.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并所有子回复为一个扁平数组
|
||||||
|
const flattenReplies = (list) => {
|
||||||
|
let result = []
|
||||||
|
for (const r of list) {
|
||||||
|
result.push(r)
|
||||||
|
if (r.reply && r.reply.length > 0) {
|
||||||
|
result = result.concat(flattenReplies(r.reply))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyList = computed(() => {
|
||||||
|
if (props.level < 1) {
|
||||||
|
return props.comment.reply
|
||||||
|
}
|
||||||
|
|
||||||
|
return flattenReplies(props.comment.reply || [])
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAuthor = computed(() => authState.username === props.comment.userName)
|
||||||
|
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||||
|
const commentMenuItems = computed(() =>
|
||||||
|
(isAuthor.value || isAdmin.value) ? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] : []
|
||||||
|
)
|
||||||
|
const deleteComment = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.debug('Deleting comment', props.comment.id)
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
console.debug('Delete comment response status', res.status)
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('已删除')
|
||||||
|
emit('deleted', props.comment.id)
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const submitReply = async (text, clear) => {
|
||||||
|
if (!text.trim()) return
|
||||||
|
isWaitingForReply.value = true
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
isWaitingForReply.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.debug('Submitting reply', { parentId: props.comment.id, text })
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ content: text })
|
||||||
|
})
|
||||||
|
console.debug('Submit reply response status', res.status)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
console.debug('Submit reply response data', data)
|
||||||
|
const replyList = props.comment.reply || (props.comment.reply = [])
|
||||||
|
replyList.push({
|
||||||
|
id: data.id,
|
||||||
|
userName: data.author.username,
|
||||||
|
time: TimeManager.format(data.createdAt),
|
||||||
|
avatar: data.author.avatar,
|
||||||
|
text: data.content,
|
||||||
|
reactions: [],
|
||||||
|
reply: (data.replies || []).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
userName: r.author.username,
|
||||||
|
time: TimeManager.format(r.createdAt),
|
||||||
|
avatar: r.author.avatar,
|
||||||
|
text: r.content,
|
||||||
|
reactions: r.reactions || [],
|
||||||
|
reply: [],
|
||||||
|
openReplies: false,
|
||||||
|
src: r.author.avatar,
|
||||||
|
iconClick: () => router.push(`/users/${r.author.id}`)
|
||||||
|
})),
|
||||||
|
openReplies: false,
|
||||||
|
src: data.author.avatar,
|
||||||
|
iconClick: () => router.push(`/users/${data.author.id}`)
|
||||||
|
})
|
||||||
|
clear()
|
||||||
|
showEditor.value = false
|
||||||
|
toast.success('回复成功')
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('回复过于频繁,请稍后再试')
|
||||||
|
} else {
|
||||||
|
toast.error(`回复失败: ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('Submit reply error', e)
|
||||||
|
toast.error(`回复失败: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
isWaitingForReply.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const copyCommentLink = () => {
|
||||||
|
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
|
||||||
|
navigator.clipboard.writeText(link).then(() => {
|
||||||
|
toast.success('已复制')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleContentClick = e => {
|
||||||
|
handleMarkdownClick(e)
|
||||||
|
if (e.target.tagName === 'IMG') {
|
||||||
|
const container = e.target.parentNode
|
||||||
|
const imgs = [...container.querySelectorAll('img')].map(i => i.src)
|
||||||
|
lightboxImgs.value = imgs
|
||||||
|
lightboxIndex.value = imgs.indexOf(e.target.src)
|
||||||
|
lightboxVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { showReplies, toggleReplies, showEditor, toggleEditor, submitReply, copyCommentLink, renderMarkdown, isWaitingForReply, commentMenuItems, deleteComment, lightboxVisible, lightboxIndex, lightboxImgs, handleContentClick, loggedIn, replyCount, replyList }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CommentItem.components = { CommentItem, CommentEditor, BaseTimeline, ReactionsGroup, DropdownMenu, VueEasyLightbox, LoginOverlay }
|
||||||
|
|
||||||
|
export default CommentItem
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reply-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--primary-color);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-list {}
|
||||||
|
|
||||||
|
.comment-reaction {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reaction:hover {
|
||||||
|
background-color: lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-highlight {
|
||||||
|
animation: highlight 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-toggle-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.common-info-content-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-icon {
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-left: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-user-name {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlight {
|
||||||
|
from {
|
||||||
|
background-color: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.reply-icon {
|
||||||
|
margin-right: 3px;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
411
frontend_nuxt/components/Dropdown.vue
Normal file
411
frontend_nuxt/components/Dropdown.vue
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dropdown" ref="wrapper">
|
||||||
|
<div class="dropdown-display" @click="toggle">
|
||||||
|
<slot
|
||||||
|
name="display"
|
||||||
|
:selected="selectedLabels"
|
||||||
|
:toggle="toggle"
|
||||||
|
:search="search"
|
||||||
|
:setSearch="setSearch"
|
||||||
|
>
|
||||||
|
<template v-if="multiple">
|
||||||
|
<span v-if="selectedLabels.length">
|
||||||
|
<template v-for="(label, idx) in selectedLabels" :key="label.id">
|
||||||
|
<div class="selected-label">
|
||||||
|
<template v-if="label.icon">
|
||||||
|
<img
|
||||||
|
v-if="isImageIcon(label.icon)"
|
||||||
|
:src="label.icon"
|
||||||
|
class="option-icon"
|
||||||
|
:alt="label.name"
|
||||||
|
/>
|
||||||
|
<i v-else :class="['option-icon', label.icon]"></i>
|
||||||
|
</template>
|
||||||
|
<span>{{ label.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="idx !== selectedLabels.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span v-else class="placeholder">{{ placeholder }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="selectedLabels.length">
|
||||||
|
<div class="selected-label">
|
||||||
|
<template v-if="selectedLabels[0].icon">
|
||||||
|
<img
|
||||||
|
v-if="isImageIcon(selectedLabels[0].icon)"
|
||||||
|
:src="selectedLabels[0].icon"
|
||||||
|
class="option-icon"
|
||||||
|
:alt="selectedLabels[0].name"
|
||||||
|
/>
|
||||||
|
<i v-else :class="['option-icon', selectedLabels[0].icon]"></i>
|
||||||
|
</template>
|
||||||
|
<span>{{ selectedLabels[0].name }}</span>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span v-else class="placeholder">{{ placeholder }}</span>
|
||||||
|
</template>
|
||||||
|
<i class="fas fa-caret-down dropdown-caret"></i>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
open &&
|
||||||
|
!isMobile &&
|
||||||
|
(loading || filteredOptions.length > 0 || showSearch)
|
||||||
|
"
|
||||||
|
:class="['dropdown-menu', menuClass]"
|
||||||
|
v-click-outside="close"
|
||||||
|
>
|
||||||
|
<div v-if="showSearch" class="dropdown-search">
|
||||||
|
<i class="fas fa-search search-icon"></i>
|
||||||
|
<input type="text" v-model="search" placeholder="搜索" />
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="dropdown-loading">
|
||||||
|
<l-hatch
|
||||||
|
size="20"
|
||||||
|
stroke="4"
|
||||||
|
speed="3.5"
|
||||||
|
color="var(--primary-color)"
|
||||||
|
></l-hatch>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="o in filteredOptions"
|
||||||
|
:key="o.id"
|
||||||
|
@click="select(o.id)"
|
||||||
|
:class="[
|
||||||
|
'dropdown-option',
|
||||||
|
optionClass,
|
||||||
|
{ selected: isSelected(o.id) },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||||
|
<template v-if="o.icon">
|
||||||
|
<img
|
||||||
|
v-if="isImageIcon(o.icon)"
|
||||||
|
:src="o.icon"
|
||||||
|
class="option-icon"
|
||||||
|
:alt="o.name"
|
||||||
|
/>
|
||||||
|
<i v-else :class="['option-icon', o.icon]"></i>
|
||||||
|
</template>
|
||||||
|
<span>{{ o.name }}</span>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open && isMobile" class="dropdown-mobile-page">
|
||||||
|
<div class="dropdown-mobile-header">
|
||||||
|
<i class="fas fa-arrow-left" @click="close"></i>
|
||||||
|
<span class="mobile-title">{{ placeholder }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-mobile-menu">
|
||||||
|
<div v-if="showSearch" class="dropdown-search">
|
||||||
|
<i class="fas fa-search search-icon"></i>
|
||||||
|
<input type="text" v-model="search" placeholder="搜索" />
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="dropdown-loading">
|
||||||
|
<l-hatch
|
||||||
|
size="20"
|
||||||
|
stroke="4"
|
||||||
|
speed="3.5"
|
||||||
|
color="var(--primary-color)"
|
||||||
|
></l-hatch>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="o in filteredOptions"
|
||||||
|
:key="o.id"
|
||||||
|
@click="select(o.id)"
|
||||||
|
:class="[
|
||||||
|
'dropdown-option',
|
||||||
|
optionClass,
|
||||||
|
{ selected: isSelected(o.id) },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||||
|
<template v-if="o.icon">
|
||||||
|
<img
|
||||||
|
v-if="isImageIcon(o.icon)"
|
||||||
|
:src="o.icon"
|
||||||
|
class="option-icon"
|
||||||
|
:alt="o.name"
|
||||||
|
/>
|
||||||
|
<i v-else :class="['option-icon', o.icon]"></i>
|
||||||
|
</template>
|
||||||
|
<span>{{ o.name }}</span>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, computed, watch, onMounted } from "vue"
|
||||||
|
import { isMobile } from "~/utils/screen"
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "BaseDropdown",
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [Array, String, Number], default: () => [] },
|
||||||
|
placeholder: { type: String, default: "返回" },
|
||||||
|
multiple: { type: Boolean, default: false },
|
||||||
|
fetchOptions: { type: Function, required: true },
|
||||||
|
remote: { type: Boolean, default: false },
|
||||||
|
menuClass: { type: String, default: "" },
|
||||||
|
optionClass: { type: String, default: "" },
|
||||||
|
showSearch: { type: Boolean, default: true },
|
||||||
|
initialOptions: { type: Array, default: () => [] },
|
||||||
|
},
|
||||||
|
emits: ["update:modelValue", "update:search", "close"],
|
||||||
|
setup(props, { emit, expose }) {
|
||||||
|
const open = ref(false)
|
||||||
|
const search = ref("")
|
||||||
|
const setSearch = (val) => {
|
||||||
|
search.value = val
|
||||||
|
}
|
||||||
|
const options = ref(
|
||||||
|
Array.isArray(props.initialOptions) ? [...props.initialOptions] : []
|
||||||
|
)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const wrapper = ref(null)
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
open.value = !open.value
|
||||||
|
if (!open.value) emit("close")
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
open.value = false
|
||||||
|
emit("close")
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = (id) => {
|
||||||
|
if (props.multiple) {
|
||||||
|
const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||||
|
const idx = arr.indexOf(id)
|
||||||
|
if (idx > -1) {
|
||||||
|
arr.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
arr.push(id)
|
||||||
|
}
|
||||||
|
emit("update:modelValue", arr)
|
||||||
|
} else {
|
||||||
|
emit("update:modelValue", id)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
search.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
if (props.remote) return options.value
|
||||||
|
if (!search.value) return options.value
|
||||||
|
return options.value.filter((o) =>
|
||||||
|
o.name.toLowerCase().includes(search.value.toLowerCase())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadOptions = async (kw = "") => {
|
||||||
|
if (!props.remote && loaded.value) return
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const res = await props.fetchOptions(props.remote ? kw : undefined)
|
||||||
|
options.value = Array.isArray(res) ? res : []
|
||||||
|
if (!props.remote) loaded.value = true
|
||||||
|
} catch {
|
||||||
|
options.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialOptions,
|
||||||
|
(val) => {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
options.value = [...val]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(open, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
if (props.remote) {
|
||||||
|
await loadOptions(search.value)
|
||||||
|
} else if (!loaded.value) {
|
||||||
|
await loadOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(search, async (val) => {
|
||||||
|
emit("update:search", val)
|
||||||
|
if (props.remote && open.value) {
|
||||||
|
await loadOptions(val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!props.remote) {
|
||||||
|
loadOptions()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedLabels = computed(() => {
|
||||||
|
if (props.multiple) {
|
||||||
|
return options.value.filter((o) =>
|
||||||
|
(props.modelValue || []).includes(o.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const match = options.value.find((o) => o.id === props.modelValue)
|
||||||
|
return match ? [match] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSelected = (id) => {
|
||||||
|
return selectedLabels.value.some((label) => label.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImageIcon = (icon) => {
|
||||||
|
if (!icon) return false
|
||||||
|
return /^https?:\/\//.test(icon) || icon.startsWith("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
expose({ toggle, close })
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
toggle,
|
||||||
|
close,
|
||||||
|
select,
|
||||||
|
search,
|
||||||
|
filteredOptions,
|
||||||
|
wrapper,
|
||||||
|
selectedLabels,
|
||||||
|
isSelected,
|
||||||
|
loading,
|
||||||
|
isImageIcon,
|
||||||
|
setSearch,
|
||||||
|
isMobile,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-display {
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--background-color);
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
z-index: 10000;
|
||||||
|
max-height: 200px;
|
||||||
|
min-width: 350px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-search input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
margin-left: 5px;
|
||||||
|
background-color: var(--menu-background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option.selected {
|
||||||
|
background-color: var(--menu-selected-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option:hover {
|
||||||
|
background-color: var(--menu-selected-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-mobile-page {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--menu-background-color);
|
||||||
|
z-index: 1300;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-mobile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-mobile-menu {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
frontend_nuxt/components/DropdownMenu.vue
Normal file
87
frontend_nuxt/components/DropdownMenu.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dropdown-wrapper" ref="wrapper">
|
||||||
|
<div class="dropdown-trigger" @click="toggle">
|
||||||
|
<slot name="trigger"></slot>
|
||||||
|
</div>
|
||||||
|
<div v-if="visible" class="dropdown-menu-container">
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in items"
|
||||||
|
:key="idx"
|
||||||
|
class="dropdown-item"
|
||||||
|
:style="{ color: item.color || 'inherit' }"
|
||||||
|
@click="handle(item)"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
export default {
|
||||||
|
name: 'DropdownMenu',
|
||||||
|
props: {
|
||||||
|
items: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
setup(props, { expose }) {
|
||||||
|
const visible = ref(false)
|
||||||
|
const wrapper = ref(null)
|
||||||
|
const toggle = () => {
|
||||||
|
visible.value = !visible.value
|
||||||
|
}
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
const handle = item => {
|
||||||
|
close()
|
||||||
|
if (item && typeof item.onClick === 'function') {
|
||||||
|
item.onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const clickOutside = e => {
|
||||||
|
if (wrapper.value && !wrapper.value.contains(e.target)) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', clickOutside)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', clickOutside)
|
||||||
|
})
|
||||||
|
expose({ close })
|
||||||
|
return { visible, toggle, wrapper, handle }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dropdown-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.dropdown-trigger {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu-container {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--menu-background-color);
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 100px;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
padding: 8px 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: var(--menu-selected-background-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
53
frontend_nuxt/components/GlobalPopups.vue
Normal file
53
frontend_nuxt/components/GlobalPopups.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ActivityPopup
|
||||||
|
:visible="showMilkTeaPopup"
|
||||||
|
:icon="milkTeaIcon"
|
||||||
|
text="建站送奶茶活动火热进行中,快来参与吧!"
|
||||||
|
@close="closeMilkTeaPopup"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ActivityPopup from '~/components/ActivityPopup.vue'
|
||||||
|
import { API_BASE_URL } from '~/main'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GlobalPopups',
|
||||||
|
components: { ActivityPopup },
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
showMilkTeaPopup: false,
|
||||||
|
milkTeaIcon: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted () {
|
||||||
|
await this.checkMilkTeaActivity()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async checkMilkTeaActivity () {
|
||||||
|
if (!process.client) return
|
||||||
|
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||||
|
if (res.ok) {
|
||||||
|
const list = await res.json()
|
||||||
|
const a = list.find(i => i.type === 'MILK_TEA' && !i.ended)
|
||||||
|
if (a) {
|
||||||
|
this.milkTeaIcon = a.icon
|
||||||
|
this.showMilkTeaPopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore network errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeMilkTeaPopup () {
|
||||||
|
if (!process.client) return
|
||||||
|
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||||
|
this.showMilkTeaPopup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
309
frontend_nuxt/components/HeaderComponent.vue
Normal file
309
frontend_nuxt/components/HeaderComponent.vue
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<template>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-content-left">
|
||||||
|
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
||||||
|
<button class="menu-btn" @click="$emit('toggle-menu')">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||||
|
</div>
|
||||||
|
<div class="logo-container" @click="goToHome">
|
||||||
|
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||||
|
width="60" height="60">
|
||||||
|
<div class="logo-text">OpenIsle</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLogin" class="header-content-right">
|
||||||
|
<div v-if="isMobile" class="search-icon" @click="search">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||||
|
<template #trigger>
|
||||||
|
<div class="avatar-container">
|
||||||
|
<img class="avatar-img" :src="avatar" alt="avatar">
|
||||||
|
<i class="fas fa-caret-down dropdown-icon"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="header-content-right">
|
||||||
|
<div v-if="isMobile" class="search-icon" @click="search">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</div>
|
||||||
|
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||||
|
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||||
|
import { watch, nextTick } from 'vue'
|
||||||
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
|
import { isMobile } from '~/utils/screen'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'HeaderComponent',
|
||||||
|
components: { DropdownMenu, SearchDropdown },
|
||||||
|
props: {
|
||||||
|
showMenuBtn: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
avatar: '',
|
||||||
|
showSearch: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isLogin() {
|
||||||
|
return authState.loggedIn
|
||||||
|
},
|
||||||
|
isMobile() {
|
||||||
|
return isMobile.value
|
||||||
|
},
|
||||||
|
headerMenuItems() {
|
||||||
|
return [
|
||||||
|
{ text: '设置', onClick: this.goToSettings },
|
||||||
|
{ text: '个人主页', onClick: this.goToProfile },
|
||||||
|
{ text: '退出', onClick: this.goToLogout }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
unreadCount() {
|
||||||
|
return notificationState.unreadCount
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
const updateAvatar = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await loadCurrentUser()
|
||||||
|
if (user && user.avatar) {
|
||||||
|
this.avatar = user.avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updateUnread = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
await fetchUnreadCount()
|
||||||
|
} else {
|
||||||
|
notificationState.unreadCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAvatar()
|
||||||
|
await updateUnread()
|
||||||
|
|
||||||
|
watch(() => authState.loggedIn, async () => {
|
||||||
|
await updateAvatar()
|
||||||
|
await updateUnread()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => this.$route.fullPath, () => {
|
||||||
|
if (this.$refs.userMenu) this.$refs.userMenu.close()
|
||||||
|
this.showSearch = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
goToHome() {
|
||||||
|
this.$router.push('/').then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
search() {
|
||||||
|
this.showSearch = true
|
||||||
|
nextTick(() => {
|
||||||
|
this.$refs.searchDropdown.toggle()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeSearch() {
|
||||||
|
nextTick(() => {
|
||||||
|
this.showSearch = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
goToLogin() {
|
||||||
|
this.$router.push('/login')
|
||||||
|
},
|
||||||
|
goToSettings() {
|
||||||
|
this.$router.push('/settings')
|
||||||
|
},
|
||||||
|
async goToProfile() {
|
||||||
|
if (!authState.loggedIn) {
|
||||||
|
this.$router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let id = authState.username || authState.userId
|
||||||
|
if (!id) {
|
||||||
|
const user = await loadCurrentUser()
|
||||||
|
if (user) {
|
||||||
|
id = user.username || user.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
this.$router.push(`/users/${id}`).then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goToSignup() {
|
||||||
|
this.$router.push('/signup')
|
||||||
|
},
|
||||||
|
goToLogout() {
|
||||||
|
clearToken()
|
||||||
|
this.$router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--header-height);
|
||||||
|
background-color: var(--background-color-blur);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: var(--header-text-color);
|
||||||
|
border-bottom: 1px solid var(--header-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--page-max-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
font-size: 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-unread-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 10px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content-item-main {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content-item-main:hover {
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content-item-secondary {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: lightgray;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
padding: 8px 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: var(--menu-selected-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.header-content {
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
width: calc(100% - 30px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-content-item-secondary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
59
frontend_nuxt/components/LevelProgress.vue
Normal file
59
frontend_nuxt/components/LevelProgress.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="level-progress">
|
||||||
|
<div class="level-progress-current">当前Lv.{{ currentLevel }}</div>
|
||||||
|
<ProgressBar :value="value" :max="max" />
|
||||||
|
<div class="level-progress-info">
|
||||||
|
<div class="level-progress-exp">{{ exp }} / {{ nextExp }}</div>
|
||||||
|
<div class="level-progress-target">🎉目标 Lv.{{ currentLevel + 1 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ProgressBar from './ProgressBar.vue'
|
||||||
|
import { prevLevelExp } from '../utils/level'
|
||||||
|
export default {
|
||||||
|
name: 'LevelProgress',
|
||||||
|
components: { ProgressBar },
|
||||||
|
props: {
|
||||||
|
exp: { type: Number, default: 0 },
|
||||||
|
currentLevel: { type: Number, default: 0 },
|
||||||
|
nextExp: { type: Number, default: 0 }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
max () {
|
||||||
|
return this.nextExp - prevLevelExp(this.currentLevel)
|
||||||
|
},
|
||||||
|
value () {
|
||||||
|
return this.exp - prevLevelExp(this.currentLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.level-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-progress-current {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-progress-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-progress-exp,
|
||||||
|
.level-progress-target {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
85
frontend_nuxt/components/LoginOverlay.vue
Normal file
85
frontend_nuxt/components/LoginOverlay.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-overlay">
|
||||||
|
<div class="login-overlay-blur"></div>
|
||||||
|
<div class="login-overlay-content">
|
||||||
|
<i class="fa-solid fa-user login-overlay-icon"></i>
|
||||||
|
<div class="login-overlay-text">
|
||||||
|
请先登录,点击跳转到登录页面
|
||||||
|
</div>
|
||||||
|
<div class="login-overlay-button" @click="goLogin">
|
||||||
|
登录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'LoginOverlay',
|
||||||
|
setup() {
|
||||||
|
const router = useRouter()
|
||||||
|
const goLogin = () => {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
return { goLogin }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 15;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-overlay-blur {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-overlay-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
row-gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-overlay-icon {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-overlay-button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-overlay-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
442
frontend_nuxt/components/MenuComponent.vue
Normal file
442
frontend_nuxt/components/MenuComponent.vue
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="slide">
|
||||||
|
<nav v-if="visible" class="menu">
|
||||||
|
<div class="menu-item-container">
|
||||||
|
<NuxtLink
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/"
|
||||||
|
@click="handleHomeClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||||
|
<span class="menu-item-text">话题</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/message"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-envelope"></i>
|
||||||
|
<span class="menu-item-text">我的消息</span>
|
||||||
|
<span v-if="unreadCount > 0" class="unread-container">
|
||||||
|
<span class="unread"> {{ showUnreadCount }} </span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/about"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||||
|
<span class="menu-item-text">关于</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/activities"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-gift"></i>
|
||||||
|
<span class="menu-item-text">🔥 活动</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="shouldShowStats"
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/about/stats"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||||
|
<span class="menu-item-text">站点统计</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/new-post"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-edit"></i>
|
||||||
|
<span class="menu-item-text">发帖</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-section">
|
||||||
|
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||||
|
<span>类别</span>
|
||||||
|
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||||
|
</div>
|
||||||
|
<div v-if="categoryOpen" class="section-items">
|
||||||
|
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
<div v-else v-for="c in categories" :key="c.id" class="section-item" @click="gotoCategory(c)">
|
||||||
|
<template v-if="c.smallIcon || c.icon">
|
||||||
|
<img v-if="isImageIcon(c.smallIcon || c.icon)" :src="c.smallIcon || c.icon" class="section-item-icon" :alt="c.name" />
|
||||||
|
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||||
|
</template>
|
||||||
|
<span class="section-item-text">
|
||||||
|
{{ c.name }}
|
||||||
|
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-section">
|
||||||
|
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||||
|
<span>tag</span>
|
||||||
|
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||||
|
</div>
|
||||||
|
<div v-if="tagOpen" class="section-items">
|
||||||
|
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
<div v-else v-for="t in tags" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||||
|
<img v-if="isImageIcon(t.smallIcon || t.icon)" :src="t.smallIcon || t.icon" class="section-item-icon" :alt="t.name" />
|
||||||
|
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||||
|
<span class="section-item-text">{{ t.name }} <span class="section-item-text-count">x {{ t.count
|
||||||
|
}}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-footer">
|
||||||
|
<div class="menu-footer-btn" @click="cycleTheme">
|
||||||
|
<i :class="iconClass"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||||
|
import { authState } from '~/utils/auth'
|
||||||
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import { API_BASE_URL } from '~/main'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'MenuComponent',
|
||||||
|
props: {
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
categories: [],
|
||||||
|
tags: [],
|
||||||
|
categoryOpen: true,
|
||||||
|
tagOpen: true,
|
||||||
|
isLoadingCategory: false,
|
||||||
|
isLoadingTag: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
iconClass() {
|
||||||
|
switch (themeState.mode) {
|
||||||
|
case ThemeMode.DARK:
|
||||||
|
return 'fas fa-moon'
|
||||||
|
case ThemeMode.LIGHT:
|
||||||
|
return 'fas fa-sun'
|
||||||
|
default:
|
||||||
|
return 'fas fa-desktop'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unreadCount() {
|
||||||
|
return notificationState.unreadCount
|
||||||
|
},
|
||||||
|
showUnreadCount() {
|
||||||
|
return this.unreadCount > 99 ? '99+' : this.unreadCount
|
||||||
|
},
|
||||||
|
shouldShowStats() {
|
||||||
|
return authState.role === 'ADMIN'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
const updateCount = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
await fetchUnreadCount()
|
||||||
|
} else {
|
||||||
|
notificationState.unreadCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => authState.loggedIn, async () => {
|
||||||
|
await updateCount()
|
||||||
|
})
|
||||||
|
|
||||||
|
const CAT_CACHE_KEY = 'menu-categories'
|
||||||
|
const TAG_CACHE_KEY = 'menu-tags'
|
||||||
|
|
||||||
|
const cachedCategories = localStorage.getItem(CAT_CACHE_KEY)
|
||||||
|
if (cachedCategories) {
|
||||||
|
try {
|
||||||
|
this.categories = JSON.parse(cachedCategories)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedTags = localStorage.getItem(TAG_CACHE_KEY)
|
||||||
|
if (cachedTags) {
|
||||||
|
try {
|
||||||
|
this.tags = JSON.parse(cachedTags)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoadingCategory = !cachedCategories
|
||||||
|
this.isLoadingTag = !cachedTags
|
||||||
|
|
||||||
|
const fetchCategories = () => {
|
||||||
|
fetch(`${API_BASE_URL}/api/categories`).then(res => {
|
||||||
|
if (res.ok) {
|
||||||
|
res.json().then(data => {
|
||||||
|
this.categories = data.slice(0, 10)
|
||||||
|
localStorage.setItem(CAT_CACHE_KEY, JSON.stringify(this.categories))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.isLoadingCategory = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTags = () => {
|
||||||
|
fetch(`${API_BASE_URL}/api/tags?limit=10`).then(res => {
|
||||||
|
if (res.ok) {
|
||||||
|
res.json().then(data => {
|
||||||
|
this.tags = data
|
||||||
|
localStorage.setItem(TAG_CACHE_KEY, JSON.stringify(this.tags))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.isLoadingTag = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedCategories) {
|
||||||
|
setTimeout(fetchCategories, 1500)
|
||||||
|
} else {
|
||||||
|
fetchCategories()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedTags) {
|
||||||
|
setTimeout(fetchTags, 1500)
|
||||||
|
} else {
|
||||||
|
fetchTags()
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateCount()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
cycleTheme,
|
||||||
|
handleHomeClick() {
|
||||||
|
this.$router.push('/').then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleItemClick() {
|
||||||
|
if (window.innerWidth <= 768) this.$emit('item-click')
|
||||||
|
},
|
||||||
|
isImageIcon(icon) {
|
||||||
|
if (!icon) return false
|
||||||
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
|
},
|
||||||
|
gotoCategory(c) {
|
||||||
|
const value = encodeURIComponent(c.id ?? c.name)
|
||||||
|
this.$router
|
||||||
|
.push({ path: '/', query: { category: value } })
|
||||||
|
.then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
this.handleItemClick()
|
||||||
|
},
|
||||||
|
gotoTag(t) {
|
||||||
|
const value = encodeURIComponent(t.id ?? t.name)
|
||||||
|
this.$router
|
||||||
|
.push({ path: '/', query: { tags: value } })
|
||||||
|
.then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
this.handleItemClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--header-height);
|
||||||
|
width: 200px;
|
||||||
|
background-color: var(--menu-background-color);
|
||||||
|
height: calc(100vh - 20px - var(--header-height));
|
||||||
|
border-right: 1px solid var(--menu-border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-container {
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
padding: 4px 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--menu-text-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item.selected {
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: var(--menu-selected-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-text {
|
||||||
|
font-size: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--menu-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgb(255, 102, 102);
|
||||||
|
margin-left: 15px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread {
|
||||||
|
color: white;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-icon {
|
||||||
|
margin-right: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-footer {
|
||||||
|
position: fixed;
|
||||||
|
height: 30px;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-footer-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
margin-right: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-section {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-items {
|
||||||
|
color: var(--menu-text-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-item {
|
||||||
|
padding: 4px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-item:hover {
|
||||||
|
background-color: var(--menu-selected-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-item-text-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--menu-text-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-item-text {
|
||||||
|
color: var(--menu-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-item-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 5px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-loading-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
left: 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border-right: none;
|
||||||
|
height: 400px;
|
||||||
|
top: calc(var(--header-height) + 10px);
|
||||||
|
padding-top: 10px;
|
||||||
|
background-color: var(--background-color-blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-active,
|
||||||
|
.slide-leave-active {
|
||||||
|
transition:
|
||||||
|
transform 0.3s ease,
|
||||||
|
opacity 0.3s ease,
|
||||||
|
width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-from,
|
||||||
|
.slide-leave-to {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-to,
|
||||||
|
.slide-leave-from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
245
frontend_nuxt/components/MilkTeaActivityComponent.vue
Normal file
245
frontend_nuxt/components/MilkTeaActivityComponent.vue
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<div class="milk-tea-activity">
|
||||||
|
<div class="milk-tea-description">
|
||||||
|
<div class="milk-tea-description-title">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span class="milk-tea-description-title-text">升级规则说明</span>
|
||||||
|
</div>
|
||||||
|
<div class="milk-tea-description-content">
|
||||||
|
<p>回复帖子每次10exp,最多3次每天</p>
|
||||||
|
<p>发布帖子每次30exp,最多1次每天</p>
|
||||||
|
<p>发表情每次5exp,最多3次每天</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="milk-tea-status-container">
|
||||||
|
<div class="milk-tea-status">
|
||||||
|
<div class="status-title">🔥 已兑换奶茶人数</div>
|
||||||
|
<ProgressBar :value="info.redeemCount" :max="50" />
|
||||||
|
<div class="status-text">当前 {{ info.redeemCount }} / 50</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isLoadingUser" class="loading-user">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
<div class="user-level-text">加载当前等级中...</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="user" class="user-level">
|
||||||
|
<LevelProgress :exp="user.experience" :current-level="user.currentLevel" :next-exp="user.nextLevelExp" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="user-level">
|
||||||
|
<div class="user-level-text"><i class="fas fa-user-circle"></i> 请登录查看自身等级</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="user && user.currentLevel >= 1 && !info.ended" class="redeem-button" @click="openDialog">兑换</div>
|
||||||
|
<div v-else class="redeem-button disabled">兑换</div>
|
||||||
|
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
||||||
|
<div class="redeem-dialog-content">
|
||||||
|
<BaseInput textarea="" rows="5" v-model="contact" placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)" />
|
||||||
|
<div class="redeem-actions">
|
||||||
|
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
|
||||||
|
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BasePopup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ProgressBar from './ProgressBar.vue'
|
||||||
|
import LevelProgress from './LevelProgress.vue'
|
||||||
|
import BaseInput from './BaseInput.vue'
|
||||||
|
import BasePopup from './BasePopup.vue'
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import { getToken, fetchCurrentUser } from '../utils/auth'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'MilkTeaActivityComponent',
|
||||||
|
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
info: { redeemCount: 0, ended: false },
|
||||||
|
user: null,
|
||||||
|
dialogVisible: false,
|
||||||
|
contact: '',
|
||||||
|
loading: false,
|
||||||
|
isLoadingUser: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted () {
|
||||||
|
await this.loadInfo()
|
||||||
|
this.isLoadingUser = true
|
||||||
|
this.user = await fetchCurrentUser()
|
||||||
|
this.isLoadingUser = false
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadInfo () {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
||||||
|
if (res.ok) {
|
||||||
|
this.info = await res.json()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openDialog () {
|
||||||
|
this.dialogVisible = true
|
||||||
|
},
|
||||||
|
closeDialog () {
|
||||||
|
this.dialogVisible = false
|
||||||
|
},
|
||||||
|
async submitRedeem () {
|
||||||
|
if (!this.contact) return
|
||||||
|
this.loading = true
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ contact: this.contact })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.message === 'updated') {
|
||||||
|
toast.success('您已提交过兑换,本次更新兑换信息')
|
||||||
|
} else {
|
||||||
|
toast.success('兑换成功!')
|
||||||
|
}
|
||||||
|
this.dialogVisible = false
|
||||||
|
await this.loadInfo()
|
||||||
|
} else {
|
||||||
|
toast.error('兑换失败')
|
||||||
|
}
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.milk-tea-description-title-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milk-tea-description-content {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milk-tea-activity {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-button {
|
||||||
|
margin-top: 20px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-button.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-button.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.milk-tea-status-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milk-tea-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-dialog-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-submit-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.redeem-submit-button:disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.redeem-submit-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
.redeem-submit-button:disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
.redeem-cancel-button {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.redeem-cancel-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.user-level-text {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.milk-tea-status-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-dialog-content {
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
63
frontend_nuxt/components/NotificationContainer.vue
Normal file
63
frontend_nuxt/components/NotificationContainer.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="notif-content-container">
|
||||||
|
<div class="notif-content-container-item">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<slot name="actions">
|
||||||
|
<div v-if="!item.read" class="mark-read-button" @click="markRead(item.id)">
|
||||||
|
{{ isMobile ? 'OK' : '标记为已读' }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="has-read-button">已读</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { isMobile } from '../utils/screen'
|
||||||
|
export default {
|
||||||
|
name: 'NotificationContainer',
|
||||||
|
props: {
|
||||||
|
item: { type: Object, required: true },
|
||||||
|
markRead: { type: Function, required: true }
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
isMobile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notif-content-container {
|
||||||
|
color: rgb(140, 140, 140);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-read-button {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-read-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-read-button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.has-read-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
152
frontend_nuxt/components/PostEditor.vue
Normal file
152
frontend_nuxt/components/PostEditor.vue
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<div class="post-editor-container">
|
||||||
|
<div :id="editorId" ref="vditorElement"></div>
|
||||||
|
<div v-if="loading" class="editor-loading-overlay">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted, watch, onUnmounted, useId } from 'vue'
|
||||||
|
import { themeState } from '../utils/theme'
|
||||||
|
import {
|
||||||
|
createVditor,
|
||||||
|
getEditorTheme as getEditorThemeUtil,
|
||||||
|
getPreviewTheme as getPreviewThemeUtil
|
||||||
|
} from '../utils/vditor'
|
||||||
|
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PostEditor',
|
||||||
|
emits: ['update:modelValue', 'update:loading'],
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
editorId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const vditorInstance = ref(null)
|
||||||
|
let vditorRender = false
|
||||||
|
const editorId = ref(props.editorId)
|
||||||
|
if (!editorId.value) {
|
||||||
|
editorId.value = 'post-editor-' + useId()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEditorTheme = getEditorThemeUtil
|
||||||
|
const getPreviewTheme = getPreviewThemeUtil
|
||||||
|
const applyTheme = () => {
|
||||||
|
if (vditorInstance.value) {
|
||||||
|
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.loading,
|
||||||
|
val => {
|
||||||
|
if (!vditorRender) return
|
||||||
|
if (val) {
|
||||||
|
vditorInstance.value.disabled()
|
||||||
|
} else {
|
||||||
|
vditorInstance.value.enable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
val => {
|
||||||
|
if (!vditorInstance.value) return
|
||||||
|
if (val) {
|
||||||
|
vditorInstance.value.disabled()
|
||||||
|
} else if (!props.loading) {
|
||||||
|
vditorInstance.value.enable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
val => {
|
||||||
|
if (vditorInstance.value && vditorInstance.value.getValue() !== val) {
|
||||||
|
vditorInstance.value.setValue(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => themeState.mode,
|
||||||
|
() => {
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
emit('update:loading', true)
|
||||||
|
vditorInstance.value = createVditor(editorId.value, {
|
||||||
|
placeholder: '请输入正文...',
|
||||||
|
input(value) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
},
|
||||||
|
after() {
|
||||||
|
vditorRender = true
|
||||||
|
emit('update:loading', false)
|
||||||
|
vditorInstance.value.setValue(props.modelValue)
|
||||||
|
if (props.loading || props.disabled) {
|
||||||
|
vditorInstance.value.disabled()
|
||||||
|
}
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// applyTheme()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearVditorStorage()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { editorId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.post-editor-container {
|
||||||
|
position: relative;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--menu-selected-background-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: all;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.post-editor-container {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
37
frontend_nuxt/components/ProgressBar.vue
Normal file
37
frontend_nuxt/components/ProgressBar.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-bar-inner" :style="{ width: `${percent}%` }" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ProgressBar',
|
||||||
|
props: {
|
||||||
|
value: { type: Number, default: 0 },
|
||||||
|
max: { type: Number, default: 100 }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
percent () {
|
||||||
|
if (this.max <= 0) return 0
|
||||||
|
const p = (this.value / this.max) * 100
|
||||||
|
return Math.max(0, Math.min(100, p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.progress-bar {
|
||||||
|
width: 200px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--normal-background-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-inner {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
308
frontend_nuxt/components/ReactionsGroup.vue
Normal file
308
frontend_nuxt/components/ReactionsGroup.vue
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
<template>
|
||||||
|
<div class="reactions-container">
|
||||||
|
<div class="reactions-viewer">
|
||||||
|
<div class="reactions-viewer-item-container" @click="openPanel" @mouseenter="cancelHide"
|
||||||
|
@mouseleave="scheduleHide">
|
||||||
|
<template v-if="displayedReactions.length">
|
||||||
|
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item">{{ reactionEmojiMap[r.type] }}</div>
|
||||||
|
<div class="reactions-count">{{ totalCount }}</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="reactions-viewer-item placeholder">
|
||||||
|
<i class="far fa-smile"></i>
|
||||||
|
<span class="reactions-viewer-item-placeholder-text">点击以表态</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="make-reaction-container">
|
||||||
|
<div class="make-reaction-item like-reaction" @click="toggleReaction('LIKE')">
|
||||||
|
<i v-if="!userReacted('LIKE')" class="far fa-heart"></i>
|
||||||
|
<i v-else class="fas fa-heart"></i>
|
||||||
|
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<div v-if="panelVisible" class="reactions-panel" @mouseenter="cancelHide" @mouseleave="scheduleHide">
|
||||||
|
<div v-for="t in panelTypes" :key="t" class="reaction-option" @click="toggleReaction(t)"
|
||||||
|
:class="{ selected: userReacted(t) }">
|
||||||
|
{{ reactionEmojiMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import { getToken, authState } from '../utils/auth'
|
||||||
|
import { reactionEmojiMap } from '../utils/reactions'
|
||||||
|
|
||||||
|
let cachedTypes = null
|
||||||
|
const fetchTypes = async () => {
|
||||||
|
if (cachedTypes) return cachedTypes
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
|
||||||
|
headers: { Authorization: token ? `Bearer ${token}` : '' }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
cachedTypes = await res.json()
|
||||||
|
} else {
|
||||||
|
cachedTypes = []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
cachedTypes = []
|
||||||
|
}
|
||||||
|
return cachedTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ReactionsGroup',
|
||||||
|
props: {
|
||||||
|
modelValue: { type: Array, default: () => [] },
|
||||||
|
contentType: { type: String, required: true },
|
||||||
|
contentId: { type: [Number, String], required: true }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const reactions = ref(props.modelValue)
|
||||||
|
watch(() => props.modelValue, v => reactions.value = v)
|
||||||
|
|
||||||
|
const reactionTypes = ref([])
|
||||||
|
onMounted(async () => {
|
||||||
|
reactionTypes.value = await fetchTypes()
|
||||||
|
})
|
||||||
|
|
||||||
|
const counts = computed(() => {
|
||||||
|
const c = {}
|
||||||
|
for (const r of reactions.value) {
|
||||||
|
c[r.type] = (c[r.type] || 0) + 1
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||||
|
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
||||||
|
|
||||||
|
const userReacted = type => reactions.value.some(r => r.type === type && r.user === authState.username)
|
||||||
|
|
||||||
|
const displayedReactions = computed(() => {
|
||||||
|
return Object.entries(counts.value)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([type]) => ({ type }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const panelTypes = computed(() => reactionTypes.value.filter(t => t !== 'LIKE'))
|
||||||
|
|
||||||
|
const panelVisible = ref(false)
|
||||||
|
let hideTimer = null
|
||||||
|
const openPanel = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
panelVisible.value = true
|
||||||
|
}
|
||||||
|
const scheduleHide = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
hideTimer = setTimeout(() => { panelVisible.value = false }, 500)
|
||||||
|
}
|
||||||
|
const cancelHide = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleReaction = async (type) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = props.contentType === 'post'
|
||||||
|
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
||||||
|
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
||||||
|
|
||||||
|
// optimistic update
|
||||||
|
const existingIdx = reactions.value.findIndex(r => r.type === type && r.user === authState.username)
|
||||||
|
let tempReaction = null
|
||||||
|
let removedReaction = null
|
||||||
|
if (existingIdx > -1) {
|
||||||
|
removedReaction = reactions.value.splice(existingIdx, 1)[0]
|
||||||
|
} else {
|
||||||
|
tempReaction = { type, user: authState.username }
|
||||||
|
reactions.value.push(tempReaction)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', reactions.value)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ type })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
if (res.status === 204) {
|
||||||
|
// removal already reflected
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
|
||||||
|
if (idx > -1) {
|
||||||
|
reactions.value.splice(idx, 1, data)
|
||||||
|
} else if (removedReaction) {
|
||||||
|
// server added back reaction even though we removed? restore data
|
||||||
|
reactions.value.push(data)
|
||||||
|
}
|
||||||
|
if (data.reward && data.reward > 0) {
|
||||||
|
toast.success(`获得 ${data.reward} 经验值`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('update:modelValue', reactions.value)
|
||||||
|
} else {
|
||||||
|
// revert optimistic update on failure
|
||||||
|
if (tempReaction) {
|
||||||
|
const idx = reactions.value.indexOf(tempReaction)
|
||||||
|
if (idx > -1) reactions.value.splice(idx, 1)
|
||||||
|
} else if (removedReaction) {
|
||||||
|
reactions.value.push(removedReaction)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', reactions.value)
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (tempReaction) {
|
||||||
|
const idx = reactions.value.indexOf(tempReaction)
|
||||||
|
if (idx > -1) reactions.value.splice(idx, 1)
|
||||||
|
} else if (removedReaction) {
|
||||||
|
reactions.value.push(removedReaction)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', reactions.value)
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reactionEmojiMap,
|
||||||
|
counts,
|
||||||
|
totalCount,
|
||||||
|
likeCount,
|
||||||
|
displayedReactions,
|
||||||
|
panelTypes,
|
||||||
|
panelVisible,
|
||||||
|
openPanel,
|
||||||
|
scheduleHide,
|
||||||
|
cancelHide,
|
||||||
|
toggleReaction,
|
||||||
|
userReacted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reactions-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reactions-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reactions-viewer-item-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #a2a2a2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reactions-viewer-item {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reactions-viewer-item.placeholder {
|
||||||
|
opacity: 0.5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reactions-viewer-item-placeholder-text {
|
||||||
|
font-size: 14px;
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.make-reaction-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.make-reaction-item {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-reaction {
|
||||||
|
color: #ff0000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.make-reaction-item:hover {
|
||||||
|
background-color: #ffe2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reactions-count {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reactions-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
left: -20px;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
max-width: 240px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
z-index: 10;
|
||||||
|
gap: 2px;
|
||||||
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option.selected {
|
||||||
|
background-color: var(--menu-selected-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.make-reaction-item {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
185
frontend_nuxt/components/SearchDropdown.vue
Normal file
185
frontend_nuxt/components/SearchDropdown.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div class="search-dropdown">
|
||||||
|
<Dropdown ref="dropdown" v-model="selected" :fetch-options="fetchResults" remote menu-class="search-menu"
|
||||||
|
option-class="search-option" :show-search="isMobile" @update:search="keyword = $event" @close="onClose">
|
||||||
|
<template #display="{ setSearch }">
|
||||||
|
<div class="search-input">
|
||||||
|
<i class="search-input-icon fas fa-search"></i>
|
||||||
|
<input class="text-input" v-model="keyword" placeholder="Search" @input="setSearch(keyword)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="search-option-item">
|
||||||
|
<i :class="['result-icon', iconMap[option.type] || 'fas fa-question']"></i>
|
||||||
|
<div class="result-body">
|
||||||
|
<div class="result-main" v-html="highlight(option.text)"></div>
|
||||||
|
<div v-if="option.subText" class="result-sub" v-html="highlight(option.subText)"></div>
|
||||||
|
<div v-if="option.extra" class="result-extra" v-html="highlight(option.extra)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { isMobile } from '~/utils/screen'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
|
import { API_BASE_URL } from '~/main'
|
||||||
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SearchDropdown',
|
||||||
|
components: { Dropdown },
|
||||||
|
emits: ['close'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const keyword = ref('')
|
||||||
|
const selected = ref(null)
|
||||||
|
const results = ref([])
|
||||||
|
const dropdown = ref(null)
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
dropdown.value.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClose = () => emit('close')
|
||||||
|
|
||||||
|
const fetchResults = async (kw) => {
|
||||||
|
if (!kw) return []
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
results.value = data.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
text: r.text,
|
||||||
|
type: r.type,
|
||||||
|
subText: r.subText,
|
||||||
|
extra: r.extra,
|
||||||
|
postId: r.postId
|
||||||
|
}))
|
||||||
|
return results.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlight = (text) => {
|
||||||
|
text = stripMarkdown(text)
|
||||||
|
if (!keyword.value) return text
|
||||||
|
const reg = new RegExp(keyword.value, 'gi')
|
||||||
|
const res = text.replace(reg, m => `<span class="highlight">${m}</span>`)
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
user: 'fas fa-user',
|
||||||
|
post: 'fas fa-file-alt',
|
||||||
|
comment: 'fas fa-comment',
|
||||||
|
category: 'fas fa-folder',
|
||||||
|
tag: 'fas fa-hashtag'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selected, val => {
|
||||||
|
if (!val) return
|
||||||
|
const opt = results.value.find(r => r.id === val)
|
||||||
|
if (!opt) return
|
||||||
|
if (opt.type === 'post' || opt.type === 'post_title') {
|
||||||
|
router.push(`/posts/${opt.id}`)
|
||||||
|
} else if (opt.type === 'user') {
|
||||||
|
router.push(`/users/${opt.id}`)
|
||||||
|
} else if (opt.type === 'comment') {
|
||||||
|
if (opt.postId) {
|
||||||
|
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
|
||||||
|
}
|
||||||
|
} else if (opt.type === 'category') {
|
||||||
|
router.push({ path: '/', query: { category: opt.id } }).then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
} else if (opt.type === 'tag') {
|
||||||
|
router.push({ path: '/', query: { tags: opt.id } }).then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
selected.value = null
|
||||||
|
keyword.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
return { keyword, selected, fetchResults, highlight, iconMap, isMobile, dropdown, onClose, toggle }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-dropdown {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-mobile-trigger {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
background-color: var(--menu-background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-menu {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-dropdown {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-option-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.highlight) {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-main {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-sub,
|
||||||
|
.result-extra {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
149
frontend_nuxt/components/TagSelect.vue
Normal file
149
frontend_nuxt/components/TagSelect.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<Dropdown v-model="selected" :fetch-options="fetchTags" multiple placeholder="选择标签" remote
|
||||||
|
:initial-options="mergedOptions">
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="option-container">
|
||||||
|
<div class="option-main">
|
||||||
|
<template v-if="option.icon">
|
||||||
|
<img v-if="isImageIcon(option.icon)" :src="option.icon" class="option-icon" :alt="option.name" />
|
||||||
|
<i v-else :class="['option-icon', option.icon]"></i>
|
||||||
|
</template>
|
||||||
|
<span>{{ option.name }}</span>
|
||||||
|
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="option.description" class="option-desc">{{ option.description }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { API_BASE_URL, toast } from '~/main'
|
||||||
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'TagSelect',
|
||||||
|
components: { Dropdown },
|
||||||
|
props: {
|
||||||
|
modelValue: { type: Array, default: () => [] },
|
||||||
|
creatable: { type: Boolean, default: false },
|
||||||
|
options: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const localTags = ref([])
|
||||||
|
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
val => {
|
||||||
|
providedTags.value = Array.isArray(val) ? [...val] : []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedOptions = computed(() => {
|
||||||
|
const arr = [...providedTags.value, ...localTags.value]
|
||||||
|
return arr.filter((v, i, a) => a.findIndex(t => t.id === v.id) === i)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isImageIcon = icon => {
|
||||||
|
if (!icon) return false
|
||||||
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildTagsUrl = (kw = '') => {
|
||||||
|
const base = API_BASE_URL || (process.client ? window.location.origin : '');
|
||||||
|
const url = new URL('/api/tags', base);
|
||||||
|
|
||||||
|
if (kw) url.searchParams.set('keyword', kw);
|
||||||
|
url.searchParams.set('limit', '10');
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTags = async (kw = '') => {
|
||||||
|
const defaultOption = { id: 0, name: '无标签' };
|
||||||
|
|
||||||
|
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||||
|
const url = buildTagsUrl(kw);
|
||||||
|
|
||||||
|
// 2) 拉数据
|
||||||
|
let data = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) data = await res.json();
|
||||||
|
} catch {
|
||||||
|
toast.error('获取标签失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 合并、去重、可创建
|
||||||
|
let options = [...data, ...localTags.value];
|
||||||
|
|
||||||
|
if (props.creatable && kw &&
|
||||||
|
!options.some(t => t.name.toLowerCase() === kw.toLowerCase())) {
|
||||||
|
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` });
|
||||||
|
}
|
||||||
|
|
||||||
|
options = Array.from(new Map(options.map(t => [t.id, t])).values());
|
||||||
|
|
||||||
|
// 4) 最终结果
|
||||||
|
return [defaultOption, ...options];
|
||||||
|
};
|
||||||
|
|
||||||
|
const selected = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: v => {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
if (v.includes(0)) {
|
||||||
|
emit('update:modelValue', [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (v.length > 2) {
|
||||||
|
toast.error('最多选择两个标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v = v.map(id => {
|
||||||
|
if (typeof id === 'string' && id.startsWith('__create__:')) {
|
||||||
|
const name = id.slice(11)
|
||||||
|
const newId = `__new__:${name}`
|
||||||
|
if (!localTags.value.find(t => t.id === newId)) {
|
||||||
|
localTags.value.push({ id: newId, name })
|
||||||
|
}
|
||||||
|
return newId
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
emit('update:modelValue', v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { fetchTags, selected, isImageIcon, mergedOptions }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.option-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-count {
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
frontend_nuxt/components/UserList.vue
Normal file
65
frontend_nuxt/components/UserList.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-list">
|
||||||
|
<BasePlaceholder v-if="users.length === 0" text="暂无用户" icon="fas fa-inbox" />
|
||||||
|
<div v-for="u in users" :key="u.id" class="user-item" @click="handleUserClick(u)">
|
||||||
|
<img :src="u.avatar" alt="avatar" class="user-avatar" />
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">{{ u.username }}</div>
|
||||||
|
<div v-if="u.introduction" class="user-intro">{{ u.introduction }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import BasePlaceholder from './BasePlaceholder.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'UserList',
|
||||||
|
components: { BasePlaceholder },
|
||||||
|
props: {
|
||||||
|
users: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleUserClick(user) {
|
||||||
|
this.$router.push(`/users/${user.id}`).then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.user-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.user-intro {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
1
frontend_nuxt/constants.js
Normal file
1
frontend_nuxt/constants.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'
|
||||||
159
frontend_nuxt/directives/clickOutside.js
Normal file
159
frontend_nuxt/directives/clickOutside.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* @file clickOutsideDirective.js
|
||||||
|
* @description 一个用于检测元素外部点击的Vue 3自定义指令。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 在 main.js 中全局注册
|
||||||
|
* import { createApp } from 'vue'
|
||||||
|
* import App from './App.vue'
|
||||||
|
* import ClickOutside from './clickOutsideDirective.js'
|
||||||
|
*
|
||||||
|
* const app = createApp(App)
|
||||||
|
* app.directive('click-outside', ClickOutside)
|
||||||
|
* app.mount('#app')
|
||||||
|
*
|
||||||
|
* // 在组件中使用
|
||||||
|
* <div v-click-outside="myMethod">...</div>
|
||||||
|
*
|
||||||
|
* // 排除特定元素
|
||||||
|
* <div v-click-outside:[myExcludedElement]="myMethod">...</div>
|
||||||
|
* <div v-click-outside:[[el1, el2]]="myMethod">...</div>
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 使用一个Map来存储所有指令绑定的元素及其对应的处理器
|
||||||
|
// 键是HTMLElement,值是一个包含处理器和回调函数的对象数组
|
||||||
|
const nodeList = new Map();
|
||||||
|
|
||||||
|
// 检查是否在客户端环境,以避免在SSR(服务器端渲染)时执行
|
||||||
|
const isClient = typeof window !== 'undefined';
|
||||||
|
|
||||||
|
// 在客户端环境中,只设置一次全局的 mousedown 和 mouseup 监听器
|
||||||
|
if (isClient) {
|
||||||
|
let startClick;
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', (e) => (startClick = e));
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', (e) => {
|
||||||
|
// 遍历所有注册的元素和它们的处理器
|
||||||
|
for (const handlers of nodeList.values()) {
|
||||||
|
for (const { documentHandler } of handlers) {
|
||||||
|
// 调用每个处理器,传入 mouseup 和 mousedown 事件
|
||||||
|
documentHandler(e, startClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 完成后重置 startClick
|
||||||
|
startClick = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个文档事件处理器。
|
||||||
|
* @param {HTMLElement} el - 指令绑定的元素。
|
||||||
|
* @param {import('vue').DirectiveBinding} binding - 指令的绑定对象。
|
||||||
|
* @returns {Function} 返回一个处理函数。
|
||||||
|
*/
|
||||||
|
function createDocumentHandler(el, binding) {
|
||||||
|
let excludes = [];
|
||||||
|
// binding.arg 可以是一个元素或一个元素数组,用于排除不需要触发回调的点击
|
||||||
|
if (Array.isArray(binding.arg)) {
|
||||||
|
excludes = binding.arg;
|
||||||
|
} else if (binding.arg instanceof HTMLElement) {
|
||||||
|
excludes.push(binding.arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return function (mouseup, mousedown) {
|
||||||
|
// 从组件实例中获取 popper 引用(如果存在),这对于处理下拉菜单、弹窗等很有用
|
||||||
|
const popperRef = binding.instance?.popperRef;
|
||||||
|
const mouseUpTarget = mouseup.target;
|
||||||
|
const mouseDownTarget = mousedown?.target;
|
||||||
|
|
||||||
|
// 检查各种条件,如果满足任一条件,则不执行回调
|
||||||
|
const isBound = !binding || !binding.instance;
|
||||||
|
const isTargetExists = !mouseUpTarget || !mouseDownTarget;
|
||||||
|
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
|
||||||
|
const isSelf = el === mouseUpTarget;
|
||||||
|
|
||||||
|
// 检查点击是否发生在任何被排除的元素内部
|
||||||
|
const isTargetExcluded =
|
||||||
|
(excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
|
||||||
|
(excludes.length && excludes.includes(mouseDownTarget));
|
||||||
|
|
||||||
|
// 检查点击是否发生在关联的 popper 元素内部
|
||||||
|
const isContainedByPopper =
|
||||||
|
popperRef &&
|
||||||
|
(popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));
|
||||||
|
|
||||||
|
if (
|
||||||
|
isBound ||
|
||||||
|
isTargetExists ||
|
||||||
|
isContainedByEl ||
|
||||||
|
isSelf ||
|
||||||
|
isTargetExcluded ||
|
||||||
|
isContainedByPopper
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果所有检查都通过,说明点击发生在外部,执行指令传入的回调函数
|
||||||
|
binding.value(mouseup, mousedown);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClickOutside = {
|
||||||
|
/**
|
||||||
|
* 在绑定元素的 attribute 或事件监听器被应用之前调用。
|
||||||
|
* @param {HTMLElement} el
|
||||||
|
* @param {import('vue').DirectiveBinding} binding
|
||||||
|
*/
|
||||||
|
beforeMount(el, binding) {
|
||||||
|
if (!nodeList.has(el)) {
|
||||||
|
nodeList.set(el, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeList.get(el).push({
|
||||||
|
documentHandler: createDocumentHandler(el, binding),
|
||||||
|
bindingFn: binding.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在包含组件的 VNode 及其子组件的 VNode 更新后调用。
|
||||||
|
* @param {HTMLElement} el
|
||||||
|
* @param {import('vue').DirectiveBinding} binding
|
||||||
|
*/
|
||||||
|
updated(el, binding) {
|
||||||
|
if (!nodeList.has(el)) {
|
||||||
|
nodeList.set(el, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = nodeList.get(el);
|
||||||
|
// 查找旧的回调函数对应的处理器
|
||||||
|
const oldHandlerIndex = handlers.findIndex(
|
||||||
|
(item) => item.bindingFn === binding.oldValue
|
||||||
|
);
|
||||||
|
|
||||||
|
const newHandler = {
|
||||||
|
documentHandler: createDocumentHandler(el, binding),
|
||||||
|
bindingFn: binding.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (oldHandlerIndex >= 0) {
|
||||||
|
// 如果找到了,就替换成新的处理器
|
||||||
|
handlers.splice(oldHandlerIndex, 1, newHandler);
|
||||||
|
} else {
|
||||||
|
// 否则,直接添加新的处理器
|
||||||
|
handlers.push(newHandler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在绑定元素的父组件卸载后调用。
|
||||||
|
* @param {HTMLElement} el
|
||||||
|
*/
|
||||||
|
unmounted(el) {
|
||||||
|
// 当元素卸载时,从Map中移除它,以进行垃圾回收并防止内存泄漏
|
||||||
|
nodeList.delete(el);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClickOutside;
|
||||||
7
frontend_nuxt/main.js
Normal file
7
frontend_nuxt/main.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const API_BASE_URL = 'http://www.open-isle.com'
|
||||||
|
// export const API_BASE_URL = 'http://127.0.0.1:8081'
|
||||||
|
export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
|
||||||
|
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
|
||||||
|
export const DISCORD_CLIENT_ID = '1394985417044000779'
|
||||||
|
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
|
||||||
|
export const toast = { success: () => {}, error: () => {} }
|
||||||
17
frontend_nuxt/nuxt.config.ts
Normal file
17
frontend_nuxt/nuxt.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineNuxtConfig } from 'nuxt/config'
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
ssr: true,
|
||||||
|
css: ['~/assets/global.css', '~/assets/toast.css'],
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
link: [
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
||||||
|
referrerpolicy: 'no-referrer'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
10627
frontend_nuxt/package-lock.json
generated
Normal file
10627
frontend_nuxt/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend_nuxt/package.json
Normal file
21
frontend_nuxt/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend_nuxt",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"start": "nuxt start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"ldrs": "^1.0.0",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"nuxt": "latest",
|
||||||
|
"vditor": "^3.11.1",
|
||||||
|
"vue-easy-lightbox": "^1.19.0",
|
||||||
|
"vue-echarts": "^7.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend_nuxt/pages/404.vue
Normal file
33
frontend_nuxt/pages/404.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div class="not-found-page">
|
||||||
|
<h1>404 - 页面不存在</h1>
|
||||||
|
<p>你访问的页面不存在或已被删除</p>
|
||||||
|
<router-link to="/">返回首页</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'NotFoundPageView'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.not-found-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-page h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-page a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
121
frontend_nuxt/pages/about/index.vue
Normal file
121
frontend_nuxt/pages/about/index.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<div class="about-page">
|
||||||
|
<div class="about-tabs">
|
||||||
|
<div
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.name"
|
||||||
|
:class="['about-tabs-item', { selected: selectedTab === tab.name }]"
|
||||||
|
@click="selectTab(tab.name)"
|
||||||
|
>
|
||||||
|
<div class="about-tabs-item-label">{{ tab.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="about-loading" v-if="isFetching">
|
||||||
|
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="about-content" v-html="renderMarkdown(content)" @click="handleContentClick"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { renderMarkdown, handleMarkdownClick } from '../utils/markdown'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AboutPageView',
|
||||||
|
setup() {
|
||||||
|
const isFetching = ref(false)
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'about', label: '关于', file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/about.md' },
|
||||||
|
{ name: 'agreement', label: '用户协议', file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/agreement.md' },
|
||||||
|
{ name: 'guideline', label: '创作准则', file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/guideline.md' },
|
||||||
|
{ name: 'privacy', label: '隐私政策', file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md' },
|
||||||
|
]
|
||||||
|
const selectedTab = ref(tabs[0].name)
|
||||||
|
const content = ref('')
|
||||||
|
|
||||||
|
const loadContent = async (file) => {
|
||||||
|
try {
|
||||||
|
isFetching.value = true
|
||||||
|
const res = await fetch(file)
|
||||||
|
if (res.ok) {
|
||||||
|
content.value = await res.text()
|
||||||
|
} else {
|
||||||
|
content.value = '# 内容加载失败'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
content.value = '# 内容加载失败'
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectTab = (name) => {
|
||||||
|
selectedTab.value = name
|
||||||
|
const tab = tabs.find(t => t.name === name)
|
||||||
|
if (tab) loadContent(tab.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadContent(tabs[0].file)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleContentClick = e => {
|
||||||
|
handleMarkdownClick(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tabs, selectedTab, content, renderMarkdown, selectTab, isFetching, handleContentClick }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.about-page {
|
||||||
|
max-width: var(--page-max-width);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tabs {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--header-height) + 1px);
|
||||||
|
z-index: 200;
|
||||||
|
background-color: var(--background-color-blur);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tabs-item {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tabs-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content {
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.about-tabs {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
53
frontend_nuxt/pages/about/stats.vue
Normal file
53
frontend_nuxt/pages/about/stats.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div class="site-stats-page">
|
||||||
|
<VChart v-if="option" :option="option" :autoresize="true" style="height:400px" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import VChart from 'vue-echarts'
|
||||||
|
import { use } from 'echarts/core'
|
||||||
|
import { LineChart } from 'echarts/charts'
|
||||||
|
import { TitleComponent, TooltipComponent, GridComponent, DataZoomComponent } from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import { API_BASE_URL } from '../main'
|
||||||
|
import { getToken } from '../utils/auth'
|
||||||
|
|
||||||
|
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
||||||
|
|
||||||
|
const option = ref(null)
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/stats/dau-range?days=30`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
data.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||||
|
const dates = data.map(d => d.date)
|
||||||
|
const values = data.map(d => d.value)
|
||||||
|
option.value = {
|
||||||
|
title: { text: '站点 DAU' },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: { type: 'category', data: dates },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
dataZoom: [{ type: 'slider', start: 80 }, { type: 'inside' }],
|
||||||
|
series: [{ type: 'line', areaStyle: {}, smooth: true, data: values }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.site-stats-page {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: var(--page-max-width);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
167
frontend_nuxt/pages/activities.vue
Normal file
167
frontend_nuxt/pages/activities.vue
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<div class="activity-list-page">
|
||||||
|
<div v-if="isLoadingActivities" class="loading-activities">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity-list-page-card" v-for="a in activities" :key="a.id">
|
||||||
|
<div class="activity-list-page-card-normal">
|
||||||
|
<div v-if="a.icon" class="activity-card-normal-left">
|
||||||
|
<img :src="a.icon" alt="avatar" class="activity-card-left-avatar-img" />
|
||||||
|
</div>
|
||||||
|
<div class="activity-card-normal-right">
|
||||||
|
<div class="activity-card-normal-right-header">
|
||||||
|
<div class="activity-list-page-card-title">{{ a.title }}</div>
|
||||||
|
<div v-if="a.ended" class="activity-list-page-card-state-end">已结束</div>
|
||||||
|
<div v-else class="activity-list-page-card-state-ongoing">进行中</div>
|
||||||
|
</div>
|
||||||
|
<div class="activity-list-page-card-content">{{ a.content }}</div>
|
||||||
|
<div class="activity-list-page-card-footer">
|
||||||
|
<div class="activity-list-page-card-footer-start-time">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<span>开始于 {{ TimeManager.format(a.startTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { API_BASE_URL } from '../main'
|
||||||
|
import TimeManager from '../utils/time'
|
||||||
|
import MilkTeaActivityComponent from '../components/MilkTeaActivityComponent.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ActivityListPageView',
|
||||||
|
components: { MilkTeaActivityComponent },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
activities: [],
|
||||||
|
TimeManager,
|
||||||
|
isLoadingActivities: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.isLoadingActivities = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||||
|
if (res.ok) {
|
||||||
|
this.activities = await res.json()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
this.isLoadingActivities = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-activities {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
padding: 20px;
|
||||||
|
height: calc(100% - 40px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card {
|
||||||
|
padding: 10px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
gap: 10px;
|
||||||
|
background-color: var(--activity-card-background-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card-left-avatar-img {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 10%;
|
||||||
|
object-fit: cover;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card-normal-right-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-normal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-content {
|
||||||
|
font-size: 1.0rem;
|
||||||
|
margin-top: 10px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-state-end,
|
||||||
|
.activity-list-page-card-state-ongoing {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-state-end {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-state-ongoing {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-footer-start-time {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.activity-card-left-avatar-img {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list-page-card-content {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
26
frontend_nuxt/pages/discord-callback.vue
Normal file
26
frontend_nuxt/pages/discord-callback.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<CallbackPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CallbackPage from '../components/CallbackPage.vue'
|
||||||
|
import { discordExchange } from '../utils/discord'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'DiscordCallbackPageView',
|
||||||
|
components: { CallbackPage },
|
||||||
|
async mounted() {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
const state = url.searchParams.get('state')
|
||||||
|
const result = await discordExchange(code, state, '')
|
||||||
|
|
||||||
|
if (result.needReason) {
|
||||||
|
this.$router.push('/signup-reason?token=' + result.token)
|
||||||
|
} else {
|
||||||
|
this.$router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
175
frontend_nuxt/pages/forgot-password.vue
Normal file
175
frontend_nuxt/pages/forgot-password.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<div class="forgot-page">
|
||||||
|
<div class="forgot-content">
|
||||||
|
<div class="forgot-title">找回密码</div>
|
||||||
|
<div v-if="step === 0" class="step-content">
|
||||||
|
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||||
|
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||||
|
<div class="primary-button" @click="sendCode" v-if="!isSending">发送验证码</div>
|
||||||
|
<div class="primary-button disabled" v-else>发送中...</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 1" class="step-content">
|
||||||
|
<BaseInput icon="fas fa-envelope" v-model="code" placeholder="邮箱验证码" />
|
||||||
|
<div class="primary-button" @click="verifyCode" v-if="!isVerifying">验证</div>
|
||||||
|
<div class="primary-button disabled" v-else>验证中...</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="step-content">
|
||||||
|
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="新密码" />
|
||||||
|
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||||
|
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||||
|
<div class="primary-button disabled" v-else>提交中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import BaseInput from '../components/BaseInput.vue'
|
||||||
|
export default {
|
||||||
|
name: 'ForgotPasswordPageView',
|
||||||
|
components: { BaseInput },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
step: 0,
|
||||||
|
email: '',
|
||||||
|
code: '',
|
||||||
|
password: '',
|
||||||
|
token: '',
|
||||||
|
emailError: '',
|
||||||
|
passwordError: '',
|
||||||
|
isSending: false,
|
||||||
|
isVerifying: false,
|
||||||
|
isResetting: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.$route.query.email) {
|
||||||
|
this.email = decodeURIComponent(this.$route.query.email)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async sendCode() {
|
||||||
|
if (!this.email) {
|
||||||
|
this.emailError = '邮箱不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.isSending = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: this.email })
|
||||||
|
})
|
||||||
|
this.isSending = false
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('验证码已发送')
|
||||||
|
this.step = 1
|
||||||
|
} else {
|
||||||
|
toast.error('请填写已注册邮箱')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.isSending = false
|
||||||
|
toast.error('发送失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async verifyCode() {
|
||||||
|
try {
|
||||||
|
this.isVerifying = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: this.email, code: this.code })
|
||||||
|
})
|
||||||
|
this.isVerifying = false
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
this.token = data.token
|
||||||
|
this.step = 2
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '验证失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.isVerifying = false
|
||||||
|
toast.error('验证失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async resetPassword() {
|
||||||
|
if (!this.password) {
|
||||||
|
this.passwordError = '密码不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.isResetting = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: this.token, password: this.password })
|
||||||
|
})
|
||||||
|
this.isResetting = false
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('密码已重置')
|
||||||
|
this.$router.push('/login')
|
||||||
|
} else if (data.field === 'password') {
|
||||||
|
this.passwordError = data.error
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '重置失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.isResetting = false
|
||||||
|
toast.error('重置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.forgot-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.forgot-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
.forgot-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.step-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.primary-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.primary-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
.primary-button.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
color: red;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.forgot-content {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
26
frontend_nuxt/pages/github-callback.vue
Normal file
26
frontend_nuxt/pages/github-callback.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<CallbackPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CallbackPage from '../components/CallbackPage.vue'
|
||||||
|
import { githubExchange } from '../utils/github'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GithubCallbackPageView',
|
||||||
|
components: { CallbackPage },
|
||||||
|
async mounted() {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
const state = url.searchParams.get('state')
|
||||||
|
const result = await githubExchange(code, state, '')
|
||||||
|
|
||||||
|
if (result.needReason) {
|
||||||
|
this.$router.push('/signup-reason?token=' + result.token)
|
||||||
|
} else {
|
||||||
|
this.$router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
27
frontend_nuxt/pages/google-callback.vue
Normal file
27
frontend_nuxt/pages/google-callback.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<CallbackPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CallbackPage from '../components/CallbackPage.vue'
|
||||||
|
import { googleAuthWithToken } from '../utils/google'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GoogleCallbackPageView',
|
||||||
|
components: { CallbackPage },
|
||||||
|
async mounted() {
|
||||||
|
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||||
|
const idToken = hash.get('id_token')
|
||||||
|
if (idToken) {
|
||||||
|
await googleAuthWithToken(idToken, () => {
|
||||||
|
this.$router.push('/')
|
||||||
|
}, token => {
|
||||||
|
this.$router.push('/signup-reason?token=' + token)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.$router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
748
frontend_nuxt/pages/index.vue
Normal file
748
frontend_nuxt/pages/index.vue
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home-page">
|
||||||
|
<div v-if="!isMobile" class="search-container">
|
||||||
|
<div class="search-title">一切可能,从此刻启航</div>
|
||||||
|
<div class="search-subtitle">愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。</div>
|
||||||
|
<SearchDropdown />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="topic-container">
|
||||||
|
<div class="topic-item-container">
|
||||||
|
<div
|
||||||
|
v-for="topic in topics"
|
||||||
|
:key="topic"
|
||||||
|
class="topic-item"
|
||||||
|
:class="{ selected: topic === selectedTopic }"
|
||||||
|
@click="selectedTopic = topic"
|
||||||
|
>
|
||||||
|
{{ topic }}
|
||||||
|
</div>
|
||||||
|
<div class="topic-select-container">
|
||||||
|
<CategorySelect v-model="selectedCategory" :options="categoryOptions" />
|
||||||
|
<TagSelect v-model="selectedTags" :options="tagOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-container">
|
||||||
|
<template v-if="selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'">
|
||||||
|
<div class="article-header-container">
|
||||||
|
<div class="header-item main-item">
|
||||||
|
<div class="header-item-text">话题</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-item avatars">
|
||||||
|
<div class="header-item-text">参与人员</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-item comments">
|
||||||
|
<div class="header-item-text">回复</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-item views">
|
||||||
|
<div class="header-item-text">浏览</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-item activity">
|
||||||
|
<div class="header-item-text">活动</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="articles.length === 0">
|
||||||
|
<div class="no-posts-container">
|
||||||
|
<div class="no-posts-text">暂时没有帖子 :( 点击发帖发送第一篇相关帖子吧!</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-item" v-for="article in articles" :key="article.id">
|
||||||
|
<div class="article-main-container">
|
||||||
|
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
||||||
|
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
||||||
|
{{ article.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="article-item-description main-item">{{ sanitizeDescription(article.description) }}</div>
|
||||||
|
<div class="article-info-container main-item">
|
||||||
|
<ArticleCategory :category="article.category" />
|
||||||
|
<ArticleTags :tags="article.tags" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-member-avatars-container">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="member in article.members"
|
||||||
|
:key="member.id"
|
||||||
|
class="article-member-avatar-item"
|
||||||
|
:to="`/users/${member.id}`"
|
||||||
|
>
|
||||||
|
<img class="article-member-avatar-item-img" :src="member.avatar" alt="avatar" />
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-comments main-info-text">
|
||||||
|
{{ article.comments }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-views main-info-text">
|
||||||
|
{{ article.views }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-time main-info-text">
|
||||||
|
{{ article.time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="selectedTopic === '热门'" class="placeholder-container">
|
||||||
|
热门帖子功能开发中,敬请期待。
|
||||||
|
</div>
|
||||||
|
<div v-else class="placeholder-container">
|
||||||
|
分类浏览功能开发中,敬请期待。
|
||||||
|
</div>
|
||||||
|
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useScrollLoadMore } from '~/utils/loadMore'
|
||||||
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
|
import { API_BASE_URL } from '~/main'
|
||||||
|
import { getToken } from '~/utils/auth'
|
||||||
|
import TimeManager from '~/utils/time'
|
||||||
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
|
import { isMobile } from '~/utils/screen'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'HomePageView',
|
||||||
|
components: {
|
||||||
|
CategorySelect,
|
||||||
|
TagSelect,
|
||||||
|
ArticleTags,
|
||||||
|
ArticleCategory,
|
||||||
|
SearchDropdown,
|
||||||
|
ClientOnly: () => import('vue').then(m => m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))))
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -------- 1. PARAMS & REFS --------
|
||||||
|
*/
|
||||||
|
const selectedCategory = ref('')
|
||||||
|
if (route.query.category) {
|
||||||
|
const c = decodeURIComponent(route.query.category)
|
||||||
|
selectedCategory.value = isNaN(c) ? c : Number(c)
|
||||||
|
}
|
||||||
|
const selectedTags = ref([])
|
||||||
|
if (route.query.tags) {
|
||||||
|
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
|
||||||
|
selectedTags.value = t
|
||||||
|
.split(',')
|
||||||
|
.filter(v => v)
|
||||||
|
.map(v => decodeURIComponent(v))
|
||||||
|
.map(v => (isNaN(v) ? v : Number(v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagOptions = ref([])
|
||||||
|
const categoryOptions = ref([])
|
||||||
|
const isLoadingPosts = ref(false)
|
||||||
|
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||||
|
const selectedTopic = ref(
|
||||||
|
route.query.view === 'ranking'
|
||||||
|
? '排行榜'
|
||||||
|
: route.query.view === 'latest'
|
||||||
|
? '最新'
|
||||||
|
: '最新回复'
|
||||||
|
)
|
||||||
|
|
||||||
|
const articles = ref([])
|
||||||
|
const page = ref(0)
|
||||||
|
const pageSize = 10
|
||||||
|
const allLoaded = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -------- 2. CLIENT‑SIDE ONLY: LDRS REGISTER --------
|
||||||
|
* 这里使用动态 import 避免 SSR 阶段触发 HTMLElement 未定义错误。
|
||||||
|
*/
|
||||||
|
onMounted(async () => {
|
||||||
|
// 首次加载
|
||||||
|
fetchContent()
|
||||||
|
await loadOptions()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -------- 3. FETCH OPTION HELPERS --------
|
||||||
|
*/
|
||||||
|
const loadOptions = async () => {
|
||||||
|
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
||||||
|
if (res.ok) {
|
||||||
|
categoryOptions.value = [await res.json()]
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTags.value.length) {
|
||||||
|
const arr = []
|
||||||
|
for (const t of selectedTags.value) {
|
||||||
|
if (!isNaN(t)) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
||||||
|
if (r.ok) arr.push(await r.json())
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagOptions.value = arr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildUrl = () => {
|
||||||
|
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
|
||||||
|
if (selectedCategory.value) {
|
||||||
|
url += `&categoryId=${selectedCategory.value}`
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length) {
|
||||||
|
selectedTags.value.forEach(t => {
|
||||||
|
url += `&tagIds=${t}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildRankUrl = () => {
|
||||||
|
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
|
||||||
|
if (selectedCategory.value) {
|
||||||
|
url += `&categoryId=${selectedCategory.value}`
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length) {
|
||||||
|
selectedTags.value.forEach(t => {
|
||||||
|
url += `&tagIds=${t}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildReplyUrl = () => {
|
||||||
|
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
|
||||||
|
if (selectedCategory.value) {
|
||||||
|
url += `&categoryId=${selectedCategory.value}`
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length) {
|
||||||
|
selectedTags.value.forEach(t => {
|
||||||
|
url += `&tagIds=${t}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -------- 4. FETCH CORE --------
|
||||||
|
*/
|
||||||
|
const fetchPosts = async (reset = false) => {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 0
|
||||||
|
allLoaded.value = false
|
||||||
|
articles.value = []
|
||||||
|
}
|
||||||
|
if (isLoadingPosts.value || allLoaded.value) return
|
||||||
|
try {
|
||||||
|
isLoadingPosts.value = true
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(buildUrl(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: token ? `Bearer ${token}` : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
isLoadingPosts.value = false
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
articles.value.push(
|
||||||
|
...data.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.content,
|
||||||
|
category: p.category,
|
||||||
|
tags: p.tags || [],
|
||||||
|
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
|
||||||
|
comments: p.commentCount,
|
||||||
|
views: p.views,
|
||||||
|
time: TimeManager.format(p.createdAt),
|
||||||
|
pinned: !!p.pinnedAt
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
if (data.length < pageSize) {
|
||||||
|
allLoaded.value = true
|
||||||
|
} else {
|
||||||
|
page.value += 1
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRanking = async (reset = false) => {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 0
|
||||||
|
allLoaded.value = false
|
||||||
|
articles.value = []
|
||||||
|
}
|
||||||
|
if (isLoadingPosts.value || allLoaded.value) return
|
||||||
|
try {
|
||||||
|
isLoadingPosts.value = true
|
||||||
|
const res = await fetch(buildRankUrl())
|
||||||
|
isLoadingPosts.value = false
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
articles.value.push(
|
||||||
|
...data.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.content,
|
||||||
|
category: p.category,
|
||||||
|
tags: p.tags || [],
|
||||||
|
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
|
||||||
|
comments: p.commentCount,
|
||||||
|
views: p.views,
|
||||||
|
time: TimeManager.format(p.createdAt),
|
||||||
|
pinned: !!p.pinnedAt
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
if (data.length < pageSize) {
|
||||||
|
allLoaded.value = true
|
||||||
|
} else {
|
||||||
|
page.value += 1
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLatestReply = async (reset = false) => {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 0
|
||||||
|
allLoaded.value = false
|
||||||
|
articles.value = []
|
||||||
|
}
|
||||||
|
if (isLoadingPosts.value || allLoaded.value) return
|
||||||
|
try {
|
||||||
|
isLoadingPosts.value = true
|
||||||
|
const res = await fetch(buildReplyUrl())
|
||||||
|
isLoadingPosts.value = false
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
articles.value.push(
|
||||||
|
...data.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.content,
|
||||||
|
category: p.category,
|
||||||
|
tags: p.tags || [],
|
||||||
|
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
|
||||||
|
comments: p.commentCount,
|
||||||
|
views: p.views,
|
||||||
|
time: TimeManager.format(p.lastReplyAt || p.createdAt),
|
||||||
|
pinned: !!p.pinnedAt
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
if (data.length < pageSize) {
|
||||||
|
allLoaded.value = true
|
||||||
|
} else {
|
||||||
|
page.value += 1
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchContent = async (reset = false) => {
|
||||||
|
if (selectedTopic.value === '排行榜') {
|
||||||
|
fetchRanking(reset)
|
||||||
|
} else if (selectedTopic.value === '最新回复') {
|
||||||
|
fetchLatestReply(reset)
|
||||||
|
} else {
|
||||||
|
fetchPosts(reset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useScrollLoadMore(fetchContent)
|
||||||
|
|
||||||
|
watch([selectedCategory, selectedTags], () => {
|
||||||
|
fetchContent(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedTopic, () => {
|
||||||
|
fetchContent(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sanitizeDescription = text => stripMarkdown(text)
|
||||||
|
|
||||||
|
return {
|
||||||
|
topics,
|
||||||
|
selectedTopic,
|
||||||
|
articles,
|
||||||
|
sanitizeDescription,
|
||||||
|
isLoadingPosts,
|
||||||
|
selectedCategory,
|
||||||
|
selectedTags,
|
||||||
|
tagOptions,
|
||||||
|
categoryOptions,
|
||||||
|
isMobile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-page {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: home-page;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
margin-top: 100px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-loading {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-posts-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-posts-text {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-container {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--header-height) + 1px);
|
||||||
|
z-index: 10;
|
||||||
|
background-color: var(--background-color-blur);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-item-container {
|
||||||
|
margin-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-select-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-item {
|
||||||
|
padding: 2px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-header-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
color: gray;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.article-main-container,
|
||||||
|
.header-item.main-item {
|
||||||
|
width: calc(60% - 20px);
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .article-member-avatars-container,
|
||||||
|
.header-item.avatars, */
|
||||||
|
.article-comments,
|
||||||
|
.header-item.comments,
|
||||||
|
.article-views,
|
||||||
|
.header-item.views,
|
||||||
|
.article-time,
|
||||||
|
.header-item.activity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-member-avatars-container,
|
||||||
|
.header-item.avatars {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-comments,
|
||||||
|
.header-item.comments {
|
||||||
|
width: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-views,
|
||||||
|
.header-item.views {
|
||||||
|
width: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-time,
|
||||||
|
.header-item.activity {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-item-title {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-item-title:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-item-description {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: gray;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-info-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-tag-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-main-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-member-avatars-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-member-avatar-item {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-member-avatar-item-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-info-text {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container home-page (max-width: 900px) {
|
||||||
|
.article-main-container,
|
||||||
|
.header-item.main-item {
|
||||||
|
width: calc(70% - 20px);
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-member-avatars-container,
|
||||||
|
.header-item.avatars {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-comments,
|
||||||
|
.header-item.comments {
|
||||||
|
width: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-views,
|
||||||
|
.header-item.views {
|
||||||
|
width: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-time,
|
||||||
|
.header-item.activity {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
.article-member-avatar-item:nth-child(n+4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container home-page (max-width: 768px) {
|
||||||
|
.article-main-container,
|
||||||
|
.header-item.main-item {
|
||||||
|
width: calc(70% - 20px);
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-member-avatars-container,
|
||||||
|
.header-item.avatars {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-comments,
|
||||||
|
.header-item.comments {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-views,
|
||||||
|
.header-item.views {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-time,
|
||||||
|
.header-item.activity {
|
||||||
|
width: 10%;
|
||||||
|
margin-right: 3%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-header-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-member-avatar-item:nth-child(n+2) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-item-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-item-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-item-description {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-info-text {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-container {
|
||||||
|
position: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
302
frontend_nuxt/pages/login.vue
Normal file
302
frontend_nuxt/pages/login.vue
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-page-content">
|
||||||
|
<div class="login-page-header">
|
||||||
|
<div class="login-page-header-title">
|
||||||
|
Welcome :)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-login-page-content">
|
||||||
|
<BaseInput icon="fas fa-envelope" v-model="username" placeholder="邮箱/用户名" />
|
||||||
|
|
||||||
|
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="密码" />
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="!isWaitingForLogin" class="login-page-button-primary" @click="submitLogin">
|
||||||
|
<div class="login-page-button-text">登录</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="login-page-button-primary disabled">
|
||||||
|
<div class="login-page-button-text">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
登录中...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-page-button-secondary">没有账号? <a class="login-page-button-secondary-link" href="/signup">注册</a> |
|
||||||
|
<a class="login-page-button-secondary-link" :href="`/forgot-password?email=${username}`">找回密码</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="other-login-page-content">
|
||||||
|
<div class="login-page-button" @click="googleAuthorize">
|
||||||
|
<img class="login-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
|
||||||
|
<div class="login-page-button-text">Google 登录</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-page-button" @click="loginWithGithub">
|
||||||
|
<img class="login-page-button-icon" src="../assets/icons/github.svg" alt="GitHub Logo" />
|
||||||
|
<div class="login-page-button-text">GitHub 登录</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-page-button" @click="loginWithDiscord">
|
||||||
|
<img class="login-page-button-icon" src="../assets/icons/discord.svg" alt="Discord Logo" />
|
||||||
|
<div class="login-page-button-text">Discord 登录</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-page-button" @click="loginWithTwitter">
|
||||||
|
<img class="login-page-button-icon" src="../assets/icons/twitter.svg" alt="Twitter Logo" />
|
||||||
|
<div class="login-page-button-text">Twitter 登录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import { setToken, loadCurrentUser } from '../utils/auth'
|
||||||
|
import { googleAuthorize } from '../utils/google'
|
||||||
|
import { githubAuthorize } from '../utils/github'
|
||||||
|
import { discordAuthorize } from '../utils/discord'
|
||||||
|
import { twitterAuthorize } from '../utils/twitter'
|
||||||
|
import BaseInput from '../components/BaseInput.vue'
|
||||||
|
import { registerPush } from '../utils/push'
|
||||||
|
export default {
|
||||||
|
name: 'LoginPageView',
|
||||||
|
components: { BaseInput },
|
||||||
|
setup() {
|
||||||
|
return { googleAuthorize }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
isWaitingForLogin: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async submitLogin() {
|
||||||
|
try {
|
||||||
|
this.isWaitingForLogin = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: this.username, password: this.password })
|
||||||
|
})
|
||||||
|
this.isWaitingForLogin = false
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
|
toast.success('登录成功')
|
||||||
|
registerPush()
|
||||||
|
this.$router.push('/')
|
||||||
|
} else if (data.reason_code === 'NOT_VERIFIED') {
|
||||||
|
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
||||||
|
this.$router.push({ path: '/signup', query: { verify: 1, u: this.username } })
|
||||||
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
|
toast.info('您的注册正在审批中, 请留意邮件')
|
||||||
|
this.$router.push('/')
|
||||||
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
|
this.$router.push('/signup-reason?token=' + data.token)
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('登录失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loginWithGithub() {
|
||||||
|
githubAuthorize()
|
||||||
|
},
|
||||||
|
loginWithDiscord() {
|
||||||
|
discordAuthorize()
|
||||||
|
},
|
||||||
|
loginWithTwitter() {
|
||||||
|
twitterAuthorize()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(40% - 120px);
|
||||||
|
border-right: 1px solid var(--normal-border-color);
|
||||||
|
padding-right: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-header-title {
|
||||||
|
font-family: 'Pacifico', 'Comic Sans MS', cursive, 'Roboto', sans-serif;
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-header {
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-login-page-content {
|
||||||
|
margin-top: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-input-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-input-text {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-login-page-content {
|
||||||
|
margin-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30%;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-primary {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-primary:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-primary.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-primary.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
min-width: 150px;
|
||||||
|
background-color: var(--login-background-color);
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button:hover {
|
||||||
|
background-color: var(--login-background-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-text {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-secondary {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-secondary-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-page {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
border-right: none;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-primary {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button-secondary {
|
||||||
|
margin-top: 0px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-login-page-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-left: 0px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page-button {
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
761
frontend_nuxt/pages/message.vue
Normal file
761
frontend_nuxt/pages/message.vue
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message-page">
|
||||||
|
<div class="message-page-header">
|
||||||
|
<div class="message-tabs">
|
||||||
|
<div :class="['message-tab-item', { selected: selectedTab === 'all' }]" @click="selectedTab = 'all'">消息</div>
|
||||||
|
<div :class="['message-tab-item', { selected: selectedTab === 'unread' }]" @click="selectedTab = 'unread'">未读
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-page-header-right">
|
||||||
|
<div class="message-page-header-right-item" @click="markAllRead">
|
||||||
|
<i class="fas fa-bolt message-page-header-right-item-button-icon"></i>
|
||||||
|
<span class="message-page-header-right-item-button-text">
|
||||||
|
已读所有消息
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoadingMessage" class="loading-message">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BasePlaceholder v-else-if="filteredNotifications.length === 0" text="暂时没有消息 :)" icon="fas fa-inbox" />
|
||||||
|
|
||||||
|
<div class="timeline-container" v-if="filteredNotifications.length > 0">
|
||||||
|
<BaseTimeline :items="filteredNotifications">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div class="notif-content" :class="{ read: item.read }">
|
||||||
|
<span v-if="!item.read" class="unread-dot"></span>
|
||||||
|
<span class="notif-type">
|
||||||
|
<template v-if="item.type === 'COMMENT_REPLY' && item.parentComment">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.comment.author.id}`">{{ item.comment.author.username }} </router-link> 对我的评论
|
||||||
|
<span>
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`">
|
||||||
|
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</span> 回复了 <span>
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'COMMENT_REPLY' && !item.parentComment">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.comment.author.id}`">{{ item.comment.author.username }} </router-link> 对我的文章
|
||||||
|
<span>
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</span> 回复了 <span>
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'ACTIVITY_REDEEM' && !item.parentComment">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<span class="notif-user">{{ item.fromUser.username }} </span> 申请进行奶茶兑换,联系方式是:{{ item.content }}
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
||||||
|
<span>
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
进行了表态
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'REACTION' && item.comment">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`">{{ item.fromUser.username }} </router-link> 对我的评论
|
||||||
|
<span>
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
进行了表态
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_VIEWED'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
查看了您的帖子
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
您关注的帖子
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
下面有新评论
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'USER_ACTIVITY' && item.parentComment">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
你关注的
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.comment.author.id}`">
|
||||||
|
{{ item.comment.author.username }}
|
||||||
|
</router-link>
|
||||||
|
在 对评论
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`">
|
||||||
|
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||||
|
</router-link>
|
||||||
|
回复了
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'USER_ACTIVITY'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
你关注的
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.comment.author.id}`">
|
||||||
|
{{ item.comment.author.username }}
|
||||||
|
</router-link>
|
||||||
|
在文章
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
下面评论了
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'MENTION' && item.comment">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
在评论中提到了你:
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'MENTION'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
在帖子
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
中提到了你
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'USER_FOLLOWED'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
开始关注你了
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'USER_UNFOLLOWED'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
取消关注你了
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'FOLLOWED_POST'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
你关注的
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
发布了文章
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_SUBSCRIBED'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
订阅了你的文章
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_UNSUBSCRIBED'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
取消订阅了你的文章
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_REVIEW_REQUEST' && item.fromUser">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
发布了帖子
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
,请审核
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_REVIEW_REQUEST'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
您发布的帖子
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
已提交审核
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'REGISTER_REQUEST'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
{{ item.fromUser.username }} 希望注册为会员,理由是:{{ item.content }}
|
||||||
|
<template #actions v-if="authState.role === 'ADMIN'">
|
||||||
|
<div v-if="!item.read" class="optional-buttons">
|
||||||
|
<div class="mark-approve-button-item" @click="approve(item.fromUser.id, item.id)">同意</div>
|
||||||
|
<div class="mark-reject-button-item" @click="reject(item.fromUser.id, item.id)">拒绝</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="has_read_button" @click="markRead(item.id)">已读</div>
|
||||||
|
</template>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
您发布的帖子
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
已审核通过
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved === false">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
您发布的帖子
|
||||||
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
已被管理员拒绝
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
{{ formatType(item.type) }}
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="notif-time">{{ TimeManager.format(item.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { API_BASE_URL } from '../main'
|
||||||
|
import BaseTimeline from '../components/BaseTimeline.vue'
|
||||||
|
import BasePlaceholder from '../components/BasePlaceholder.vue'
|
||||||
|
import NotificationContainer from '../components/NotificationContainer.vue'
|
||||||
|
import { getToken, authState } from '../utils/auth'
|
||||||
|
import { markNotificationsRead, fetchUnreadCount, notificationState } from '../utils/notification'
|
||||||
|
import { toast } from '../main'
|
||||||
|
import { stripMarkdownLength } from '../utils/markdown'
|
||||||
|
import TimeManager from '../utils/time'
|
||||||
|
import { reactionEmojiMap } from '../utils/reactions'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'MessagePageView',
|
||||||
|
components: { BaseTimeline, BasePlaceholder, NotificationContainer },
|
||||||
|
setup() {
|
||||||
|
const router = useRouter()
|
||||||
|
const notifications = ref([])
|
||||||
|
const isLoadingMessage = ref(false)
|
||||||
|
const selectedTab = ref('unread')
|
||||||
|
const filteredNotifications = computed(() =>
|
||||||
|
selectedTab.value === 'all'
|
||||||
|
? notifications.value
|
||||||
|
: notifications.value.filter(n => !n.read)
|
||||||
|
)
|
||||||
|
|
||||||
|
const markRead = async id => {
|
||||||
|
if (!id) return
|
||||||
|
const n = notifications.value.find(n => n.id === id)
|
||||||
|
if (!n || n.read) return
|
||||||
|
n.read = true
|
||||||
|
if (notificationState.unreadCount > 0) notificationState.unreadCount--
|
||||||
|
const ok = await markNotificationsRead([id])
|
||||||
|
if (!ok) {
|
||||||
|
n.read = false
|
||||||
|
notificationState.unreadCount++
|
||||||
|
} else {
|
||||||
|
fetchUnreadCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAllRead = async () => {
|
||||||
|
// 除了 REGISTER_REQUEST 类型消息
|
||||||
|
const idsToMark = notifications.value
|
||||||
|
.filter(n => n.type !== 'REGISTER_REQUEST' && !n.read)
|
||||||
|
.map(n => n.id)
|
||||||
|
if (idsToMark.length === 0) return
|
||||||
|
notifications.value.forEach(n => {
|
||||||
|
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
||||||
|
})
|
||||||
|
notificationState.unreadCount = notifications.value.filter(n => !n.read).length
|
||||||
|
const ok = await markNotificationsRead(idsToMark)
|
||||||
|
if (!ok) {
|
||||||
|
notifications.value.forEach(n => {
|
||||||
|
if (idsToMark.includes(n.id)) n.read = false
|
||||||
|
})
|
||||||
|
await fetchUnreadCount()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetchUnreadCount()
|
||||||
|
if (authState.role === 'ADMIN') {
|
||||||
|
toast.success('已读所有消息(注册请求除外)')
|
||||||
|
} else {
|
||||||
|
toast.success('已读所有消息')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
POST_VIEWED: 'fas fa-eye',
|
||||||
|
COMMENT_REPLY: 'fas fa-reply',
|
||||||
|
POST_REVIEWED: 'fas fa-shield-alt',
|
||||||
|
POST_REVIEW_REQUEST: 'fas fa-gavel',
|
||||||
|
POST_UPDATED: 'fas fa-comment-dots',
|
||||||
|
USER_ACTIVITY: 'fas fa-user',
|
||||||
|
FOLLOWED_POST: 'fas fa-feather-alt',
|
||||||
|
USER_FOLLOWED: 'fas fa-user-plus',
|
||||||
|
USER_UNFOLLOWED: 'fas fa-user-minus',
|
||||||
|
POST_SUBSCRIBED: 'fas fa-bookmark',
|
||||||
|
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||||
|
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||||
|
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||||
|
MENTION: 'fas fa-at'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoadingMessage.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
isLoadingMessage.value = false
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error('获取通知失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
for (const n of data) {
|
||||||
|
if (n.type === 'COMMENT_REPLY') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
src: n.comment.author.avatar,
|
||||||
|
iconClick: () => {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/users/${n.comment.author.id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'REACTION') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
emoji: reactionEmojiMap[n.reactionType],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.fromUser) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/users/${n.fromUser.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'POST_VIEWED') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
src: n.fromUser ? n.fromUser.avatar : null,
|
||||||
|
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.fromUser) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/users/${n.fromUser.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'POST_UPDATED') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
src: n.comment.author.avatar,
|
||||||
|
iconClick: () => {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/users/${n.comment.author.id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'USER_ACTIVITY') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
src: n.comment.author.avatar,
|
||||||
|
iconClick: () => {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/users/${n.comment.author.id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'MENTION') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.fromUser) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/users/${n.fromUser.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.fromUser) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/users/${n.fromUser.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'FOLLOWED_POST') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.post) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/posts/${n.post.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.post) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/posts/${n.post.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
src: n.fromUser ? n.fromUser.avatar : null,
|
||||||
|
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.post) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/posts/${n.post.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (n.type === 'REGISTER_REQUEST') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
iconClick: () => { }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const approve = async (id, nid) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
markRead(nid)
|
||||||
|
toast.success('已同意')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reject = async (id, nid) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
markRead(nid)
|
||||||
|
toast.success('已拒绝')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatType = t => {
|
||||||
|
switch (t) {
|
||||||
|
case 'POST_VIEWED':
|
||||||
|
return '帖子被查看'
|
||||||
|
case 'COMMENT_REPLY':
|
||||||
|
return '有人回复了你'
|
||||||
|
case 'REACTION':
|
||||||
|
return '有人点赞'
|
||||||
|
case 'POST_REVIEW_REQUEST':
|
||||||
|
return '帖子待审核'
|
||||||
|
case 'POST_REVIEWED':
|
||||||
|
return '帖子审核结果'
|
||||||
|
case 'POST_UPDATED':
|
||||||
|
return '关注的帖子有新评论'
|
||||||
|
case 'FOLLOWED_POST':
|
||||||
|
return '关注的用户发布了新文章'
|
||||||
|
case 'POST_SUBSCRIBED':
|
||||||
|
return '有人订阅了你的文章'
|
||||||
|
case 'POST_UNSUBSCRIBED':
|
||||||
|
return '有人取消订阅你的文章'
|
||||||
|
case 'USER_FOLLOWED':
|
||||||
|
return '有人关注了你'
|
||||||
|
case 'USER_UNFOLLOWED':
|
||||||
|
return '有人取消关注你'
|
||||||
|
case 'USER_ACTIVITY':
|
||||||
|
return '关注的用户有新动态'
|
||||||
|
case 'MENTION':
|
||||||
|
return '有人提到了你'
|
||||||
|
default:
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchNotifications)
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
formatType,
|
||||||
|
isLoadingMessage,
|
||||||
|
stripMarkdownLength,
|
||||||
|
markRead,
|
||||||
|
approve,
|
||||||
|
reject,
|
||||||
|
TimeManager,
|
||||||
|
selectedTab,
|
||||||
|
filteredNotifications,
|
||||||
|
markAllRead,
|
||||||
|
authState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-message {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-page {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-page-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 1px;
|
||||||
|
z-index: 200;
|
||||||
|
background-color: var(--background-color-blur);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-page-header-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-page-header-right-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--primary-color);
|
||||||
|
padding-right: 10px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-page-header-right-item-button-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-page-header-right-item-button-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-page-header-right-item-button-text:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
padding: 10px 20px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-content.read {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
top: 4px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-type {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-content-text {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-approve-button-item {
|
||||||
|
color: green;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-reject-button-item {
|
||||||
|
color: red;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-approve-button-item:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-reject-button-item:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has_read_button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-content-text:hover {
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-user {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tab-item {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tab-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.has_read_button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
387
frontend_nuxt/pages/new-post.vue
Normal file
387
frontend_nuxt/pages/new-post.vue
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
<template>
|
||||||
|
<div class="new-post-page">
|
||||||
|
<div class="new-post-form">
|
||||||
|
<input class="post-title-input" v-model="title" placeholder="标题" />
|
||||||
|
<div class="post-editor-container">
|
||||||
|
<PostEditor v-model="content" v-model:loading="isAiLoading" :disabled="!isLogin" />
|
||||||
|
<LoginOverlay v-if="!isLogin" />
|
||||||
|
</div>
|
||||||
|
<div class="post-options">
|
||||||
|
<div class="post-options-left">
|
||||||
|
<CategorySelect v-model="selectedCategory" />
|
||||||
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
|
</div>
|
||||||
|
<div class="post-options-right">
|
||||||
|
<div class="post-clear" @click="clearPost">
|
||||||
|
<i class="fa-solid fa-eraser"></i> 清空
|
||||||
|
</div>
|
||||||
|
<div class="ai-generate" @click="aiGenerate">
|
||||||
|
<i class="fa-solid fa-robot"></i>
|
||||||
|
md格式优化
|
||||||
|
</div>
|
||||||
|
<div class="post-draft" @click="saveDraft">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>
|
||||||
|
存草稿
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!isWaitingPosting"
|
||||||
|
class="post-submit"
|
||||||
|
:class="{ disabled: !isLogin }"
|
||||||
|
@click="submitPost"
|
||||||
|
>发布</div>
|
||||||
|
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 发布中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import PostEditor from '../components/PostEditor.vue'
|
||||||
|
import CategorySelect from '../components/CategorySelect.vue'
|
||||||
|
import TagSelect from '../components/TagSelect.vue'
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import { getToken, authState } from '../utils/auth'
|
||||||
|
import LoginOverlay from '../components/LoginOverlay.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'NewPostPageView',
|
||||||
|
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
|
||||||
|
setup() {
|
||||||
|
const title = ref('')
|
||||||
|
const content = ref('')
|
||||||
|
const selectedCategory = ref('')
|
||||||
|
const selectedTags = ref([])
|
||||||
|
const isWaitingPosting = ref(false)
|
||||||
|
const isAiLoading = ref(false)
|
||||||
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
|
|
||||||
|
const loadDraft = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok && res.status !== 204) {
|
||||||
|
const data = await res.json()
|
||||||
|
title.value = data.title || ''
|
||||||
|
content.value = data.content || ''
|
||||||
|
selectedCategory.value = data.categoryId || ''
|
||||||
|
selectedTags.value = data.tagIds || []
|
||||||
|
|
||||||
|
toast.success('草稿已加载')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDraft)
|
||||||
|
|
||||||
|
const clearPost = async () => {
|
||||||
|
title.value = ''
|
||||||
|
content.value = ''
|
||||||
|
selectedCategory.value = ''
|
||||||
|
selectedTags.value = []
|
||||||
|
|
||||||
|
// 删除草稿
|
||||||
|
const token = getToken()
|
||||||
|
if (token) {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('草稿已清空')
|
||||||
|
} else {
|
||||||
|
toast.error('云端草稿清空失败, 请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDraft = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tagIds = selectedTags.value.filter(t => typeof t === 'number')
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
categoryId: selectedCategory.value || null,
|
||||||
|
tagIds
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('草稿已保存')
|
||||||
|
} else {
|
||||||
|
toast.error('保存失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ensureTags = async (token) => {
|
||||||
|
for (let i = 0; i < selectedTags.value.length; i++) {
|
||||||
|
const t = selectedTags.value[i]
|
||||||
|
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
||||||
|
const name = t.slice(8)
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, description: '' })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
selectedTags.value[i] = data.id
|
||||||
|
// update local TagSelect options handled by component
|
||||||
|
} else {
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
data = null
|
||||||
|
}
|
||||||
|
toast.error((data && data.error) || '创建标签失败')
|
||||||
|
throw new Error('create tag failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiGenerate = async () => {
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容为空,无法优化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isAiLoading.value = true
|
||||||
|
try {
|
||||||
|
toast.info('AI 优化中...')
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: content.value })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
content.value = data.content || ''
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('今日AI优化次数已用尽')
|
||||||
|
} else {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
|
} finally {
|
||||||
|
isAiLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitPost = async () => {
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
toast.error('标题不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedCategory.value) {
|
||||||
|
toast.error('请选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length === 0) {
|
||||||
|
toast.error('请选择标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
await ensureTags(token)
|
||||||
|
isWaitingPosting.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
categoryId: selectedCategory.value,
|
||||||
|
tagIds: selectedTags.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
if (data.reward && data.reward > 0) {
|
||||||
|
toast.success(`发布成功,获得 ${data.reward} 经验值`)
|
||||||
|
} else {
|
||||||
|
toast.success('发布成功')
|
||||||
|
}
|
||||||
|
if (data.id) {
|
||||||
|
window.location.href = `/posts/${data.id}`
|
||||||
|
}
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('发布过于频繁,请稍后再试')
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '发布失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('发布失败')
|
||||||
|
} finally {
|
||||||
|
isWaitingPosting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.new-post-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
padding-right: 20px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-post-form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 42px;
|
||||||
|
width: 100%;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-draft {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-draft:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-generate {
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-generate:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.post-clear {
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-submit {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-submit.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-submit:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
.post-submit.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-submit-loading {
|
||||||
|
color: white;
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-options-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-options-right {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
row-gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-options {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.new-post-page {
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title-input {
|
||||||
|
font-size: 24px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-options {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1016
frontend_nuxt/pages/posts/[id].vue
Normal file
1016
frontend_nuxt/pages/posts/[id].vue
Normal file
File diff suppressed because it is too large
Load Diff
339
frontend_nuxt/pages/posts/[id]/edit.vue
Normal file
339
frontend_nuxt/pages/posts/[id]/edit.vue
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
<template>
|
||||||
|
<div class="new-post-page">
|
||||||
|
<div class="new-post-form">
|
||||||
|
<input class="post-title-input" v-model="title" placeholder="标题" />
|
||||||
|
<div class="post-editor-container">
|
||||||
|
<PostEditor v-model="content" :loading="isAiLoading" :disabled="!isLogin" />
|
||||||
|
<LoginOverlay v-if="!isLogin" />
|
||||||
|
</div>
|
||||||
|
<div class="post-options">
|
||||||
|
<div class="post-options-left">
|
||||||
|
<CategorySelect v-model="selectedCategory" />
|
||||||
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
|
</div>
|
||||||
|
<div class="post-options-right">
|
||||||
|
<div class="post-clear" @click="clearPost">
|
||||||
|
<i class="fa-solid fa-eraser"></i> 清空
|
||||||
|
</div>
|
||||||
|
<div class="ai-generate" @click="aiGenerate">
|
||||||
|
<i class="fa-solid fa-robot"></i>
|
||||||
|
md格式优化
|
||||||
|
</div>
|
||||||
|
<div class="post-cancel" @click="cancelEdit">
|
||||||
|
取消
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!isWaitingPosting"
|
||||||
|
class="post-submit"
|
||||||
|
:class="{ disabled: !isLogin }"
|
||||||
|
@click="submitPost"
|
||||||
|
>更新</div>
|
||||||
|
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 更新中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import PostEditor from '../components/PostEditor.vue'
|
||||||
|
import CategorySelect from '../components/CategorySelect.vue'
|
||||||
|
import TagSelect from '../components/TagSelect.vue'
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import { getToken, authState } from '../utils/auth'
|
||||||
|
import LoginOverlay from '../components/LoginOverlay.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'EditPostPageView',
|
||||||
|
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
|
||||||
|
setup() {
|
||||||
|
const title = ref('')
|
||||||
|
const content = ref('')
|
||||||
|
const selectedCategory = ref('')
|
||||||
|
const selectedTags = ref([])
|
||||||
|
const isWaitingPosting = ref(false)
|
||||||
|
const isAiLoading = ref(false)
|
||||||
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const postId = route.params.id
|
||||||
|
|
||||||
|
const loadPost = async () => {
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
title.value = data.title || ''
|
||||||
|
content.value = data.content || ''
|
||||||
|
selectedCategory.value = data.category.id || ''
|
||||||
|
selectedTags.value = (data.tags || []).map(t => t.id)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('加载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPost)
|
||||||
|
|
||||||
|
const clearPost = () => {
|
||||||
|
title.value = ''
|
||||||
|
content.value = ''
|
||||||
|
selectedCategory.value = ''
|
||||||
|
selectedTags.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureTags = async (token) => {
|
||||||
|
for (let i = 0; i < selectedTags.value.length; i++) {
|
||||||
|
const t = selectedTags.value[i]
|
||||||
|
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
||||||
|
const name = t.slice(8)
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, description: '' })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
selectedTags.value[i] = data.id
|
||||||
|
// update local TagSelect options handled by component
|
||||||
|
} else {
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
data = null
|
||||||
|
}
|
||||||
|
toast.error((data && data.error) || '创建标签失败')
|
||||||
|
throw new Error('create tag failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiGenerate = async () => {
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容为空,无法优化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isAiLoading.value = true
|
||||||
|
try {
|
||||||
|
toast.info('AI 优化中...')
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: content.value })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
content.value = data.content || ''
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('今日AI优化次数已用尽')
|
||||||
|
} else {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
|
} finally {
|
||||||
|
isAiLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitPost = async () => {
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
toast.error('标题不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedCategory.value) {
|
||||||
|
toast.error('请选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length === 0) {
|
||||||
|
toast.error('请选择标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
await ensureTags(token)
|
||||||
|
isWaitingPosting.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
categoryId: selectedCategory.value,
|
||||||
|
tagIds: selectedTags.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('更新成功')
|
||||||
|
window.location.href = `/posts/${postId}`
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '更新失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('更新失败')
|
||||||
|
} finally {
|
||||||
|
isWaitingPosting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cancelEdit = () => {
|
||||||
|
router.push(`/posts/${postId}`)
|
||||||
|
}
|
||||||
|
return { title, content, selectedCategory, selectedTags, submitPost, clearPost, cancelEdit, isWaitingPosting, aiGenerate, isAiLoading, isLogin }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.new-post-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
height: 100%;
|
||||||
|
padding-right: 20px;
|
||||||
|
padding-left: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-post-form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 42px;
|
||||||
|
width: 100%;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-cancel {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-cancel:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-generate {
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-generate:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.post-clear {
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-submit {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-submit.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-submit:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
.post-submit.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-submit-loading {
|
||||||
|
color: white;
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-options-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-options-right {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
row-gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-options {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.new-post-page {
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title-input {
|
||||||
|
font-size: 24px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-options {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
374
frontend_nuxt/pages/settings.vue
Normal file
374
frontend_nuxt/pages/settings.vue
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-page">
|
||||||
|
<AvatarCropper
|
||||||
|
:src="tempAvatar"
|
||||||
|
:show="showCropper"
|
||||||
|
@close="showCropper = false"
|
||||||
|
@crop="onCropped"
|
||||||
|
/>
|
||||||
|
<div v-if="isLoadingPage" class="loading-page">
|
||||||
|
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="settings-title">个人资料设置</div>
|
||||||
|
<div class="profile-section">
|
||||||
|
|
||||||
|
<div class="avatar-row">
|
||||||
|
<!-- label 充当点击区域,内部隐藏 input -->
|
||||||
|
<label class="avatar-container">
|
||||||
|
<img :src="avatar" class="avatar-preview" alt="avatar" />
|
||||||
|
<!-- 半透明蒙层:hover 时出现 -->
|
||||||
|
<div class="avatar-overlay">更换头像</div>
|
||||||
|
<input type="file" class="avatar-input" accept="image/*" @change="onAvatarChange" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row username-row">
|
||||||
|
<BaseInput icon="fas fa-user" v-model="username" @input="usernameError = ''" placeholder="用户名" />
|
||||||
|
<div class="setting-description">用户名是你在社区的唯一标识</div>
|
||||||
|
<div v-if="usernameError" class="error-message">{{ usernameError }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row introduction-row">
|
||||||
|
<div class="setting-title">自我介绍</div>
|
||||||
|
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
||||||
|
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||||
|
<h3>管理员设置</h3>
|
||||||
|
<div class="form-row dropdown-row">
|
||||||
|
<div class="setting-title">发布规则</div>
|
||||||
|
<Dropdown v-model="publishMode" :fetch-options="fetchPublishModes" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row dropdown-row">
|
||||||
|
<div class="setting-title">密码强度</div>
|
||||||
|
<Dropdown v-model="passwordStrength" :fetch-options="fetchPasswordStrengths" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row dropdown-row">
|
||||||
|
<div class="setting-title">AI 优化次数</div>
|
||||||
|
<Dropdown v-model="aiFormatLimit" :fetch-options="fetchAiLimits" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row dropdown-row">
|
||||||
|
<div class="setting-title">注册模式</div>
|
||||||
|
<Dropdown v-model="registerMode" :fetch-options="fetchRegisterModes" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<div v-if="isSaving" class="save-button disabled">保存中...</div>
|
||||||
|
<div v-else @click="save" class="save-button">保存</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import { getToken, fetchCurrentUser, setToken } from '../utils/auth'
|
||||||
|
import BaseInput from '../components/BaseInput.vue'
|
||||||
|
import Dropdown from '../components/Dropdown.vue'
|
||||||
|
import AvatarCropper from '../components/AvatarCropper.vue'
|
||||||
|
export default {
|
||||||
|
name: 'SettingsPageView',
|
||||||
|
components: { BaseInput, Dropdown, AvatarCropper },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
introduction: '',
|
||||||
|
usernameError: '',
|
||||||
|
avatar: '',
|
||||||
|
avatarFile: null,
|
||||||
|
tempAvatar: '',
|
||||||
|
showCropper: false,
|
||||||
|
role: '',
|
||||||
|
publishMode: 'DIRECT',
|
||||||
|
passwordStrength: 'LOW',
|
||||||
|
aiFormatLimit: 3,
|
||||||
|
registerMode: 'DIRECT',
|
||||||
|
isLoadingPage: false,
|
||||||
|
isSaving: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.isLoadingPage = true
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
this.username = user.username
|
||||||
|
this.introduction = user.introduction || ''
|
||||||
|
this.avatar = user.avatar
|
||||||
|
this.role = user.role
|
||||||
|
if (this.role === 'ADMIN') {
|
||||||
|
this.loadAdminConfig()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('请先登录')
|
||||||
|
this.$router.push('/login')
|
||||||
|
}
|
||||||
|
this.isLoadingPage = false
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onAvatarChange(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
this.tempAvatar = reader.result
|
||||||
|
this.showCropper = true
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCropped({ file, url }) {
|
||||||
|
this.avatarFile = file
|
||||||
|
this.avatar = url
|
||||||
|
},
|
||||||
|
fetchPublishModes() {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
||||||
|
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
fetchPasswordStrengths() {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
|
||||||
|
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
|
||||||
|
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
fetchAiLimits() {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 3, name: '3次' },
|
||||||
|
{ id: 5, name: '5次' },
|
||||||
|
{ id: 10, name: '10次' },
|
||||||
|
{ id: -1, name: '无限' }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
fetchRegisterModes() {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
||||||
|
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
async loadAdminConfig() {
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
this.publishMode = data.publishMode
|
||||||
|
this.passwordStrength = data.passwordStrength
|
||||||
|
this.aiFormatLimit = data.aiFormatLimit
|
||||||
|
this.registerMode = data.registerMode
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.isSaving = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let token = getToken()
|
||||||
|
this.usernameError = ''
|
||||||
|
if (!this.username) {
|
||||||
|
this.usernameError = '用户名不能为空'
|
||||||
|
}
|
||||||
|
if (this.usernameError) {
|
||||||
|
toast.error(this.usernameError)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (this.avatarFile) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', this.avatarFile)
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
this.avatar = data.url
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '上传失败')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ username: this.username, introduction: this.introduction })
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(data.error || '保存失败')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (data.token) {
|
||||||
|
setToken(data.token)
|
||||||
|
token = data.token
|
||||||
|
}
|
||||||
|
if (this.role === 'ADMIN') {
|
||||||
|
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ publishMode: this.publishMode, passwordStrength: this.passwordStrength, aiFormatLimit: this.aiFormatLimit, registerMode: this.registerMode })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.success('保存成功')
|
||||||
|
} while (!this.isSaving)
|
||||||
|
|
||||||
|
this.isSaving = false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
padding: 40px;
|
||||||
|
height: calc(100% - 80px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-row {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction-row {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-row {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏默认文件选择按钮 */
|
||||||
|
.avatar-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 蒙层:初始透明,hover 时渐显 */
|
||||||
|
.avatar-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 40px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hover 触发 */
|
||||||
|
.avatar-container:hover .avatar-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: red;
|
||||||
|
font-size: 14px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
margin-top: -10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
margin-top: 40px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
142
frontend_nuxt/pages/signup-reason.vue
Normal file
142
frontend_nuxt/pages/signup-reason.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="reason-page">
|
||||||
|
<div class="reason-content">
|
||||||
|
<div class="reason-title">请填写注册理由</div>
|
||||||
|
<div class="reason-description">
|
||||||
|
为了我们社区的良性发展,请填写注册理由,我们将根据你的理由审核你的注册, 谢谢!
|
||||||
|
</div>
|
||||||
|
<div class="reason-input-container">
|
||||||
|
<BaseInput textarea rows="4" v-model="reason" placeholder="20个字以上"></BaseInput>
|
||||||
|
<div class="char-count">{{ reason.length }}/20</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
<div v-if="!isWaitingForRegister" class="signup-page-button-primary" @click="submit">提交</div>
|
||||||
|
<div v-else class="signup-page-button-primary disabled">提交中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import BaseInput from '../components/BaseInput.vue'
|
||||||
|
import {API_BASE_URL, toast} from '../main'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SignupReasonPageView',
|
||||||
|
components: {BaseInput},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
reason: '',
|
||||||
|
error: '',
|
||||||
|
isWaitingForRegister: false,
|
||||||
|
token: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.token = this.$route.query.token || ''
|
||||||
|
if (!this.token) {
|
||||||
|
this.$router.push('/signup')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async submit() {
|
||||||
|
if (!this.reason || this.reason.trim().length < 20) {
|
||||||
|
this.error = '请至少输入20个字'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isWaitingForRegister = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: this.token,
|
||||||
|
reason: this.reason
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.isWaitingForRegister = false
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('注册理由已提交,请等待审核')
|
||||||
|
this.$router.push('/')
|
||||||
|
} else if (data.reason_code === 'INVALID_CREDENTIALS') {
|
||||||
|
toast.error('登录已过期,请重新登录')
|
||||||
|
this.$router.push('/login')
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '提交失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.isWaitingForRegister = false
|
||||||
|
toast.error('提交失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reason-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-description {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
width: 100%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: red;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-primary:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-primary.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-primary.disabled:hover {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.reason-content {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
412
frontend_nuxt/pages/signup.vue
Normal file
412
frontend_nuxt/pages/signup.vue
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
<template>
|
||||||
|
<div class="signup-page">
|
||||||
|
<div class="signup-page-content">
|
||||||
|
<div class="signup-page-header">
|
||||||
|
<div class="signup-page-header-title">
|
||||||
|
Welcome :)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="emailStep === 0" class="email-signup-page-content">
|
||||||
|
<BaseInput
|
||||||
|
icon="fas fa-envelope"
|
||||||
|
v-model="email"
|
||||||
|
@input="emailError = ''"
|
||||||
|
placeholder="邮箱"
|
||||||
|
/>
|
||||||
|
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||||
|
|
||||||
|
<BaseInput
|
||||||
|
icon="fas fa-user"
|
||||||
|
v-model="username"
|
||||||
|
@input="usernameError = ''"
|
||||||
|
placeholder="用户名"
|
||||||
|
/>
|
||||||
|
<div v-if="usernameError" class="error-message">{{ usernameError }}</div>
|
||||||
|
|
||||||
|
<BaseInput
|
||||||
|
icon="fas fa-lock"
|
||||||
|
v-model="password"
|
||||||
|
@input="passwordError = ''"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
/>
|
||||||
|
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="!isWaitingForEmailSent" class="signup-page-button-primary" @click="sendVerification">
|
||||||
|
<div class="signup-page-button-text">验证邮箱</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="signup-page-button-primary disabled">
|
||||||
|
<div class="signup-page-button-text">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
发送中...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signup-page-button-secondary">已经有账号? <a class="signup-page-button-secondary-link"
|
||||||
|
href="/login">登录</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="emailStep === 1" class="email-signup-page-content">
|
||||||
|
<BaseInput
|
||||||
|
icon="fas fa-envelope"
|
||||||
|
v-model="code"
|
||||||
|
placeholder="邮箱验证码"
|
||||||
|
/>
|
||||||
|
<div v-if="!isWaitingForEmailVerified" class="signup-page-button-primary" @click="verifyCode">
|
||||||
|
<div class="signup-page-button-text">注册</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="signup-page-button-primary disabled">
|
||||||
|
<div class="signup-page-button-text">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
验证中...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="other-signup-page-content">
|
||||||
|
<div class="signup-page-button" @click="googleAuthorize">
|
||||||
|
<img class="signup-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
|
||||||
|
<div class="signup-page-button-text">Google 注册</div>
|
||||||
|
</div>
|
||||||
|
<div class="signup-page-button" @click="signupWithGithub">
|
||||||
|
<img class="signup-page-button-icon" src="../assets/icons/github.svg" alt="GitHub Logo" />
|
||||||
|
<div class="signup-page-button-text">GitHub 注册</div>
|
||||||
|
</div>
|
||||||
|
<div class="signup-page-button" @click="signupWithDiscord">
|
||||||
|
<img class="signup-page-button-icon" src="../assets/icons/discord.svg" alt="Discord Logo" />
|
||||||
|
<div class="signup-page-button-text">Discord 注册</div>
|
||||||
|
</div>
|
||||||
|
<div class="signup-page-button" @click="signupWithTwitter">
|
||||||
|
<img class="signup-page-button-icon" src="../assets/icons/twitter.svg" alt="Twitter Logo" />
|
||||||
|
<div class="signup-page-button-text">Twitter 注册</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import { googleAuthorize } from '../utils/google'
|
||||||
|
import { githubAuthorize } from '../utils/github'
|
||||||
|
import { discordAuthorize } from '../utils/discord'
|
||||||
|
import { twitterAuthorize } from '../utils/twitter'
|
||||||
|
import BaseInput from '../components/BaseInput.vue'
|
||||||
|
export default {
|
||||||
|
name: 'SignupPageView',
|
||||||
|
components: { BaseInput },
|
||||||
|
setup() {
|
||||||
|
return { googleAuthorize }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
emailStep: 0,
|
||||||
|
email: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
registerMode: 'DIRECT',
|
||||||
|
emailError: '',
|
||||||
|
usernameError: '',
|
||||||
|
passwordError: '',
|
||||||
|
code: '',
|
||||||
|
isWaitingForEmailSent: false,
|
||||||
|
isWaitingForEmailVerified: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.username = this.$route.query.u || ''
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/config`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
this.registerMode = data.registerMode
|
||||||
|
}
|
||||||
|
} catch {/* ignore */}
|
||||||
|
if (this.$route.query.verify) {
|
||||||
|
this.emailStep = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clearErrors() {
|
||||||
|
this.emailError = ''
|
||||||
|
this.usernameError = ''
|
||||||
|
this.passwordError = ''
|
||||||
|
},
|
||||||
|
async sendVerification() {
|
||||||
|
this.clearErrors()
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(this.email)) {
|
||||||
|
this.emailError = '邮箱格式不正确'
|
||||||
|
}
|
||||||
|
if (!this.password || this.password.length < 6) {
|
||||||
|
this.passwordError = '密码至少6位'
|
||||||
|
}
|
||||||
|
if (!this.username) {
|
||||||
|
this.usernameError = '用户名不能为空'
|
||||||
|
}
|
||||||
|
if (this.emailError || this.passwordError || this.usernameError) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.isWaitingForEmailSent = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: this.username,
|
||||||
|
email: this.email,
|
||||||
|
password: this.password
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.isWaitingForEmailSent = false
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
this.emailStep = 1
|
||||||
|
toast.success('验证码已发送,请查看邮箱')
|
||||||
|
} else if (data.field) {
|
||||||
|
if (data.field === 'username') this.usernameError = data.error
|
||||||
|
if (data.field === 'email') this.emailError = data.error
|
||||||
|
if (data.field === 'password') this.passwordError = data.error
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '发送失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('发送失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async verifyCode() {
|
||||||
|
try {
|
||||||
|
this.isWaitingForEmailVerified = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: this.code,
|
||||||
|
username: this.username
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.isWaitingForEmailVerified = false
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
if (this.registerMode === 'WHITELIST') {
|
||||||
|
this.$router.push('/signup-reason?token=' + data.token)
|
||||||
|
} else {
|
||||||
|
toast.success('注册成功,请登录')
|
||||||
|
this.$router.push('/login')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '注册失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('注册失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
signupWithGithub() {
|
||||||
|
githubAuthorize()
|
||||||
|
},
|
||||||
|
signupWithDiscord() {
|
||||||
|
discordAuthorize()
|
||||||
|
},
|
||||||
|
signupWithTwitter() {
|
||||||
|
twitterAuthorize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.signup-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(40% - 120px);
|
||||||
|
border-right: 1px solid var(--normal-border-color);
|
||||||
|
padding-right: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-header-title {
|
||||||
|
font-family: 'Pacifico', 'Comic Sans MS', cursive, 'Roboto', sans-serif;
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-header {
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-signup-page-content {
|
||||||
|
margin-top: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-input-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-input-text {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-signup-page-content {
|
||||||
|
margin-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30%;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-primary {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-primary.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-primary.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-primary:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: var(--login-background-color);
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 150px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button:hover {
|
||||||
|
background-color: var(--login-background-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-text {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-secondary {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-secondary-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: red;
|
||||||
|
font-size: 14px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
margin-top: -10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.signup-page {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-signup-page-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
border-right: none;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-primary {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button-secondary {
|
||||||
|
margin-top: 0px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-signup-page-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-left: 0px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-page-button {
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
26
frontend_nuxt/pages/twitter-callback.vue
Normal file
26
frontend_nuxt/pages/twitter-callback.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<CallbackPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CallbackPage from '../components/CallbackPage.vue'
|
||||||
|
import { twitterExchange } from '../utils/twitter'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'TwitterCallbackPageView',
|
||||||
|
components: { CallbackPage },
|
||||||
|
async mounted() {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
const state = url.searchParams.get('state')
|
||||||
|
const result = await twitterExchange(code, state, '')
|
||||||
|
|
||||||
|
if (result.needReason) {
|
||||||
|
this.$router.push('/signup-reason?token=' + result.token)
|
||||||
|
} else {
|
||||||
|
this.$router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
817
frontend_nuxt/pages/users/[id].vue
Normal file
817
frontend_nuxt/pages/users/[id].vue
Normal file
@@ -0,0 +1,817 @@
|
|||||||
|
<template>
|
||||||
|
<div class="profile-page">
|
||||||
|
<div v-if="isLoading" class="loading-page">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="profile-page-header">
|
||||||
|
<div class="profile-page-header-avatar">
|
||||||
|
<img :src="user.avatar" alt="avatar" class="profile-page-header-avatar-img" />
|
||||||
|
</div>
|
||||||
|
<div class="profile-page-header-user-info">
|
||||||
|
<div class="profile-page-header-user-info-name">{{ user.username }}</div>
|
||||||
|
<div class="profile-page-header-user-info-description">{{ user.introduction }}</div>
|
||||||
|
<div v-if="!isMine && !subscribed" class="profile-page-header-subscribe-button" @click="subscribeUser">
|
||||||
|
<i class="fas fa-user-plus"></i>
|
||||||
|
关注
|
||||||
|
</div>
|
||||||
|
<div v-if="!isMine && subscribed" class="profile-page-header-unsubscribe-button" @click="unsubscribeUser">
|
||||||
|
<i class="fas fa-user-minus"></i>
|
||||||
|
取消关注
|
||||||
|
</div>
|
||||||
|
<LevelProgress
|
||||||
|
:exp="levelInfo.exp"
|
||||||
|
:current-level="levelInfo.currentLevel"
|
||||||
|
:next-exp="levelInfo.nextExp"
|
||||||
|
/>
|
||||||
|
<div class="profile-level-target">
|
||||||
|
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||||
|
<i
|
||||||
|
class="fas fa-info-circle profile-exp-info"
|
||||||
|
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-info">
|
||||||
|
<div class="profile-info-item">
|
||||||
|
<div class="profile-info-item-label">加入时间:</div>
|
||||||
|
<div class="profile-info-item-value">{{ formatDate(user.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-info-item">
|
||||||
|
<div class="profile-info-item-label">最后发帖时间:</div>
|
||||||
|
<div class="profile-info-item-value">{{ formatDate(user.lastPostTime) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-info-item">
|
||||||
|
<div class="profile-info-item-label">最后评论时间:</div>
|
||||||
|
<div class="profile-info-item-value">{{ user.lastCommentTime!=null?formatDate(user.lastCommentTime):"暂无评论" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-info-item">
|
||||||
|
<div class="profile-info-item-label">浏览量:</div>
|
||||||
|
<div class="profile-info-item-value">{{ user.totalViews }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-tabs">
|
||||||
|
<div :class="['profile-tabs-item', { selected: selectedTab === 'summary' }]" @click="selectedTab = 'summary'">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
<div class="profile-tabs-item-label">总结</div>
|
||||||
|
</div>
|
||||||
|
<div :class="['profile-tabs-item', { selected: selectedTab === 'timeline' }]" @click="selectedTab = 'timeline'">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<div class="profile-tabs-item-label">时间线</div>
|
||||||
|
</div>
|
||||||
|
<div :class="['profile-tabs-item', { selected: selectedTab === 'following' }]"
|
||||||
|
@click="selectedTab = 'following'">
|
||||||
|
<i class="fas fa-user-plus"></i>
|
||||||
|
<div class="profile-tabs-item-label">关注</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tabLoading" class="tab-loading">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="selectedTab === 'summary'" class="profile-summary">
|
||||||
|
<div class="total-summary">
|
||||||
|
<div class="summary-title">统计信息</div>
|
||||||
|
<div class="total-summary-content">
|
||||||
|
<div class="total-summary-item">
|
||||||
|
<div class="total-summary-item-label">访问天数</div>
|
||||||
|
<div class="total-summary-item-value">{{ user.visitedDays }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="total-summary-item">
|
||||||
|
<div class="total-summary-item-label">已读帖子</div>
|
||||||
|
<div class="total-summary-item-value">{{ user.readPosts }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="total-summary-item">
|
||||||
|
<div class="total-summary-item-label">已送出的💗</div>
|
||||||
|
<div class="total-summary-item-value">{{ user.likesSent }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="total-summary-item">
|
||||||
|
<div class="total-summary-item-label">已收到的💗</div>
|
||||||
|
<div class="total-summary-item-value">{{ user.likesReceived }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider">
|
||||||
|
<div class="hot-reply">
|
||||||
|
<div class="summary-title">热门回复</div>
|
||||||
|
<div class="summary-content" v-if="hotReplies.length > 0">
|
||||||
|
<BaseTimeline :items="hotReplies">
|
||||||
|
<template #item="{ item }">
|
||||||
|
在
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||||
|
{{ item.comment.post.title }}
|
||||||
|
</router-link>
|
||||||
|
<template v-if="item.comment.parentComment">
|
||||||
|
下对
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||||
|
class="timeline-link">
|
||||||
|
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
回复了
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
下评论了
|
||||||
|
</template>
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||||
|
class="timeline-link">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
<div class="timeline-date">
|
||||||
|
{{ formatDate(item.comment.createdAt) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="summary-empty">暂无热门回复</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hot-topic">
|
||||||
|
<div class="summary-title">热门话题</div>
|
||||||
|
<div class="summary-content" v-if="hotPosts.length > 0">
|
||||||
|
<BaseTimeline :items="hotPosts">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||||
|
{{ item.post.title }}
|
||||||
|
</router-link>
|
||||||
|
<div class="timeline-snippet">
|
||||||
|
{{ stripMarkdown(item.post.snippet) }}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-date">
|
||||||
|
{{ formatDate(item.post.createdAt) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="summary-empty">暂无热门话题</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hot-tag">
|
||||||
|
<div class="summary-title">TA创建的tag</div>
|
||||||
|
<div class="summary-content" v-if="hotTags.length > 0">
|
||||||
|
<BaseTimeline :items="hotTags">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||||
|
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="timeline-snippet" v-if="item.tag.description">
|
||||||
|
{{ item.tag.description }}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-date">
|
||||||
|
{{ formatDate(item.tag.createdAt) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="summary-empty">暂无标签</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||||
|
<BasePlaceholder v-if="timelineItems.length === 0" text="暂无时间线" icon="fas fa-inbox" />
|
||||||
|
<BaseTimeline :items="timelineItems">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<template v-if="item.type === 'post'">
|
||||||
|
发布了文章
|
||||||
|
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||||
|
{{ item.post.title }}
|
||||||
|
</router-link>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'comment'">
|
||||||
|
在
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||||
|
{{ item.comment.post.title }}
|
||||||
|
</router-link>
|
||||||
|
下评论了
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`" class="timeline-link">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'reply'">
|
||||||
|
在
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||||
|
{{ item.comment.post.title }}
|
||||||
|
</router-link>
|
||||||
|
下对
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||||
|
class="timeline-link">
|
||||||
|
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
回复了
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`" class="timeline-link">
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'tag'">
|
||||||
|
创建了标签
|
||||||
|
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||||
|
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="timeline-snippet" v-if="item.tag.description">
|
||||||
|
{{ item.tag.description }}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="follow-container">
|
||||||
|
<div class="follow-tabs">
|
||||||
|
<div :class="['follow-tab-item', { selected: followTab === 'followers' }]"
|
||||||
|
@click="followTab = 'followers'">关注者
|
||||||
|
</div>
|
||||||
|
<div :class="['follow-tab-item', { selected: followTab === 'following' }]"
|
||||||
|
@click="followTab = 'following'">正在关注
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="follow-list">
|
||||||
|
<UserList v-if="followTab === 'followers'" :users="followers" />
|
||||||
|
<UserList v-else :users="followings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
import { getToken, authState } from '../utils/auth'
|
||||||
|
import BaseTimeline from '../components/BaseTimeline.vue'
|
||||||
|
import UserList from '../components/UserList.vue'
|
||||||
|
import BasePlaceholder from '../components/BasePlaceholder.vue'
|
||||||
|
import LevelProgress from '../components/LevelProgress.vue'
|
||||||
|
import { stripMarkdown, stripMarkdownLength } from '../utils/markdown'
|
||||||
|
import TimeManager from '../utils/time'
|
||||||
|
import { prevLevelExp } from '../utils/level'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ProfileView',
|
||||||
|
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress },
|
||||||
|
setup() {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const username = route.params.id
|
||||||
|
|
||||||
|
const user = ref({})
|
||||||
|
const hotPosts = ref([])
|
||||||
|
const hotReplies = ref([])
|
||||||
|
const hotTags = ref([])
|
||||||
|
const timelineItems = ref([])
|
||||||
|
const followers = ref([])
|
||||||
|
const followings = ref([])
|
||||||
|
const subscribed = ref(false)
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const tabLoading = ref(false)
|
||||||
|
const selectedTab = ref('summary')
|
||||||
|
const followTab = ref('followers')
|
||||||
|
|
||||||
|
const levelInfo = computed(() => {
|
||||||
|
const exp = user.value.experience || 0
|
||||||
|
const currentLevel = user.value.currentLevel || 0
|
||||||
|
const nextExp = user.value.nextLevelExp || 0
|
||||||
|
const prevExp = prevLevelExp(currentLevel)
|
||||||
|
const total = nextExp - prevExp
|
||||||
|
const ratio = total > 0 ? (exp - prevExp) / total : 1
|
||||||
|
const percent = Math.max(0, Math.min(1, ratio)) * 100
|
||||||
|
return { exp, currentLevel, nextExp, percent }
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMine = computed(() => authState.username === username)
|
||||||
|
|
||||||
|
const formatDate = (d) => {
|
||||||
|
if (!d) return ''
|
||||||
|
return TimeManager.format(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/${username}`, { headers })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
user.value = data
|
||||||
|
subscribed.value = !!data.subscribed
|
||||||
|
} else if (res.status === 404) {
|
||||||
|
router.replace('/404')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSummary = async () => {
|
||||||
|
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
|
||||||
|
if (postsRes.ok) {
|
||||||
|
const data = await postsRes.json()
|
||||||
|
hotPosts.value = data.map(p => ({ icon: 'fas fa-book', post: p }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
|
||||||
|
if (repliesRes.ok) {
|
||||||
|
const data = await repliesRes.json()
|
||||||
|
hotReplies.value = data.map(c => ({ icon: 'fas fa-comment', comment: c }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
|
||||||
|
if (tagsRes.ok) {
|
||||||
|
const data = await tagsRes.json()
|
||||||
|
hotTags.value = data.map(t => ({ icon: 'fas fa-tag', tag: t }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTimeline = async () => {
|
||||||
|
const [postsRes, repliesRes, tagsRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`),
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/tags?limit=50`)
|
||||||
|
])
|
||||||
|
const posts = postsRes.ok ? await postsRes.json() : []
|
||||||
|
const replies = repliesRes.ok ? await repliesRes.json() : []
|
||||||
|
const tags = tagsRes.ok ? await tagsRes.json() : []
|
||||||
|
const mapped = [
|
||||||
|
...posts.map(p => ({
|
||||||
|
type: 'post',
|
||||||
|
icon: 'fas fa-book',
|
||||||
|
post: p,
|
||||||
|
createdAt: p.createdAt
|
||||||
|
})),
|
||||||
|
...replies.map(r => ({
|
||||||
|
type: r.parentComment ? 'reply' : 'comment',
|
||||||
|
icon: 'fas fa-comment',
|
||||||
|
comment: r,
|
||||||
|
createdAt: r.createdAt
|
||||||
|
})),
|
||||||
|
...tags.map(t => ({
|
||||||
|
type: 'tag',
|
||||||
|
icon: 'fas fa-tag',
|
||||||
|
tag: t,
|
||||||
|
createdAt: t.createdAt
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
timelineItems.value = mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFollowUsers = async () => {
|
||||||
|
const [followerRes, followingRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
||||||
|
fetch(`${API_BASE_URL}/api/users/${username}/following`)
|
||||||
|
])
|
||||||
|
followers.value = followerRes.ok ? await followerRes.json() : []
|
||||||
|
followings.value = followingRes.ok ? await followingRes.json() : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchSummary()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTimeline = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchTimeline()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFollow = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchFollowUsers()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeUser = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
subscribed.value = true
|
||||||
|
toast.success('已关注')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribeUser = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
subscribed.value = false
|
||||||
|
toast.success('已取消关注')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gotoTag = tag => {
|
||||||
|
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||||
|
router.push({ path: '/', query: { tags: value } }).then(() => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
await fetchUser()
|
||||||
|
await loadSummary()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
|
||||||
|
watch(selectedTab, async val => {
|
||||||
|
if (val === 'timeline' && timelineItems.value.length === 0) {
|
||||||
|
await loadTimeline()
|
||||||
|
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||||
|
await loadFollow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
hotPosts,
|
||||||
|
hotReplies,
|
||||||
|
timelineItems,
|
||||||
|
followers,
|
||||||
|
followings,
|
||||||
|
subscribed,
|
||||||
|
isMine,
|
||||||
|
isLoading,
|
||||||
|
tabLoading,
|
||||||
|
selectedTab,
|
||||||
|
followTab,
|
||||||
|
formatDate,
|
||||||
|
stripMarkdown,
|
||||||
|
stripMarkdownLength,
|
||||||
|
loadTimeline,
|
||||||
|
loadFollow,
|
||||||
|
loadSummary,
|
||||||
|
subscribeUser,
|
||||||
|
unsubscribeUser,
|
||||||
|
gotoTag,
|
||||||
|
hotTags,
|
||||||
|
levelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-header-avatar-img {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-header-user-info {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-header-user-info-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-header-user-info-description {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-header-subscribe-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: white;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
margin-top: 15px;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-header-subscribe-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-header-unsubscribe-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
margin-top: 15px;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-level-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-level-current {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-level-bar {
|
||||||
|
width: 200px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--normal-background-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-level-bar-inner {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-level-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-level-exp,
|
||||||
|
.profile-level-target {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-exp-info {
|
||||||
|
margin-left: 4px;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 0 20px;
|
||||||
|
gap: 20px;
|
||||||
|
border-top: 1px solid var(--normal-border-color);
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
scrollbar-width: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-item-label {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-item-value {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tabs {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--header-height) + 1px);
|
||||||
|
z-index: 200;
|
||||||
|
background-color: var(--background-color-blur);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
scrollbar-width: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tabs-item {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
width: 200px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tabs-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-summary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-summary-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0px;
|
||||||
|
column-gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-summary-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-summary-item-label {
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-summary-item-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-divider {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 20px;
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-reply,
|
||||||
|
.hot-topic,
|
||||||
|
.hot-tag {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-timeline {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: gray;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-snippet {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-link {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-empty {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-content {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-container {}
|
||||||
|
|
||||||
|
.follow-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-tab-item {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-tab-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-list {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.profile-page {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-header-avatar-img {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tabs-item {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-divider {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-reply,
|
||||||
|
.hot-topic,
|
||||||
|
.hot-tag {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-timeline {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
frontend_nuxt/plugins/click-outside.ts
Normal file
5
frontend_nuxt/plugins/click-outside.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import ClickOutside from '~/directives/clickOutside.js'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.vueApp.directive('click-outside', ClickOutside)
|
||||||
|
})
|
||||||
12
frontend_nuxt/plugins/ldrs.client.ts
Normal file
12
frontend_nuxt/plugins/ldrs.client.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// plugins/ldrs.client.ts
|
||||||
|
import { defineNuxtPlugin } from '#app'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
// 动态引入,防止打包时把 ldrs 拉进 SSR bundle
|
||||||
|
const { hatch, helix, spiral } = await import('ldrs')
|
||||||
|
|
||||||
|
// 想用几个就注册几个
|
||||||
|
hatch.register()
|
||||||
|
helix.register()
|
||||||
|
spiral.register()
|
||||||
|
})
|
||||||
7
frontend_nuxt/router/index.js
Normal file
7
frontend_nuxt/router/index.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
push(path) {
|
||||||
|
if (process.client) {
|
||||||
|
window.location.href = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
frontend_nuxt/utils/auth.js
Normal file
105
frontend_nuxt/utils/auth.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { API_BASE_URL } from '~/main'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'token'
|
||||||
|
const USER_ID_KEY = 'userId'
|
||||||
|
const USERNAME_KEY = 'username'
|
||||||
|
const ROLE_KEY = 'role'
|
||||||
|
|
||||||
|
export const authState = reactive({
|
||||||
|
loggedIn: false,
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
role: null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
authState.loggedIn = localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
||||||
|
authState.userId = localStorage.getItem(USER_ID_KEY)
|
||||||
|
authState.username = localStorage.getItem(USERNAME_KEY)
|
||||||
|
authState.role = localStorage.getItem(ROLE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return process.client ? localStorage.getItem(TOKEN_KEY) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token) {
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
authState.loggedIn = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
clearUserInfo()
|
||||||
|
authState.loggedIn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserInfo({ id, username }) {
|
||||||
|
if (process.client) {
|
||||||
|
authState.userId = id
|
||||||
|
authState.username = username
|
||||||
|
if (arguments[0] && arguments[0].role) {
|
||||||
|
authState.role = arguments[0].role
|
||||||
|
localStorage.setItem(ROLE_KEY, arguments[0].role)
|
||||||
|
}
|
||||||
|
if (id !== undefined && id !== null) localStorage.setItem(USER_ID_KEY, id)
|
||||||
|
if (username) localStorage.setItem(USERNAME_KEY, username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearUserInfo() {
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.removeItem(USER_ID_KEY)
|
||||||
|
localStorage.removeItem(USERNAME_KEY)
|
||||||
|
localStorage.removeItem(ROLE_KEY)
|
||||||
|
authState.userId = null
|
||||||
|
authState.username = null
|
||||||
|
authState.role = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCurrentUser() {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (!res.ok) return null
|
||||||
|
return await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCurrentUser() {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
if (user) {
|
||||||
|
setUserInfo({ id: user.id, username: user.username, role: user.role })
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLogin() {
|
||||||
|
return authState.loggedIn
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkToken() {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return false
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
authState.loggedIn = res.ok
|
||||||
|
return res.ok
|
||||||
|
} catch (e) {
|
||||||
|
authState.loggedIn = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend_nuxt/utils/clearVditorStorage.js
Normal file
7
frontend_nuxt/utils/clearVditorStorage.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function clearVditorStorage() {
|
||||||
|
Object.keys(localStorage).forEach(key => {
|
||||||
|
if (key.startsWith('vditoreditor-') || key === 'vditor') {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
62
frontend_nuxt/utils/discord.js
Normal file
62
frontend_nuxt/utils/discord.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main'
|
||||||
|
import { WEBSITE_BASE_URL } from '../constants'
|
||||||
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
|
import { registerPush } from './push'
|
||||||
|
|
||||||
|
export function discordAuthorize(state = '') {
|
||||||
|
if (!DISCORD_CLIENT_ID) {
|
||||||
|
toast.error('Discord 登录不可用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
||||||
|
const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}`
|
||||||
|
window.location.href = url
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discordExchange(code, state, reason) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, redirectUri: `${window.location.origin}/discord-callback`, reason, state })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
|
toast.success('登录成功')
|
||||||
|
registerPush()
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
needReason: false
|
||||||
|
}
|
||||||
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
needReason: true,
|
||||||
|
token: data.token
|
||||||
|
}
|
||||||
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
|
toast.info('您的注册理由正在审批中')
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
needReason: false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '登录失败')
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
needReason: false,
|
||||||
|
error: data.error || '登录失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('登录失败')
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
needReason: false,
|
||||||
|
error: '登录失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
frontend_nuxt/utils/github.js
Normal file
62
frontend_nuxt/utils/github.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { API_BASE_URL, GITHUB_CLIENT_ID, toast } from '../main'
|
||||||
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
|
import { WEBSITE_BASE_URL } from '../constants'
|
||||||
|
import { registerPush } from './push'
|
||||||
|
|
||||||
|
export function githubAuthorize(state = '') {
|
||||||
|
if (!GITHUB_CLIENT_ID) {
|
||||||
|
toast.error('GitHub 登录不可用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
||||||
|
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}`
|
||||||
|
window.location.href = url
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function githubExchange(code, state, reason) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, redirectUri: `${window.location.origin}/github-callback`, reason, state })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
|
toast.success('登录成功')
|
||||||
|
registerPush()
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
needReason: false
|
||||||
|
}
|
||||||
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
needReason: true,
|
||||||
|
token: data.token
|
||||||
|
}
|
||||||
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
|
toast.info('您的注册理由正在审批中')
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
needReason: false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '登录失败')
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
needReason: false,
|
||||||
|
error: data.error || '登录失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('登录失败')
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
needReason: false,
|
||||||
|
error: '登录失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
frontend_nuxt/utils/google.js
Normal file
79
frontend_nuxt/utils/google.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main'
|
||||||
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
|
import { registerPush } from './push'
|
||||||
|
import { WEBSITE_BASE_URL } from '../constants'
|
||||||
|
|
||||||
|
export async function googleGetIdToken() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!window.google || !GOOGLE_CLIENT_ID) {
|
||||||
|
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||||
|
reject()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.google.accounts.id.initialize({
|
||||||
|
client_id: GOOGLE_CLIENT_ID,
|
||||||
|
callback: ({ credential }) => resolve(credential),
|
||||||
|
use_fedcm: true
|
||||||
|
})
|
||||||
|
window.google.accounts.id.prompt()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function googleAuthorize() {
|
||||||
|
if (!GOOGLE_CLIENT_ID) {
|
||||||
|
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
||||||
|
const nonce = Math.random().toString(36).substring(2)
|
||||||
|
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=id_token&scope=openid%20email%20profile&nonce=${nonce}`
|
||||||
|
window.location.href = url
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ idToken })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
|
toast.success('登录成功')
|
||||||
|
registerPush()
|
||||||
|
if (redirect_success) redirect_success()
|
||||||
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
|
if (redirect_not_approved) redirect_not_approved(data.token)
|
||||||
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
|
toast.info('您的注册理由正在审批中')
|
||||||
|
if (redirect_success) redirect_success()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('登录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function googleSignIn(redirect_success, redirect_not_approved) {
|
||||||
|
try {
|
||||||
|
const token = await googleGetIdToken()
|
||||||
|
await googleAuthWithToken(token, redirect_success, redirect_not_approved)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import router from '../router'
|
||||||
|
|
||||||
|
export function loginWithGoogle() {
|
||||||
|
googleSignIn(
|
||||||
|
() => {
|
||||||
|
router.push('/')
|
||||||
|
},
|
||||||
|
token => {
|
||||||
|
router.push('/signup-reason?token=' + token)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
7
frontend_nuxt/utils/level.js
Normal file
7
frontend_nuxt/utils/level.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const LEVEL_EXP = [100, 200, 300, 600, 1200, 10000]
|
||||||
|
|
||||||
|
export const prevLevelExp = level => {
|
||||||
|
if (level <= 0) return 0
|
||||||
|
if (level - 1 < LEVEL_EXP.length) return LEVEL_EXP[level - 1]
|
||||||
|
return LEVEL_EXP[LEVEL_EXP.length - 1]
|
||||||
|
}
|
||||||
38
frontend_nuxt/utils/loadMore.js
Normal file
38
frontend_nuxt/utils/loadMore.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ref, onMounted, onUnmounted, onActivated, nextTick } from 'vue'
|
||||||
|
|
||||||
|
export function useScrollLoadMore(loadMore, offset = 50) {
|
||||||
|
const savedScrollTop = ref(0)
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!process.client) return
|
||||||
|
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
||||||
|
const scrollHeight = document.documentElement.scrollHeight
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
savedScrollTop.value = scrollTop
|
||||||
|
if (scrollHeight - (scrollTop + windowHeight) <= offset) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (process.client) {
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (process.client) {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
if (process.client) {
|
||||||
|
nextTick(() => {
|
||||||
|
window.scrollTo({ top: savedScrollTop.value })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { savedScrollTop }
|
||||||
|
}
|
||||||
101
frontend_nuxt/utils/markdown.js
Normal file
101
frontend_nuxt/utils/markdown.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
import 'highlight.js/styles/github.css'
|
||||||
|
import { toast } from '../main'
|
||||||
|
import { tiebaEmoji } from './tiebaEmoji'
|
||||||
|
|
||||||
|
function mentionPlugin(md) {
|
||||||
|
const mentionReg = /^@\[([^\]]+)\]/
|
||||||
|
function mention(state, silent) {
|
||||||
|
const pos = state.pos
|
||||||
|
if (state.src.charCodeAt(pos) !== 0x40) return false
|
||||||
|
const match = mentionReg.exec(state.src.slice(pos))
|
||||||
|
if (!match) return false
|
||||||
|
if (!silent) {
|
||||||
|
const tokenOpen = state.push('link_open', 'a', 1)
|
||||||
|
tokenOpen.attrs = [
|
||||||
|
['href', `/users/${match[1]}`],
|
||||||
|
['target', '_blank'],
|
||||||
|
['class', 'mention-link']
|
||||||
|
]
|
||||||
|
const text = state.push('text', '', 0)
|
||||||
|
text.content = `@${match[1]}`
|
||||||
|
state.push('link_close', 'a', -1)
|
||||||
|
}
|
||||||
|
state.pos += match[0].length
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
md.inline.ruler.before('emphasis', 'mention', mention)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tiebaEmojiPlugin(md) {
|
||||||
|
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
|
||||||
|
const name = tokens[idx].content
|
||||||
|
const file = tiebaEmoji[name]
|
||||||
|
return `<img class="emoji" src="${file}" alt="${name}">`
|
||||||
|
}
|
||||||
|
md.inline.ruler.before('emphasis', 'tieba-emoji', (state, silent) => {
|
||||||
|
const pos = state.pos
|
||||||
|
if (state.src.charCodeAt(pos) !== 0x3a) return false
|
||||||
|
const match = state.src.slice(pos).match(/^:tieba(\d+):/)
|
||||||
|
if (!match) return false
|
||||||
|
const key = `tieba${match[1]}`
|
||||||
|
if (!tiebaEmoji[key]) return false
|
||||||
|
if (!silent) {
|
||||||
|
const token = state.push('tieba-emoji', '', 0)
|
||||||
|
token.content = key
|
||||||
|
}
|
||||||
|
state.pos += match[0].length
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: false,
|
||||||
|
linkify: true,
|
||||||
|
breaks: true,
|
||||||
|
highlight: (str, lang) => {
|
||||||
|
let code = ''
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
code = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
|
||||||
|
} else {
|
||||||
|
code = hljs.highlightAuto(str).value
|
||||||
|
}
|
||||||
|
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
md.use(mentionPlugin)
|
||||||
|
md.use(tiebaEmojiPlugin)
|
||||||
|
|
||||||
|
export function renderMarkdown(text) {
|
||||||
|
return md.render(text || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleMarkdownClick(e) {
|
||||||
|
if (e.target.classList.contains('copy-code-btn')) {
|
||||||
|
const pre = e.target.closest('pre')
|
||||||
|
const codeEl = pre && pre.querySelector('code')
|
||||||
|
if (codeEl) {
|
||||||
|
navigator.clipboard.writeText(codeEl.innerText).then(() => {
|
||||||
|
toast.success('已复制')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripMarkdown(text) {
|
||||||
|
const html = md.render(text || '')
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.innerHTML = html
|
||||||
|
return el.textContent || el.innerText || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripMarkdownLength(text, length) {
|
||||||
|
const plain = stripMarkdown(text)
|
||||||
|
if (!length || plain.length <= length) {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
// 截断并加省略号
|
||||||
|
return plain.slice(0, length) + '...'
|
||||||
|
}
|
||||||
48
frontend_nuxt/utils/notification.js
Normal file
48
frontend_nuxt/utils/notification.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { API_BASE_URL } from '~/main'
|
||||||
|
import { getToken } from './auth'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
export const notificationState = reactive({
|
||||||
|
unreadCount: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function fetchUnreadCount() {
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
notificationState.unreadCount = 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/notifications/unread-count`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
notificationState.unreadCount = 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
notificationState.unreadCount = data.count
|
||||||
|
return data.count
|
||||||
|
} catch (e) {
|
||||||
|
notificationState.unreadCount = 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationsRead(ids) {
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token || !ids || ids.length === 0) return false
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids })
|
||||||
|
})
|
||||||
|
return res.ok
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
48
frontend_nuxt/utils/push.js
Normal file
48
frontend_nuxt/utils/push.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { API_BASE_URL } from '../main'
|
||||||
|
import { getToken } from './auth'
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = '='.repeat((4 - base64String.length % 4) % 4)
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const rawData = atob(base64)
|
||||||
|
const outputArray = new Uint8Array(rawData.length)
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return outputArray
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ''
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b)
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerPush() {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.register('/notifications-sw.js')
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/push/public-key`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const { key } = await res.json()
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(key)
|
||||||
|
})
|
||||||
|
await fetch(`${API_BASE_URL}/api/push/subscribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${getToken()}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
p256dh: arrayBufferToBase64(sub.getKey('p256dh')),
|
||||||
|
auth: arrayBufferToBase64(sub.getKey('auth'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend_nuxt/utils/reactions.js
Normal file
25
frontend_nuxt/utils/reactions.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export const reactionEmojiMap = {
|
||||||
|
LIKE: '❤️',
|
||||||
|
DISLIKE: '👎',
|
||||||
|
RECOMMEND: '👏',
|
||||||
|
ANGRY: '😡',
|
||||||
|
FLUSHED: '😳',
|
||||||
|
STAR_STRUCK: '🤩',
|
||||||
|
ROFL: '🤣',
|
||||||
|
HOLDING_BACK_TEARS: '🥹',
|
||||||
|
MIND_BLOWN: '🤯',
|
||||||
|
POOP: '💩',
|
||||||
|
CLOWN: '🤡',
|
||||||
|
SKULL: '☠️',
|
||||||
|
FIRE: '🔥',
|
||||||
|
EYES: '👀',
|
||||||
|
FROWN: '☹️',
|
||||||
|
HOT: '🥵',
|
||||||
|
EAGLE: '🦅',
|
||||||
|
SPIDER: '🕷️',
|
||||||
|
BAT: '🦇',
|
||||||
|
CHINA: '🇨🇳',
|
||||||
|
USA: '🇺🇸',
|
||||||
|
JAPAN: '🇯🇵',
|
||||||
|
KOREA: '🇰🇷'
|
||||||
|
}
|
||||||
12
frontend_nuxt/utils/screen.js
Normal file
12
frontend_nuxt/utils/screen.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const width = ref(0)
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
width.value = window.innerWidth
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
width.value = window.innerWidth
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isMobile = computed(() => width.value <= 768)
|
||||||
64
frontend_nuxt/utils/theme.js
Normal file
64
frontend_nuxt/utils/theme.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import { toast } from '~/main'
|
||||||
|
|
||||||
|
export const ThemeMode = {
|
||||||
|
SYSTEM: 'system',
|
||||||
|
LIGHT: 'light',
|
||||||
|
DARK: 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_KEY = 'theme-mode'
|
||||||
|
|
||||||
|
export const themeState = reactive({
|
||||||
|
mode: ThemeMode.SYSTEM
|
||||||
|
})
|
||||||
|
|
||||||
|
function apply(mode) {
|
||||||
|
if (!process.client) return
|
||||||
|
const root = document.documentElement
|
||||||
|
if (mode === ThemeMode.SYSTEM) {
|
||||||
|
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
} else {
|
||||||
|
root.dataset.theme = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTheme() {
|
||||||
|
if (!process.client) return
|
||||||
|
const saved = localStorage.getItem(THEME_KEY)
|
||||||
|
if (saved && Object.values(ThemeMode).includes(saved)) {
|
||||||
|
themeState.mode = saved
|
||||||
|
}
|
||||||
|
apply(themeState.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(mode) {
|
||||||
|
if (!process.client) return
|
||||||
|
if (!Object.values(ThemeMode).includes(mode)) return
|
||||||
|
themeState.mode = mode
|
||||||
|
localStorage.setItem(THEME_KEY, mode)
|
||||||
|
apply(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cycleTheme() {
|
||||||
|
if (!process.client) return
|
||||||
|
const modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
|
||||||
|
const index = modes.indexOf(themeState.mode)
|
||||||
|
const next = modes[(index + 1) % modes.length]
|
||||||
|
if (next === ThemeMode.SYSTEM) {
|
||||||
|
toast.success('💻 已经切换到系统主题')
|
||||||
|
} else if (next === ThemeMode.LIGHT) {
|
||||||
|
toast.success('🌞 已经切换到明亮主题')
|
||||||
|
} else {
|
||||||
|
toast.success('🌙 已经切换到暗色主题')
|
||||||
|
}
|
||||||
|
setTheme(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.client && window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
if (themeState.mode === ThemeMode.SYSTEM) {
|
||||||
|
apply(ThemeMode.SYSTEM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
11
frontend_nuxt/utils/tiebaEmoji.js
Normal file
11
frontend_nuxt/utils/tiebaEmoji.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export const TIEBA_EMOJI_CDN = 'https://cdn.jsdelivr.net/gh/microlong666/tieba_mobile_emotions@master/'
|
||||||
|
// export const TIEBA_EMOJI_CDN = 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor/dist/images/emoji/'
|
||||||
|
|
||||||
|
export const tiebaEmoji = (() => {
|
||||||
|
const map = { tieba1: TIEBA_EMOJI_CDN + 'image_emoticon.png' }
|
||||||
|
for (let i = 2; i <= 124; i++) {
|
||||||
|
if (i > 50 && i < 62) continue
|
||||||
|
map[`tieba${i}`] = TIEBA_EMOJI_CDN + `image_emoticon${i}.png`
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})()
|
||||||
32
frontend_nuxt/utils/time.js
Normal file
32
frontend_nuxt/utils/time.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export default class TimeManager {
|
||||||
|
static format(input) {
|
||||||
|
const date = new Date(input)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)
|
||||||
|
|
||||||
|
const hh = date.getHours().toString().padStart(2, '0')
|
||||||
|
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||||
|
const timePart = `${hh}:${mm}`
|
||||||
|
|
||||||
|
if (diffDays === 0) return `今天 ${timePart}`
|
||||||
|
if (diffDays === 1) return `昨天 ${timePart}`
|
||||||
|
if (diffDays === 2) return `前天 ${timePart}`
|
||||||
|
|
||||||
|
const month = date.getMonth() + 1
|
||||||
|
const day = date.getDate()
|
||||||
|
|
||||||
|
if (date.getFullYear() === now.getFullYear()) {
|
||||||
|
return `${month}.${day} ${timePart}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date.getFullYear() === now.getFullYear() - 1) {
|
||||||
|
return `去年 ${month}.${day} ${timePart}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${date.getFullYear()}.${month}.${day} ${timePart}`
|
||||||
|
}
|
||||||
|
}
|
||||||
79
frontend_nuxt/utils/twitter.js
Normal file
79
frontend_nuxt/utils/twitter.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main'
|
||||||
|
import { WEBSITE_BASE_URL } from '../constants'
|
||||||
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
|
import { registerPush } from './push'
|
||||||
|
|
||||||
|
function generateCodeVerifier() {
|
||||||
|
const array = new Uint8Array(32)
|
||||||
|
window.crypto.getRandomValues(array)
|
||||||
|
return Array.from(array)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCodeChallenge(codeVerifier) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(codeVerifier)
|
||||||
|
const digest = await window.crypto.subtle.digest('SHA-256', data)
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function twitterAuthorize(state = '') {
|
||||||
|
if (!TWITTER_CLIENT_ID) {
|
||||||
|
toast.error('Twitter 登录不可用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state === '') {
|
||||||
|
state = Math.random().toString(36).substring(2, 15)
|
||||||
|
}
|
||||||
|
const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback`
|
||||||
|
const codeVerifier = generateCodeVerifier()
|
||||||
|
sessionStorage.setItem('twitter_code_verifier', codeVerifier)
|
||||||
|
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||||
|
const url =
|
||||||
|
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read` +
|
||||||
|
`&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`
|
||||||
|
window.location.href = url
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function twitterExchange(code, state, reason) {
|
||||||
|
try {
|
||||||
|
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
||||||
|
sessionStorage.removeItem('twitter_code_verifier')
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
code,
|
||||||
|
redirectUri: `${window.location.origin}/twitter-callback`,
|
||||||
|
reason,
|
||||||
|
state,
|
||||||
|
codeVerifier
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
|
toast.success('登录成功')
|
||||||
|
registerPush()
|
||||||
|
return { success: true, needReason: false }
|
||||||
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
|
return { success: false, needReason: true, token: data.token }
|
||||||
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
|
toast.info('您的注册理由正在审批中')
|
||||||
|
return { success: true, needReason: false }
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '登录失败')
|
||||||
|
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('登录失败')
|
||||||
|
return { success: false, needReason: false, error: '登录失败' }
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend_nuxt/utils/user.js
Normal file
30
frontend_nuxt/utils/user.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { API_BASE_URL } from '../main'
|
||||||
|
|
||||||
|
export async function fetchFollowings(username) {
|
||||||
|
if (!username) return []
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
|
||||||
|
return res.ok ? await res.json() : []
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdmins() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/admins`)
|
||||||
|
return res.ok ? await res.json() : []
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUsers(keyword) {
|
||||||
|
if (!keyword) return []
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`)
|
||||||
|
return res.ok ? await res.json() : []
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
176
frontend_nuxt/utils/vditor.js
Normal file
176
frontend_nuxt/utils/vditor.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import Vditor from 'vditor'
|
||||||
|
import 'vditor/dist/index.css'
|
||||||
|
import { API_BASE_URL } from '../main'
|
||||||
|
import { getToken, authState } from './auth'
|
||||||
|
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
|
||||||
|
import { tiebaEmoji } from './tiebaEmoji'
|
||||||
|
|
||||||
|
export function getEditorTheme() {
|
||||||
|
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreviewTheme() {
|
||||||
|
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVditor(editorId, options = {}) {
|
||||||
|
const {
|
||||||
|
placeholder = '',
|
||||||
|
preview = {},
|
||||||
|
input,
|
||||||
|
after
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const fetchMentions = async (value) => {
|
||||||
|
if (!value) {
|
||||||
|
const [followings, admins] = await Promise.all([
|
||||||
|
fetchFollowings(authState.username),
|
||||||
|
fetchAdmins()
|
||||||
|
])
|
||||||
|
const combined = [...followings, ...admins]
|
||||||
|
const seen = new Set()
|
||||||
|
return combined.filter(u => {
|
||||||
|
if (seen.has(u.id)) return false
|
||||||
|
seen.add(u.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return searchUsers(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobile = window.innerWidth <= 768
|
||||||
|
const toolbar = isMobile
|
||||||
|
? ['emoji', 'upload']
|
||||||
|
: [
|
||||||
|
'emoji',
|
||||||
|
'bold',
|
||||||
|
'italic',
|
||||||
|
'strike',
|
||||||
|
'|',
|
||||||
|
'list',
|
||||||
|
'line',
|
||||||
|
'quote',
|
||||||
|
'code',
|
||||||
|
'inline-code',
|
||||||
|
'|',
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
'|',
|
||||||
|
'link',
|
||||||
|
'upload'
|
||||||
|
]
|
||||||
|
|
||||||
|
let vditor
|
||||||
|
vditor = new Vditor(editorId, {
|
||||||
|
placeholder,
|
||||||
|
height: 'auto',
|
||||||
|
theme: getEditorTheme(),
|
||||||
|
preview: Object.assign({
|
||||||
|
theme: { current: getPreviewTheme() },
|
||||||
|
}, preview),
|
||||||
|
hint: {
|
||||||
|
emoji: tiebaEmoji,
|
||||||
|
extend: [
|
||||||
|
{
|
||||||
|
key: '@',
|
||||||
|
hint: async (key) => {
|
||||||
|
const list = await fetchMentions(key)
|
||||||
|
return list.map(u => ({
|
||||||
|
value: `@[${u.username}]`,
|
||||||
|
html: `<img src="${u.avatar}" /> @${u.username}`
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
|
||||||
|
toolbar,
|
||||||
|
upload: {
|
||||||
|
accept: 'image/*,video/*',
|
||||||
|
multiple: false,
|
||||||
|
handler: async (files) => {
|
||||||
|
const file = files[0]
|
||||||
|
vditor.tip('图片上传中', 0)
|
||||||
|
vditor.disabled()
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${getToken()}` } }
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
vditor.enable()
|
||||||
|
vditor.tip('获取上传地址失败')
|
||||||
|
return '获取上传地址失败'
|
||||||
|
}
|
||||||
|
const info = await res.json()
|
||||||
|
const put = await fetch(info.uploadUrl, { method: 'PUT', body: file })
|
||||||
|
if (!put.ok) {
|
||||||
|
vditor.enable()
|
||||||
|
vditor.tip('上传失败')
|
||||||
|
return '上传失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.name.split('.').pop().toLowerCase()
|
||||||
|
const imageExts = [
|
||||||
|
'apng',
|
||||||
|
'bmp',
|
||||||
|
'gif',
|
||||||
|
'ico',
|
||||||
|
'cur',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'jfif',
|
||||||
|
'pjp',
|
||||||
|
'pjpeg',
|
||||||
|
'png',
|
||||||
|
'svg',
|
||||||
|
'webp'
|
||||||
|
]
|
||||||
|
const audioExts = ['wav', 'mp3', 'ogg']
|
||||||
|
let md
|
||||||
|
if (imageExts.includes(ext)) {
|
||||||
|
md = ``
|
||||||
|
} else if (audioExts.includes(ext)) {
|
||||||
|
md = `<audio controls="controls" src="${info.fileUrl}"></audio>`
|
||||||
|
} else {
|
||||||
|
md = `[${file.name}](${info.fileUrl})`
|
||||||
|
}
|
||||||
|
vditor.insertValue(md + '\n')
|
||||||
|
vditor.enable()
|
||||||
|
vditor.tip('上传成功')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// upload: {
|
||||||
|
// fieldName: 'file',
|
||||||
|
// url: `${API_BASE_URL}/api/upload`,
|
||||||
|
// accept: 'image/*,video/*',
|
||||||
|
// multiple: false,
|
||||||
|
// headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
// format(files, responseText) {
|
||||||
|
// const res = JSON.parse(responseText)
|
||||||
|
// if (res.code === 0) {
|
||||||
|
// return JSON.stringify({
|
||||||
|
// code: 0,
|
||||||
|
// msg: '',
|
||||||
|
// data: {
|
||||||
|
// errFiles: [],
|
||||||
|
// succMap: { [files[0].name]: res.data.url }
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// } else {
|
||||||
|
// return JSON.stringify({
|
||||||
|
// code: 1,
|
||||||
|
// msg: '上传失败',
|
||||||
|
// data: { errFiles: files.map(f => f.name), succMap: {} }
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
toolbarConfig: { pin: true },
|
||||||
|
cache: { enable: false },
|
||||||
|
input,
|
||||||
|
after
|
||||||
|
})
|
||||||
|
|
||||||
|
return vditor
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user