mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-07-17 10:06:10 +00:00
fix: 全局格式化
This commit is contained in:
@@ -11,29 +11,28 @@ jobs:
|
|||||||
environment: Deploy
|
environment: Deploy
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# - uses: actions/setup-java@v4
|
# - uses: actions/setup-java@v4
|
||||||
# with:
|
# with:
|
||||||
# java-version: '17'
|
# java-version: '17'
|
||||||
# distribution: 'temurin'
|
# distribution: 'temurin'
|
||||||
|
|
||||||
# - run: mvn -B clean package -DskipTests
|
# - run: mvn -B clean package -DskipTests
|
||||||
|
|
||||||
# - uses: actions/setup-node@v4
|
# - uses: actions/setup-node@v4
|
||||||
# with:
|
# with:
|
||||||
# node-version: '20'
|
# node-version: '20'
|
||||||
|
|
||||||
# - run: |
|
# - run: |
|
||||||
# cd open-isle-cli
|
# cd open-isle-cli
|
||||||
# npm ci
|
# npm ci
|
||||||
# npm run build
|
# npm run build
|
||||||
|
|
||||||
- name: Deploy to Server
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: root
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
script: bash /opt/openisle/deploy.sh
|
|
||||||
|
|
||||||
|
- name: Deploy to Server
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
script: bash /opt/openisle/deploy.sh
|
||||||
|
|||||||
@@ -13,16 +13,19 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
|||||||
## 🚀 部署
|
## 🚀 部署
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
|
|
||||||
1. 确保安装 JDK 17 及 Maven
|
1. 确保安装 JDK 17 及 Maven
|
||||||
2. 信息配置修改 `src/main/resources/application.properties`,或通过环境变量设置数据库等参数
|
2. 信息配置修改 `src/main/resources/application.properties`,或通过环境变量设置数据库等参数
|
||||||
3. 执行 `mvn clean package` 生成包,之后使用 `java -jar target/openisle-0.0.1-SNAPSHOT.jar`启动,或在开发时直接使用 `mvn spring-boot:run`
|
3. 执行 `mvn clean package` 生成包,之后使用 `java -jar target/openisle-0.0.1-SNAPSHOT.jar`启动,或在开发时直接使用 `mvn spring-boot:run`
|
||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
|
|
||||||
1. `cd open-isle-cli`
|
1. `cd open-isle-cli`
|
||||||
2. 执行 `npm install`
|
2. 执行 `npm install`
|
||||||
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
|
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
|
||||||
|
|
||||||
## ✨ 项目特点
|
## ✨ 项目特点
|
||||||
|
|
||||||
- JWT 认证以及 Google、GitHub、Discord、Twitter 等多种 OAuth 登录
|
- JWT 认证以及 Google、GitHub、Discord、Twitter 等多种 OAuth 登录
|
||||||
- 支持分类、标签的贴文管理以及草稿保存功能
|
- 支持分类、标签的贴文管理以及草稿保存功能
|
||||||
- 嵌套评论、指定贴文或评论的点赞/抖弹系统
|
- 嵌套评论、指定贴文或评论的点赞/抖弹系统
|
||||||
@@ -35,6 +38,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
|||||||
- 浏览器推送通知,离开网站也能及时收到提醒
|
- 浏览器推送通知,离开网站也能及时收到提醒
|
||||||
|
|
||||||
## 🌟 项目优势
|
## 🌟 项目优势
|
||||||
|
|
||||||
- 全面开源,便于二次开发和自定义扩展
|
- 全面开源,便于二次开发和自定义扩展
|
||||||
- Spring Boot + Vue 3 成熟技术栈,学习起点低,社区资源丰富
|
- Spring Boot + Vue 3 成熟技术栈,学习起点低,社区资源丰富
|
||||||
- 支持多种登录方式和角色权限,容易展展到不同场景
|
- 支持多种登录方式和角色权限,容易展展到不同场景
|
||||||
@@ -52,6 +56,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
|||||||
本项目以 MIT License 发布,欢迎自由使用与修改。
|
本项目以 MIT License 发布,欢迎自由使用与修改。
|
||||||
|
|
||||||
## 🙏 鼓赞
|
## 🙏 鼓赞
|
||||||
|
|
||||||
- [Spring Boot](https://spring.io/projects/spring-boot)
|
- [Spring Boot](https://spring.io/projects/spring-boot)
|
||||||
- [JJWT](https://github.com/jwtk/jjwt)
|
- [JJWT](https://github.com/jwtk/jjwt)
|
||||||
- [Lombok](https://github.com/projectlombok/lombok)
|
- [Lombok](https://github.com/projectlombok/lombok)
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ body {
|
|||||||
margin: 1.2em 0;
|
margin: 1.2em 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
overflow-x: auto; /* 小屏可横向滚动 */
|
overflow-x: auto; /* 小屏可横向滚动 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text thead th {
|
.info-content-text thead th {
|
||||||
@@ -196,11 +196,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] .info-content-text thead th {
|
[data-theme='dark'] .info-content-text thead th {
|
||||||
background-color: var(--primary-color-hover); /* 暗色稍暗一点 */
|
background-color: var(--primary-color-hover); /* 暗色稍暗一点 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text tbody tr:nth-child(even) {
|
.info-content-text tbody tr:nth-child(even) {
|
||||||
background-color: rgba(208, 250, 255, 0.25); /* 斑马纹 */
|
background-color: rgba(208, 250, 255, 0.25); /* 斑马纹 */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] .info-content-text tbody tr:nth-child(even) {
|
[data-theme='dark'] .info-content-text tbody tr:nth-child(even) {
|
||||||
@@ -255,7 +255,6 @@ body {
|
|||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.vditor-toolbar--pin {
|
.vditor-toolbar--pin {
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
}
|
}
|
||||||
@@ -296,7 +295,9 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-shadow: 0 0 10px var(--primary-color), 0 0 5px var(--primary-color);
|
box-shadow:
|
||||||
|
0 0 10px var(--primary-color),
|
||||||
|
0 0 5px var(--primary-color);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: rotate(3deg) translate(0px, -4px);
|
transform: rotate(3deg) translate(0px, -4px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
<div
|
<div
|
||||||
v-for="medal in sortedMedals"
|
v-for="medal in sortedMedals"
|
||||||
:key="medal.type"
|
:key="medal.type"
|
||||||
:class="['achievements-list-item', { select: medal.selected && canSelect, clickable: canSelect }]"
|
:class="[
|
||||||
|
'achievements-list-item',
|
||||||
|
{ select: medal.selected && canSelect, clickable: canSelect },
|
||||||
|
]"
|
||||||
@click="selectMedal(medal)"
|
@click="selectMedal(medal)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -11,7 +14,9 @@
|
|||||||
:alt="medal.title"
|
:alt="medal.title"
|
||||||
:class="['achievements-list-item-icon', { not_completed: !medal.completed }]"
|
:class="['achievements-list-item-icon', { not_completed: !medal.completed }]"
|
||||||
/>
|
/>
|
||||||
<div v-if="medal.selected && canSelect" class="achievements-list-item-top-right-label">展示</div>
|
<div v-if="medal.selected && canSelect" class="achievements-list-item-top-right-label">
|
||||||
|
展示
|
||||||
|
</div>
|
||||||
<div class="achievements-list-item-title">{{ medal.title }}</div>
|
<div class="achievements-list-item-title">{{ medal.title }}</div>
|
||||||
<div class="achievements-list-item-description">
|
<div class="achievements-list-item-description">
|
||||||
{{ medal.description }}
|
{{ medal.description }}
|
||||||
@@ -38,12 +43,12 @@ const props = defineProps({
|
|||||||
medals: {
|
medals: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
default: () => []
|
default: () => [],
|
||||||
},
|
},
|
||||||
canSelect: {
|
canSelect: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedMedals = computed(() => {
|
const sortedMedals = computed(() => {
|
||||||
@@ -64,12 +69,14 @@ const selectMedal = async (medal) => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${getToken()}`
|
Authorization: `Bearer ${getToken()}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ type: medal.type })
|
body: JSON.stringify({ type: medal.type }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
props.medals.forEach(m => { m.selected = m.type === medal.type })
|
props.medals.forEach((m) => {
|
||||||
|
m.selected = m.type === medal.type
|
||||||
|
})
|
||||||
toast('展示勋章已更新')
|
toast('展示勋章已更新')
|
||||||
} else {
|
} else {
|
||||||
toast('选择勋章失败')
|
toast('选择勋章失败')
|
||||||
@@ -78,7 +85,6 @@ const selectMedal = async (medal) => {
|
|||||||
toast('选择勋章失败')
|
toast('选择勋章失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -158,6 +164,4 @@ const selectMedal = async (medal) => {
|
|||||||
min-width: calc(50% - 30px);
|
min-width: calc(50% - 30px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
icon: String,
|
icon: String,
|
||||||
text: String
|
text: String,
|
||||||
},
|
},
|
||||||
emits: ['close'],
|
emits: ['close'],
|
||||||
setup (props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const gotoActivity = () => {
|
const gotoActivity = () => {
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -32,7 +32,7 @@ export default {
|
|||||||
}
|
}
|
||||||
const close = () => emit('close')
|
const close = () => emit('close')
|
||||||
return { gotoActivity, close }
|
return { gotoActivity, close }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useRouter } from 'vue-router'
|
|||||||
export default {
|
export default {
|
||||||
name: 'ArticleCategory',
|
name: 'ArticleCategory',
|
||||||
props: {
|
props: {
|
||||||
category: { type: Object, default: null }
|
category: { type: Object, default: null },
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -30,7 +30,7 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { gotoCategory }
|
return { gotoCategory }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -23,30 +23,28 @@ import { useRouter } from 'vue-router'
|
|||||||
export default {
|
export default {
|
||||||
name: 'ArticleTags',
|
name: 'ArticleTags',
|
||||||
props: {
|
props: {
|
||||||
tags: { type: Array, default: () => [] }
|
tags: { type: Array, default: () => [] },
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const gotoTag = tag => {
|
const gotoTag = (tag) => {
|
||||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||||
router.push({ path: '/', query: { tags: value } }).then(() => {
|
router.push({ path: '/', query: { tags: value } }).then(() => {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { gotoTag }
|
return { gotoTag }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.article-tags-container {
|
.article-tags-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.article-info-item {
|
.article-info-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -74,6 +72,4 @@ export default {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
src: {
|
src: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
emits: ['close', 'crop'],
|
emits: ['close', 'crop'],
|
||||||
data() {
|
data() {
|
||||||
@@ -39,7 +39,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.destroy()
|
this.destroy()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.show) {
|
if (this.show) {
|
||||||
@@ -53,7 +53,7 @@ export default {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
viewMode: 1,
|
viewMode: 1,
|
||||||
autoCropArea: 1,
|
autoCropArea: 1,
|
||||||
responsive: true
|
responsive: true,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
@@ -64,15 +64,15 @@ export default {
|
|||||||
},
|
},
|
||||||
onConfirm() {
|
onConfirm() {
|
||||||
if (!this.cropper) return
|
if (!this.cropper) return
|
||||||
this.cropper.getCroppedCanvas({ width: 256, height: 256 }).toBlob(blob => {
|
this.cropper.getCroppedCanvas({ width: 256, height: 256 }).toBlob((blob) => {
|
||||||
const file = new File([blob], 'avatar.png', { type: 'image/png' })
|
const file = new File([blob], 'avatar.png', { type: 'image/png' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
this.$emit('crop', { file, url })
|
this.$emit('crop', { file, url })
|
||||||
this.$emit('close')
|
this.$emit('close')
|
||||||
this.destroy()
|
this.destroy()
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export default {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
opacity: 1.0;
|
opacity: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -139,4 +139,3 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: 'BaseInput',
|
name: 'BaseInput',
|
||||||
@@ -32,7 +31,7 @@ export default {
|
|||||||
modelValue: { type: [String, Number], default: '' },
|
modelValue: { type: [String, Number], default: '' },
|
||||||
icon: { type: String, default: '' },
|
icon: { type: String, default: '' },
|
||||||
type: { type: String, default: 'text' },
|
type: { type: String, default: 'text' },
|
||||||
textarea: { type: Boolean, default: false }
|
textarea: { type: Boolean, default: false },
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
computed: {
|
computed: {
|
||||||
@@ -42,9 +41,9 @@ export default {
|
|||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('update:modelValue', val)
|
this.$emit('update:modelValue', val)
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export default {
|
|||||||
name: 'BasePlaceholder',
|
name: 'BasePlaceholder',
|
||||||
props: {
|
props: {
|
||||||
text: { type: String, default: '' },
|
text: { type: String, default: '' },
|
||||||
icon: { type: String, default: 'fas fa-inbox' }
|
icon: { type: String, default: 'fas fa-inbox' },
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ export default {
|
|||||||
name: 'BasePopup',
|
name: 'BasePopup',
|
||||||
props: {
|
props: {
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
closeOnOverlay: { type: Boolean, default: true }
|
closeOnOverlay: { type: Boolean, default: true },
|
||||||
},
|
},
|
||||||
emits: ['close'],
|
emits: ['close'],
|
||||||
methods: {
|
methods: {
|
||||||
onOverlayClick () {
|
onOverlayClick() {
|
||||||
if (this.closeOnOverlay) this.$emit('close')
|
if (this.closeOnOverlay) this.$emit('close')
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
export default {
|
export default {
|
||||||
name: 'BaseTimeline',
|
name: 'BaseTimeline',
|
||||||
props: {
|
props: {
|
||||||
items: { type: Array, default: () => [] }
|
items: { type: Array, default: () => [] },
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -92,12 +92,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
.timeline-icon {
|
.timeline-icon {
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: 'CallbackPage'
|
name: 'CallbackPage',
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dropdown v-model="selected" :fetch-options="fetchCategories" placeholder="选择分类" :initial-options="providedOptions">
|
<Dropdown
|
||||||
|
v-model="selected"
|
||||||
|
:fetch-options="fetchCategories"
|
||||||
|
placeholder="选择分类"
|
||||||
|
:initial-options="providedOptions"
|
||||||
|
>
|
||||||
<template #option="{ option }">
|
<template #option="{ option }">
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
<div class="option-main">
|
<div class="option-main">
|
||||||
<template v-if="option.icon">
|
<template v-if="option.icon">
|
||||||
<img v-if="isImageIcon(option.icon)" :src="option.icon" class="option-icon" :alt="option.name" />
|
<img
|
||||||
|
v-if="isImageIcon(option.icon)"
|
||||||
|
:src="option.icon"
|
||||||
|
class="option-icon"
|
||||||
|
:alt="option.name"
|
||||||
|
/>
|
||||||
<i v-else :class="['option-icon', option.icon]"></i>
|
<i v-else :class="['option-icon', option.icon]"></i>
|
||||||
</template>
|
</template>
|
||||||
<span>{{ option.name }}</span>
|
<span>{{ option.name }}</span>
|
||||||
@@ -26,7 +36,7 @@ export default {
|
|||||||
components: { Dropdown },
|
components: { Dropdown },
|
||||||
props: {
|
props: {
|
||||||
modelValue: { type: [String, Number], default: '' },
|
modelValue: { type: [String, Number], default: '' },
|
||||||
options: { type: Array, default: () => [] }
|
options: { type: Array, default: () => [] },
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
@@ -34,9 +44,9 @@ export default {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.options,
|
() => props.options,
|
||||||
val => {
|
(val) => {
|
||||||
providedOptions.value = Array.isArray(val) ? [...val] : []
|
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = async () => {
|
||||||
@@ -46,18 +56,18 @@ export default {
|
|||||||
return [{ id: '', name: '无分类' }, ...data]
|
return [{ id: '', name: '无分类' }, ...data]
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImageIcon = icon => {
|
const isImageIcon = (icon) => {
|
||||||
if (!icon) return false
|
if (!icon) return false
|
||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = computed({
|
const selected = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: v => emit('update:modelValue', v)
|
set: (v) => emit('update:modelValue', v),
|
||||||
})
|
})
|
||||||
|
|
||||||
return { fetchCategories, selected, isImageIcon, providedOptions }
|
return { fetchCategories, selected, isImageIcon, providedOptions }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="comment-bottom-container">
|
<div class="comment-bottom-container">
|
||||||
<div class="comment-submit" :class="{ disabled: isDisabled }" @click="submit">
|
<div class="comment-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||||
<template v-if="!loading">
|
<template v-if="!loading"> 发布评论 </template>
|
||||||
发布评论
|
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发布中... </template>
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<i class="fa-solid fa-spinner fa-spin"></i> 发布中...
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,7 +19,7 @@ import { themeState } from '../utils/theme'
|
|||||||
import {
|
import {
|
||||||
createVditor,
|
createVditor,
|
||||||
getEditorTheme as getEditorThemeUtil,
|
getEditorTheme as getEditorThemeUtil,
|
||||||
getPreviewTheme as getPreviewThemeUtil
|
getPreviewTheme as getPreviewThemeUtil,
|
||||||
} from '../utils/vditor'
|
} from '../utils/vditor'
|
||||||
import LoginOverlay from './LoginOverlay.vue'
|
import LoginOverlay from './LoginOverlay.vue'
|
||||||
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
||||||
@@ -34,24 +30,24 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
editorId: {
|
editorId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: '',
|
||||||
},
|
},
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
},
|
},
|
||||||
showLoginOverlay: {
|
showLoginOverlay: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
},
|
},
|
||||||
parentUserName: {
|
parentUserName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: '',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
components: { LoginOverlay },
|
components: { LoginOverlay },
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
@@ -87,7 +83,7 @@ export default {
|
|||||||
placeholder: '说点什么...',
|
placeholder: '说点什么...',
|
||||||
preview: {
|
preview: {
|
||||||
actions: [],
|
actions: [],
|
||||||
markdown: { toc: false }
|
markdown: { toc: false },
|
||||||
},
|
},
|
||||||
input(value) {
|
input(value) {
|
||||||
text.value = value
|
text.value = value
|
||||||
@@ -97,7 +93,7 @@ export default {
|
|||||||
vditorInstance.value.disabled()
|
vditorInstance.value.disabled()
|
||||||
}
|
}
|
||||||
applyTheme()
|
applyTheme()
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
// applyTheme()
|
// applyTheme()
|
||||||
})
|
})
|
||||||
@@ -108,37 +104,37 @@ export default {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.loading,
|
() => props.loading,
|
||||||
val => {
|
(val) => {
|
||||||
if (!vditorInstance.value) return
|
if (!vditorInstance.value) return
|
||||||
if (val) {
|
if (val) {
|
||||||
vditorInstance.value.disabled()
|
vditorInstance.value.disabled()
|
||||||
} else if (!props.disabled) {
|
} else if (!props.disabled) {
|
||||||
vditorInstance.value.enable()
|
vditorInstance.value.enable()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.disabled,
|
() => props.disabled,
|
||||||
val => {
|
(val) => {
|
||||||
if (!vditorInstance.value) return
|
if (!vditorInstance.value) return
|
||||||
if (val) {
|
if (val) {
|
||||||
vditorInstance.value.disabled()
|
vditorInstance.value.disabled()
|
||||||
} else if (!props.loading) {
|
} else if (!props.loading) {
|
||||||
vditorInstance.value.enable()
|
vditorInstance.value.enable()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => themeState.mode,
|
() => themeState.mode,
|
||||||
() => {
|
() => {
|
||||||
applyTheme()
|
applyTheme()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return { submit, isDisabled, editorId }
|
return { submit, isDisabled, editorId }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="info-content-container" :id="'comment-' + comment.id" :style="{
|
<div
|
||||||
...(level > 0 ? { /*borderLeft: '1px solid #e0e0e0', */borderBottom: 'none' } : {})
|
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-container">
|
||||||
<div class="user-avatar-item">
|
<div class="user-avatar-item">
|
||||||
<img class="user-avatar-item-img" :src="comment.avatar" alt="avatar" />
|
<img class="user-avatar-item-img" :src="comment.avatar" alt="avatar" />
|
||||||
@@ -16,7 +20,8 @@
|
|||||||
v-if="comment.medal"
|
v-if="comment.medal"
|
||||||
class="medal-name"
|
class="medal-name"
|
||||||
:to="`/users/${comment.userId}?tab=achievements`"
|
:to="`/users/${comment.userId}?tab=achievements`"
|
||||||
>{{ getMedalTitle(comment.medal) }}</router-link>
|
>{{ getMedalTitle(comment.medal) }}</router-link
|
||||||
|
>
|
||||||
<span v-if="level >= 2">
|
<span v-if="level >= 2">
|
||||||
<i class="fas fa-reply reply-icon"></i>
|
<i class="fas fa-reply reply-icon"></i>
|
||||||
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
||||||
@@ -31,7 +36,11 @@
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-content-text" v-html="renderMarkdown(comment.text)" @click="handleContentClick"></div>
|
<div
|
||||||
|
class="info-content-text"
|
||||||
|
v-html="renderMarkdown(comment.text)"
|
||||||
|
@click="handleContentClick"
|
||||||
|
></div>
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
||||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
||||||
@@ -43,8 +52,14 @@
|
|||||||
</ReactionsGroup>
|
</ReactionsGroup>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-editor-wrapper" ref="editorWrapper">
|
<div class="comment-editor-wrapper" ref="editorWrapper">
|
||||||
<CommentEditor v-if="showEditor" @submit="submitReply" :loading="isWaitingForReply" :disabled="!loggedIn"
|
<CommentEditor
|
||||||
:show-login-overlay="!loggedIn" :parent-user-name="comment.userName" />
|
v-if="showEditor"
|
||||||
|
@submit="submitReply"
|
||||||
|
:loading="isWaitingForReply"
|
||||||
|
:disabled="!loggedIn"
|
||||||
|
:show-login-overlay="!loggedIn"
|
||||||
|
:parent-user-name="comment.userName"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="replyCount && level < 2" class="reply-toggle" @click="toggleReplies">
|
<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-if="showReplies" class="fas fa-chevron-up reply-toggle-icon"></i>
|
||||||
@@ -54,12 +69,21 @@
|
|||||||
<div v-if="showReplies && level < 2" class="reply-list">
|
<div v-if="showReplies && level < 2" class="reply-list">
|
||||||
<BaseTimeline :items="replyList">
|
<BaseTimeline :items="replyList">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<CommentItem :key="item.id" :comment="item" :level="level + 1" :default-show-replies="item.openReplies" />
|
<CommentItem
|
||||||
|
:key="item.id"
|
||||||
|
:comment="item"
|
||||||
|
:level="level + 1"
|
||||||
|
:default-show-replies="item.openReplies"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
<vue-easy-lightbox :visible="lightboxVisible" :imgs="lightboxImgs" :index="lightboxIndex"
|
<vue-easy-lightbox
|
||||||
@hide="lightboxVisible = false" />
|
:visible="lightboxVisible"
|
||||||
|
:imgs="lightboxImgs"
|
||||||
|
:index="lightboxIndex"
|
||||||
|
@hide="lightboxVisible = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -85,16 +109,16 @@ const CommentItem = {
|
|||||||
props: {
|
props: {
|
||||||
comment: {
|
comment: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
level: {
|
level: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0,
|
||||||
},
|
},
|
||||||
defaultShowReplies: {
|
defaultShowReplies: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -103,7 +127,7 @@ const CommentItem = {
|
|||||||
() => props.defaultShowReplies,
|
() => props.defaultShowReplies,
|
||||||
(val) => {
|
(val) => {
|
||||||
showReplies.value = props.level === 0 ? true : val
|
showReplies.value = props.level === 0 ? true : val
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
const showEditor = ref(false)
|
const showEditor = ref(false)
|
||||||
const editorWrapper = ref(null)
|
const editorWrapper = ref(null)
|
||||||
@@ -149,7 +173,9 @@ const CommentItem = {
|
|||||||
const isAuthor = computed(() => authState.username === props.comment.userName)
|
const isAuthor = computed(() => authState.username === props.comment.userName)
|
||||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||||
const commentMenuItems = computed(() =>
|
const commentMenuItems = computed(() =>
|
||||||
(isAuthor.value || isAdmin.value) ? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] : []
|
isAuthor.value || isAdmin.value
|
||||||
|
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }]
|
||||||
|
: [],
|
||||||
)
|
)
|
||||||
const deleteComment = async () => {
|
const deleteComment = async () => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -160,7 +186,7 @@ const CommentItem = {
|
|||||||
console.debug('Deleting comment', props.comment.id)
|
console.debug('Deleting comment', props.comment.id)
|
||||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
|
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
console.debug('Delete comment response status', res.status)
|
console.debug('Delete comment response status', res.status)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -184,7 +210,7 @@ const CommentItem = {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
|
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content: text }),
|
||||||
})
|
})
|
||||||
console.debug('Submit reply response status', res.status)
|
console.debug('Submit reply response status', res.status)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -200,7 +226,7 @@ const CommentItem = {
|
|||||||
text: data.content,
|
text: data.content,
|
||||||
parentUserName: parentUserName,
|
parentUserName: parentUserName,
|
||||||
reactions: [],
|
reactions: [],
|
||||||
reply: (data.replies || []).map(r => ({
|
reply: (data.replies || []).map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
userName: r.author.username,
|
userName: r.author.username,
|
||||||
time: TimeManager.format(r.createdAt),
|
time: TimeManager.format(r.createdAt),
|
||||||
@@ -210,11 +236,11 @@ const CommentItem = {
|
|||||||
reply: [],
|
reply: [],
|
||||||
openReplies: false,
|
openReplies: false,
|
||||||
src: r.author.avatar,
|
src: r.author.avatar,
|
||||||
iconClick: () => router.push(`/users/${r.author.id}`)
|
iconClick: () => router.push(`/users/${r.author.id}`),
|
||||||
})),
|
})),
|
||||||
openReplies: false,
|
openReplies: false,
|
||||||
src: data.author.avatar,
|
src: data.author.avatar,
|
||||||
iconClick: () => router.push(`/users/${data.author.id}`)
|
iconClick: () => router.push(`/users/${data.author.id}`),
|
||||||
})
|
})
|
||||||
clear()
|
clear()
|
||||||
showEditor.value = false
|
showEditor.value = false
|
||||||
@@ -237,21 +263,49 @@ const CommentItem = {
|
|||||||
toast.success('已复制')
|
toast.success('已复制')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const handleContentClick = e => {
|
const handleContentClick = (e) => {
|
||||||
handleMarkdownClick(e)
|
handleMarkdownClick(e)
|
||||||
if (e.target.tagName === 'IMG') {
|
if (e.target.tagName === 'IMG') {
|
||||||
const container = e.target.parentNode
|
const container = e.target.parentNode
|
||||||
const imgs = [...container.querySelectorAll('img')].map(i => i.src)
|
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
|
||||||
lightboxImgs.value = imgs
|
lightboxImgs.value = imgs
|
||||||
lightboxIndex.value = imgs.indexOf(e.target.src)
|
lightboxIndex.value = imgs.indexOf(e.target.src)
|
||||||
lightboxVisible.value = true
|
lightboxVisible.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { showReplies, toggleReplies, showEditor, toggleEditor, submitReply, copyCommentLink, renderMarkdown, isWaitingForReply, commentMenuItems, deleteComment, lightboxVisible, lightboxIndex, lightboxImgs, handleContentClick, loggedIn, replyCount, replyList, getMedalTitle, editorWrapper }
|
return {
|
||||||
}
|
showReplies,
|
||||||
|
toggleReplies,
|
||||||
|
showEditor,
|
||||||
|
toggleEditor,
|
||||||
|
submitReply,
|
||||||
|
copyCommentLink,
|
||||||
|
renderMarkdown,
|
||||||
|
isWaitingForReply,
|
||||||
|
commentMenuItems,
|
||||||
|
deleteComment,
|
||||||
|
lightboxVisible,
|
||||||
|
lightboxIndex,
|
||||||
|
lightboxImgs,
|
||||||
|
handleContentClick,
|
||||||
|
loggedIn,
|
||||||
|
replyCount,
|
||||||
|
replyList,
|
||||||
|
getMedalTitle,
|
||||||
|
editorWrapper,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
CommentItem.components = { CommentItem, CommentEditor, BaseTimeline, ReactionsGroup, DropdownMenu, VueEasyLightbox, LoginOverlay }
|
CommentItem.components = {
|
||||||
|
CommentItem,
|
||||||
|
CommentEditor,
|
||||||
|
BaseTimeline,
|
||||||
|
ReactionsGroup,
|
||||||
|
DropdownMenu,
|
||||||
|
VueEasyLightbox,
|
||||||
|
LoginOverlay,
|
||||||
|
}
|
||||||
|
|
||||||
export default CommentItem
|
export default CommentItem
|
||||||
</script>
|
</script>
|
||||||
@@ -263,7 +317,8 @@ export default CommentItem
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-list {}
|
.reply-list {
|
||||||
|
}
|
||||||
|
|
||||||
.comment-reaction {
|
.comment-reaction {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
|||||||
@@ -49,11 +49,7 @@
|
|||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
|
||||||
open &&
|
|
||||||
!isMobile &&
|
|
||||||
(loading || filteredOptions.length > 0 || showSearch)
|
|
||||||
"
|
|
||||||
:class="['dropdown-menu', menuClass]"
|
:class="['dropdown-menu', menuClass]"
|
||||||
v-click-outside="close"
|
v-click-outside="close"
|
||||||
>
|
>
|
||||||
@@ -62,32 +58,18 @@
|
|||||||
<input type="text" v-model="search" placeholder="搜索" />
|
<input type="text" v-model="search" placeholder="搜索" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="dropdown-loading">
|
<div v-if="loading" class="dropdown-loading">
|
||||||
<l-hatch
|
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
size="20"
|
|
||||||
stroke="4"
|
|
||||||
speed="3.5"
|
|
||||||
color="var(--primary-color)"
|
|
||||||
></l-hatch>
|
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="o in filteredOptions"
|
v-for="o in filteredOptions"
|
||||||
:key="o.id"
|
:key="o.id"
|
||||||
@click="select(o.id)"
|
@click="select(o.id)"
|
||||||
:class="[
|
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||||
'dropdown-option',
|
|
||||||
optionClass,
|
|
||||||
{ selected: isSelected(o.id) },
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||||
<template v-if="o.icon">
|
<template v-if="o.icon">
|
||||||
<img
|
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
|
||||||
v-if="isImageIcon(o.icon)"
|
|
||||||
:src="o.icon"
|
|
||||||
class="option-icon"
|
|
||||||
:alt="o.name"
|
|
||||||
/>
|
|
||||||
<i v-else :class="['option-icon', o.icon]"></i>
|
<i v-else :class="['option-icon', o.icon]"></i>
|
||||||
</template>
|
</template>
|
||||||
<span>{{ o.name }}</span>
|
<span>{{ o.name }}</span>
|
||||||
@@ -107,32 +89,18 @@
|
|||||||
<input type="text" v-model="search" placeholder="搜索" />
|
<input type="text" v-model="search" placeholder="搜索" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="dropdown-loading">
|
<div v-if="loading" class="dropdown-loading">
|
||||||
<l-hatch
|
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
size="20"
|
|
||||||
stroke="4"
|
|
||||||
speed="3.5"
|
|
||||||
color="var(--primary-color)"
|
|
||||||
></l-hatch>
|
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="o in filteredOptions"
|
v-for="o in filteredOptions"
|
||||||
:key="o.id"
|
:key="o.id"
|
||||||
@click="select(o.id)"
|
@click="select(o.id)"
|
||||||
:class="[
|
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||||
'dropdown-option',
|
|
||||||
optionClass,
|
|
||||||
{ selected: isSelected(o.id) },
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||||
<template v-if="o.icon">
|
<template v-if="o.icon">
|
||||||
<img
|
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
|
||||||
v-if="isImageIcon(o.icon)"
|
|
||||||
:src="o.icon"
|
|
||||||
class="option-icon"
|
|
||||||
:alt="o.name"
|
|
||||||
/>
|
|
||||||
<i v-else :class="['option-icon', o.icon]"></i>
|
<i v-else :class="['option-icon', o.icon]"></i>
|
||||||
</template>
|
</template>
|
||||||
<span>{{ o.name }}</span>
|
<span>{{ o.name }}</span>
|
||||||
@@ -146,33 +114,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, computed, watch, onMounted } from "vue"
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { useIsMobile } from "~/utils/screen"
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "BaseDropdown",
|
name: 'BaseDropdown',
|
||||||
props: {
|
props: {
|
||||||
modelValue: { type: [Array, String, Number], default: () => [] },
|
modelValue: { type: [Array, String, Number], default: () => [] },
|
||||||
placeholder: { type: String, default: "返回" },
|
placeholder: { type: String, default: '返回' },
|
||||||
multiple: { type: Boolean, default: false },
|
multiple: { type: Boolean, default: false },
|
||||||
fetchOptions: { type: Function, required: true },
|
fetchOptions: { type: Function, required: true },
|
||||||
remote: { type: Boolean, default: false },
|
remote: { type: Boolean, default: false },
|
||||||
menuClass: { type: String, default: "" },
|
menuClass: { type: String, default: '' },
|
||||||
optionClass: { type: String, default: "" },
|
optionClass: { type: String, default: '' },
|
||||||
showSearch: { type: Boolean, default: true },
|
showSearch: { type: Boolean, default: true },
|
||||||
initialOptions: { type: Array, default: () => [] },
|
initialOptions: { type: Array, default: () => [] },
|
||||||
},
|
},
|
||||||
emits: ["update:modelValue", "update:search", "close"],
|
emits: ['update:modelValue', 'update:search', 'close'],
|
||||||
setup(props, { emit, expose }) {
|
setup(props, { emit, expose }) {
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const search = ref("")
|
const search = ref('')
|
||||||
const setSearch = (val) => {
|
const setSearch = (val) => {
|
||||||
search.value = val
|
search.value = val
|
||||||
}
|
}
|
||||||
const options = ref(
|
const options = ref(Array.isArray(props.initialOptions) ? [...props.initialOptions] : [])
|
||||||
Array.isArray(props.initialOptions) ? [...props.initialOptions] : []
|
|
||||||
)
|
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const wrapper = ref(null)
|
const wrapper = ref(null)
|
||||||
@@ -180,12 +145,12 @@ export default {
|
|||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
open.value = !open.value
|
open.value = !open.value
|
||||||
if (!open.value) emit("close")
|
if (!open.value) emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
open.value = false
|
open.value = false
|
||||||
emit("close")
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const select = (id) => {
|
const select = (id) => {
|
||||||
@@ -197,23 +162,21 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
arr.push(id)
|
arr.push(id)
|
||||||
}
|
}
|
||||||
emit("update:modelValue", arr)
|
emit('update:modelValue', arr)
|
||||||
} else {
|
} else {
|
||||||
emit("update:modelValue", id)
|
emit('update:modelValue', id)
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
search.value = ""
|
search.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredOptions = computed(() => {
|
const filteredOptions = computed(() => {
|
||||||
if (props.remote) return options.value
|
if (props.remote) return options.value
|
||||||
if (!search.value) return options.value
|
if (!search.value) return options.value
|
||||||
return options.value.filter((o) =>
|
return options.value.filter((o) => o.name.toLowerCase().includes(search.value.toLowerCase()))
|
||||||
o.name.toLowerCase().includes(search.value.toLowerCase())
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadOptions = async (kw = "") => {
|
const loadOptions = async (kw = '') => {
|
||||||
if (!props.remote && loaded.value) return
|
if (!props.remote && loaded.value) return
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -233,7 +196,7 @@ export default {
|
|||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
options.value = [...val]
|
options.value = [...val]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(open, async (val) => {
|
watch(open, async (val) => {
|
||||||
@@ -247,7 +210,7 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(search, async (val) => {
|
watch(search, async (val) => {
|
||||||
emit("update:search", val)
|
emit('update:search', val)
|
||||||
if (props.remote && open.value) {
|
if (props.remote && open.value) {
|
||||||
await loadOptions(val)
|
await loadOptions(val)
|
||||||
}
|
}
|
||||||
@@ -261,9 +224,7 @@ export default {
|
|||||||
|
|
||||||
const selectedLabels = computed(() => {
|
const selectedLabels = computed(() => {
|
||||||
if (props.multiple) {
|
if (props.multiple) {
|
||||||
return options.value.filter((o) =>
|
return options.value.filter((o) => (props.modelValue || []).includes(o.id))
|
||||||
(props.modelValue || []).includes(o.id)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const match = options.value.find((o) => o.id === props.modelValue)
|
const match = options.value.find((o) => o.id === props.modelValue)
|
||||||
return match ? [match] : []
|
return match ? [match] : []
|
||||||
@@ -275,7 +236,7 @@ export default {
|
|||||||
|
|
||||||
const isImageIcon = (icon) => {
|
const isImageIcon = (icon) => {
|
||||||
if (!icon) return false
|
if (!icon) return false
|
||||||
return /^https?:\/\//.test(icon) || icon.startsWith("/")
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
expose({ toggle, close })
|
expose({ toggle, close })
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
export default {
|
export default {
|
||||||
name: 'DropdownMenu',
|
name: 'DropdownMenu',
|
||||||
props: {
|
props: {
|
||||||
items: { type: Array, default: () => [] }
|
items: { type: Array, default: () => [] },
|
||||||
},
|
},
|
||||||
setup(props, { expose }) {
|
setup(props, { expose }) {
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -33,13 +33,13 @@ export default {
|
|||||||
const close = () => {
|
const close = () => {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
}
|
}
|
||||||
const handle = item => {
|
const handle = (item) => {
|
||||||
close()
|
close()
|
||||||
if (item && typeof item.onClick === 'function') {
|
if (item && typeof item.onClick === 'function') {
|
||||||
item.onClick()
|
item.onClick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const clickOutside = e => {
|
const clickOutside = (e) => {
|
||||||
if (wrapper.value && !wrapper.value.contains(e.target)) {
|
if (wrapper.value && !wrapper.value.contains(e.target)) {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export default {
|
|||||||
})
|
})
|
||||||
expose({ close })
|
expose({ close })
|
||||||
return { visible, toggle, wrapper, handle }
|
return { visible, toggle, wrapper, handle }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,7 @@
|
|||||||
text="建站送奶茶活动火热进行中,快来参与吧!"
|
text="建站送奶茶活动火热进行中,快来参与吧!"
|
||||||
@close="closeMilkTeaPopup"
|
@close="closeMilkTeaPopup"
|
||||||
/>
|
/>
|
||||||
<MedalPopup
|
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
|
||||||
:visible="showMedalPopup"
|
|
||||||
:medals="newMedals"
|
|
||||||
@close="closeMedalPopup"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -23,29 +19,29 @@ import { authState } from '~/utils/auth'
|
|||||||
export default {
|
export default {
|
||||||
name: 'GlobalPopups',
|
name: 'GlobalPopups',
|
||||||
components: { ActivityPopup, MedalPopup },
|
components: { ActivityPopup, MedalPopup },
|
||||||
data () {
|
data() {
|
||||||
return {
|
return {
|
||||||
showMilkTeaPopup: false,
|
showMilkTeaPopup: false,
|
||||||
milkTeaIcon: '',
|
milkTeaIcon: '',
|
||||||
showMedalPopup: false,
|
showMedalPopup: false,
|
||||||
newMedals: []
|
newMedals: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted () {
|
async mounted() {
|
||||||
await this.checkMilkTeaActivity()
|
await this.checkMilkTeaActivity()
|
||||||
if (!this.showMilkTeaPopup) {
|
if (!this.showMilkTeaPopup) {
|
||||||
await this.checkNewMedals()
|
await this.checkNewMedals()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async checkMilkTeaActivity () {
|
async checkMilkTeaActivity() {
|
||||||
if (!process.client) return
|
if (!process.client) return
|
||||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const list = await res.json()
|
const list = await res.json()
|
||||||
const a = list.find(i => i.type === 'MILK_TEA' && !i.ended)
|
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
|
||||||
if (a) {
|
if (a) {
|
||||||
this.milkTeaIcon = a.icon
|
this.milkTeaIcon = a.icon
|
||||||
this.showMilkTeaPopup = true
|
this.showMilkTeaPopup = true
|
||||||
@@ -55,13 +51,13 @@ export default {
|
|||||||
// ignore network errors
|
// ignore network errors
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
closeMilkTeaPopup () {
|
closeMilkTeaPopup() {
|
||||||
if (!process.client) return
|
if (!process.client) return
|
||||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||||
this.showMilkTeaPopup = false
|
this.showMilkTeaPopup = false
|
||||||
this.checkNewMedals()
|
this.checkNewMedals()
|
||||||
},
|
},
|
||||||
async checkNewMedals () {
|
async checkNewMedals() {
|
||||||
if (!process.client) return
|
if (!process.client) return
|
||||||
if (!authState.loggedIn || !authState.userId) return
|
if (!authState.loggedIn || !authState.userId) return
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +65,7 @@ export default {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const medals = await res.json()
|
const medals = await res.json()
|
||||||
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
|
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
|
||||||
const m = medals.filter(i => i.completed && !seen.includes(i.type))
|
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
|
||||||
if (m.length > 0) {
|
if (m.length > 0) {
|
||||||
this.newMedals = m
|
this.newMedals = m
|
||||||
this.showMedalPopup = true
|
this.showMedalPopup = true
|
||||||
@@ -79,14 +75,13 @@ export default {
|
|||||||
// ignore errors
|
// ignore errors
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
closeMedalPopup () {
|
closeMedalPopup() {
|
||||||
if (!process.client) return
|
if (!process.client) return
|
||||||
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
||||||
this.newMedals.forEach(m => seen.add(m.type))
|
this.newMedals.forEach((m) => seen.add(m.type))
|
||||||
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
||||||
this.showMedalPopup = false
|
this.showMedalPopup = false
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="logo-container" @click="goToHome">
|
<div class="logo-container" @click="goToHome">
|
||||||
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
<img
|
||||||
width="60" height="60">
|
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 class="logo-text">OpenIsle</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,7 +27,7 @@
|
|||||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="avatar-container">
|
<div class="avatar-container">
|
||||||
<img class="avatar-img" :src="avatar" alt="avatar">
|
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||||
<i class="fas fa-caret-down dropdown-icon"></i>
|
<i class="fas fa-caret-down dropdown-icon"></i>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -59,14 +63,14 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
showMenuBtn: {
|
showMenuBtn: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
avatar: '',
|
avatar: '',
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
searchDropdown: null
|
searchDropdown: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
@@ -124,7 +128,7 @@ export default {
|
|||||||
const headerMenuItems = computed(() => [
|
const headerMenuItems = computed(() => [
|
||||||
{ text: '设置', onClick: goToSettings },
|
{ text: '设置', onClick: goToSettings },
|
||||||
{ text: '个人主页', onClick: goToProfile },
|
{ text: '个人主页', onClick: goToProfile },
|
||||||
{ text: '退出', onClick: goToLogout }
|
{ text: '退出', onClick: goToLogout },
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -139,7 +143,7 @@ export default {
|
|||||||
goToSettings,
|
goToSettings,
|
||||||
goToProfile,
|
goToProfile,
|
||||||
goToSignup,
|
goToSignup,
|
||||||
goToLogout
|
goToLogout,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -163,15 +167,21 @@ export default {
|
|||||||
await updateAvatar()
|
await updateAvatar()
|
||||||
await updateUnread()
|
await updateUnread()
|
||||||
|
|
||||||
watch(() => authState.loggedIn, async () => {
|
watch(
|
||||||
await updateAvatar()
|
() => authState.loggedIn,
|
||||||
await updateUnread()
|
async () => {
|
||||||
})
|
await updateAvatar()
|
||||||
|
await updateUnread()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
watch(() => this.$route.fullPath, () => {
|
watch(
|
||||||
if (this.$refs.userMenu) this.$refs.userMenu.close()
|
() => this.$route.fullPath,
|
||||||
this.showSearch = false
|
() => {
|
||||||
})
|
if (this.$refs.userMenu) this.$refs.userMenu.close()
|
||||||
|
this.showSearch = false
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -257,7 +267,6 @@ export default {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.header-content-item-main:hover {
|
.header-content-item-main:hover {
|
||||||
background-color: var(--primary-color-hover);
|
background-color: var(--primary-color-hover);
|
||||||
}
|
}
|
||||||
@@ -318,5 +327,4 @@ export default {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
exp: { type: Number, default: 0 },
|
exp: { type: Number, default: 0 },
|
||||||
currentLevel: { type: Number, default: 0 },
|
currentLevel: { type: Number, default: 0 },
|
||||||
nextExp: { type: Number, default: 0 }
|
nextExp: { type: Number, default: 0 },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
max () {
|
max() {
|
||||||
return this.nextExp - prevLevelExp(this.currentLevel)
|
return this.nextExp - prevLevelExp(this.currentLevel)
|
||||||
},
|
},
|
||||||
value () {
|
value() {
|
||||||
return this.exp - prevLevelExp(this.currentLevel)
|
return this.exp - prevLevelExp(this.currentLevel)
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,8 @@
|
|||||||
<div class="login-overlay-blur"></div>
|
<div class="login-overlay-blur"></div>
|
||||||
<div class="login-overlay-content">
|
<div class="login-overlay-content">
|
||||||
<i class="fa-solid fa-user login-overlay-icon"></i>
|
<i class="fa-solid fa-user login-overlay-icon"></i>
|
||||||
<div class="login-overlay-text">
|
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
||||||
请先登录,点击跳转到登录页面
|
<div class="login-overlay-button" @click="goLogin">登录</div>
|
||||||
</div>
|
|
||||||
<div class="login-overlay-button" @click="goLogin">
|
|
||||||
登录
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -24,7 +20,7 @@ export default {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
return { goLogin }
|
return { goLogin }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -81,5 +77,4 @@ export default {
|
|||||||
.login-overlay-button:hover {
|
.login-overlay-button:hover {
|
||||||
background-color: var(--primary-color-hover);
|
background-color: var(--primary-color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ export default {
|
|||||||
components: { BasePopup },
|
components: { BasePopup },
|
||||||
props: {
|
props: {
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
medals: { type: Array, default: () => [] }
|
medals: { type: Array, default: () => [] },
|
||||||
},
|
},
|
||||||
emits: ['close'],
|
emits: ['close'],
|
||||||
setup (props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const gotoMedals = () => {
|
const gotoMedals = () => {
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -41,7 +41,7 @@ export default {
|
|||||||
}
|
}
|
||||||
const close = () => emit('close')
|
const close = () => emit('close')
|
||||||
return { gotoMedals, close }
|
return { gotoMedals, close }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -110,4 +110,3 @@ export default {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<nav v-if="visible" class="menu">
|
<nav v-if="visible" class="menu">
|
||||||
<div class="menu-item-container">
|
<div class="menu-item-container">
|
||||||
<NuxtLink
|
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
|
||||||
class="menu-item"
|
|
||||||
exact-active-class="selected"
|
|
||||||
to="/"
|
|
||||||
@click="handleHomeClick"
|
|
||||||
>
|
|
||||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||||
<span class="menu-item-text">话题</span>
|
<span class="menu-item-text">话题</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -71,9 +66,20 @@
|
|||||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
<div v-else v-for="c in categoryData" :key="c.id" class="section-item" @click="gotoCategory(c)">
|
<div
|
||||||
|
v-else
|
||||||
|
v-for="c in categoryData"
|
||||||
|
:key="c.id"
|
||||||
|
class="section-item"
|
||||||
|
@click="gotoCategory(c)"
|
||||||
|
>
|
||||||
<template v-if="c.smallIcon || c.icon">
|
<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" />
|
<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>
|
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||||
</template>
|
</template>
|
||||||
<span class="section-item-text">
|
<span class="section-item-text">
|
||||||
@@ -94,10 +100,16 @@
|
|||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
<div v-else v-for="t in tagData" :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" />
|
<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>
|
<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 class="section-item-text"
|
||||||
}}</span></span>
|
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,8 +135,8 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
visible: {
|
visible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
async setup(props, { emit }) {
|
async setup(props, { emit }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -165,9 +177,7 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const unreadCount = computed(() => notificationState.unreadCount)
|
const unreadCount = computed(() => notificationState.unreadCount)
|
||||||
const showUnreadCount = computed(() =>
|
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||||
unreadCount.value > 99 ? '99+' : unreadCount.value
|
|
||||||
)
|
|
||||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
||||||
|
|
||||||
const updateCount = async () => {
|
const updateCount = async () => {
|
||||||
@@ -200,19 +210,17 @@ export default {
|
|||||||
|
|
||||||
const gotoCategory = (c) => {
|
const gotoCategory = (c) => {
|
||||||
const value = encodeURIComponent(c.id ?? c.name)
|
const value = encodeURIComponent(c.id ?? c.name)
|
||||||
router
|
router.push({ path: '/', query: { category: value } }).then(() => {
|
||||||
.push({ path: '/', query: { category: value } }).then(() => {
|
window.location.reload()
|
||||||
window.location.reload()
|
})
|
||||||
})
|
|
||||||
handleItemClick()
|
handleItemClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
const gotoTag = (t) => {
|
const gotoTag = (t) => {
|
||||||
const value = encodeURIComponent(t.id ?? t.name)
|
const value = encodeURIComponent(t.id ?? t.name)
|
||||||
router
|
router.push({ path: '/', query: { tags: value } }).then(() => {
|
||||||
.push({ path: '/', query: { tags: value } }).then(() => {
|
window.location.reload()
|
||||||
window.location.reload()
|
})
|
||||||
})
|
|
||||||
handleItemClick()
|
handleItemClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,9 +242,9 @@ export default {
|
|||||||
handleItemClick,
|
handleItemClick,
|
||||||
isImageIcon,
|
isImageIcon,
|
||||||
gotoCategory,
|
gotoCategory,
|
||||||
gotoTag
|
gotoTag,
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -380,7 +388,6 @@ export default {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.menu {
|
.menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -12,33 +12,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="milk-tea-status-container">
|
<div class="milk-tea-status-container">
|
||||||
<div class="milk-tea-status">
|
<div class="milk-tea-status">
|
||||||
<div class="status-title">🔥 已兑换奶茶人数</div>
|
<div class="status-title">🔥 已兑换奶茶人数</div>
|
||||||
<ProgressBar :value="info.redeemCount" :max="50" />
|
<ProgressBar :value="info.redeemCount" :max="50" />
|
||||||
<div class="status-text">当前 {{ info.redeemCount }} / 50</div>
|
<div class="status-text">当前 {{ info.redeemCount }} / 50</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoadingUser" class="loading-user">
|
<div v-if="isLoadingUser" class="loading-user">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
<div class="user-level-text">加载当前等级中...</div>
|
<div class="user-level-text">加载当前等级中...</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="user" class="user-level">
|
<div v-else-if="user" class="user-level">
|
||||||
<LevelProgress :exp="user.experience" :current-level="user.currentLevel" :next-exp="user.nextLevelExp" />
|
<LevelProgress
|
||||||
|
:exp="user.experience"
|
||||||
|
:current-level="user.currentLevel"
|
||||||
|
:next-exp="user.nextLevelExp"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="user-level">
|
<div v-else class="user-level">
|
||||||
<div class="user-level-text"><i class="fas fa-user-circle"></i> 请登录查看自身等级</div>
|
<div class="user-level-text"><i class="fas fa-user-circle"></i> 请登录查看自身等级</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="user && user.currentLevel >= 1 && !info.ended" class="redeem-button" @click="openDialog">兑换</div>
|
<div
|
||||||
|
v-if="user && user.currentLevel >= 1 && !info.ended"
|
||||||
|
class="redeem-button"
|
||||||
|
@click="openDialog"
|
||||||
|
>
|
||||||
|
兑换
|
||||||
|
</div>
|
||||||
<div v-else class="redeem-button disabled">兑换</div>
|
<div v-else class="redeem-button disabled">兑换</div>
|
||||||
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
||||||
<div class="redeem-dialog-content">
|
<div class="redeem-dialog-content">
|
||||||
<BaseInput textarea="" rows="5" v-model="contact" placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)" />
|
<BaseInput
|
||||||
<div class="redeem-actions">
|
textarea=""
|
||||||
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
|
rows="5"
|
||||||
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
|
v-model="contact"
|
||||||
</div>
|
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>
|
||||||
|
</BasePopup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -53,7 +68,7 @@ import { getToken, fetchCurrentUser } from '../utils/auth'
|
|||||||
export default {
|
export default {
|
||||||
name: 'MilkTeaActivityComponent',
|
name: 'MilkTeaActivityComponent',
|
||||||
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
|
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
|
||||||
data () {
|
data() {
|
||||||
return {
|
return {
|
||||||
info: { redeemCount: 0, ended: false },
|
info: { redeemCount: 0, ended: false },
|
||||||
user: null,
|
user: null,
|
||||||
@@ -63,26 +78,26 @@ export default {
|
|||||||
isLoadingUser: true,
|
isLoadingUser: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted () {
|
async mounted() {
|
||||||
await this.loadInfo()
|
await this.loadInfo()
|
||||||
this.isLoadingUser = true
|
this.isLoadingUser = true
|
||||||
this.user = await fetchCurrentUser()
|
this.user = await fetchCurrentUser()
|
||||||
this.isLoadingUser = false
|
this.isLoadingUser = false
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async loadInfo () {
|
async loadInfo() {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
this.info = await res.json()
|
this.info = await res.json()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
openDialog () {
|
openDialog() {
|
||||||
this.dialogVisible = true
|
this.dialogVisible = true
|
||||||
},
|
},
|
||||||
closeDialog () {
|
closeDialog() {
|
||||||
this.dialogVisible = false
|
this.dialogVisible = false
|
||||||
},
|
},
|
||||||
async submitRedeem () {
|
async submitRedeem() {
|
||||||
if (!this.contact) return
|
if (!this.contact) return
|
||||||
this.loading = true
|
this.loading = true
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -90,9 +105,9 @@ export default {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ contact: this.contact })
|
body: JSON.stringify({ contact: this.contact }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -107,8 +122,8 @@ export default {
|
|||||||
toast.error('兑换失败')
|
toast.error('兑换失败')
|
||||||
}
|
}
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -161,7 +176,6 @@ export default {
|
|||||||
background-color: var(--primary-color-disabled);
|
background-color: var(--primary-color-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.milk-tea-status-container {
|
.milk-tea-status-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -228,7 +242,6 @@ export default {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.milk-tea-status-container {
|
.milk-tea-status-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -240,6 +253,4 @@ export default {
|
|||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ export default {
|
|||||||
name: 'NotificationContainer',
|
name: 'NotificationContainer',
|
||||||
props: {
|
props: {
|
||||||
item: { type: Object, required: true },
|
item: { type: Object, required: true },
|
||||||
markRead: { type: Function, required: true }
|
markRead: { type: Function, required: true },
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
return {
|
return {
|
||||||
isMobile
|
isMobile,
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -60,5 +60,4 @@ export default {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { themeState } from '../utils/theme'
|
|||||||
import {
|
import {
|
||||||
createVditor,
|
createVditor,
|
||||||
getEditorTheme as getEditorThemeUtil,
|
getEditorTheme as getEditorThemeUtil,
|
||||||
getPreviewTheme as getPreviewThemeUtil
|
getPreviewTheme as getPreviewThemeUtil,
|
||||||
} from '../utils/vditor'
|
} from '../utils/vditor'
|
||||||
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
||||||
|
|
||||||
@@ -23,20 +23,20 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: '',
|
||||||
},
|
},
|
||||||
editorId: {
|
editorId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: '',
|
||||||
},
|
},
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const vditorInstance = ref(null)
|
const vditorInstance = ref(null)
|
||||||
@@ -56,42 +56,42 @@ export default {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.loading,
|
() => props.loading,
|
||||||
val => {
|
(val) => {
|
||||||
if (!vditorRender) return
|
if (!vditorRender) return
|
||||||
if (val) {
|
if (val) {
|
||||||
vditorInstance.value.disabled()
|
vditorInstance.value.disabled()
|
||||||
} else {
|
} else {
|
||||||
vditorInstance.value.enable()
|
vditorInstance.value.enable()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.disabled,
|
() => props.disabled,
|
||||||
val => {
|
(val) => {
|
||||||
if (!vditorInstance.value) return
|
if (!vditorInstance.value) return
|
||||||
if (val) {
|
if (val) {
|
||||||
vditorInstance.value.disabled()
|
vditorInstance.value.disabled()
|
||||||
} else if (!props.loading) {
|
} else if (!props.loading) {
|
||||||
vditorInstance.value.enable()
|
vditorInstance.value.enable()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
val => {
|
(val) => {
|
||||||
if (vditorInstance.value && vditorInstance.value.getValue() !== val) {
|
if (vditorInstance.value && vditorInstance.value.getValue() !== val) {
|
||||||
vditorInstance.value.setValue(val)
|
vditorInstance.value.setValue(val)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => themeState.mode,
|
() => themeState.mode,
|
||||||
() => {
|
() => {
|
||||||
applyTheme()
|
applyTheme()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -109,7 +109,7 @@ export default {
|
|||||||
vditorInstance.value.disabled()
|
vditorInstance.value.disabled()
|
||||||
}
|
}
|
||||||
applyTheme()
|
applyTheme()
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
// applyTheme()
|
// applyTheme()
|
||||||
})
|
})
|
||||||
@@ -119,7 +119,7 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return { editorId }
|
return { editorId }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -148,5 +148,4 @@ export default {
|
|||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dropdown v-model="selected" :fetch-options="fetchTypes" placeholder="选择帖子类型" :initial-options="providedOptions" />
|
<Dropdown
|
||||||
|
v-model="selected"
|
||||||
|
:fetch-options="fetchTypes"
|
||||||
|
placeholder="选择帖子类型"
|
||||||
|
:initial-options="providedOptions"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -11,7 +16,7 @@ export default {
|
|||||||
components: { Dropdown },
|
components: { Dropdown },
|
||||||
props: {
|
props: {
|
||||||
modelValue: { type: String, default: 'NORMAL' },
|
modelValue: { type: String, default: 'NORMAL' },
|
||||||
options: { type: Array, default: () => [] }
|
options: { type: Array, default: () => [] },
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
@@ -19,28 +24,26 @@ export default {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.options,
|
() => props.options,
|
||||||
val => {
|
(val) => {
|
||||||
providedOptions.value = Array.isArray(val) ? [...val] : []
|
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchTypes = async () => {
|
const fetchTypes = async () => {
|
||||||
return [
|
return [
|
||||||
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
|
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
|
||||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' }
|
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = computed({
|
const selected = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: v => emit('update:modelValue', v)
|
set: (v) => emit('update:modelValue', v),
|
||||||
})
|
})
|
||||||
|
|
||||||
return { fetchTypes, selected, providedOptions }
|
return { fetchTypes, selected, providedOptions }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ export default {
|
|||||||
name: 'ProgressBar',
|
name: 'ProgressBar',
|
||||||
props: {
|
props: {
|
||||||
value: { type: Number, default: 0 },
|
value: { type: Number, default: 0 },
|
||||||
max: { type: Number, default: 100 }
|
max: { type: Number, default: 100 },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
percent () {
|
percent() {
|
||||||
if (this.max <= 0) return 0
|
if (this.max <= 0) return 0
|
||||||
const p = (this.value / this.max) * 100
|
const p = (this.value / this.max) * 100
|
||||||
return Math.max(0, Math.min(100, p))
|
return Math.max(0, Math.min(100, p))
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="reactions-container">
|
<div class="reactions-container">
|
||||||
<div class="reactions-viewer">
|
<div class="reactions-viewer">
|
||||||
<div class="reactions-viewer-item-container" @click="openPanel" @mouseenter="cancelHide"
|
<div
|
||||||
@mouseleave="scheduleHide">
|
class="reactions-viewer-item-container"
|
||||||
|
@click="openPanel"
|
||||||
|
@mouseenter="cancelHide"
|
||||||
|
@mouseleave="scheduleHide"
|
||||||
|
>
|
||||||
<template v-if="displayedReactions.length">
|
<template v-if="displayedReactions.length">
|
||||||
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item">{{ reactionEmojiMap[r.type] }}</div>
|
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item">
|
||||||
|
{{ reactionEmojiMap[r.type] }}
|
||||||
|
</div>
|
||||||
<div class="reactions-count">{{ totalCount }}</div>
|
<div class="reactions-count">{{ totalCount }}</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="reactions-viewer-item placeholder">
|
<div v-else class="reactions-viewer-item placeholder">
|
||||||
@@ -21,9 +27,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="panelVisible" class="reactions-panel" @mouseenter="cancelHide" @mouseleave="scheduleHide">
|
<div
|
||||||
<div v-for="t in panelTypes" :key="t" class="reaction-option" @click="toggleReaction(t)"
|
v-if="panelVisible"
|
||||||
:class="{ selected: userReacted(t) }">
|
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>
|
{{ reactionEmojiMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +58,7 @@ const fetchTypes = async () => {
|
|||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
|
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
|
||||||
headers: { Authorization: token ? `Bearer ${token}` : '' }
|
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
cachedTypes = await res.json()
|
cachedTypes = await res.json()
|
||||||
@@ -60,12 +76,15 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
modelValue: { type: Array, default: () => [] },
|
modelValue: { type: Array, default: () => [] },
|
||||||
contentType: { type: String, required: true },
|
contentType: { type: String, required: true },
|
||||||
contentId: { type: [Number, String], required: true }
|
contentId: { type: [Number, String], required: true },
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const reactions = ref(props.modelValue)
|
const reactions = ref(props.modelValue)
|
||||||
watch(() => props.modelValue, v => reactions.value = v)
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(v) => (reactions.value = v),
|
||||||
|
)
|
||||||
|
|
||||||
const reactionTypes = ref([])
|
const reactionTypes = ref([])
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -83,7 +102,8 @@ export default {
|
|||||||
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||||
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
||||||
|
|
||||||
const userReacted = type => reactions.value.some(r => r.type === type && r.user === authState.username)
|
const userReacted = (type) =>
|
||||||
|
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
||||||
|
|
||||||
const displayedReactions = computed(() => {
|
const displayedReactions = computed(() => {
|
||||||
return Object.entries(counts.value)
|
return Object.entries(counts.value)
|
||||||
@@ -92,7 +112,7 @@ export default {
|
|||||||
.map(([type]) => ({ type }))
|
.map(([type]) => ({ type }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const panelTypes = computed(() => reactionTypes.value.filter(t => t !== 'LIKE'))
|
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
|
||||||
|
|
||||||
const panelVisible = ref(false)
|
const panelVisible = ref(false)
|
||||||
let hideTimer = null
|
let hideTimer = null
|
||||||
@@ -102,7 +122,9 @@ export default {
|
|||||||
}
|
}
|
||||||
const scheduleHide = () => {
|
const scheduleHide = () => {
|
||||||
clearTimeout(hideTimer)
|
clearTimeout(hideTimer)
|
||||||
hideTimer = setTimeout(() => { panelVisible.value = false }, 500)
|
hideTimer = setTimeout(() => {
|
||||||
|
panelVisible.value = false
|
||||||
|
}, 500)
|
||||||
}
|
}
|
||||||
const cancelHide = () => {
|
const cancelHide = () => {
|
||||||
clearTimeout(hideTimer)
|
clearTimeout(hideTimer)
|
||||||
@@ -114,12 +136,15 @@ export default {
|
|||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const url = props.contentType === 'post'
|
const url =
|
||||||
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
props.contentType === 'post'
|
||||||
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
||||||
|
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
||||||
|
|
||||||
// optimistic update
|
// optimistic update
|
||||||
const existingIdx = reactions.value.findIndex(r => r.type === type && r.user === authState.username)
|
const existingIdx = reactions.value.findIndex(
|
||||||
|
(r) => r.type === type && r.user === authState.username,
|
||||||
|
)
|
||||||
let tempReaction = null
|
let tempReaction = null
|
||||||
let removedReaction = null
|
let removedReaction = null
|
||||||
if (existingIdx > -1) {
|
if (existingIdx > -1) {
|
||||||
@@ -134,7 +159,7 @@ export default {
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ type })
|
body: JSON.stringify({ type }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (res.status === 204) {
|
if (res.status === 204) {
|
||||||
@@ -188,9 +213,9 @@ export default {
|
|||||||
scheduleHide,
|
scheduleHide,
|
||||||
cancelHide,
|
cancelHide,
|
||||||
toggleReaction,
|
toggleReaction,
|
||||||
userReacted
|
userReacted,
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="search-dropdown">
|
<div class="search-dropdown">
|
||||||
<Dropdown ref="dropdown" v-model="selected" :fetch-options="fetchResults" remote menu-class="search-menu"
|
<Dropdown
|
||||||
option-class="search-option" :show-search="isMobile" @update:search="keyword = $event" @close="onClose">
|
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 }">
|
<template #display="{ setSearch }">
|
||||||
<div class="search-input">
|
<div class="search-input">
|
||||||
<i class="search-input-icon fas fa-search"></i>
|
<i class="search-input-icon fas fa-search"></i>
|
||||||
<input class="text-input" v-model="keyword" placeholder="Search" @input="setSearch(keyword)" />
|
<input
|
||||||
|
class="text-input"
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="Search"
|
||||||
|
@input="setSearch(keyword)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #option="{ option }">
|
<template #option="{ option }">
|
||||||
@@ -53,13 +67,13 @@ export default {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
|
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
results.value = data.map(r => ({
|
results.value = data.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
text: r.text,
|
text: r.text,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
subText: r.subText,
|
subText: r.subText,
|
||||||
extra: r.extra,
|
extra: r.extra,
|
||||||
postId: r.postId
|
postId: r.postId,
|
||||||
}))
|
}))
|
||||||
return results.value
|
return results.value
|
||||||
}
|
}
|
||||||
@@ -68,8 +82,8 @@ export default {
|
|||||||
text = stripMarkdown(text)
|
text = stripMarkdown(text)
|
||||||
if (!keyword.value) return text
|
if (!keyword.value) return text
|
||||||
const reg = new RegExp(keyword.value, 'gi')
|
const reg = new RegExp(keyword.value, 'gi')
|
||||||
const res = text.replace(reg, m => `<span class="highlight">${m}</span>`)
|
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
||||||
return res;
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
@@ -77,12 +91,12 @@ export default {
|
|||||||
post: 'fas fa-file-alt',
|
post: 'fas fa-file-alt',
|
||||||
comment: 'fas fa-comment',
|
comment: 'fas fa-comment',
|
||||||
category: 'fas fa-folder',
|
category: 'fas fa-folder',
|
||||||
tag: 'fas fa-hashtag'
|
tag: 'fas fa-hashtag',
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(selected, val => {
|
watch(selected, (val) => {
|
||||||
if (!val) return
|
if (!val) return
|
||||||
const opt = results.value.find(r => r.id === val)
|
const opt = results.value.find((r) => r.id === val)
|
||||||
if (!opt) return
|
if (!opt) return
|
||||||
if (opt.type === 'post' || opt.type === 'post_title') {
|
if (opt.type === 'post' || opt.type === 'post_title') {
|
||||||
router.push(`/posts/${opt.id}`)
|
router.push(`/posts/${opt.id}`)
|
||||||
@@ -101,8 +115,18 @@ export default {
|
|||||||
keyword.value = ''
|
keyword.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
return { keyword, selected, fetchResults, highlight, iconMap, isMobile, dropdown, onClose, toggle }
|
return {
|
||||||
}
|
keyword,
|
||||||
|
selected,
|
||||||
|
fetchResults,
|
||||||
|
highlight,
|
||||||
|
iconMap,
|
||||||
|
isMobile,
|
||||||
|
dropdown,
|
||||||
|
onClose,
|
||||||
|
toggle,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dropdown v-model="selected" :fetch-options="fetchTags" multiple placeholder="选择标签" remote
|
<Dropdown
|
||||||
:initial-options="mergedOptions">
|
v-model="selected"
|
||||||
|
:fetch-options="fetchTags"
|
||||||
|
multiple
|
||||||
|
placeholder="选择标签"
|
||||||
|
remote
|
||||||
|
:initial-options="mergedOptions"
|
||||||
|
>
|
||||||
<template #option="{ option }">
|
<template #option="{ option }">
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
<div class="option-main">
|
<div class="option-main">
|
||||||
<template v-if="option.icon">
|
<template v-if="option.icon">
|
||||||
<img v-if="isImageIcon(option.icon)" :src="option.icon" class="option-icon" :alt="option.name" />
|
<img
|
||||||
|
v-if="isImageIcon(option.icon)"
|
||||||
|
:src="option.icon"
|
||||||
|
class="option-icon"
|
||||||
|
:alt="option.name"
|
||||||
|
/>
|
||||||
<i v-else :class="['option-icon', option.icon]"></i>
|
<i v-else :class="['option-icon', option.icon]"></i>
|
||||||
</template>
|
</template>
|
||||||
<span>{{ option.name }}</span>
|
<span>{{ option.name }}</span>
|
||||||
@@ -28,7 +39,7 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
modelValue: { type: Array, default: () => [] },
|
modelValue: { type: Array, default: () => [] },
|
||||||
creatable: { type: Boolean, default: false },
|
creatable: { type: Boolean, default: false },
|
||||||
options: { type: Array, default: () => [] }
|
options: { type: Array, default: () => [] },
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
@@ -37,63 +48,66 @@ export default {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.options,
|
() => props.options,
|
||||||
val => {
|
(val) => {
|
||||||
providedTags.value = Array.isArray(val) ? [...val] : []
|
providedTags.value = Array.isArray(val) ? [...val] : []
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedOptions = computed(() => {
|
const mergedOptions = computed(() => {
|
||||||
const arr = [...providedTags.value, ...localTags.value]
|
const arr = [...providedTags.value, ...localTags.value]
|
||||||
return arr.filter((v, i, a) => a.findIndex(t => t.id === v.id) === i)
|
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
||||||
})
|
})
|
||||||
|
|
||||||
const isImageIcon = icon => {
|
const isImageIcon = (icon) => {
|
||||||
if (!icon) return false
|
if (!icon) return false
|
||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildTagsUrl = (kw = '') => {
|
const buildTagsUrl = (kw = '') => {
|
||||||
const base = API_BASE_URL || (process.client ? window.location.origin : '');
|
const base = API_BASE_URL || (process.client ? window.location.origin : '')
|
||||||
const url = new URL('/api/tags', base);
|
const url = new URL('/api/tags', base)
|
||||||
|
|
||||||
if (kw) url.searchParams.set('keyword', kw);
|
if (kw) url.searchParams.set('keyword', kw)
|
||||||
url.searchParams.set('limit', '10');
|
url.searchParams.set('limit', '10')
|
||||||
|
|
||||||
return url.toString();
|
return url.toString()
|
||||||
};
|
}
|
||||||
|
|
||||||
const fetchTags = async (kw = '') => {
|
const fetchTags = async (kw = '') => {
|
||||||
const defaultOption = { id: 0, name: '无标签' };
|
const defaultOption = { id: 0, name: '无标签' }
|
||||||
|
|
||||||
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||||
const url = buildTagsUrl(kw);
|
const url = buildTagsUrl(kw)
|
||||||
|
|
||||||
// 2) 拉数据
|
// 2) 拉数据
|
||||||
let data = [];
|
let data = []
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url)
|
||||||
if (res.ok) data = await res.json();
|
if (res.ok) data = await res.json()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('获取标签失败');
|
toast.error('获取标签失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) 合并、去重、可创建
|
// 3) 合并、去重、可创建
|
||||||
let options = [...data, ...localTags.value];
|
let options = [...data, ...localTags.value]
|
||||||
|
|
||||||
if (props.creatable && kw &&
|
if (
|
||||||
!options.some(t => t.name.toLowerCase() === kw.toLowerCase())) {
|
props.creatable &&
|
||||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` });
|
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());
|
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
||||||
|
|
||||||
// 4) 最终结果
|
// 4) 最终结果
|
||||||
return [defaultOption, ...options];
|
return [defaultOption, ...options]
|
||||||
};
|
}
|
||||||
|
|
||||||
const selected = computed({
|
const selected = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: v => {
|
set: (v) => {
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
if (v.includes(0)) {
|
if (v.includes(0)) {
|
||||||
emit('update:modelValue', [])
|
emit('update:modelValue', [])
|
||||||
@@ -103,11 +117,11 @@ export default {
|
|||||||
toast.error('最多选择两个标签')
|
toast.error('最多选择两个标签')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
v = v.map(id => {
|
v = v.map((id) => {
|
||||||
if (typeof id === 'string' && id.startsWith('__create__:')) {
|
if (typeof id === 'string' && id.startsWith('__create__:')) {
|
||||||
const name = id.slice(11)
|
const name = id.slice(11)
|
||||||
const newId = `__new__:${name}`
|
const newId = `__new__:${name}`
|
||||||
if (!localTags.value.find(t => t.id === newId)) {
|
if (!localTags.value.find((t) => t.id === newId)) {
|
||||||
localTags.value.push({ id: newId, name })
|
localTags.value.push({ id: newId, name })
|
||||||
}
|
}
|
||||||
return newId
|
return newId
|
||||||
@@ -116,11 +130,11 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
emit('update:modelValue', v)
|
emit('update:modelValue', v)
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return { fetchTags, selected, isImageIcon, mergedOptions }
|
return { fetchTags, selected, isImageIcon, mergedOptions }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ export default {
|
|||||||
name: 'UserList',
|
name: 'UserList',
|
||||||
components: { BasePlaceholder },
|
components: { BasePlaceholder },
|
||||||
props: {
|
props: {
|
||||||
users: { type: Array, default: () => [] }
|
users: { type: Array, default: () => [] },
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleUserClick(user) {
|
handleUserClick(user) {
|
||||||
this.$router.push(`/users/${user.id}`)
|
this.$router.push(`/users/${user.id}`)
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -59,5 +59,4 @@ export default {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const toast = {
|
|||||||
console.warn('Toast not available:', error)
|
console.warn('Toast not available:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出 useToast composable
|
// 导出 useToast composable
|
||||||
@@ -59,7 +59,7 @@ export const useToast = () => {
|
|||||||
success: () => {},
|
success: () => {},
|
||||||
error: () => {},
|
error: () => {},
|
||||||
warning: () => {},
|
warning: () => {},
|
||||||
info: () => {}
|
info: () => {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -69,6 +69,6 @@ export const useToast = () => {
|
|||||||
success: () => {},
|
success: () => {},
|
||||||
error: () => {},
|
error: () => {},
|
||||||
warning: () => {},
|
warning: () => {},
|
||||||
info: () => {}
|
info: () => {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -22,28 +22,28 @@
|
|||||||
|
|
||||||
// 使用一个Map来存储所有指令绑定的元素及其对应的处理器
|
// 使用一个Map来存储所有指令绑定的元素及其对应的处理器
|
||||||
// 键是HTMLElement,值是一个包含处理器和回调函数的对象数组
|
// 键是HTMLElement,值是一个包含处理器和回调函数的对象数组
|
||||||
const nodeList = new Map();
|
const nodeList = new Map()
|
||||||
|
|
||||||
// 检查是否在客户端环境,以避免在SSR(服务器端渲染)时执行
|
// 检查是否在客户端环境,以避免在SSR(服务器端渲染)时执行
|
||||||
const isClient = typeof window !== 'undefined';
|
const isClient = typeof window !== 'undefined'
|
||||||
|
|
||||||
// 在客户端环境中,只设置一次全局的 mousedown 和 mouseup 监听器
|
// 在客户端环境中,只设置一次全局的 mousedown 和 mouseup 监听器
|
||||||
if (isClient) {
|
if (isClient) {
|
||||||
let startClick;
|
let startClick
|
||||||
|
|
||||||
document.addEventListener('mousedown', (e) => (startClick = e));
|
document.addEventListener('mousedown', (e) => (startClick = e))
|
||||||
|
|
||||||
document.addEventListener('mouseup', (e) => {
|
document.addEventListener('mouseup', (e) => {
|
||||||
// 遍历所有注册的元素和它们的处理器
|
// 遍历所有注册的元素和它们的处理器
|
||||||
for (const handlers of nodeList.values()) {
|
for (const handlers of nodeList.values()) {
|
||||||
for (const { documentHandler } of handlers) {
|
for (const { documentHandler } of handlers) {
|
||||||
// 调用每个处理器,传入 mouseup 和 mousedown 事件
|
// 调用每个处理器,传入 mouseup 和 mousedown 事件
|
||||||
documentHandler(e, startClick);
|
documentHandler(e, startClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 完成后重置 startClick
|
// 完成后重置 startClick
|
||||||
startClick = undefined;
|
startClick = undefined
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,35 +53,34 @@ if (isClient) {
|
|||||||
* @returns {Function} 返回一个处理函数。
|
* @returns {Function} 返回一个处理函数。
|
||||||
*/
|
*/
|
||||||
function createDocumentHandler(el, binding) {
|
function createDocumentHandler(el, binding) {
|
||||||
let excludes = [];
|
let excludes = []
|
||||||
// binding.arg 可以是一个元素或一个元素数组,用于排除不需要触发回调的点击
|
// binding.arg 可以是一个元素或一个元素数组,用于排除不需要触发回调的点击
|
||||||
if (Array.isArray(binding.arg)) {
|
if (Array.isArray(binding.arg)) {
|
||||||
excludes = binding.arg;
|
excludes = binding.arg
|
||||||
} else if (binding.arg instanceof HTMLElement) {
|
} else if (binding.arg instanceof HTMLElement) {
|
||||||
excludes.push(binding.arg);
|
excludes.push(binding.arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return function (mouseup, mousedown) {
|
return function (mouseup, mousedown) {
|
||||||
// 从组件实例中获取 popper 引用(如果存在),这对于处理下拉菜单、弹窗等很有用
|
// 从组件实例中获取 popper 引用(如果存在),这对于处理下拉菜单、弹窗等很有用
|
||||||
const popperRef = binding.instance?.popperRef;
|
const popperRef = binding.instance?.popperRef
|
||||||
const mouseUpTarget = mouseup.target;
|
const mouseUpTarget = mouseup.target
|
||||||
const mouseDownTarget = mousedown?.target;
|
const mouseDownTarget = mousedown?.target
|
||||||
|
|
||||||
// 检查各种条件,如果满足任一条件,则不执行回调
|
// 检查各种条件,如果满足任一条件,则不执行回调
|
||||||
const isBound = !binding || !binding.instance;
|
const isBound = !binding || !binding.instance
|
||||||
const isTargetExists = !mouseUpTarget || !mouseDownTarget;
|
const isTargetExists = !mouseUpTarget || !mouseDownTarget
|
||||||
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
|
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget)
|
||||||
const isSelf = el === mouseUpTarget;
|
const isSelf = el === mouseUpTarget
|
||||||
|
|
||||||
// 检查点击是否发生在任何被排除的元素内部
|
// 检查点击是否发生在任何被排除的元素内部
|
||||||
const isTargetExcluded =
|
const isTargetExcluded =
|
||||||
(excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
|
(excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
|
||||||
(excludes.length && excludes.includes(mouseDownTarget));
|
(excludes.length && excludes.includes(mouseDownTarget))
|
||||||
|
|
||||||
// 检查点击是否发生在关联的 popper 元素内部
|
// 检查点击是否发生在关联的 popper 元素内部
|
||||||
const isContainedByPopper =
|
const isContainedByPopper =
|
||||||
popperRef &&
|
popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget))
|
||||||
(popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isBound ||
|
isBound ||
|
||||||
@@ -91,12 +90,12 @@ function createDocumentHandler(el, binding) {
|
|||||||
isTargetExcluded ||
|
isTargetExcluded ||
|
||||||
isContainedByPopper
|
isContainedByPopper
|
||||||
) {
|
) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果所有检查都通过,说明点击发生在外部,执行指令传入的回调函数
|
// 如果所有检查都通过,说明点击发生在外部,执行指令传入的回调函数
|
||||||
binding.value(mouseup, mousedown);
|
binding.value(mouseup, mousedown)
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClickOutside = {
|
const ClickOutside = {
|
||||||
@@ -107,13 +106,13 @@ const ClickOutside = {
|
|||||||
*/
|
*/
|
||||||
beforeMount(el, binding) {
|
beforeMount(el, binding) {
|
||||||
if (!nodeList.has(el)) {
|
if (!nodeList.has(el)) {
|
||||||
nodeList.set(el, []);
|
nodeList.set(el, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeList.get(el).push({
|
nodeList.get(el).push({
|
||||||
documentHandler: createDocumentHandler(el, binding),
|
documentHandler: createDocumentHandler(el, binding),
|
||||||
bindingFn: binding.value,
|
bindingFn: binding.value,
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,26 +122,24 @@ const ClickOutside = {
|
|||||||
*/
|
*/
|
||||||
updated(el, binding) {
|
updated(el, binding) {
|
||||||
if (!nodeList.has(el)) {
|
if (!nodeList.has(el)) {
|
||||||
nodeList.set(el, []);
|
nodeList.set(el, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = nodeList.get(el);
|
const handlers = nodeList.get(el)
|
||||||
// 查找旧的回调函数对应的处理器
|
// 查找旧的回调函数对应的处理器
|
||||||
const oldHandlerIndex = handlers.findIndex(
|
const oldHandlerIndex = handlers.findIndex((item) => item.bindingFn === binding.oldValue)
|
||||||
(item) => item.bindingFn === binding.oldValue
|
|
||||||
);
|
|
||||||
|
|
||||||
const newHandler = {
|
const newHandler = {
|
||||||
documentHandler: createDocumentHandler(el, binding),
|
documentHandler: createDocumentHandler(el, binding),
|
||||||
bindingFn: binding.value,
|
bindingFn: binding.value,
|
||||||
};
|
}
|
||||||
|
|
||||||
if (oldHandlerIndex >= 0) {
|
if (oldHandlerIndex >= 0) {
|
||||||
// 如果找到了,就替换成新的处理器
|
// 如果找到了,就替换成新的处理器
|
||||||
handlers.splice(oldHandlerIndex, 1, newHandler);
|
handlers.splice(oldHandlerIndex, 1, newHandler)
|
||||||
} else {
|
} else {
|
||||||
// 否则,直接添加新的处理器
|
// 否则,直接添加新的处理器
|
||||||
handlers.push(newHandler);
|
handlers.push(newHandler)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -152,8 +149,8 @@ const ClickOutside = {
|
|||||||
*/
|
*/
|
||||||
unmounted(el) {
|
unmounted(el) {
|
||||||
// 当元素卸载时,从Map中移除它,以进行垃圾回收并防止内存泄漏
|
// 当元素卸载时,从Map中移除它,以进行垃圾回收并防止内存泄漏
|
||||||
nodeList.delete(el);
|
nodeList.delete(el)
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ClickOutside;
|
export default ClickOutside
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export const API_BASE_URL = 'https://www.open-isle.com'
|
export const API_BASE_URL = 'https://www.open-isle.com'
|
||||||
// export const API_BASE_URL = 'http://127.0.0.1:8081'
|
// export const API_BASE_URL = 'http://127.0.0.1:8081'
|
||||||
// export const API_BASE_URL = 'http://30.211.97.238:8081'
|
// export const API_BASE_URL = 'http://30.211.97.238:8081'
|
||||||
export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
|
export const GOOGLE_CLIENT_ID =
|
||||||
|
'777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
|
||||||
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
|
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
|
||||||
export const DISCORD_CLIENT_ID = '1394985417044000779'
|
export const DISCORD_CLIENT_ID = '1394985417044000779'
|
||||||
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
|
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ export default defineNuxtConfig({
|
|||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})();
|
})();
|
||||||
`
|
`,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
link: [
|
link: [
|
||||||
{
|
{
|
||||||
rel: 'stylesheet',
|
rel: 'stylesheet',
|
||||||
href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
||||||
referrerpolicy: 'no-referrer'
|
referrerpolicy: 'no-referrer',
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: 'NotFoundPageView'
|
name: 'NotFoundPageView',
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@
|
|||||||
<div class="about-loading" v-if="isFetching">
|
<div class="about-loading" v-if="isFetching">
|
||||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="about-content" v-html="renderMarkdown(content)" @click="handleContentClick"></div>
|
<div
|
||||||
|
v-else
|
||||||
|
class="about-content"
|
||||||
|
v-html="renderMarkdown(content)"
|
||||||
|
@click="handleContentClick"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -26,10 +31,26 @@ export default {
|
|||||||
setup() {
|
setup() {
|
||||||
const isFetching = ref(false)
|
const isFetching = ref(false)
|
||||||
const tabs = [
|
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: 'about',
|
||||||
{ name: 'guideline', label: '创作准则', file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/guideline.md' },
|
label: '关于',
|
||||||
{ name: 'privacy', label: '隐私政策', file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md' },
|
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 selectedTab = ref(tabs[0].name)
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
@@ -52,7 +73,7 @@ export default {
|
|||||||
|
|
||||||
const selectTab = (name) => {
|
const selectTab = (name) => {
|
||||||
selectedTab.value = name
|
selectedTab.value = name
|
||||||
const tab = tabs.find(t => t.name === name)
|
const tab = tabs.find((t) => t.name === name)
|
||||||
if (tab) loadContent(tab.file)
|
if (tab) loadContent(tab.file)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +81,12 @@ export default {
|
|||||||
loadContent(tabs[0].file)
|
loadContent(tabs[0].file)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleContentClick = e => {
|
const handleContentClick = (e) => {
|
||||||
handleMarkdownClick(e)
|
handleMarkdownClick(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tabs, selectedTab, content, renderMarkdown, selectTab, isFetching, handleContentClick }
|
return { tabs, selectedTab, content, renderMarkdown, selectTab, isFetching, handleContentClick }
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -117,5 +138,4 @@ export default {
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="site-stats-page">
|
<div class="site-stats-page">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<VChart v-if="option" :option="option" :autoresize="true" style="height:400px" />
|
<VChart v-if="option" :option="option" :autoresize="true" style="height: 400px" />
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -11,7 +11,12 @@ import { ref, onMounted } from 'vue'
|
|||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
import { use } from 'echarts/core'
|
import { use } from 'echarts/core'
|
||||||
import { LineChart } from 'echarts/charts'
|
import { LineChart } from 'echarts/charts'
|
||||||
import { TitleComponent, TooltipComponent, GridComponent, DataZoomComponent } from 'echarts/components'
|
import {
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
GridComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
} from 'echarts/components'
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import { API_BASE_URL } from '../main'
|
import { API_BASE_URL } from '../main'
|
||||||
import { getToken } from '../utils/auth'
|
import { getToken } from '../utils/auth'
|
||||||
@@ -23,20 +28,20 @@ const option = ref(null)
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(`${API_BASE_URL}/api/stats/dau-range?days=30`, {
|
const res = await fetch(`${API_BASE_URL}/api/stats/dau-range?days=30`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
data.sort((a, b) => new Date(a.date) - new Date(b.date))
|
data.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||||
const dates = data.map(d => d.date)
|
const dates = data.map((d) => d.date)
|
||||||
const values = data.map(d => d.value)
|
const values = data.map((d) => d.value)
|
||||||
option.value = {
|
option.value = {
|
||||||
title: { text: '站点 DAU' },
|
title: { text: '站点 DAU' },
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
xAxis: { type: 'category', data: dates },
|
xAxis: { type: 'category', data: dates },
|
||||||
yAxis: { type: 'value' },
|
yAxis: { type: 'value' },
|
||||||
dataZoom: [{ type: 'slider', start: 80 }, { type: 'inside' }],
|
dataZoom: [{ type: 'slider', start: 80 }, { type: 'inside' }],
|
||||||
series: [{ type: 'line', areaStyle: {}, smooth: true, data: values }]
|
series: [{ type: 'line', areaStyle: {}, smooth: true, data: values }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
activities: [],
|
activities: [],
|
||||||
TimeManager,
|
TimeManager,
|
||||||
isLoadingActivities: false
|
isLoadingActivities: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
@@ -111,7 +111,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.activity-list-page-card-content {
|
.activity-list-page-card-content {
|
||||||
font-size: 1.0rem;
|
font-size: 1rem;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
@@ -163,5 +163,4 @@ export default {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.$router.push('/')
|
this.$router.push('/')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default {
|
|||||||
passwordError: '',
|
passwordError: '',
|
||||||
isSending: false,
|
isSending: false,
|
||||||
isVerifying: false,
|
isVerifying: false,
|
||||||
isResetting: false
|
isResetting: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -59,7 +59,7 @@ export default {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email: this.email })
|
body: JSON.stringify({ email: this.email }),
|
||||||
})
|
})
|
||||||
this.isSending = false
|
this.isSending = false
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -79,7 +79,7 @@ export default {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email: this.email, code: this.code })
|
body: JSON.stringify({ email: this.email, code: this.code }),
|
||||||
})
|
})
|
||||||
this.isVerifying = false
|
this.isVerifying = false
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -104,7 +104,7 @@ export default {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ token: this.token, password: this.password })
|
body: JSON.stringify({ token: this.token, password: this.password }),
|
||||||
})
|
})
|
||||||
this.isResetting = false
|
this.isResetting = false
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -120,8 +120,8 @@ export default {
|
|||||||
this.isResetting = false
|
this.isResetting = false
|
||||||
toast.error('重置失败')
|
toast.error('重置失败')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.$router.push('/')
|
this.$router.push('/')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ export default {
|
|||||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||||
const idToken = hash.get('id_token')
|
const idToken = hash.get('id_token')
|
||||||
if (idToken) {
|
if (idToken) {
|
||||||
await googleAuthWithToken(idToken, () => {
|
await googleAuthWithToken(
|
||||||
this.$router.push('/')
|
idToken,
|
||||||
}, token => {
|
() => {
|
||||||
this.$router.push('/signup-reason?token=' + token)
|
this.$router.push('/')
|
||||||
})
|
},
|
||||||
|
(token) => {
|
||||||
|
this.$router.push('/signup-reason?token=' + token)
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
this.$router.push('/login')
|
this.$router.push('/login')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<div v-if="!isMobile" class="search-container">
|
<div v-if="!isMobile" class="search-container">
|
||||||
<div class="search-title">一切可能,从此刻启航</div>
|
<div class="search-title">一切可能,从此刻启航</div>
|
||||||
<div class="search-subtitle">愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。</div>
|
<div class="search-subtitle">
|
||||||
|
愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。
|
||||||
|
</div>
|
||||||
<SearchDropdown />
|
<SearchDropdown />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="topic-container">
|
<div class="topic-container">
|
||||||
<div class="topic-item-container">
|
<div class="topic-item-container">
|
||||||
<div
|
<div
|
||||||
@@ -26,7 +27,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-container">
|
<div class="article-container">
|
||||||
<template v-if="selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'">
|
<template
|
||||||
|
v-if="
|
||||||
|
selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'
|
||||||
|
"
|
||||||
|
>
|
||||||
<div class="article-header-container">
|
<div class="article-header-container">
|
||||||
<div class="header-item main-item">
|
<div class="header-item main-item">
|
||||||
<div class="header-item-text">话题</div>
|
<div class="header-item-text">话题</div>
|
||||||
@@ -62,7 +67,9 @@
|
|||||||
<i v-if="article.type === 'LOTTERY'" class="fa-solid fa-gift lottery-icon"></i>
|
<i v-if="article.type === 'LOTTERY'" class="fa-solid fa-gift lottery-icon"></i>
|
||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="article-item-description main-item">{{ sanitizeDescription(article.description) }}</div>
|
<div class="article-item-description main-item">
|
||||||
|
{{ sanitizeDescription(article.description) }}
|
||||||
|
</div>
|
||||||
<div class="article-info-container main-item">
|
<div class="article-info-container main-item">
|
||||||
<ArticleCategory :category="article.category" />
|
<ArticleCategory :category="article.category" />
|
||||||
<ArticleTags :tags="article.tags" />
|
<ArticleTags :tags="article.tags" />
|
||||||
@@ -96,14 +103,11 @@
|
|||||||
<div v-else-if="selectedTopic === '热门'" class="placeholder-container">
|
<div v-else-if="selectedTopic === '热门'" class="placeholder-container">
|
||||||
热门帖子功能开发中,敬请期待。
|
热门帖子功能开发中,敬请期待。
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="placeholder-container">
|
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||||
分类浏览功能开发中,敬请期待。
|
|
||||||
</div>
|
|
||||||
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
|
<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>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -130,7 +134,10 @@ export default {
|
|||||||
ArticleTags,
|
ArticleTags,
|
||||||
ArticleCategory,
|
ArticleCategory,
|
||||||
SearchDropdown,
|
SearchDropdown,
|
||||||
ClientOnly: () => import('vue').then(m => m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))))
|
ClientOnly: () =>
|
||||||
|
import('vue').then((m) =>
|
||||||
|
m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
async setup() {
|
async setup() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -144,9 +151,9 @@ export default {
|
|||||||
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
|
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
|
||||||
selectedTags.value = t
|
selectedTags.value = t
|
||||||
.split(',')
|
.split(',')
|
||||||
.filter(v => v)
|
.filter((v) => v)
|
||||||
.map(v => decodeURIComponent(v))
|
.map((v) => decodeURIComponent(v))
|
||||||
.map(v => (isNaN(v) ? v : Number(v)))
|
.map((v) => (isNaN(v) ? v : Number(v)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagOptions = ref([])
|
const tagOptions = ref([])
|
||||||
@@ -158,7 +165,7 @@ export default {
|
|||||||
? '排行榜'
|
? '排行榜'
|
||||||
: route.query.view === 'latest'
|
: route.query.view === 'latest'
|
||||||
? '最新'
|
? '最新'
|
||||||
: '最新回复'
|
: '最新回复',
|
||||||
)
|
)
|
||||||
|
|
||||||
const articles = ref([])
|
const articles = ref([])
|
||||||
@@ -174,7 +181,9 @@ export default {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
categoryOptions.value = [await res.json()]
|
categoryOptions.value = [await res.json()]
|
||||||
}
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTags.value.length) {
|
if (selectedTags.value.length) {
|
||||||
@@ -184,7 +193,9 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
||||||
if (r.ok) arr.push(await r.json())
|
if (r.ok) arr.push(await r.json())
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tagOptions.value = arr
|
tagOptions.value = arr
|
||||||
@@ -197,7 +208,7 @@ export default {
|
|||||||
url += `&categoryId=${selectedCategory.value}`
|
url += `&categoryId=${selectedCategory.value}`
|
||||||
}
|
}
|
||||||
if (selectedTags.value.length) {
|
if (selectedTags.value.length) {
|
||||||
selectedTags.value.forEach(t => {
|
selectedTags.value.forEach((t) => {
|
||||||
url += `&tagIds=${t}`
|
url += `&tagIds=${t}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -210,7 +221,7 @@ export default {
|
|||||||
url += `&categoryId=${selectedCategory.value}`
|
url += `&categoryId=${selectedCategory.value}`
|
||||||
}
|
}
|
||||||
if (selectedTags.value.length) {
|
if (selectedTags.value.length) {
|
||||||
selectedTags.value.forEach(t => {
|
selectedTags.value.forEach((t) => {
|
||||||
url += `&tagIds=${t}`
|
url += `&tagIds=${t}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -223,7 +234,7 @@ export default {
|
|||||||
url += `&categoryId=${selectedCategory.value}`
|
url += `&categoryId=${selectedCategory.value}`
|
||||||
}
|
}
|
||||||
if (selectedTags.value.length) {
|
if (selectedTags.value.length) {
|
||||||
selectedTags.value.forEach(t => {
|
selectedTags.value.forEach((t) => {
|
||||||
url += `&tagIds=${t}`
|
url += `&tagIds=${t}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -242,26 +253,26 @@ export default {
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(buildUrl(), {
|
const res = await fetch(buildUrl(), {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token ? `Bearer ${token}` : ''
|
Authorization: token ? `Bearer ${token}` : '',
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
isLoadingPosts.value = false
|
isLoadingPosts.value = false
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
articles.value.push(
|
articles.value.push(
|
||||||
...data.map(p => ({
|
...data.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
description: p.content,
|
description: p.content,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
tags: p.tags || [],
|
tags: p.tags || [],
|
||||||
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
time: TimeManager.format(p.createdAt),
|
time: TimeManager.format(p.createdAt),
|
||||||
pinned: !!p.pinnedAt,
|
pinned: !!p.pinnedAt,
|
||||||
type: p.type
|
type: p.type,
|
||||||
}))
|
})),
|
||||||
)
|
)
|
||||||
if (data.length < pageSize) {
|
if (data.length < pageSize) {
|
||||||
allLoaded.value = true
|
allLoaded.value = true
|
||||||
@@ -285,26 +296,26 @@ export default {
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(buildRankUrl(), {
|
const res = await fetch(buildRankUrl(), {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token ? `Bearer ${token}` : ''
|
Authorization: token ? `Bearer ${token}` : '',
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
isLoadingPosts.value = false
|
isLoadingPosts.value = false
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
articles.value.push(
|
articles.value.push(
|
||||||
...data.map(p => ({
|
...data.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
description: p.content,
|
description: p.content,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
tags: p.tags || [],
|
tags: p.tags || [],
|
||||||
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
time: TimeManager.format(p.createdAt),
|
time: TimeManager.format(p.createdAt),
|
||||||
pinned: !!p.pinnedAt,
|
pinned: !!p.pinnedAt,
|
||||||
type: p.type
|
type: p.type,
|
||||||
}))
|
})),
|
||||||
)
|
)
|
||||||
if (data.length < pageSize) {
|
if (data.length < pageSize) {
|
||||||
allLoaded.value = true
|
allLoaded.value = true
|
||||||
@@ -328,26 +339,26 @@ export default {
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(buildReplyUrl(), {
|
const res = await fetch(buildReplyUrl(), {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token ? `Bearer ${token}` : ''
|
Authorization: token ? `Bearer ${token}` : '',
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
isLoadingPosts.value = false
|
isLoadingPosts.value = false
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
articles.value.push(
|
articles.value.push(
|
||||||
...data.map(p => ({
|
...data.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
description: p.content,
|
description: p.content,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
tags: p.tags || [],
|
tags: p.tags || [],
|
||||||
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
time: TimeManager.format(p.lastReplyAt || p.createdAt),
|
time: TimeManager.format(p.lastReplyAt || p.createdAt),
|
||||||
pinned: !!p.pinnedAt,
|
pinned: !!p.pinnedAt,
|
||||||
type: p.type
|
type: p.type,
|
||||||
}))
|
})),
|
||||||
)
|
)
|
||||||
if (data.length < pageSize) {
|
if (data.length < pageSize) {
|
||||||
allLoaded.value = true
|
allLoaded.value = true
|
||||||
@@ -379,7 +390,7 @@ export default {
|
|||||||
fetchContent(true)
|
fetchContent(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
const sanitizeDescription = text => stripMarkdown(text)
|
const sanitizeDescription = (text) => stripMarkdown(text)
|
||||||
|
|
||||||
await Promise.all([loadOptions(), fetchContent()])
|
await Promise.all([loadOptions(), fetchContent()])
|
||||||
|
|
||||||
@@ -393,9 +404,9 @@ export default {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
tagOptions,
|
tagOptions,
|
||||||
categoryOptions,
|
categoryOptions,
|
||||||
isMobile
|
isMobile,
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -427,7 +438,6 @@ export default {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -517,7 +527,6 @@ export default {
|
|||||||
border-bottom: 1px solid var(--normal-border-color);
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.article-main-container,
|
.article-main-container,
|
||||||
.header-item.main-item {
|
.header-item.main-item {
|
||||||
width: calc(60% - 20px);
|
width: calc(60% - 20px);
|
||||||
@@ -677,7 +686,7 @@ export default {
|
|||||||
.header-item.activity {
|
.header-item.activity {
|
||||||
width: 10%;
|
width: 10%;
|
||||||
}
|
}
|
||||||
.article-member-avatar-item:nth-child(n+4) {
|
.article-member-avatar-item:nth-child(n + 4) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -714,7 +723,7 @@ export default {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-member-avatar-item:nth-child(n+2) {
|
.article-member-avatar-item:nth-child(n + 2) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,5 +751,4 @@ export default {
|
|||||||
position: initial;
|
position: initial;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
<div class="login-page-content">
|
<div class="login-page-content">
|
||||||
<div class="login-page-header">
|
<div class="login-page-header">
|
||||||
<div class="login-page-header-title">
|
<div class="login-page-header-title">Welcome :)</div>
|
||||||
Welcome :)
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="email-login-page-content">
|
<div class="email-login-page-content">
|
||||||
@@ -12,7 +10,6 @@
|
|||||||
|
|
||||||
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="密码" />
|
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="密码" />
|
||||||
|
|
||||||
|
|
||||||
<div v-if="!isWaitingForLogin" class="login-page-button-primary" @click="submitLogin">
|
<div v-if="!isWaitingForLogin" class="login-page-button-primary" @click="submitLogin">
|
||||||
<div class="login-page-button-text">登录</div>
|
<div class="login-page-button-text">登录</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,8 +21,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="login-page-button-secondary">没有账号? <a class="login-page-button-secondary-link" href="/signup">注册</a> |
|
<div class="login-page-button-secondary">
|
||||||
<a class="login-page-button-secondary-link" :href="`/forgot-password?email=${username}`">找回密码</a>
|
没有账号? <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>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +70,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
isWaitingForLogin: false
|
isWaitingForLogin: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -80,7 +80,7 @@ export default {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: this.username, password: this.password })
|
body: JSON.stringify({ username: this.username, password: this.password }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
@@ -116,7 +116,7 @@ export default {
|
|||||||
loginWithTwitter() {
|
loginWithTwitter() {
|
||||||
twitterAuthorize()
|
twitterAuthorize()
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+250
-88
@@ -2,17 +2,24 @@
|
|||||||
<div class="message-page">
|
<div class="message-page">
|
||||||
<div class="message-page-header">
|
<div class="message-page-header">
|
||||||
<div class="message-tabs">
|
<div class="message-tabs">
|
||||||
<div :class="['message-tab-item', { selected: selectedTab === 'all' }]" @click="selectedTab = 'all'">消息</div>
|
<div
|
||||||
<div :class="['message-tab-item', { selected: selectedTab === 'unread' }]" @click="selectedTab = 'unread'">未读
|
: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>
|
</div>
|
||||||
|
|
||||||
<div class="message-page-header-right">
|
<div class="message-page-header-right">
|
||||||
<div class="message-page-header-right-item" @click="markAllRead">
|
<div class="message-page-header-right-item" @click="markAllRead">
|
||||||
<i class="fas fa-bolt message-page-header-right-item-button-icon"></i>
|
<i class="fas fa-bolt message-page-header-right-item-button-icon"></i>
|
||||||
<span class="message-page-header-right-item-button-text">
|
<span class="message-page-header-right-item-button-text"> 已读所有消息 </span>
|
||||||
已读所有消息
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,7 +28,11 @@
|
|||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BasePlaceholder v-else-if="filteredNotifications.length === 0" text="暂时没有消息 :)" icon="fas fa-inbox" />
|
<BasePlaceholder
|
||||||
|
v-else-if="filteredNotifications.length === 0"
|
||||||
|
text="暂时没有消息 :)"
|
||||||
|
icon="fas fa-inbox"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="timeline-container" v-if="filteredNotifications.length > 0">
|
<div class="timeline-container" v-if="filteredNotifications.length > 0">
|
||||||
<BaseTimeline :items="filteredNotifications">
|
<BaseTimeline :items="filteredNotifications">
|
||||||
@@ -31,16 +42,29 @@
|
|||||||
<span class="notif-type">
|
<span class="notif-type">
|
||||||
<template v-if="item.type === 'COMMENT_REPLY' && item.parentComment">
|
<template v-if="item.type === 'COMMENT_REPLY' && item.parentComment">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/users/${item.comment.author.id}`">{{ item.comment.author.username }} </router-link> 对我的评论
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.comment.author.id}`"
|
||||||
|
>{{ item.comment.author.username }}
|
||||||
|
</router-link>
|
||||||
|
对我的评论
|
||||||
<span>
|
<span>
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`">
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span> 回复了 <span>
|
</span>
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
回复了
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
<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) }}
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span>
|
</span>
|
||||||
@@ -48,15 +72,29 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'COMMENT_REPLY' && !item.parentComment">
|
<template v-else-if="item.type === 'COMMENT_REPLY' && !item.parentComment">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/users/${item.comment.author.id}`">{{ item.comment.author.username }} </router-link> 对我的文章
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.comment.author.id}`"
|
||||||
|
>{{ item.comment.author.username }}
|
||||||
|
</router-link>
|
||||||
|
对我的文章
|
||||||
<span>
|
<span>
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span> 回复了 <span>
|
</span>
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
回复了
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
<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) }}
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span>
|
</span>
|
||||||
@@ -64,14 +102,19 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'ACTIVITY_REDEEM' && !item.parentComment">
|
<template v-else-if="item.type === 'ACTIVITY_REDEEM' && !item.parentComment">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<span class="notif-user">{{ item.fromUser.username }} </span> 申请进行奶茶兑换,联系方式是:{{ item.content }}
|
<span class="notif-user">{{ item.fromUser.username }} </span>
|
||||||
|
申请进行奶茶兑换,联系方式是:{{ item.content }}
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
|
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
||||||
<span>
|
<span>
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span>
|
</span>
|
||||||
@@ -80,11 +123,19 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'REACTION' && item.comment">
|
<template v-else-if="item.type === 'REACTION' && item.comment">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/users/${item.fromUser.id}`">{{ item.fromUser.username }} </router-link> 对我的评论
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>{{ item.fromUser.username }}
|
||||||
|
</router-link>
|
||||||
|
对我的评论
|
||||||
<span>
|
<span>
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span>
|
</span>
|
||||||
@@ -93,11 +144,19 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_VIEWED'">
|
<template v-else-if="item.type === 'POST_VIEWED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
{{ item.fromUser.username }}
|
{{ item.fromUser.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
查看了您的帖子
|
查看了您的帖子
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -105,12 +164,19 @@
|
|||||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
下面有新评论
|
下面有新评论
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -118,18 +184,27 @@
|
|||||||
<template v-else-if="item.type === 'USER_ACTIVITY' && item.parentComment">
|
<template v-else-if="item.type === 'USER_ACTIVITY' && item.parentComment">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
你关注的
|
你关注的
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/users/${item.comment.author.id}`">
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.comment.author.id}`"
|
||||||
|
>
|
||||||
{{ item.comment.author.username }}
|
{{ item.comment.author.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
在 对评论
|
在 对评论
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`">
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
回复了
|
回复了
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -137,40 +212,65 @@
|
|||||||
<template v-else-if="item.type === 'USER_ACTIVITY'">
|
<template v-else-if="item.type === 'USER_ACTIVITY'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
你关注的
|
你关注的
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/users/${item.comment.author.id}`">
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.comment.author.id}`"
|
||||||
|
>
|
||||||
{{ item.comment.author.username }}
|
{{ item.comment.author.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
在文章
|
在文章
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
下面评论了
|
下面评论了
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'MENTION' && item.comment">
|
<template v-else-if="item.type === 'MENTION' && item.comment">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
{{ item.fromUser.username }}
|
{{ item.fromUser.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
在评论中提到了你:
|
在评论中提到了你:
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
<router-link
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'MENTION'">
|
<template v-else-if="item.type === 'MENTION'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
{{ item.fromUser.username }}
|
{{ item.fromUser.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
在帖子
|
在帖子
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
中提到了你
|
中提到了你
|
||||||
@@ -178,7 +278,11 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'USER_FOLLOWED'">
|
<template v-else-if="item.type === 'USER_FOLLOWED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
{{ item.fromUser.username }}
|
{{ item.fromUser.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
开始关注你了
|
开始关注你了
|
||||||
@@ -186,7 +290,11 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'USER_UNFOLLOWED'">
|
<template v-else-if="item.type === 'USER_UNFOLLOWED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
{{ item.fromUser.username }}
|
{{ item.fromUser.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
取消关注你了
|
取消关注你了
|
||||||
@@ -195,44 +303,76 @@
|
|||||||
<template v-else-if="item.type === 'FOLLOWED_POST'">
|
<template v-else-if="item.type === 'FOLLOWED_POST'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
你关注的
|
你关注的
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
{{ item.fromUser.username }}
|
{{ item.fromUser.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
发布了文章
|
发布了文章
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_SUBSCRIBED'">
|
<template v-else-if="item.type === 'POST_SUBSCRIBED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
{{ item.fromUser.username }}
|
{{ item.fromUser.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
订阅了你的文章
|
订阅了你的文章
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_UNSUBSCRIBED'">
|
<template v-else-if="item.type === 'POST_UNSUBSCRIBED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
{{ item.fromUser.username }}
|
{{ item.fromUser.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
取消订阅了你的文章
|
取消订阅了你的文章
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_REVIEW_REQUEST' && item.fromUser">
|
<template v-else-if="item.type === 'POST_REVIEW_REQUEST' && item.fromUser">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
{{ item.fromUser.username }}
|
{{ item.fromUser.username }}
|
||||||
</router-link>
|
</router-link>
|
||||||
发布了帖子
|
发布了帖子
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
,请审核
|
,请审核
|
||||||
@@ -241,7 +381,11 @@
|
|||||||
<template v-else-if="item.type === 'POST_REVIEW_REQUEST'">
|
<template v-else-if="item.type === 'POST_REVIEW_REQUEST'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您发布的帖子
|
您发布的帖子
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
已提交审核
|
已提交审核
|
||||||
@@ -252,8 +396,18 @@
|
|||||||
{{ item.fromUser.username }} 希望注册为会员,理由是:{{ item.content }}
|
{{ item.fromUser.username }} 希望注册为会员,理由是:{{ item.content }}
|
||||||
<template #actions v-if="authState.role === 'ADMIN'">
|
<template #actions v-if="authState.role === 'ADMIN'">
|
||||||
<div v-if="!item.read" class="optional-buttons">
|
<div v-if="!item.read" class="optional-buttons">
|
||||||
<div class="mark-approve-button-item" @click="approve(item.fromUser.id, item.id)">同意</div>
|
<div
|
||||||
<div class="mark-reject-button-item" @click="reject(item.fromUser.id, item.id)">拒绝</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>
|
||||||
<div v-else class="has_read_button" @click="markRead(item.id)">已读</div>
|
<div v-else class="has_read_button" @click="markRead(item.id)">已读</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -262,7 +416,11 @@
|
|||||||
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved">
|
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您发布的帖子
|
您发布的帖子
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
已审核通过
|
已审核通过
|
||||||
@@ -271,7 +429,11 @@
|
|||||||
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved === false">
|
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved === false">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您发布的帖子
|
您发布的帖子
|
||||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
已被管理员拒绝
|
已被管理员拒绝
|
||||||
@@ -316,12 +478,12 @@ export default {
|
|||||||
const filteredNotifications = computed(() =>
|
const filteredNotifications = computed(() =>
|
||||||
selectedTab.value === 'all'
|
selectedTab.value === 'all'
|
||||||
? notifications.value
|
? notifications.value
|
||||||
: notifications.value.filter(n => !n.read)
|
: notifications.value.filter((n) => !n.read),
|
||||||
)
|
)
|
||||||
|
|
||||||
const markRead = async id => {
|
const markRead = async (id) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
const n = notifications.value.find(n => n.id === id)
|
const n = notifications.value.find((n) => n.id === id)
|
||||||
if (!n || n.read) return
|
if (!n || n.read) return
|
||||||
n.read = true
|
n.read = true
|
||||||
if (notificationState.unreadCount > 0) notificationState.unreadCount--
|
if (notificationState.unreadCount > 0) notificationState.unreadCount--
|
||||||
@@ -337,16 +499,16 @@ export default {
|
|||||||
const markAllRead = async () => {
|
const markAllRead = async () => {
|
||||||
// 除了 REGISTER_REQUEST 类型消息
|
// 除了 REGISTER_REQUEST 类型消息
|
||||||
const idsToMark = notifications.value
|
const idsToMark = notifications.value
|
||||||
.filter(n => n.type !== 'REGISTER_REQUEST' && !n.read)
|
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
|
||||||
.map(n => n.id)
|
.map((n) => n.id)
|
||||||
if (idsToMark.length === 0) return
|
if (idsToMark.length === 0) return
|
||||||
notifications.value.forEach(n => {
|
notifications.value.forEach((n) => {
|
||||||
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
||||||
})
|
})
|
||||||
notificationState.unreadCount = notifications.value.filter(n => !n.read).length
|
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
|
||||||
const ok = await markNotificationsRead(idsToMark)
|
const ok = await markNotificationsRead(idsToMark)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
notifications.value.forEach(n => {
|
notifications.value.forEach((n) => {
|
||||||
if (idsToMark.includes(n.id)) n.read = false
|
if (idsToMark.includes(n.id)) n.read = false
|
||||||
})
|
})
|
||||||
await fetchUnreadCount()
|
await fetchUnreadCount()
|
||||||
@@ -374,7 +536,7 @@ export default {
|
|||||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||||
MENTION: 'fas fa-at'
|
MENTION: 'fas fa-at',
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchNotifications = async () => {
|
const fetchNotifications = async () => {
|
||||||
@@ -387,8 +549,8 @@ export default {
|
|||||||
isLoadingMessage.value = true
|
isLoadingMessage.value = true
|
||||||
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
isLoadingMessage.value = false
|
isLoadingMessage.value = false
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -405,7 +567,7 @@ export default {
|
|||||||
iconClick: () => {
|
iconClick: () => {
|
||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/users/${n.comment.author.id}`)
|
router.push(`/users/${n.comment.author.id}`)
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'REACTION') {
|
} else if (n.type === 'REACTION') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -416,7 +578,7 @@ export default {
|
|||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
router.push(`/users/${n.fromUser.id}`)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'POST_VIEWED') {
|
} else if (n.type === 'POST_VIEWED') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -428,7 +590,7 @@ export default {
|
|||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
router.push(`/users/${n.fromUser.id}`)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'POST_UPDATED') {
|
} else if (n.type === 'POST_UPDATED') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -437,7 +599,7 @@ export default {
|
|||||||
iconClick: () => {
|
iconClick: () => {
|
||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/users/${n.comment.author.id}`)
|
router.push(`/users/${n.comment.author.id}`)
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'USER_ACTIVITY') {
|
} else if (n.type === 'USER_ACTIVITY') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -446,7 +608,7 @@ export default {
|
|||||||
iconClick: () => {
|
iconClick: () => {
|
||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/users/${n.comment.author.id}`)
|
router.push(`/users/${n.comment.author.id}`)
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'MENTION') {
|
} else if (n.type === 'MENTION') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -457,7 +619,7 @@ export default {
|
|||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
router.push(`/users/${n.fromUser.id}`)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -468,7 +630,7 @@ export default {
|
|||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
router.push(`/users/${n.fromUser.id}`)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'FOLLOWED_POST') {
|
} else if (n.type === 'FOLLOWED_POST') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -479,7 +641,7 @@ export default {
|
|||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/posts/${n.post.id}`)
|
router.push(`/posts/${n.post.id}`)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -490,7 +652,7 @@ export default {
|
|||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/posts/${n.post.id}`)
|
router.push(`/posts/${n.post.id}`)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -502,13 +664,13 @@ export default {
|
|||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
router.push(`/posts/${n.post.id}`)
|
router.push(`/posts/${n.post.id}`)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'REGISTER_REQUEST') {
|
} else if (n.type === 'REGISTER_REQUEST') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
...n,
|
...n,
|
||||||
icon: iconMap[n.type],
|
icon: iconMap[n.type],
|
||||||
iconClick: () => { }
|
iconClick: () => {},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -527,7 +689,7 @@ export default {
|
|||||||
if (!token) return
|
if (!token) return
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
markRead(nid)
|
markRead(nid)
|
||||||
@@ -542,7 +704,7 @@ export default {
|
|||||||
if (!token) return
|
if (!token) return
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
markRead(nid)
|
markRead(nid)
|
||||||
@@ -552,7 +714,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatType = t => {
|
const formatType = (t) => {
|
||||||
switch (t) {
|
switch (t) {
|
||||||
case 'POST_VIEWED':
|
case 'POST_VIEWED':
|
||||||
return '帖子被查看'
|
return '帖子被查看'
|
||||||
@@ -599,9 +761,9 @@ export default {
|
|||||||
selectedTab,
|
selectedTab,
|
||||||
filteredNotifications,
|
filteredNotifications,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
authState
|
authState,
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,7 @@
|
|||||||
<PostTypeSelect v-model="postType" />
|
<PostTypeSelect v-model="postType" />
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options-right">
|
<div class="post-options-right">
|
||||||
<div class="post-clear" @click="clearPost">
|
<div class="post-clear" @click="clearPost"><i class="fa-solid fa-eraser"></i> 清空</div>
|
||||||
<i class="fa-solid fa-eraser"></i> 清空
|
|
||||||
</div>
|
|
||||||
<div class="ai-generate" @click="aiGenerate">
|
<div class="ai-generate" @click="aiGenerate">
|
||||||
<i class="fa-solid fa-robot"></i>
|
<i class="fa-solid fa-robot"></i>
|
||||||
md格式优化
|
md格式优化
|
||||||
@@ -29,8 +27,12 @@
|
|||||||
class="post-submit"
|
class="post-submit"
|
||||||
:class="{ disabled: !isLogin }"
|
:class="{ disabled: !isLogin }"
|
||||||
@click="submitPost"
|
@click="submitPost"
|
||||||
>发布</div>
|
>
|
||||||
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 发布中...</div>
|
发布
|
||||||
|
</div>
|
||||||
|
<div v-else class="post-submit-loading">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin"></i> 发布中...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="postType === 'LOTTERY'" class="lottery-section">
|
<div v-if="postType === 'LOTTERY'" class="lottery-section">
|
||||||
@@ -55,7 +57,12 @@
|
|||||||
<div class="prize-count-row">
|
<div class="prize-count-row">
|
||||||
<span>奖品数量</span>
|
<span>奖品数量</span>
|
||||||
<div class="prize-count-input">
|
<div class="prize-count-input">
|
||||||
<input class="prize-count-input-field" type="number" v-model.number="prizeCount" min="1" />
|
<input
|
||||||
|
class="prize-count-input-field"
|
||||||
|
type="number"
|
||||||
|
v-model.number="prizeCount"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prize-time-row">
|
<div class="prize-time-row">
|
||||||
@@ -85,7 +92,15 @@ import BaseInput from '../components/BaseInput.vue'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'NewPostPageView',
|
name: 'NewPostPageView',
|
||||||
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay, PostTypeSelect, AvatarCropper, FlatPickr },
|
components: {
|
||||||
|
PostEditor,
|
||||||
|
CategorySelect,
|
||||||
|
TagSelect,
|
||||||
|
LoginOverlay,
|
||||||
|
PostTypeSelect,
|
||||||
|
AvatarCropper,
|
||||||
|
FlatPickr,
|
||||||
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
@@ -106,7 +121,7 @@ export default {
|
|||||||
const isAiLoading = ref(false)
|
const isAiLoading = ref(false)
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
|
|
||||||
const onPrizeIconChange = e => {
|
const onPrizeIconChange = (e) => {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (file) {
|
if (file) {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -123,7 +138,7 @@ export default {
|
|||||||
prizeIcon.value = url
|
prizeIcon.value = url
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(prizeCount, val => {
|
watch(prizeCount, (val) => {
|
||||||
if (!val || val < 1) prizeCount.value = 1
|
if (!val || val < 1) prizeCount.value = 1
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -132,7 +147,7 @@ export default {
|
|||||||
if (!token) return
|
if (!token) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok && res.status !== 204) {
|
if (res.ok && res.status !== 204) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -171,8 +186,8 @@ export default {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('草稿已清空')
|
toast.success('草稿已清空')
|
||||||
@@ -189,19 +204,19 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const tagIds = selectedTags.value.filter(t => typeof t === 'number')
|
const tagIds = selectedTags.value.filter((t) => typeof t === 'number')
|
||||||
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
|
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: title.value,
|
title: title.value,
|
||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value || null,
|
categoryId: selectedCategory.value || null,
|
||||||
tagIds
|
tagIds,
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('草稿已保存')
|
toast.success('草稿已保存')
|
||||||
@@ -221,9 +236,9 @@ export default {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name, description: '' })
|
body: JSON.stringify({ name, description: '' }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -256,9 +271,9 @@ export default {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ text: content.value })
|
body: JSON.stringify({ text: content.value }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -321,7 +336,7 @@ export default {
|
|||||||
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: form
|
body: form,
|
||||||
})
|
})
|
||||||
const uploadData = await uploadRes.json()
|
const uploadData = await uploadRes.json()
|
||||||
if (!uploadRes.ok || uploadData.code !== 0) {
|
if (!uploadRes.ok || uploadData.code !== 0) {
|
||||||
@@ -334,7 +349,7 @@ export default {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: title.value,
|
title: title.value,
|
||||||
@@ -346,10 +361,14 @@ export default {
|
|||||||
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
|
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
|
||||||
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
|
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
|
||||||
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
|
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
|
||||||
startTime: postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
startTime:
|
||||||
|
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
||||||
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
||||||
endTime: postType.value === 'LOTTERY' ? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString() : undefined
|
endTime:
|
||||||
})
|
postType.value === 'LOTTERY'
|
||||||
|
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -372,8 +391,31 @@ export default {
|
|||||||
isWaitingPosting.value = false
|
isWaitingPosting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { title, content, selectedCategory, selectedTags, postType, prizeIcon, prizeCount, endTime, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin, onPrizeIconChange, onPrizeCropped, showPrizeCropper, tempPrizeIcon, dateConfig, prizeName, prizeDescription }
|
return {
|
||||||
}
|
title,
|
||||||
|
content,
|
||||||
|
selectedCategory,
|
||||||
|
selectedTags,
|
||||||
|
postType,
|
||||||
|
prizeIcon,
|
||||||
|
prizeCount,
|
||||||
|
endTime,
|
||||||
|
submitPost,
|
||||||
|
saveDraft,
|
||||||
|
clearPost,
|
||||||
|
isWaitingPosting,
|
||||||
|
aiGenerate,
|
||||||
|
isAiLoading,
|
||||||
|
isLogin,
|
||||||
|
onPrizeIconChange,
|
||||||
|
onPrizeCropped,
|
||||||
|
showPrizeCropper,
|
||||||
|
tempPrizeIcon,
|
||||||
|
dateConfig,
|
||||||
|
prizeName,
|
||||||
|
prizeDescription,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -421,8 +463,6 @@ export default {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.post-clear {
|
.post-clear {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -12,23 +12,23 @@
|
|||||||
<TagSelect v-model="selectedTags" creatable />
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options-right">
|
<div class="post-options-right">
|
||||||
<div class="post-clear" @click="clearPost">
|
<div class="post-clear" @click="clearPost"><i class="fa-solid fa-eraser"></i> 清空</div>
|
||||||
<i class="fa-solid fa-eraser"></i> 清空
|
|
||||||
</div>
|
|
||||||
<div class="ai-generate" @click="aiGenerate">
|
<div class="ai-generate" @click="aiGenerate">
|
||||||
<i class="fa-solid fa-robot"></i>
|
<i class="fa-solid fa-robot"></i>
|
||||||
md格式优化
|
md格式优化
|
||||||
</div>
|
</div>
|
||||||
<div class="post-cancel" @click="cancelEdit">
|
<div class="post-cancel" @click="cancelEdit">取消</div>
|
||||||
取消
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="!isWaitingPosting"
|
v-if="!isWaitingPosting"
|
||||||
class="post-submit"
|
class="post-submit"
|
||||||
:class="{ disabled: !isLogin }"
|
:class="{ disabled: !isLogin }"
|
||||||
@click="submitPost"
|
@click="submitPost"
|
||||||
>更新</div>
|
>
|
||||||
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 更新中...</div>
|
更新
|
||||||
|
</div>
|
||||||
|
<div v-else class="post-submit-loading">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin"></i> 更新中...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,14 +65,14 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
title.value = data.title || ''
|
title.value = data.title || ''
|
||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
selectedCategory.value = data.category.id || ''
|
selectedCategory.value = data.category.id || ''
|
||||||
selectedTags.value = (data.tags || []).map(t => t.id)
|
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('加载失败')
|
toast.error('加载失败')
|
||||||
@@ -97,9 +97,9 @@ export default {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name, description: '' })
|
body: JSON.stringify({ name, description: '' }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -132,9 +132,9 @@ export default {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ text: content.value })
|
body: JSON.stringify({ text: content.value }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -176,14 +176,14 @@ export default {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: title.value,
|
title: title.value,
|
||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value,
|
categoryId: selectedCategory.value,
|
||||||
tagIds: selectedTags.value
|
tagIds: selectedTags.value,
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -201,8 +201,20 @@ export default {
|
|||||||
const cancelEdit = () => {
|
const cancelEdit = () => {
|
||||||
router.push(`/posts/${postId}`)
|
router.push(`/posts/${postId}`)
|
||||||
}
|
}
|
||||||
return { title, content, selectedCategory, selectedTags, submitPost, clearPost, cancelEdit, isWaitingPosting, aiGenerate, isAiLoading, isLogin }
|
return {
|
||||||
}
|
title,
|
||||||
|
content,
|
||||||
|
selectedCategory,
|
||||||
|
selectedTags,
|
||||||
|
submitPost,
|
||||||
|
clearPost,
|
||||||
|
cancelEdit,
|
||||||
|
isWaitingPosting,
|
||||||
|
aiGenerate,
|
||||||
|
isAiLoading,
|
||||||
|
isLogin,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -252,8 +264,6 @@ export default {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.post-clear {
|
.post-clear {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -13,19 +13,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="article-title-container-right">
|
<div class="article-title-container-right">
|
||||||
<div v-if="status === 'PENDING'" class="article-pending-button">
|
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||||||
审核中
|
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||||
</div>
|
<div
|
||||||
<div v-if="status === 'REJECTED'" class="article-block-button">
|
v-if="loggedIn && !isAuthor && !subscribed"
|
||||||
已拒绝
|
class="article-subscribe-button"
|
||||||
</div>
|
@click="subscribePost"
|
||||||
<div v-if="loggedIn && !isAuthor && !subscribed" class="article-subscribe-button" @click="subscribePost">
|
>
|
||||||
<i class="fas fa-user-plus"></i>
|
<i class="fas fa-user-plus"></i>
|
||||||
<div class="article-subscribe-button-text">
|
<div class="article-subscribe-button-text">
|
||||||
{{ isMobile ? '订阅' : '订阅文章' }}
|
{{ isMobile ? '订阅' : '订阅文章' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loggedIn && !isAuthor && subscribed" class="article-unsubscribe-button" @click="unsubscribePost">
|
<div
|
||||||
|
v-if="loggedIn && !isAuthor && subscribed"
|
||||||
|
class="article-unsubscribe-button"
|
||||||
|
@click="unsubscribePost"
|
||||||
|
>
|
||||||
<i class="fas fa-user-minus"></i>
|
<i class="fas fa-user-minus"></i>
|
||||||
<div class="article-unsubscribe-button-text">
|
<div class="article-unsubscribe-button-text">
|
||||||
{{ isMobile ? '退订' : '取消订阅' }}
|
{{ isMobile ? '退订' : '取消订阅' }}
|
||||||
@@ -42,14 +46,18 @@
|
|||||||
<div class="info-content-container author-info-container">
|
<div class="info-content-container author-info-container">
|
||||||
<div class="user-avatar-container" @click="gotoProfile">
|
<div class="user-avatar-container" @click="gotoProfile">
|
||||||
<div class="user-avatar-item">
|
<div class="user-avatar-item">
|
||||||
<img class="user-avatar-item-img" :src="author.avatar" alt="avatar">
|
<img class="user-avatar-item-img" :src="author.avatar" alt="avatar" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isMobile" class="info-content-header">
|
<div v-if="isMobile" class="info-content-header">
|
||||||
<div class="user-name">
|
<div class="user-name">
|
||||||
{{ author.username }}
|
{{ author.username }}
|
||||||
<i class="fas fa-medal medal-icon"></i>
|
<i class="fas fa-medal medal-icon"></i>
|
||||||
<router-link v-if="author.displayMedal" class="user-medal" :to="`/users/${author.id}?tab=achievements`">{{
|
<router-link
|
||||||
getMedalTitle(author.displayMedal) }}</router-link>
|
v-if="author.displayMedal"
|
||||||
|
class="user-medal"
|
||||||
|
:to="`/users/${author.id}?tab=achievements`"
|
||||||
|
>{{ getMedalTitle(author.displayMedal) }}</router-link
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-time">{{ postTime }}</div>
|
<div class="post-time">{{ postTime }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,12 +68,20 @@
|
|||||||
<div class="user-name">
|
<div class="user-name">
|
||||||
{{ author.username }}
|
{{ author.username }}
|
||||||
<i class="fas fa-medal medal-icon"></i>
|
<i class="fas fa-medal medal-icon"></i>
|
||||||
<router-link v-if="author.displayMedal" class="user-medal" :to="`/users/${author.id}?tab=achievements`">{{
|
<router-link
|
||||||
getMedalTitle(author.displayMedal) }}</router-link>
|
v-if="author.displayMedal"
|
||||||
|
class="user-medal"
|
||||||
|
:to="`/users/${author.id}?tab=achievements`"
|
||||||
|
>{{ getMedalTitle(author.displayMedal) }}</router-link
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-time">{{ postTime }}</div>
|
<div class="post-time">{{ postTime }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-content-text" v-html="renderMarkdown(postContent)" @click="handleContentClick"></div>
|
<div
|
||||||
|
class="info-content-text"
|
||||||
|
v-html="renderMarkdown(postContent)"
|
||||||
|
@click="handleContentClick"
|
||||||
|
></div>
|
||||||
|
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
|
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
|
||||||
@@ -82,7 +98,12 @@
|
|||||||
<div class="prize-info">
|
<div class="prize-info">
|
||||||
<div class="prize-info-left">
|
<div class="prize-info-left">
|
||||||
<div class="prize-icon">
|
<div class="prize-icon">
|
||||||
<img class="prize-icon-img" v-if="lottery.prizeIcon" :src="lottery.prizeIcon" alt="prize" />
|
<img
|
||||||
|
class="prize-icon-img"
|
||||||
|
v-if="lottery.prizeIcon"
|
||||||
|
:src="lottery.prizeIcon"
|
||||||
|
alt="prize"
|
||||||
|
/>
|
||||||
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
|
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="prize-name">{{ lottery.prizeDescription }}</div>
|
<div class="prize-name">{{ lottery.prizeDescription }}</div>
|
||||||
@@ -91,7 +112,11 @@
|
|||||||
<div class="prize-end-time prize-info-right">
|
<div class="prize-end-time prize-info-right">
|
||||||
<div class="prize-end-time-title">离结束还有</div>
|
<div class="prize-end-time-title">离结束还有</div>
|
||||||
<div class="prize-end-time-value">{{ countdown }}</div>
|
<div class="prize-end-time-value">{{ countdown }}</div>
|
||||||
<div v-if="loggedIn && !hasJoined && !lotteryEnded" class="join-prize-button" @click="joinLottery">
|
<div
|
||||||
|
v-if="loggedIn && !hasJoined && !lotteryEnded"
|
||||||
|
class="join-prize-button"
|
||||||
|
@click="joinLottery"
|
||||||
|
>
|
||||||
<div class="join-prize-button-text">参与抽奖</div>
|
<div class="join-prize-button-text">参与抽奖</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||||
@@ -101,21 +126,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prize-member-container">
|
<div class="prize-member-container">
|
||||||
<img v-for="p in lotteryParticipants" :key="p.id" class="prize-member-avatar" :src="p.avatar" alt="avatar" @click="gotoUser(p.id)" />
|
<img
|
||||||
|
v-for="p in lotteryParticipants"
|
||||||
|
:key="p.id"
|
||||||
|
class="prize-member-avatar"
|
||||||
|
:src="p.avatar"
|
||||||
|
alt="avatar"
|
||||||
|
@click="gotoUser(p.id)"
|
||||||
|
/>
|
||||||
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
|
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
|
||||||
<i class="fas fa-medal medal-icon"></i>
|
<i class="fas fa-medal medal-icon"></i>
|
||||||
<span class="prize-member-winner-name">获奖者: </span>
|
<span class="prize-member-winner-name">获奖者: </span>
|
||||||
<img v-for="w in lotteryWinners" :key="w.id" class="prize-member-avatar" :src="w.avatar" alt="avatar" @click="gotoUser(w.id)" />
|
<img
|
||||||
|
v-for="w in lotteryWinners"
|
||||||
|
:key="w.id"
|
||||||
|
class="prize-member-avatar"
|
||||||
|
:src="w.avatar"
|
||||||
|
alt="avatar"
|
||||||
|
@click="gotoUser(w.id)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommentEditor @submit="postComment" :loading="isWaitingPostingComment" :disabled="!loggedIn"
|
<CommentEditor
|
||||||
:show-login-overlay="!loggedIn" :parent-user-name="author.username" />
|
@submit="postComment"
|
||||||
|
:loading="isWaitingPostingComment"
|
||||||
|
:disabled="!loggedIn"
|
||||||
|
:show-login-overlay="!loggedIn"
|
||||||
|
:parent-user-name="author.username"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="comment-config-container">
|
<div class="comment-config-container">
|
||||||
<div class="comment-sort-container">
|
<div class="comment-sort-container">
|
||||||
<div class="comment-sort-title">Sort by: </div>
|
<div class="comment-sort-title">Sort by:</div>
|
||||||
<Dropdown v-model="commentSort" :fetch-options="fetchCommentSorts" />
|
<Dropdown v-model="commentSort" :fetch-options="fetchCommentSorts" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,8 +170,13 @@
|
|||||||
<div v-else class="comments-container">
|
<div v-else class="comments-container">
|
||||||
<BaseTimeline :items="comments">
|
<BaseTimeline :items="comments">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<CommentItem :key="item.id" :comment="item" :level="0" :default-show-replies="item.openReplies"
|
<CommentItem
|
||||||
@deleted="onCommentDeleted" />
|
:key="item.id"
|
||||||
|
:comment="item"
|
||||||
|
:level="0"
|
||||||
|
:default-show-replies="item.openReplies"
|
||||||
|
@deleted="onCommentDeleted"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,16 +187,26 @@
|
|||||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||||
<div v-else class="scroller-time">{{ scrollerTopTime }}</div>
|
<div v-else class="scroller-time">{{ scrollerTopTime }}</div>
|
||||||
<div class="scroller-middle">
|
<div class="scroller-middle">
|
||||||
<input type="range" class="scroller-range" :max="totalPosts" :min="1" v-model.number="currentIndex"
|
<input
|
||||||
@input="onSliderInput" />
|
type="range"
|
||||||
|
class="scroller-range"
|
||||||
|
:max="totalPosts"
|
||||||
|
:min="1"
|
||||||
|
v-model.number="currentIndex"
|
||||||
|
@input="onSliderInput"
|
||||||
|
/>
|
||||||
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
|
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||||
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
|
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<vue-easy-lightbox :visible="lightboxVisible" :index="lightboxIndex" :imgs="lightboxImgs"
|
<vue-easy-lightbox
|
||||||
@hide="lightboxVisible = false" />
|
:visible="lightboxVisible"
|
||||||
|
:index="lightboxIndex"
|
||||||
|
:imgs="lightboxImgs"
|
||||||
|
@hide="lightboxVisible = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -173,7 +232,17 @@ import Dropdown from '../../../components/Dropdown.vue'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PostPageView',
|
name: 'PostPageView',
|
||||||
components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags, ArticleCategory, ReactionsGroup, DropdownMenu, VueEasyLightbox, Dropdown },
|
components: {
|
||||||
|
CommentItem,
|
||||||
|
CommentEditor,
|
||||||
|
BaseTimeline,
|
||||||
|
ArticleTags,
|
||||||
|
ArticleCategory,
|
||||||
|
ReactionsGroup,
|
||||||
|
DropdownMenu,
|
||||||
|
VueEasyLightbox,
|
||||||
|
Dropdown,
|
||||||
|
},
|
||||||
async setup() {
|
async setup() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const postId = route.params.id
|
const postId = route.params.id
|
||||||
@@ -188,8 +257,8 @@ export default {
|
|||||||
const comments = ref([])
|
const comments = ref([])
|
||||||
const status = ref('PUBLISHED')
|
const status = ref('PUBLISHED')
|
||||||
const pinnedAt = ref(null)
|
const pinnedAt = ref(null)
|
||||||
const isWaitingFetchingPost = ref(false);
|
const isWaitingFetchingPost = ref(false)
|
||||||
const isWaitingPostingComment = ref(false);
|
const isWaitingPostingComment = ref(false)
|
||||||
const postTime = ref('')
|
const postTime = ref('')
|
||||||
const postItems = ref([])
|
const postItems = ref([])
|
||||||
const mainContainer = ref(null)
|
const mainContainer = ref(null)
|
||||||
@@ -204,19 +273,20 @@ export default {
|
|||||||
const metaDescriptionEl = process.client
|
const metaDescriptionEl = process.client
|
||||||
? document.querySelector('meta[name="description"]')
|
? document.querySelector('meta[name="description"]')
|
||||||
: null
|
: null
|
||||||
const defaultDescription = process.client && metaDescriptionEl
|
const defaultDescription =
|
||||||
? metaDescriptionEl.getAttribute('content')
|
process.client && metaDescriptionEl ? metaDescriptionEl.getAttribute('content') : ''
|
||||||
: ''
|
|
||||||
const headerHeight = process.client
|
const headerHeight = process.client
|
||||||
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
|
? parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--header-height'),
|
||||||
|
) || 0
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
if (process.client) {
|
if (process.client) {
|
||||||
watch(title, t => {
|
watch(title, (t) => {
|
||||||
document.title = `OpenIsle - ${t}`
|
document.title = `OpenIsle - ${t}`
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(postContent, c => {
|
watch(postContent, (c) => {
|
||||||
if (metaDescriptionEl) {
|
if (metaDescriptionEl) {
|
||||||
metaDescriptionEl.setAttribute('content', stripMarkdownLength(c, 400))
|
metaDescriptionEl.setAttribute('content', stripMarkdownLength(c, 400))
|
||||||
}
|
}
|
||||||
@@ -247,7 +317,7 @@ export default {
|
|||||||
})
|
})
|
||||||
const hasJoined = computed(() => {
|
const hasJoined = computed(() => {
|
||||||
if (!loggedIn.value) return false
|
if (!loggedIn.value) return false
|
||||||
return lotteryParticipants.value.some(p => p.id === Number(authState.userId))
|
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
|
||||||
})
|
})
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
if (!lottery.value || !lottery.value.endTime) {
|
if (!lottery.value || !lottery.value.endTime) {
|
||||||
@@ -274,7 +344,7 @@ export default {
|
|||||||
updateCountdown()
|
updateCountdown()
|
||||||
countdownTimer = setInterval(updateCountdown, 1000)
|
countdownTimer = setInterval(updateCountdown, 1000)
|
||||||
}
|
}
|
||||||
const gotoUser = id => router.push(`/users/${id}`)
|
const gotoUser = (id) => router.push(`/users/${id}`)
|
||||||
const articleMenuItems = computed(() => {
|
const articleMenuItems = computed(() => {
|
||||||
const items = []
|
const items = []
|
||||||
if (isAuthor.value || isAdmin.value) {
|
if (isAuthor.value || isAdmin.value) {
|
||||||
@@ -309,7 +379,7 @@ export default {
|
|||||||
}
|
}
|
||||||
// 根据 top 排序,防止评论异步插入后顺序错乱
|
// 根据 top 排序,防止评论异步插入后顺序错乱
|
||||||
items.sort((a, b) => a.top - b.top)
|
items.sort((a, b) => a.top - b.top)
|
||||||
postItems.value = items.map(i => i.el)
|
postItems.value = items.map((i) => i.el)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,11 +392,11 @@ export default {
|
|||||||
avatar: c.author.avatar,
|
avatar: c.author.avatar,
|
||||||
text: c.content,
|
text: c.content,
|
||||||
reactions: c.reactions || [],
|
reactions: c.reactions || [],
|
||||||
reply: (c.replies || []).map(r => mapComment(r, c.author.username, level + 1)),
|
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
||||||
openReplies: level === 0,
|
openReplies: level === 0,
|
||||||
src: c.author.avatar,
|
src: c.author.avatar,
|
||||||
iconClick: () => router.push(`/users/${c.author.id}`),
|
iconClick: () => router.push(`/users/${c.author.id}`),
|
||||||
parentUserName: parentUserName
|
parentUserName: parentUserName,
|
||||||
})
|
})
|
||||||
|
|
||||||
const getTop = (el) => {
|
const getTop = (el) => {
|
||||||
@@ -368,11 +438,11 @@ export default {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleContentClick = e => {
|
const handleContentClick = (e) => {
|
||||||
handleMarkdownClick(e)
|
handleMarkdownClick(e)
|
||||||
if (e.target.tagName === 'IMG') {
|
if (e.target.tagName === 'IMG') {
|
||||||
const container = e.target.parentNode
|
const container = e.target.parentNode
|
||||||
const imgs = [...container.querySelectorAll('img')].map(i => i.src)
|
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
|
||||||
lightboxImgs.value = imgs
|
lightboxImgs.value = imgs
|
||||||
lightboxIndex.value = imgs.indexOf(e.target.src)
|
lightboxIndex.value = imgs.indexOf(e.target.src)
|
||||||
lightboxVisible.value = true
|
lightboxVisible.value = true
|
||||||
@@ -386,12 +456,12 @@ export default {
|
|||||||
|
|
||||||
const fetchPost = async () => {
|
const fetchPost = async () => {
|
||||||
try {
|
try {
|
||||||
isWaitingFetchingPost.value = true;
|
isWaitingFetchingPost.value = true
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||||
headers: { Authorization: token ? `Bearer ${token}` : '' }
|
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||||
})
|
})
|
||||||
isWaitingFetchingPost.value = false;
|
isWaitingFetchingPost.value = false
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 404 && process.client) {
|
if (res.status === 404 && process.client) {
|
||||||
router.replace('/404')
|
router.replace('/404')
|
||||||
@@ -419,13 +489,13 @@ export default {
|
|||||||
|
|
||||||
const totalPosts = computed(() => comments.value.length + 1)
|
const totalPosts = computed(() => comments.value.length + 1)
|
||||||
const lastReplyTime = computed(() =>
|
const lastReplyTime = computed(() =>
|
||||||
comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value
|
comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value,
|
||||||
)
|
)
|
||||||
const firstReplyTime = computed(() =>
|
const firstReplyTime = computed(() =>
|
||||||
comments.value.length ? comments.value[0].time : postTime.value
|
comments.value.length ? comments.value[0].time : postTime.value,
|
||||||
)
|
)
|
||||||
const scrollerTopTime = computed(() =>
|
const scrollerTopTime = computed(() =>
|
||||||
commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value
|
commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -434,7 +504,7 @@ export default {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
gatherPostItems()
|
gatherPostItems()
|
||||||
updateCurrentIndex()
|
updateCurrentIndex()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateCurrentIndex = () => {
|
const updateCurrentIndex = () => {
|
||||||
@@ -476,7 +546,7 @@ export default {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/comments`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content: text }),
|
||||||
})
|
})
|
||||||
console.debug('Post comment response status', res.status)
|
console.debug('Post comment response status', res.status)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -516,7 +586,7 @@ export default {
|
|||||||
}
|
}
|
||||||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
subscribed.value = true
|
subscribed.value = true
|
||||||
@@ -531,7 +601,7 @@ export default {
|
|||||||
if (!token) return
|
if (!token) return
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/approve`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
status.value = 'PUBLISHED'
|
status.value = 'PUBLISHED'
|
||||||
@@ -546,7 +616,7 @@ export default {
|
|||||||
if (!token) return
|
if (!token) return
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/pin`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/pin`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
pinnedAt.value = new Date().toISOString()
|
pinnedAt.value = new Date().toISOString()
|
||||||
@@ -561,7 +631,7 @@ export default {
|
|||||||
if (!token) return
|
if (!token) return
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/unpin`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/unpin`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
pinnedAt.value = null
|
pinnedAt.value = null
|
||||||
@@ -583,7 +653,7 @@ export default {
|
|||||||
}
|
}
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('已删除')
|
toast.success('已删除')
|
||||||
@@ -598,7 +668,7 @@ export default {
|
|||||||
if (!token) return
|
if (!token) return
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/reject`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/reject`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
status.value = 'REJECTED'
|
status.value = 'REJECTED'
|
||||||
@@ -614,10 +684,9 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
subscribed.value = false
|
subscribed.value = false
|
||||||
@@ -635,7 +704,7 @@ export default {
|
|||||||
}
|
}
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/lottery/join`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/lottery/join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('已参与抽奖')
|
toast.success('已参与抽奖')
|
||||||
@@ -658,9 +727,12 @@ export default {
|
|||||||
console.debug('Fetching comments', { postId, sort: commentSort.value })
|
console.debug('Fetching comments', { postId, sort: commentSort.value })
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/comments?sort=${commentSort.value}`, {
|
const res = await fetch(
|
||||||
headers: { Authorization: token ? `Bearer ${token}` : '' }
|
`${API_BASE_URL}/api/posts/${postId}/comments?sort=${commentSort.value}`,
|
||||||
})
|
{
|
||||||
|
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||||
|
},
|
||||||
|
)
|
||||||
console.debug('Fetch comments response status', res.status)
|
console.debug('Fetch comments response status', res.status)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -683,7 +755,7 @@ export default {
|
|||||||
const hash = location.hash
|
const hash = location.hash
|
||||||
if (hash.startsWith('#comment-')) {
|
if (hash.startsWith('#comment-')) {
|
||||||
const id = hash.substring('#comment-'.length)
|
const id = hash.substring('#comment-'.length)
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
const el = document.getElementById('comment-' + id)
|
const el = document.getElementById('comment-' + id)
|
||||||
if (el) {
|
if (el) {
|
||||||
const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - 20 // 20 for beauty
|
const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - 20 // 20 for beauty
|
||||||
@@ -765,9 +837,9 @@ export default {
|
|||||||
lotteryParticipants,
|
lotteryParticipants,
|
||||||
lotteryWinners,
|
lotteryWinners,
|
||||||
lotteryEnded,
|
lotteryEnded,
|
||||||
hasJoined
|
hasJoined,
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="settings-title">个人资料设置</div>
|
<div class="settings-title">个人资料设置</div>
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
|
|
||||||
<div class="avatar-row">
|
<div class="avatar-row">
|
||||||
<!-- label 充当点击区域,内部隐藏 input -->
|
<!-- label 充当点击区域,内部隐藏 input -->
|
||||||
<label class="avatar-container">
|
<label class="avatar-container">
|
||||||
@@ -23,7 +22,12 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row username-row">
|
<div class="form-row username-row">
|
||||||
<BaseInput icon="fas fa-user" v-model="username" @input="usernameError = ''" placeholder="用户名" />
|
<BaseInput
|
||||||
|
icon="fas fa-user"
|
||||||
|
v-model="username"
|
||||||
|
@input="usernameError = ''"
|
||||||
|
placeholder="用户名"
|
||||||
|
/>
|
||||||
<div class="setting-description">用户名是你在社区的唯一标识</div>
|
<div class="setting-description">用户名是你在社区的唯一标识</div>
|
||||||
<div v-if="usernameError" class="error-message">{{ usernameError }}</div>
|
<div v-if="usernameError" class="error-message">{{ usernameError }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +88,7 @@ export default {
|
|||||||
aiFormatLimit: 3,
|
aiFormatLimit: 3,
|
||||||
registerMode: 'DIRECT',
|
registerMode: 'DIRECT',
|
||||||
isLoadingPage: false,
|
isLoadingPage: false,
|
||||||
isSaving: false
|
isSaving: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
@@ -124,14 +128,14 @@ export default {
|
|||||||
fetchPublishModes() {
|
fetchPublishModes() {
|
||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
||||||
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' }
|
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
fetchPasswordStrengths() {
|
fetchPasswordStrengths() {
|
||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
|
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
|
||||||
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
|
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
|
||||||
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' }
|
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
fetchAiLimits() {
|
fetchAiLimits() {
|
||||||
@@ -139,20 +143,20 @@ export default {
|
|||||||
{ id: 3, name: '3次' },
|
{ id: 3, name: '3次' },
|
||||||
{ id: 5, name: '5次' },
|
{ id: 5, name: '5次' },
|
||||||
{ id: 10, name: '10次' },
|
{ id: 10, name: '10次' },
|
||||||
{ id: -1, name: '无限' }
|
{ id: -1, name: '无限' },
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
fetchRegisterModes() {
|
fetchRegisterModes() {
|
||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
||||||
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' }
|
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
async loadAdminConfig() {
|
async loadAdminConfig() {
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -184,7 +188,7 @@ export default {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
|
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: form
|
body: form,
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -197,7 +201,7 @@ export default {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ username: this.username, introduction: this.introduction })
|
body: JSON.stringify({ username: this.username, introduction: this.introduction }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -213,7 +217,12 @@ export default {
|
|||||||
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ publishMode: this.publishMode, passwordStrength: this.passwordStrength, aiFormatLimit: this.aiFormatLimit, registerMode: this.registerMode })
|
body: JSON.stringify({
|
||||||
|
publishMode: this.publishMode,
|
||||||
|
passwordStrength: this.passwordStrength,
|
||||||
|
aiFormatLimit: this.aiFormatLimit,
|
||||||
|
registerMode: this.registerMode,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
toast.success('保存成功')
|
toast.success('保存成功')
|
||||||
@@ -221,7 +230,7 @@ export default {
|
|||||||
|
|
||||||
this.isSaving = false
|
this.isSaving = false
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
<div class="char-count">{{ reason.length }}/20</div>
|
<div class="char-count">{{ reason.length }}/20</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
<div v-if="!isWaitingForRegister" class="signup-page-button-primary" @click="submit">提交</div>
|
<div v-if="!isWaitingForRegister" class="signup-page-button-primary" @click="submit">
|
||||||
|
提交
|
||||||
|
</div>
|
||||||
<div v-else class="signup-page-button-primary disabled">提交中...</div>
|
<div v-else class="signup-page-button-primary disabled">提交中...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,11 +20,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import BaseInput from '../components/BaseInput.vue'
|
import BaseInput from '../components/BaseInput.vue'
|
||||||
import {API_BASE_URL, toast} from '../main'
|
import { API_BASE_URL, toast } from '../main'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SignupReasonPageView',
|
name: 'SignupReasonPageView',
|
||||||
components: {BaseInput},
|
components: { BaseInput },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
reason: '',
|
reason: '',
|
||||||
@@ -53,8 +55,8 @@ export default {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token: this.token,
|
token: this.token,
|
||||||
reason: this.reason
|
reason: this.reason,
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
this.isWaitingForRegister = false
|
this.isWaitingForRegister = false
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -71,8 +73,8 @@ export default {
|
|||||||
this.isWaitingForRegister = false
|
this.isWaitingForRegister = false
|
||||||
toast.error('提交失败')
|
toast.error('提交失败')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
<div class="signup-page">
|
<div class="signup-page">
|
||||||
<div class="signup-page-content">
|
<div class="signup-page-content">
|
||||||
<div class="signup-page-header">
|
<div class="signup-page-header">
|
||||||
<div class="signup-page-header-title">
|
<div class="signup-page-header-title">Welcome :)</div>
|
||||||
Welcome :)
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="emailStep === 0" class="email-signup-page-content">
|
<div v-if="emailStep === 0" class="email-signup-page-content">
|
||||||
@@ -33,8 +31,11 @@
|
|||||||
/>
|
/>
|
||||||
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||||
|
|
||||||
|
<div
|
||||||
<div v-if="!isWaitingForEmailSent" class="signup-page-button-primary" @click="sendVerification">
|
v-if="!isWaitingForEmailSent"
|
||||||
|
class="signup-page-button-primary"
|
||||||
|
@click="sendVerification"
|
||||||
|
>
|
||||||
<div class="signup-page-button-text">验证邮箱</div>
|
<div class="signup-page-button-text">验证邮箱</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="signup-page-button-primary disabled">
|
<div v-else class="signup-page-button-primary disabled">
|
||||||
@@ -44,17 +45,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="signup-page-button-secondary">已经有账号? <a class="signup-page-button-secondary-link"
|
<div class="signup-page-button-secondary">
|
||||||
href="/login">登录</a></div>
|
已经有账号? <a class="signup-page-button-secondary-link" href="/login">登录</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="emailStep === 1" class="email-signup-page-content">
|
<div v-if="emailStep === 1" class="email-signup-page-content">
|
||||||
<BaseInput
|
<BaseInput icon="fas fa-envelope" v-model="code" placeholder="邮箱验证码" />
|
||||||
icon="fas fa-envelope"
|
<div
|
||||||
v-model="code"
|
v-if="!isWaitingForEmailVerified"
|
||||||
placeholder="邮箱验证码"
|
class="signup-page-button-primary"
|
||||||
/>
|
@click="verifyCode"
|
||||||
<div v-if="!isWaitingForEmailVerified" class="signup-page-button-primary" @click="verifyCode">
|
>
|
||||||
<div class="signup-page-button-text">注册</div>
|
<div class="signup-page-button-text">注册</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="signup-page-button-primary disabled">
|
<div v-else class="signup-page-button-primary disabled">
|
||||||
@@ -112,7 +114,7 @@ export default {
|
|||||||
passwordError: '',
|
passwordError: '',
|
||||||
code: '',
|
code: '',
|
||||||
isWaitingForEmailSent: false,
|
isWaitingForEmailSent: false,
|
||||||
isWaitingForEmailVerified: false
|
isWaitingForEmailVerified: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
@@ -123,7 +125,9 @@ export default {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
this.registerMode = data.registerMode
|
this.registerMode = data.registerMode
|
||||||
}
|
}
|
||||||
} catch {/* ignore */}
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
if (this.$route.query.verify) {
|
if (this.$route.query.verify) {
|
||||||
this.emailStep = 1
|
this.emailStep = 1
|
||||||
}
|
}
|
||||||
@@ -157,8 +161,8 @@ export default {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: this.username,
|
username: this.username,
|
||||||
email: this.email,
|
email: this.email,
|
||||||
password: this.password
|
password: this.password,
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
this.isWaitingForEmailSent = false
|
this.isWaitingForEmailSent = false
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -184,8 +188,8 @@ export default {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
code: this.code,
|
code: this.code,
|
||||||
username: this.username
|
username: this.username,
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -212,8 +216,8 @@ export default {
|
|||||||
},
|
},
|
||||||
signupWithTwitter() {
|
signupWithTwitter() {
|
||||||
twitterAuthorize()
|
twitterAuthorize()
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.$router.push('/')
|
this.$router.push('/')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,18 +12,33 @@
|
|||||||
<div class="profile-page-header-user-info">
|
<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-name">{{ user.username }}</div>
|
||||||
<div class="profile-page-header-user-info-description">{{ user.introduction }}</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">
|
<div
|
||||||
|
v-if="!isMine && !subscribed"
|
||||||
|
class="profile-page-header-subscribe-button"
|
||||||
|
@click="subscribeUser"
|
||||||
|
>
|
||||||
<i class="fas fa-user-plus"></i>
|
<i class="fas fa-user-plus"></i>
|
||||||
关注
|
关注
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isMine && subscribed" class="profile-page-header-unsubscribe-button" @click="unsubscribeUser">
|
<div
|
||||||
|
v-if="!isMine && subscribed"
|
||||||
|
class="profile-page-header-unsubscribe-button"
|
||||||
|
@click="unsubscribeUser"
|
||||||
|
>
|
||||||
<i class="fas fa-user-minus"></i>
|
<i class="fas fa-user-minus"></i>
|
||||||
取消关注
|
取消关注
|
||||||
</div>
|
</div>
|
||||||
<LevelProgress :exp="levelInfo.exp" :current-level="levelInfo.currentLevel" :next-exp="levelInfo.nextExp" />
|
<LevelProgress
|
||||||
|
:exp="levelInfo.exp"
|
||||||
|
:current-level="levelInfo.currentLevel"
|
||||||
|
:next-exp="levelInfo.nextExp"
|
||||||
|
/>
|
||||||
<div class="profile-level-target">
|
<div class="profile-level-target">
|
||||||
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||||
<i class="fas fa-info-circle profile-exp-info" title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"></i>
|
<i
|
||||||
|
class="fas fa-info-circle profile-exp-info"
|
||||||
|
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||||
|
></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,8 +54,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="profile-info-item">
|
<div class="profile-info-item">
|
||||||
<div class="profile-info-item-label">最后评论时间:</div>
|
<div class="profile-info-item-label">最后评论时间:</div>
|
||||||
<div class="profile-info-item-value">{{ user.lastCommentTime != null ? formatDate(user.lastCommentTime) :
|
<div class="profile-info-item-value">
|
||||||
"暂无评论" }}
|
{{ user.lastCommentTime != null ? formatDate(user.lastCommentTime) : '暂无评论' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-info-item">
|
<div class="profile-info-item">
|
||||||
@@ -50,21 +65,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="profile-tabs">
|
<div class="profile-tabs">
|
||||||
<div :class="['profile-tabs-item', { selected: selectedTab === 'summary' }]" @click="selectedTab = 'summary'">
|
<div
|
||||||
|
:class="['profile-tabs-item', { selected: selectedTab === 'summary' }]"
|
||||||
|
@click="selectedTab = 'summary'"
|
||||||
|
>
|
||||||
<i class="fas fa-chart-line"></i>
|
<i class="fas fa-chart-line"></i>
|
||||||
<div class="profile-tabs-item-label">总结</div>
|
<div class="profile-tabs-item-label">总结</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="['profile-tabs-item', { selected: selectedTab === 'timeline' }]" @click="selectedTab = 'timeline'">
|
<div
|
||||||
|
:class="['profile-tabs-item', { selected: selectedTab === 'timeline' }]"
|
||||||
|
@click="selectedTab = 'timeline'"
|
||||||
|
>
|
||||||
<i class="fas fa-clock"></i>
|
<i class="fas fa-clock"></i>
|
||||||
<div class="profile-tabs-item-label">时间线</div>
|
<div class="profile-tabs-item-label">时间线</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="['profile-tabs-item', { selected: selectedTab === 'following' }]"
|
<div
|
||||||
@click="selectedTab = 'following'">
|
:class="['profile-tabs-item', { selected: selectedTab === 'following' }]"
|
||||||
|
@click="selectedTab = 'following'"
|
||||||
|
>
|
||||||
<i class="fas fa-user-plus"></i>
|
<i class="fas fa-user-plus"></i>
|
||||||
<div class="profile-tabs-item-label">关注</div>
|
<div class="profile-tabs-item-label">关注</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="['profile-tabs-item', { selected: selectedTab === 'achievements' }]"
|
<div
|
||||||
@click="selectedTab = 'achievements'">
|
:class="['profile-tabs-item', { selected: selectedTab === 'achievements' }]"
|
||||||
|
@click="selectedTab = 'achievements'"
|
||||||
|
>
|
||||||
<i class="fas fa-medal"></i>
|
<i class="fas fa-medal"></i>
|
||||||
<div class="profile-tabs-item-label">勋章</div>
|
<div class="profile-tabs-item-label">勋章</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,17 +133,19 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
<template v-if="item.comment.parentComment">
|
<template v-if="item.comment.parentComment">
|
||||||
下对
|
下对
|
||||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
<router-link
|
||||||
class="timeline-link">
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
回复了
|
回复了
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else> 下评论了 </template>
|
||||||
下评论了
|
<router-link
|
||||||
</template>
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
class="timeline-link"
|
||||||
class="timeline-link">
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="timeline-date">
|
<div class="timeline-date">
|
||||||
@@ -177,7 +204,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||||
<BasePlaceholder v-if="timelineItems.length === 0" text="暂无时间线" icon="fas fa-inbox" />
|
<BasePlaceholder
|
||||||
|
v-if="timelineItems.length === 0"
|
||||||
|
text="暂无时间线"
|
||||||
|
icon="fas fa-inbox"
|
||||||
|
/>
|
||||||
<BaseTimeline :items="timelineItems">
|
<BaseTimeline :items="timelineItems">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<template v-if="item.type === 'post'">
|
<template v-if="item.type === 'post'">
|
||||||
@@ -193,7 +224,10 @@
|
|||||||
{{ item.comment.post.title }}
|
{{ item.comment.post.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
下评论了
|
下评论了
|
||||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`" class="timeline-link">
|
<router-link
|
||||||
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
@@ -204,12 +238,17 @@
|
|||||||
{{ item.comment.post.title }}
|
{{ item.comment.post.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
下对
|
下对
|
||||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
<router-link
|
||||||
class="timeline-link">
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
回复了
|
回复了
|
||||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`" class="timeline-link">
|
<router-link
|
||||||
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
@@ -230,10 +269,16 @@
|
|||||||
|
|
||||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||||
<div class="follow-tabs">
|
<div class="follow-tabs">
|
||||||
<div :class="['follow-tab-item', { selected: followTab === 'followers' }]" @click="followTab = 'followers'">
|
<div
|
||||||
|
:class="['follow-tab-item', { selected: followTab === 'followers' }]"
|
||||||
|
@click="followTab = 'followers'"
|
||||||
|
>
|
||||||
关注者
|
关注者
|
||||||
</div>
|
</div>
|
||||||
<div :class="['follow-tab-item', { selected: followTab === 'following' }]" @click="followTab = 'following'">
|
<div
|
||||||
|
:class="['follow-tab-item', { selected: followTab === 'following' }]"
|
||||||
|
@click="followTab = 'following'"
|
||||||
|
>
|
||||||
正在关注
|
正在关注
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +311,7 @@ import { prevLevelExp } from '../utils/level'
|
|||||||
import AchievementList from '../components/AchievementList.vue'
|
import AchievementList from '../components/AchievementList.vue'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
alias: ['/users/:id/']
|
alias: ['/users/:id/'],
|
||||||
})
|
})
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -291,7 +336,7 @@ export default {
|
|||||||
const selectedTab = ref(
|
const selectedTab = ref(
|
||||||
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
|
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
|
||||||
? route.query.tab
|
? route.query.tab
|
||||||
: 'summary'
|
: 'summary',
|
||||||
)
|
)
|
||||||
const followTab = ref('followers')
|
const followTab = ref('followers')
|
||||||
|
|
||||||
@@ -306,7 +351,7 @@ export default {
|
|||||||
return { exp, currentLevel, nextExp, percent }
|
return { exp, currentLevel, nextExp, percent }
|
||||||
})
|
})
|
||||||
|
|
||||||
const isMine = computed(function() {
|
const isMine = computed(function () {
|
||||||
const mine = authState.username === username || String(authState.userId) === username
|
const mine = authState.username === username || String(authState.userId) === username
|
||||||
console.log(mine)
|
console.log(mine)
|
||||||
return mine
|
return mine
|
||||||
@@ -334,19 +379,19 @@ export default {
|
|||||||
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
|
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
|
||||||
if (postsRes.ok) {
|
if (postsRes.ok) {
|
||||||
const data = await postsRes.json()
|
const data = await postsRes.json()
|
||||||
hotPosts.value = data.map(p => ({ icon: 'fas fa-book', post: p }))
|
hotPosts.value = data.map((p) => ({ icon: 'fas fa-book', post: p }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
|
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
|
||||||
if (repliesRes.ok) {
|
if (repliesRes.ok) {
|
||||||
const data = await repliesRes.json()
|
const data = await repliesRes.json()
|
||||||
hotReplies.value = data.map(c => ({ icon: 'fas fa-comment', comment: c }))
|
hotReplies.value = data.map((c) => ({ icon: 'fas fa-comment', comment: c }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
|
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
|
||||||
if (tagsRes.ok) {
|
if (tagsRes.ok) {
|
||||||
const data = await tagsRes.json()
|
const data = await tagsRes.json()
|
||||||
hotTags.value = data.map(t => ({ icon: 'fas fa-tag', tag: t }))
|
hotTags.value = data.map((t) => ({ icon: 'fas fa-tag', tag: t }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,30 +399,30 @@ export default {
|
|||||||
const [postsRes, repliesRes, tagsRes] = await Promise.all([
|
const [postsRes, repliesRes, tagsRes] = await Promise.all([
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
|
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}/replies?limit=50`),
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/tags?limit=50`)
|
fetch(`${API_BASE_URL}/api/users/${username}/tags?limit=50`),
|
||||||
])
|
])
|
||||||
const posts = postsRes.ok ? await postsRes.json() : []
|
const posts = postsRes.ok ? await postsRes.json() : []
|
||||||
const replies = repliesRes.ok ? await repliesRes.json() : []
|
const replies = repliesRes.ok ? await repliesRes.json() : []
|
||||||
const tags = tagsRes.ok ? await tagsRes.json() : []
|
const tags = tagsRes.ok ? await tagsRes.json() : []
|
||||||
const mapped = [
|
const mapped = [
|
||||||
...posts.map(p => ({
|
...posts.map((p) => ({
|
||||||
type: 'post',
|
type: 'post',
|
||||||
icon: 'fas fa-book',
|
icon: 'fas fa-book',
|
||||||
post: p,
|
post: p,
|
||||||
createdAt: p.createdAt
|
createdAt: p.createdAt,
|
||||||
})),
|
})),
|
||||||
...replies.map(r => ({
|
...replies.map((r) => ({
|
||||||
type: r.parentComment ? 'reply' : 'comment',
|
type: r.parentComment ? 'reply' : 'comment',
|
||||||
icon: 'fas fa-comment',
|
icon: 'fas fa-comment',
|
||||||
comment: r,
|
comment: r,
|
||||||
createdAt: r.createdAt
|
createdAt: r.createdAt,
|
||||||
})),
|
})),
|
||||||
...tags.map(t => ({
|
...tags.map((t) => ({
|
||||||
type: 'tag',
|
type: 'tag',
|
||||||
icon: 'fas fa-tag',
|
icon: 'fas fa-tag',
|
||||||
tag: t,
|
tag: t,
|
||||||
createdAt: t.createdAt
|
createdAt: t.createdAt,
|
||||||
}))
|
})),
|
||||||
]
|
]
|
||||||
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
timelineItems.value = mapped
|
timelineItems.value = mapped
|
||||||
@@ -386,7 +431,7 @@ export default {
|
|||||||
const fetchFollowUsers = async () => {
|
const fetchFollowUsers = async () => {
|
||||||
const [followerRes, followingRes] = await Promise.all([
|
const [followerRes, followingRes] = await Promise.all([
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/following`)
|
fetch(`${API_BASE_URL}/api/users/${username}/following`),
|
||||||
])
|
])
|
||||||
followers.value = followerRes.ok ? await followerRes.json() : []
|
followers.value = followerRes.ok ? await followerRes.json() : []
|
||||||
followings.value = followingRes.ok ? await followingRes.json() : []
|
followings.value = followingRes.ok ? await followingRes.json() : []
|
||||||
@@ -434,7 +479,7 @@ export default {
|
|||||||
}
|
}
|
||||||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
subscribed.value = true
|
subscribed.value = true
|
||||||
@@ -452,7 +497,7 @@ export default {
|
|||||||
}
|
}
|
||||||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
subscribed.value = false
|
subscribed.value = false
|
||||||
@@ -462,7 +507,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const gotoTag = tag => {
|
const gotoTag = (tag) => {
|
||||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||||
router.push({ path: '/', query: { tags: value } })
|
router.push({ path: '/', query: { tags: value } })
|
||||||
}
|
}
|
||||||
@@ -488,11 +533,15 @@ export default {
|
|||||||
|
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
|
|
||||||
watch(selectedTab, async val => {
|
watch(selectedTab, async (val) => {
|
||||||
// router.replace({ query: { ...route.query, tab: val } })
|
// router.replace({ query: { ...route.query, tab: val } })
|
||||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
if (val === 'timeline' && timelineItems.value.length === 0) {
|
||||||
await loadTimeline()
|
await loadTimeline()
|
||||||
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
} else if (
|
||||||
|
val === 'following' &&
|
||||||
|
followers.value.length === 0 &&
|
||||||
|
followings.value.length === 0
|
||||||
|
) {
|
||||||
await loadFollow()
|
await loadFollow()
|
||||||
} else if (val === 'achievements' && medals.value.length === 0) {
|
} else if (val === 'achievements' && medals.value.length === 0) {
|
||||||
await loadAchievements()
|
await loadAchievements()
|
||||||
@@ -524,9 +573,9 @@ export default {
|
|||||||
unsubscribeUser,
|
unsubscribeUser,
|
||||||
gotoTag,
|
gotoTag,
|
||||||
hotTags,
|
hotTags,
|
||||||
levelInfo
|
levelInfo,
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -808,7 +857,8 @@ export default {
|
|||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.follow-container {}
|
.follow-container {
|
||||||
|
}
|
||||||
|
|
||||||
.follow-tabs {
|
.follow-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ import { initTheme } from '~/utils/theme'
|
|||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
initTheme()
|
initTheme()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ export default {
|
|||||||
if (process.client) {
|
if (process.client) {
|
||||||
window.location.href = path
|
window.location.href = path
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ export const authState = reactive({
|
|||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
username: null,
|
username: null,
|
||||||
role: null
|
role: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (process.client) {
|
if (process.client) {
|
||||||
authState.loggedIn = localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
authState.loggedIn =
|
||||||
|
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
||||||
authState.userId = localStorage.getItem(USER_ID_KEY)
|
authState.userId = localStorage.getItem(USER_ID_KEY)
|
||||||
authState.username = localStorage.getItem(USERNAME_KEY)
|
authState.username = localStorage.getItem(USERNAME_KEY)
|
||||||
authState.role = localStorage.getItem(ROLE_KEY)
|
authState.role = localStorage.getItem(ROLE_KEY)
|
||||||
@@ -68,7 +69,7 @@ export async function fetchCurrentUser() {
|
|||||||
if (!token) return null
|
if (!token) return null
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
return await res.json()
|
return await res.json()
|
||||||
@@ -94,7 +95,7 @@ export async function checkToken() {
|
|||||||
if (!token) return false
|
if (!token) return false
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
authState.loggedIn = res.ok
|
authState.loggedIn = res.ok
|
||||||
return res.ok
|
return res.ok
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export function clearVditorStorage() {
|
export function clearVditorStorage() {
|
||||||
Object.keys(localStorage).forEach(key => {
|
Object.keys(localStorage).forEach((key) => {
|
||||||
if (key.startsWith('vditoreditor-') || key === 'vditor') {
|
if (key.startsWith('vditoreditor-') || key === 'vditor') {
|
||||||
localStorage.removeItem(key)
|
localStorage.removeItem(key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ export async function discordExchange(code, state, reason) {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code, redirectUri: `${window.location.origin}/discord-callback`, reason, state })
|
body: JSON.stringify({
|
||||||
|
code,
|
||||||
|
redirectUri: `${window.location.origin}/discord-callback`,
|
||||||
|
reason,
|
||||||
|
state,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
@@ -28,27 +33,27 @@ export async function discordExchange(code, state, reason) {
|
|||||||
registerPush()
|
registerPush()
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
needReason: false
|
needReason: false,
|
||||||
}
|
}
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
needReason: true,
|
needReason: true,
|
||||||
token: data.token
|
token: data.token,
|
||||||
}
|
}
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
toast.info('您的注册理由正在审批中')
|
toast.info('您的注册理由正在审批中')
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
needReason: false
|
needReason: false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '登录失败')
|
toast.error(data.error || '登录失败')
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
needReason: false,
|
needReason: false,
|
||||||
error: data.error || '登录失败'
|
error: data.error || '登录失败',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -56,7 +61,7 @@ export async function discordExchange(code, state, reason) {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
needReason: false,
|
needReason: false,
|
||||||
error: '登录失败'
|
error: '登录失败',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ export async function githubExchange(code, state, reason) {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code, redirectUri: `${window.location.origin}/github-callback`, reason, state })
|
body: JSON.stringify({
|
||||||
|
code,
|
||||||
|
redirectUri: `${window.location.origin}/github-callback`,
|
||||||
|
reason,
|
||||||
|
state,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
@@ -28,27 +33,27 @@ export async function githubExchange(code, state, reason) {
|
|||||||
registerPush()
|
registerPush()
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
needReason: false
|
needReason: false,
|
||||||
}
|
}
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
needReason: true,
|
needReason: true,
|
||||||
token: data.token
|
token: data.token,
|
||||||
}
|
}
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
toast.info('您的注册理由正在审批中')
|
toast.info('您的注册理由正在审批中')
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
needReason: false
|
needReason: false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '登录失败')
|
toast.error(data.error || '登录失败')
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
needReason: false,
|
needReason: false,
|
||||||
error: data.error || '登录失败'
|
error: data.error || '登录失败',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -56,7 +61,7 @@ export async function githubExchange(code, state, reason) {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
needReason: false,
|
needReason: false,
|
||||||
error: '登录失败'
|
error: '登录失败',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export async function googleGetIdToken() {
|
|||||||
window.google.accounts.id.initialize({
|
window.google.accounts.id.initialize({
|
||||||
client_id: GOOGLE_CLIENT_ID,
|
client_id: GOOGLE_CLIENT_ID,
|
||||||
callback: ({ credential }) => resolve(credential),
|
callback: ({ credential }) => resolve(credential),
|
||||||
use_fedcm: true
|
use_fedcm: true,
|
||||||
})
|
})
|
||||||
window.google.accounts.id.prompt()
|
window.google.accounts.id.prompt()
|
||||||
})
|
})
|
||||||
@@ -35,7 +35,7 @@ export async function googleAuthWithToken(idToken, redirect_success, redirect_no
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ idToken })
|
body: JSON.stringify({ idToken }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
@@ -72,8 +72,8 @@ export function loginWithGoogle() {
|
|||||||
() => {
|
() => {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
},
|
},
|
||||||
token => {
|
(token) => {
|
||||||
router.push('/signup-reason?token=' + token)
|
router.push('/signup-reason?token=' + token)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export const LEVEL_EXP = [100, 200, 300, 600, 1200, 10000]
|
export const LEVEL_EXP = [100, 200, 300, 600, 1200, 10000]
|
||||||
|
|
||||||
export const prevLevelExp = level => {
|
export const prevLevelExp = (level) => {
|
||||||
if (level <= 0) return 0
|
if (level <= 0) return 0
|
||||||
if (level - 1 < LEVEL_EXP.length) return LEVEL_EXP[level - 1]
|
if (level - 1 < LEVEL_EXP.length) return LEVEL_EXP[level - 1]
|
||||||
return LEVEL_EXP[LEVEL_EXP.length - 1]
|
return LEVEL_EXP[LEVEL_EXP.length - 1]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function mentionPlugin(md) {
|
|||||||
tokenOpen.attrs = [
|
tokenOpen.attrs = [
|
||||||
['href', `/users/${match[1]}`],
|
['href', `/users/${match[1]}`],
|
||||||
['target', '_blank'],
|
['target', '_blank'],
|
||||||
['class', 'mention-link']
|
['class', 'mention-link'],
|
||||||
]
|
]
|
||||||
const text = state.push('text', '', 0)
|
const text = state.push('text', '', 0)
|
||||||
text.content = `@${match[1]}`
|
text.content = `@${match[1]}`
|
||||||
@@ -62,7 +62,7 @@ const md = new MarkdownIt({
|
|||||||
code = hljs.highlightAuto(str).value
|
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>`
|
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(mentionPlugin)
|
||||||
@@ -89,7 +89,10 @@ export function stripMarkdown(text) {
|
|||||||
// SSR 环境下没有 document
|
// SSR 环境下没有 document
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
// 用正则去除 HTML 标签
|
// 用正则去除 HTML 标签
|
||||||
return html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
return html
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
} else {
|
} else {
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.innerHTML = html
|
el.innerHTML = html
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const medalTitles = {
|
|||||||
COMMENT: '评论达人',
|
COMMENT: '评论达人',
|
||||||
POST: '发帖达人',
|
POST: '发帖达人',
|
||||||
SEED: '种子用户',
|
SEED: '种子用户',
|
||||||
CONTRIBUTOR: '贡献者'
|
CONTRIBUTOR: '贡献者',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMedalTitle(type) {
|
export function getMedalTitle(type) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getToken } from './auth'
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
export const notificationState = reactive({
|
export const notificationState = reactive({
|
||||||
unreadCount: 0
|
unreadCount: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function fetchUnreadCount() {
|
export async function fetchUnreadCount() {
|
||||||
@@ -14,7 +14,7 @@ export async function fetchUnreadCount() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
const res = await fetch(`${API_BASE_URL}/api/notifications/unread-count`, {
|
const res = await fetch(`${API_BASE_URL}/api/notifications/unread-count`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
notificationState.unreadCount = 0
|
notificationState.unreadCount = 0
|
||||||
@@ -37,9 +37,9 @@ export async function markNotificationsRead(ids) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ids })
|
body: JSON.stringify({ ids }),
|
||||||
})
|
})
|
||||||
return res.ok
|
return res.ok
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { API_BASE_URL } from '../main'
|
|||||||
import { getToken } from './auth'
|
import { getToken } from './auth'
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String) {
|
function urlBase64ToUint8Array(base64String) {
|
||||||
const padding = '='.repeat((4 - base64String.length % 4) % 4)
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
const rawData = atob(base64)
|
const rawData = atob(base64)
|
||||||
const outputArray = new Uint8Array(rawData.length)
|
const outputArray = new Uint8Array(rawData.length)
|
||||||
@@ -28,19 +28,19 @@ export async function registerPush() {
|
|||||||
const { key } = await res.json()
|
const { key } = await res.json()
|
||||||
const sub = await reg.pushManager.subscribe({
|
const sub = await reg.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(key)
|
applicationServerKey: urlBase64ToUint8Array(key),
|
||||||
})
|
})
|
||||||
await fetch(`${API_BASE_URL}/api/push/subscribe`, {
|
await fetch(`${API_BASE_URL}/api/push/subscribe`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${getToken()}`
|
Authorization: `Bearer ${getToken()}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
endpoint: sub.endpoint,
|
endpoint: sub.endpoint,
|
||||||
p256dh: arrayBufferToBase64(sub.getKey('p256dh')),
|
p256dh: arrayBufferToBase64(sub.getKey('p256dh')),
|
||||||
auth: arrayBufferToBase64(sub.getKey('auth'))
|
auth: arrayBufferToBase64(sub.getKey('auth')),
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -21,5 +21,5 @@ export const reactionEmojiMap = {
|
|||||||
CHINA: '🇨🇳',
|
CHINA: '🇨🇳',
|
||||||
USA: '🇺🇸',
|
USA: '🇺🇸',
|
||||||
JAPAN: '🇯🇵',
|
JAPAN: '🇯🇵',
|
||||||
KOREA: '🇰🇷'
|
KOREA: '🇰🇷',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,19 @@ export const useIsMobile = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mobileKeywords = [
|
const mobileKeywords = [
|
||||||
'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone',
|
'android',
|
||||||
'mobile', 'tablet', 'opera mini', 'iemobile'
|
'iphone',
|
||||||
|
'ipad',
|
||||||
|
'ipod',
|
||||||
|
'blackberry',
|
||||||
|
'windows phone',
|
||||||
|
'mobile',
|
||||||
|
'tablet',
|
||||||
|
'opera mini',
|
||||||
|
'iemobile',
|
||||||
]
|
]
|
||||||
|
|
||||||
return mobileKeywords.some(keyword => userAgent.includes(keyword))
|
return mobileKeywords.some((keyword) => userAgent.includes(keyword))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -43,4 +51,3 @@ export const useIsMobile = () => {
|
|||||||
return isMobileUserAgent()
|
return isMobileUserAgent()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,22 @@ import { toast } from '~/main'
|
|||||||
export const ThemeMode = {
|
export const ThemeMode = {
|
||||||
SYSTEM: 'system',
|
SYSTEM: 'system',
|
||||||
LIGHT: 'light',
|
LIGHT: 'light',
|
||||||
DARK: 'dark'
|
DARK: 'dark',
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_KEY = 'theme-mode'
|
const THEME_KEY = 'theme-mode'
|
||||||
|
|
||||||
export const themeState = reactive({
|
export const themeState = reactive({
|
||||||
mode: ThemeMode.SYSTEM
|
mode: ThemeMode.SYSTEM,
|
||||||
})
|
})
|
||||||
|
|
||||||
function apply(mode) {
|
function apply(mode) {
|
||||||
if (!process.client) return
|
if (!process.client) return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (mode === ThemeMode.SYSTEM) {
|
if (mode === ThemeMode.SYSTEM) {
|
||||||
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
} else {
|
} else {
|
||||||
root.dataset.theme = mode
|
root.dataset.theme = mode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const TIEBA_EMOJI_CDN = 'https://cdn.jsdelivr.net/gh/microlong666/tieba_mobile_emotions@master/'
|
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 TIEBA_EMOJI_CDN = 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor/dist/images/emoji/'
|
||||||
|
|
||||||
export const tiebaEmoji = (() => {
|
export const tiebaEmoji = (() => {
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
redirectUri: `${window.location.origin}/twitter-callback`,
|
redirectUri: `${window.location.origin}/twitter-callback`,
|
||||||
reason,
|
reason,
|
||||||
state,
|
state,
|
||||||
codeVerifier
|
codeVerifier,
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export async function fetchAdmins() {
|
|||||||
export async function searchUsers(keyword) {
|
export async function searchUsers(keyword) {
|
||||||
if (!keyword) return []
|
if (!keyword) return []
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`)
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`,
|
||||||
|
)
|
||||||
return res.ok ? await res.json() : []
|
return res.ok ? await res.json() : []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -13,22 +13,17 @@ export function getPreviewTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createVditor(editorId, options = {}) {
|
export function createVditor(editorId, options = {}) {
|
||||||
const {
|
const { placeholder = '', preview = {}, input, after } = options
|
||||||
placeholder = '',
|
|
||||||
preview = {},
|
|
||||||
input,
|
|
||||||
after
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const fetchMentions = async (value) => {
|
const fetchMentions = async (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
const [followings, admins] = await Promise.all([
|
const [followings, admins] = await Promise.all([
|
||||||
fetchFollowings(authState.username),
|
fetchFollowings(authState.username),
|
||||||
fetchAdmins()
|
fetchAdmins(),
|
||||||
])
|
])
|
||||||
const combined = [...followings, ...admins]
|
const combined = [...followings, ...admins]
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
return combined.filter(u => {
|
return combined.filter((u) => {
|
||||||
if (seen.has(u.id)) return false
|
if (seen.has(u.id)) return false
|
||||||
seen.add(u.id)
|
seen.add(u.id)
|
||||||
return true
|
return true
|
||||||
@@ -56,7 +51,7 @@ export function createVditor(editorId, options = {}) {
|
|||||||
'redo',
|
'redo',
|
||||||
'|',
|
'|',
|
||||||
'link',
|
'link',
|
||||||
'upload'
|
'upload',
|
||||||
]
|
]
|
||||||
|
|
||||||
let vditor
|
let vditor
|
||||||
@@ -64,9 +59,12 @@ export function createVditor(editorId, options = {}) {
|
|||||||
placeholder,
|
placeholder,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
theme: getEditorTheme(),
|
theme: getEditorTheme(),
|
||||||
preview: Object.assign({
|
preview: Object.assign(
|
||||||
theme: { current: getPreviewTheme() },
|
{
|
||||||
}, preview),
|
theme: { current: getPreviewTheme() },
|
||||||
|
},
|
||||||
|
preview,
|
||||||
|
),
|
||||||
hint: {
|
hint: {
|
||||||
emoji: tiebaEmoji,
|
emoji: tiebaEmoji,
|
||||||
extend: [
|
extend: [
|
||||||
@@ -74,9 +72,9 @@ export function createVditor(editorId, options = {}) {
|
|||||||
key: '@',
|
key: '@',
|
||||||
hint: async (key) => {
|
hint: async (key) => {
|
||||||
const list = await fetchMentions(key)
|
const list = await fetchMentions(key)
|
||||||
return list.map(u => ({
|
return list.map((u) => ({
|
||||||
value: `@[${u.username}]`,
|
value: `@[${u.username}]`,
|
||||||
html: `<img src="${u.avatar}" /> @${u.username}`
|
html: `<img src="${u.avatar}" /> @${u.username}`,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -93,7 +91,7 @@ export function createVditor(editorId, options = {}) {
|
|||||||
vditor.disabled()
|
vditor.disabled()
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`,
|
`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`,
|
||||||
{ headers: { Authorization: `Bearer ${getToken()}` } }
|
{ headers: { Authorization: `Bearer ${getToken()}` } },
|
||||||
)
|
)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
vditor.enable()
|
vditor.enable()
|
||||||
@@ -122,7 +120,7 @@ export function createVditor(editorId, options = {}) {
|
|||||||
'pjpeg',
|
'pjpeg',
|
||||||
'png',
|
'png',
|
||||||
'svg',
|
'svg',
|
||||||
'webp'
|
'webp',
|
||||||
]
|
]
|
||||||
const audioExts = ['wav', 'mp3', 'ogg']
|
const audioExts = ['wav', 'mp3', 'ogg']
|
||||||
let md
|
let md
|
||||||
@@ -137,7 +135,7 @@ export function createVditor(editorId, options = {}) {
|
|||||||
vditor.enable()
|
vditor.enable()
|
||||||
vditor.tip('上传成功')
|
vditor.tip('上传成功')
|
||||||
return null
|
return null
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
// upload: {
|
// upload: {
|
||||||
// fieldName: 'file',
|
// fieldName: 'file',
|
||||||
@@ -168,7 +166,7 @@ export function createVditor(editorId, options = {}) {
|
|||||||
toolbarConfig: { pin: true },
|
toolbarConfig: { pin: true },
|
||||||
cache: { enable: false },
|
cache: { enable: false },
|
||||||
input,
|
input,
|
||||||
after
|
after,
|
||||||
})
|
})
|
||||||
|
|
||||||
return vditor
|
return vditor
|
||||||
|
|||||||
Reference in New Issue
Block a user