Compare commits

..

38 Commits

Author SHA1 Message Date
Tim
d7e58a5741 feat: add base item group component 2025-10-15 21:04:29 +08:00
tim
a68c925c68 fix: 集成一下父亲容器 2025-10-15 19:52:30 +08:00
tim
4f248e8a71 fix: 布局微调 2025-10-15 18:08:16 +08:00
Tim
277883f9d9 Merge pull request #1064 from nagisa77/codex/refactor-reactionsgroup-to-remove-slot-mechanism
Refactor ReactionsGroup layout responsibilities
2025-10-15 17:55:55 +08:00
Tim
e9e996f291 refactor: simplify reactions group usage 2025-10-15 17:47:45 +08:00
Tim
a8667ce5e9 Merge pull request #1062 from smallclover/main
修复markdown手机模式下换行问题
2025-10-14 20:37:52 +08:00
夢夢の幻想郷
0d316af22a Merge branch 'nagisa77:main' into main 2025-10-14 20:25:46 +09:00
smallclover
f8e13af672 修复,markdown在手机模式下换行,导致行号和代码无法一致 2025-10-14 20:24:11 +09:00
Tim
92d90c997c Merge pull request #1061 from smallclover/main
追加
2025-10-13 20:56:40 +08:00
smallclover
303ec9b6c1 追加
首页贴吧表情显示
2025-10-13 19:35:18 +09:00
Tim
90eafe27fd Merge pull request #1060 from smallclover/main
icon对齐
2025-10-13 13:30:10 +08:00
smallclover
98e2ea7ef8 icon对齐
https://github.com/nagisa77/OpenIsle/issues/854
2025-10-13 09:47:58 +09:00
Tim
e3290f3431 Merge pull request #1059 from smallclover/main
header菜单栏调整
2025-10-12 16:32:34 +08:00
smallclover
160570574c 1.header菜单栏格式统一
2.修复未登录的情况下邀请链接状态错误
2025-10-11 10:13:03 +09:00
Tim
cf7b667f30 Merge pull request #1058 from sivdead/fix/issue-857-signup-ui-optimization
fix: 优化申请注册页面UI (#857)
2025-10-10 16:12:53 +08:00
sivdead
60fa6051b7 fix: 优化申请注册页面UI (#857)
- 将字符计数移至输入框内部右下角
- 错误提示距离输入框8px
- 优化布局结构,使用input-wrapper包裹输入区域
2025-10-10 15:20:39 +08:00
Tim
1c0e90d32d Merge pull request #1056 from smallclover/main
主页对齐方式修复
2025-10-10 09:56:28 +08:00
smallclover
a15065575d 修复问题
https://github.com/nagisa77/OpenIsle/issues/1057
问题原因:行号的css限制宽度,导致行数超过99错位
2025-10-09 21:40:28 +09:00
夢夢の幻想郷
cb958e162e Merge branch 'nagisa77:main' into main 2025-10-08 21:32:11 +09:00
smallclover
660d8ffe51 https://github.com/nagisa77/OpenIsle/issues/843
对齐方式修复
2025-10-08 21:31:36 +09:00
tim
5509a1eead Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-10-08 19:18:10 +08:00
tim
1acd776d3b fix: 启动排队机制 2025-10-08 19:17:45 +08:00
Tim
53be8d943a Merge pull request #1055 from immortal521/fix/theme-toggle-flicker
fix: theme-toggle-flicker
2025-10-08 15:48:23 +08:00
immortal521
9957042746 fix: theme-toggle-flicker
- remove unnecessary await nextTick in view transition

- Simplify transition callback

- Add fill: 'both' to transition style
2025-10-08 01:19:38 +08:00
tim
302f98f44e Revert "feat: 先把每日定时构件给注释掉"
This reverts commit 0119605649.
2025-10-07 18:02:06 +08:00
Tim
790c4db8ea Merge pull request #1054 from nagisa77/codex/update-dropdown.vue-for-empty-state-rendering
Add empty dropdown state message when search yields no results
2025-10-07 18:00:59 +08:00
tim
bbb0a11d49 fix: searchdropdown新增空state、 2025-10-07 18:00:37 +08:00
tim
35340319c6 fix: 限定profile 2025-10-07 16:38:04 +08:00
Tim
343c4d3793 Add empty state to dropdown when no search results 2025-10-07 16:29:53 +08:00
Tim
87b214cbc0 Merge pull request #1052 from smallclover/main
修改主页各section间距
2025-10-07 16:20:48 +08:00
tim
e7f06787d2 fix: 仅支配websocket打头域名 2025-10-07 15:59:23 +08:00
tim
d7d2fd5dcb fix: 仅支配websocket打头域名 2025-10-07 15:58:18 +08:00
smallclover
76b65a1400 修改主页各section间距 2025-10-05 21:08:05 +09:00
tim
fa8ee113a2 fix: 设置telegram测试环境 2025-10-05 18:05:45 +08:00
tim
181237adee fix: 更新GitHub预发环境登录 2025-10-05 17:45:40 +08:00
tim
1b8135acfb Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-10-05 17:44:25 +08:00
tim
67bbe832a0 fix: 更新GitHub预发环境登录 2025-10-05 17:43:45 +08:00
Tim
9d67f7d8d6 Merge pull request #1051 from nagisa77/codex/fix-comment-pinning-order-in-article
Ensure pinned comments stay at top of post timeline
2025-10-05 17:22:16 +08:00
20 changed files with 724 additions and 252 deletions

View File

@@ -80,26 +80,39 @@ WEBPUSH_PRIVATE_KEY=
LOG_LEVEL=INFO
# === Frontend (Nuxt) ===
# 本地开发
NUXT_PUBLIC_API_BASE_URL=http://localhost:8080
# 线上环境
# NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
# 测试环境
# NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
# 本地开发
NUXT_PUBLIC_WEBSOCKET_URL=http://localhost:8082
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com
# 线上环境
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com/websocket
# 测试环境
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com/websocket
# 本地开发
NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000
# 线上 & 本地均可使用
# 线上 & 测试 (www.staging.open-isle.com) & 本地均可使用
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
# 线上
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
# 测试环境 (www.staging.open-isle.com)
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23li6GHPxx4MwipWnM
# 本地
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
# 线上 & 本地均可使用
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
# 线上 & 本地均可使用
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
# 线上
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
# 测试环境 (www.staging.open-isle.com)
# NUXT_PUBLIC_TELEGRAM_BOT_ID=7832207011

View File

@@ -11,12 +11,17 @@ on:
permissions:
contents: write
# 文档发布自己的排队锁,不影响服务器部署
concurrency:
group: openisle-docs
cancel-in-progress: false
jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -2,22 +2,27 @@ name: Staging CI & CD
on:
push:
branches: [main]
branches: [ "main" ]
workflow_dispatch:
permissions:
contents: write
# 与生产部署共用同一把锁,确保服务器上始终串行(跨工作流也互斥)
concurrency:
group: openisle-server
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: Deploy
if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行
if: ${{ !github.event.repository.fork }}
steps:
- uses: actions/checkout@v4
- name: Deploy to Server
- name: Deploy to Server (staging)
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}

View File

@@ -2,8 +2,13 @@ name: CI & CD
on:
workflow_dispatch:
# schedule:
# - cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点
schedule:
- cron: "0 19 * * *" # 每天 UTC 19:00(北京 03:00
# 与 Staging 共用同一把锁,避免两边同时在 8G 服务器上跑
concurrency:
group: openisle-server
cancel-in-progress: false
jobs:
build-and-deploy:
@@ -13,7 +18,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Deploy to Server
- name: Deploy to Server (prod)
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}

View File

@@ -226,6 +226,8 @@ services:
websocket-service:
condition: service_healthy
restart: unless-stopped
profiles: ["staging", "prod"]
loopback_8080:
image: alpine/socat

View File

@@ -179,7 +179,9 @@ body {
.info-content-text pre .line-numbers {
counter-reset: line-number 0;
width: 2em;
white-space: nowrap; /* 禁止数字换行 */
font-variant-numeric: tabular-nums; /* 数字等宽 */
/* width: 2em; */
font-size: 13px;
position: sticky;
flex-shrink: 0;
@@ -203,6 +205,7 @@ body {
border-radius: 4px;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
white-space: pre; /* 禁止自动换行 */
}
.copy-code-btn {

View File

@@ -0,0 +1,157 @@
<template>
<div
class="base-item-group"
:style="{
width: `${containerWidth}px`,
height: `${itemSize}px`,
'--base-item-group-duration': `${animationDuration}ms`,
}"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<div
v-for="(item, index) in items"
:key="itemKey(item, index)"
class="base-item-group__item"
:style="{
width: `${itemSize}px`,
height: `${itemSize}px`,
transform: `translateX(${index * activeGap}px)`,
zIndex: items.length - index,
}"
>
<slot :item="item" :index="index">
<BaseImage
v-if="item && (item.src || typeof item === 'string')"
class="base-item-group__image"
:src="typeof item === 'string' ? item : item.src"
:alt="itemAlt(item, index)"
/>
<div v-else class="base-item-group__placeholder">{{ placeholderText(item) }}</div>
</slot>
</div>
</div>
</template>
<script setup>
import { computed, ref, watchEffect } from 'vue'
import BaseImage from './BaseImage.vue'
const props = defineProps({
items: {
type: Array,
default: () => [],
},
itemSize: {
type: Number,
default: 40,
},
collapsedGap: {
type: Number,
default: 12,
},
expandedGap: {
type: Number,
default: null,
},
animationDuration: {
type: Number,
default: 200,
},
itemKeyField: {
type: String,
default: 'id',
},
})
const isHovered = ref(false)
const onMouseEnter = () => {
isHovered.value = true
}
const onMouseLeave = () => {
isHovered.value = false
}
const effectiveExpandedGap = computed(() =>
props.expandedGap == null ? props.itemSize : props.expandedGap,
)
const activeGap = computed(() =>
isHovered.value ? effectiveExpandedGap.value : props.collapsedGap,
)
const containerWidth = computed(() =>
props.items.length ? props.itemSize + (props.items.length - 1) * activeGap.value : props.itemSize,
)
watchEffect(() => {
if (effectiveExpandedGap.value < props.collapsedGap) {
console.warn('[BaseItemGroup] `expandedGap` should be greater than or equal to `collapsedGap`.')
}
})
const itemKey = (item, index) => {
if (item && typeof item === 'object' && props.itemKeyField in item) {
return item[props.itemKeyField]
}
return index
}
const itemAlt = (item, index) => {
if (item && typeof item === 'object') {
return item.alt || `item-${index}`
}
if (typeof item === 'string') {
return `item-${index}`
}
return 'item'
}
const placeholderText = (item) => {
if (item == null) return ''
if (typeof item === 'object' && 'text' in item) return item.text
return String(item)
}
</script>
<style scoped>
.base-item-group {
display: flex;
position: relative;
transition: width var(--base-item-group-duration) ease;
}
.base-item-group__item {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
overflow: hidden;
background-color: var(--color-neutral-100, #f0f2f5);
transition: transform var(--base-item-group-duration) ease;
box-shadow: 0 0 0 2px var(--color-surface, #fff);
}
.base-item-group__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.base-item-group__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-neutral-500, #666);
background-color: var(--color-neutral-200, #e5e7eb);
}
</style>

View File

@@ -53,14 +53,29 @@
@click="handleContentClick"
></div>
<div class="article-footer-container">
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
<ReactionsGroup
ref="commentReactionsGroupRef"
v-model="comment.reactions"
content-type="comment"
:content-id="comment.id"
/>
<div class="comment-reaction-actions">
<div
class="reaction-action like-action"
:class="{ selected: commentLikedByMe }"
@click="toggleCommentLike"
>
<like v-if="!commentLikedByMe" />
<like v-else theme="filled" />
<span v-if="commentLikeCount" class="reaction-count">{{ commentLikeCount }}</span>
</div>
<div class="reaction-action comment-reaction" @click="toggleEditor">
<comment-icon />
</div>
<div class="make-reaction-item copy-link" @click="copyCommentLink">
<div class="reaction-action copy-link" @click="copyCommentLink">
<link-icon />
</div>
</ReactionsGroup>
</div>
</div>
<div class="comment-editor-wrapper" ref="editorWrapper">
<CommentEditor
@@ -156,6 +171,18 @@ const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn)
const commentReactionsGroupRef = ref(null)
const commentLikeCount = computed(
() => (props.comment.reactions || []).filter((reaction) => reaction.type === 'LIKE').length,
)
const commentLikedByMe = computed(() =>
(props.comment.reactions || []).some(
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
),
)
const toggleCommentLike = () => {
commentReactionsGroupRef.value?.toggleReaction('LIKE')
}
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || []))
const isCommentFromPostAuthor = computed(() => {
@@ -365,6 +392,47 @@ const handleContentClick = (e) => {
</script>
<style scoped>
.comment-reaction-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.reaction-action {
cursor: pointer;
padding: 4px 10px;
border-radius: 10px;
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
opacity: 0.6;
font-size: 18px;
transition:
background-color 0.2s ease,
opacity 0.2s ease;
}
.reaction-action:hover {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-action.like-action {
color: #ff0000;
}
.reaction-action.selected {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-count {
font-size: 14px;
font-weight: bold;
}
.reply-toggle {
cursor: pointer;
color: var(--primary-color);
@@ -378,10 +446,6 @@ const handleContentClick = (e) => {
color: var(--primary-color);
}
.comment-reaction:hover {
background-color: lightgray;
}
.comment-highlight {
animation: highlight 2s;
}

View File

@@ -49,7 +49,11 @@
</slot>
</div>
<div
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
v-if="
open &&
!isMobile &&
(loading || filteredOptions.length > 0 || showSearch || (remote && search))
"
:class="['dropdown-menu', menuClass]"
v-click-outside="close"
ref="menuRef"
@@ -62,26 +66,29 @@
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<template v-else>
<div
v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<component v-else :is="o.icon" class="option-icon" :size="16" />
</template>
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
<div v-if="filteredOptions.length === 0" class="dropdown-empty">没有搜索结果</div>
<template v-else>
<div
v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<component v-else :is="o.icon" class="option-icon" :size="16" />
</template>
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
</template>
</template>
</div>
<Teleport to="body">
@@ -99,26 +106,29 @@
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<template v-else>
<div
v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<component v-else :is="o.icon" class="option-icon" :size="16" />
</template>
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
<div v-if="filteredOptions.length === 0" class="dropdown-empty">没有搜索结果</div>
<template v-else>
<div
v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<component v-else :is="o.icon" class="option-icon" :size="16" />
</template>
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
</template>
</template>
</div>
</div>
@@ -283,6 +293,7 @@ export default {
isImageIcon,
setSearch,
isMobile,
remote: props.remote,
}
},
}
@@ -384,6 +395,13 @@ export default {
padding: 10px 0;
}
.dropdown-empty {
padding: 20px;
text-align: center;
color: var(--muted-text-color, #8c8c8c);
font-size: 14px;
}
.dropdown-mobile-page {
position: fixed;
top: 0;

View File

@@ -26,43 +26,59 @@
<ClientOnly>
<div class="header-content-right">
<div v-if="isMobile" class="search-icon" @click="search">
<search-icon />
</div>
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
<component :is="iconClass" />
</div>
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
<copy />
邀请
<loading v-if="isCopying" />
</div>
<!-- 搜索 -->
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
<div class="header-icon-item" @click="search">
<search-icon class="header-icon" />
<span class="header-label">搜索</span>
</div>
</ToolTip>
<!-- 主题切换 -->
<ToolTip v-if="isMobile" content="切换主题" placement="bottom">
<div class="header-icon-item" @click="cycleTheme">
<component :is="iconClass" class="header-icon" />
<span class="header-label">主题</span>
</div>
</ToolTip>
<!-- 邀请 -->
<ToolTip v-if="!isMobile" content="邀请好友" placement="bottom">
<div class="header-icon-item" @click="copyInviteLink">
<template v-if="!isCopying">
<copy-link class="header-icon" />
<span class="header-label">邀请</span>
</template>
<loading v-else />
</div>
</ToolTip>
<!-- 在线人数 -->
<ToolTip v-if="!isMobile" content="当前在线人数" placement="bottom">
<div class="online-count">
<peoples-two />
<span>{{ onlineCount }}</span>
<div class="header-icon-item">
<peoples-two class="header-icon" />
<span class="header-label">在线</span>
<span class="header-badge">{{ onlineCount }}</span>
</div>
</ToolTip>
<!-- RSS -->
<ToolTip content="复制RSS链接" placement="bottom">
<div class="rss-icon" @click="copyRssLink">
<rss />
<div class="header-icon-item" @click="copyRssLink">
<rss class="header-icon" />
<span class="header-label">RSS</span>
</div>
</ToolTip>
<!-- 发帖 -->
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
<div class="new-post-icon" @click="goToNewPost">
<edit />
<div class="header-icon-item" @click="goToNewPost">
<edit class="header-icon" />
<span class="header-label">发帖</span>
</div>
</ToolTip>
<!-- 消息 -->
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
<div class="messages-icon" @click="goToMessages">
<message-emoji />
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
unreadMessageCount
}}</span>
<div class="header-icon-item" @click="goToMessages">
<message-emoji class="header-icon" />
<span class="header-label">消息</span>
<span v-if="unreadMessageCount > 0" class="unread-badge">{{ unreadMessageCount }}</span>
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
</div>
</ToolTip>
@@ -192,6 +208,7 @@ const copyInviteLink = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
isCopying.value = false // 🔥 修复:未登录时立即复原状态
return
}
try {
@@ -333,7 +350,7 @@ onMounted(async () => {
height: var(--header-height);
background-color: var(--background-color-blur);
backdrop-filter: var(--blur-10);
color: var(--header-text-color);
color: var(--primary-color);
border-bottom: 1px solid var(--header-border-color);
}
@@ -376,6 +393,7 @@ onMounted(async () => {
flex-direction: row;
align-items: center;
gap: 20px;
padding-right: 15px;
}
.micon {
@@ -464,16 +482,14 @@ onMounted(async () => {
cursor: pointer;
}
.invite_text {
font-size: 12px;
cursor: pointer;
color: var(--primary-color);
}
.invite_text:hover {
opacity: 0.8;
text-decoration: underline;
}
.invite_text,
.online-count,
.rss-icon,
.new-post-icon,
.messages-icon {
@@ -484,8 +500,8 @@ onMounted(async () => {
.unread-badge {
position: absolute;
top: -5px;
right: -10px;
top: -4px;
right: -6px;
background-color: #ff4d4f;
color: white;
border-radius: 50%;
@@ -500,8 +516,8 @@ onMounted(async () => {
.unread-dot {
position: absolute;
top: -2px;
right: -4px;
top: 0;
right: -1px;
width: 8px;
height: 8px;
border-radius: 50%;
@@ -513,14 +529,58 @@ onMounted(async () => {
}
.online-count {
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
color: var(--primary-color);
cursor: default;
}
/* === 统一图标按钮风格 === */
.header-icon-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 14px;
color: var(--primary-color);
cursor: pointer;
position: relative;
transition: color 0.25s ease, transform 0.15s ease, opacity 0.2s ease;
}
.header-icon-item:hover {
opacity: 0.8;
transform: translateY(-1px);
}
/* 点击时瞬间高亮 + 轻微缩放 */
.header-icon-item:active {
color: var(--primary-color-hover);
transform: scale(0.92);
}
.header-icon {
font-size: 20px;
line-height: 1;
}
.header-label {
font-size: 12px;
line-height: 1;
}
/* 在线人数的数字文字样式(无背景) */
.header-badge {
position: absolute;
top: -4px;
right: -6px;
color: var(--primary-color); /* 🔹 使用主题主色 */
background: none; /* 🔹 去掉背景 */
font-size: 11px; /* 字体稍微大一点以便清晰 */
font-weight: 600; /* 加一点权重让数字更醒目 */
line-height: 1;
padding: 0; /* 去掉内边距 */
}
@keyframes rss-glow {
0% {
text-shadow: 0 0 0px var(--primary-color);
@@ -556,5 +616,12 @@ onMounted(async () => {
.header-content-right {
gap: 15px;
}
/* 手机不显示文字 */
.header-label {
display: none;
}
.header-badge {
display: none;
}
}
</style>

View File

@@ -35,21 +35,11 @@
</template>
</div>
</div>
<div class="make-reaction-container">
<div
v-if="props.contentType !== 'message'"
class="make-reaction-item like-reaction"
@click="toggleReaction('LIKE')"
>
<like v-if="!userReacted('LIKE')" />
<like v-else theme="filled" />
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
</div>
<slot></slot>
</div>
<div
v-if="panelVisible"
class="reactions-panel"
ref="reactionsPanelRef"
:style="panelInlineStyle"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
@@ -69,7 +59,7 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions'
@@ -102,8 +92,6 @@ const counts = computed(() => {
})
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
const likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username)
@@ -152,9 +140,11 @@ const displayedReactions = computed(() => {
.map((type) => ({ type }))
})
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
const panelTypes = computed(() => sortedReactionTypes.value)
const panelVisible = ref(false)
const reactionsPanelRef = ref(null)
const panelInlineStyle = ref({})
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
@@ -170,6 +160,33 @@ const cancelHide = () => {
clearTimeout(hideTimer)
}
const updatePanelInlineStyle = () => {
if (!panelVisible.value) return
const panelEl = reactionsPanelRef.value
if (!panelEl) return
const parentEl = panelEl.closest('.reactions-container')?.parentElement
if (!parentEl) return
const parentWidth = parentEl.clientWidth - 20
panelInlineStyle.value = {
width: 'max-content',
maxWidth: `${parentWidth}px`,
}
}
watch(panelVisible, async (visible) => {
if (visible) {
await nextTick()
updatePanelInlineStyle()
}
})
watch(panelTypes, async () => {
if (panelVisible.value) {
await nextTick()
updatePanelInlineStyle()
}
})
const toggleReaction = async (type) => {
const token = getToken()
if (!token) {
@@ -245,6 +262,15 @@ const toggleReaction = async (type) => {
onMounted(async () => {
await initialize()
window.addEventListener('resize', updatePanelInlineStyle)
})
defineExpose({
toggleReaction,
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePanelInlineStyle)
})
</script>
@@ -253,11 +279,7 @@ onMounted(async () => {
position: relative;
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.reactions-viewer {
@@ -295,32 +317,6 @@ onMounted(async () => {
padding-left: 5px;
}
.make-reaction-container {
display: flex;
flex-direction: row;
gap: 10px;
}
.make-reaction-item {
cursor: pointer;
padding: 4px;
opacity: 0.5;
border-radius: 8px;
font-size: 20px;
}
.like-reaction {
color: #ff0000;
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
}
.make-reaction-item:hover {
background-color: #ffe2e2;
}
.reactions-count {
font-size: 16px;
font-weight: bold;
@@ -328,7 +324,7 @@ onMounted(async () => {
.reactions-panel {
position: absolute;
bottom: 50px;
bottom: 40px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;

View File

@@ -202,6 +202,7 @@ defineExpose({
}
.result-body {
line-height: 1;
display: flex;
flex-direction: column;
}

View File

@@ -75,8 +75,8 @@
<star v-if="!article.rssExcluded" class="featured-icon" />
{{ article.title }}
</NuxtLink>
<NuxtLink class="article-item-description main-item">
{{ sanitizeDescription(article.description) }}
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
<div v-html="sanitizeDescription(article.description)"></div>
</NuxtLink>
<div class="article-info-container main-item">
<ArticleCategory :category="article.category" />
@@ -378,8 +378,27 @@ onBeforeUnmount(() => {
/** 供 InfiniteLoadMore 重建用的 key筛选/Tab 改变即重建内部状态 */
const ioKey = computed(() => asyncKey.value.join('::'))
/** 其他工具函数 **/
const sanitizeDescription = (text) => stripMarkdown(text)
// 在首页摘要加载贴吧表情包
const sanitizeDescription = (text) => {
if (!text) return ''
// 1⃣ 先把 Markdown 转成纯文本
const plain = stripMarkdown(text)
// 2⃣ 替换 :tieba123: 为 <img>
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
const key = `tieba${num}`
const file = tiebaEmoji[key]
return file
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
: match // 没有匹配到图片则保留原样
})
// 3 可选截断纯文本长度防止撑太长
const truncated = withEmoji.length > 500 ? withEmoji.slice(0, 500) + '…' : withEmoji
return truncated
}
// 页面选项同步到全局状态
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
@@ -537,16 +556,22 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
.article-comments,
.header-item.comments {
width: 5%;
justify-content: flex-end;
text-align: right;
}
.article-views,
.header-item.views {
width: 5%;
justify-content: flex-end;
text-align: right;
}
.article-time,
.header-item.activity {
width: 10%;
justify-content: flex-end;
text-align: left;
}
.article-item-title {

View File

@@ -61,14 +61,31 @@
@click="handleContentClick"
></div>
</div>
<ReactionsGroup
:model-value="item.reactions"
content-type="message"
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
>
<div @click="setReply(item)" class="reply-btn"><next /> 写个回复...</div>
</ReactionsGroup>
<div class="message-reaction-row">
<ReactionsGroup
:ref="(el) => setMessageReactionRef(item.id, el)"
:model-value="item.reactions"
content-type="message"
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
/>
<div class="message-reaction-actions">
<div
class="reaction-action like-action"
:class="{ selected: isMessageLiked(item) }"
@click="toggleMessageLike(item)"
>
<like v-if="!isMessageLiked(item)" />
<like v-else theme="filled" />
<span v-if="getMessageLikeCount(item)" class="reaction-count">{{
getMessageLikeCount(item)
}}</span>
</div>
<div @click="setReply(item)" class="reaction-action reply-btn">
<next /> 写个回复...
</div>
</div>
</div>
</template>
</BaseTimeline>
<div class="empty-container">
@@ -180,6 +197,32 @@ function setReply(message) {
replyTo.value = message
}
const messageReactionRefs = new Map()
function setMessageReactionRef(id, el) {
if (el) {
messageReactionRefs.set(id, el)
} else {
messageReactionRefs.delete(id)
}
}
function getMessageLikeCount(message) {
return (message.reactions || []).filter((reaction) => reaction.type === 'LIKE').length
}
function isMessageLiked(message) {
const username = currentUser.value?.username
if (!username) return false
return (message.reactions || []).some(
(reaction) => reaction.type === 'LIKE' && reaction.user === username,
)
}
function toggleMessageLike(message) {
const group = messageReactionRefs.get(message.id)
group?.toggleReaction('LIKE')
}
/** 改造:滚动函数 —— smooth & instant */
function scrollToBottomSmooth() {
const el = messagesListEl.value
@@ -710,6 +753,55 @@ function goBack() {
background-color: var(--normal-light-background-color);
}
.message-reaction-row {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-top: 6px;
}
.message-reaction-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.reaction-action {
cursor: pointer;
padding: 4px 10px;
border-radius: 10px;
display: flex;
align-items: center;
gap: 5px;
opacity: 0.6;
font-size: 16px;
transition:
background-color 0.2s ease,
opacity 0.2s ease;
}
.reaction-action:hover {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-action.like-action {
color: #ff0000;
}
.reaction-action.selected {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-count {
font-size: 14px;
font-weight: bold;
}
.reply-header {
display: flex;
flex-direction: row;
@@ -723,14 +815,8 @@ function goBack() {
}
.reply-btn {
cursor: pointer;
padding: 4px;
opacity: 0.6;
font-size: 12px;
}
.reply-btn:hover {
opacity: 1;
color: var(--primary-color);
}
.active-reply {

View File

@@ -92,11 +92,26 @@
></div>
<div class="article-footer-container">
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
<div class="make-reaction-item copy-link" @click="copyPostLink">
<ReactionsGroup
ref="postReactionsGroupRef"
v-model="postReactions"
content-type="post"
:content-id="postId"
/>
<div class="article-footer-actions">
<div
class="reaction-action like-action"
:class="{ selected: postLikedByMe }"
@click="togglePostLike"
>
<like v-if="!postLikedByMe" />
<like v-else theme="filled" />
<span v-if="postLikeCount" class="reaction-count">{{ postLikeCount }}</span>
</div>
<div class="reaction-action copy-link" @click="copyPostLink">
<link-icon />
</div>
</ReactionsGroup>
</div>
</div>
</div>
</div>
@@ -223,6 +238,18 @@ const postContent = ref('')
const category = ref('')
const tags = ref([])
const postReactions = ref([])
const postReactionsGroupRef = ref(null)
const postLikeCount = computed(
() => postReactions.value.filter((reaction) => reaction.type === 'LIKE').length,
)
const postLikedByMe = computed(() =>
postReactions.value.some(
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
),
)
const togglePostLike = () => {
postReactionsGroupRef.value?.toggleReaction('LIKE')
}
const comments = ref([])
const changeLogs = ref([])
const status = ref('PUBLISHED')
@@ -366,9 +393,9 @@ const changeLogIcon = (l) => {
return 'unlock'
}
} else if (l.type === 'PINNED') {
if(l.newPinnedAt){
if (l.newPinnedAt) {
return 'pin'
}else{
} else {
return 'clear-icon'
}
} else if (l.type === 'FEATURED') {
@@ -1245,35 +1272,53 @@ onMounted(async () => {
.article-footer-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
margin-top: 0px;
flex-wrap: wrap;
}
.reactions-viewer {
display: flex;
flex-direction: row;
gap: 20px;
align-items: center;
}
.reactions-viewer-item-container {
display: flex;
flex-direction: row;
gap: 2px;
align-items: center;
}
.reactions-viewer-item {
font-size: 16px;
}
.make-reaction-container {
.article-footer-actions {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
}
.copy-link:hover {
.reaction-action {
cursor: pointer;
padding: 4px 10px;
opacity: 0.6;
border-radius: 10px;
font-size: 20px;
display: flex;
align-items: center;
gap: 5px;
transition:
background-color 0.2s ease,
opacity 0.2s ease;
}
.reaction-action:hover {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-action.like-action {
color: #ff0000;
}
.reaction-action.selected {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-count {
font-size: 16px;
font-weight: bold;
}
.reaction-action.copy-link:hover {
background-color: #e2e2e2;
}

View File

@@ -5,11 +5,18 @@
<div class="reason-description">
为了我们社区的良性发展请填写注册理由我们将根据你的理由审核你的注册, 谢谢!
</div>
<div class="reason-input-container">
<BaseInput textarea rows="4" v-model="reason" placeholder="20个字以上"></BaseInput>
<div class="char-count">{{ reason.length }}/20</div>
<div class="input-wrapper">
<div class="reason-input-container">
<BaseInput
textarea
rows="4"
v-model="reason"
placeholder="请输入至少20个字符"
></BaseInput>
<div class="char-count">{{ reason.length }}/20</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="!isWaitingForRegister" class="signup-page-button-primary" @click="submit">
提交
</div>
@@ -38,8 +45,9 @@ onMounted(async () => {
})
const submit = async () => {
if (!reason.value || reason.value.trim().length < 20) {
error.value = '请至少输入20个字'
const trimmedReason = reason.value.trim()
if (!trimmedReason || trimmedReason.length < 20) {
error.value = '请至少输入20个字符'
return
}
@@ -98,16 +106,29 @@ const submit = async () => {
width: 400px;
}
.input-wrapper {
display: flex;
flex-direction: column;
}
.reason-input-container {
position: relative;
}
.char-count {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 12px;
color: #888;
width: 100%;
text-align: right;
background-color: transparent;
pointer-events: none;
}
.error-message {
color: red;
font-size: 14px;
margin-top: 8px;
}
.signup-page-button-primary {

View File

@@ -849,7 +849,8 @@ watch(selectedTab, async (val) => {
display: flex;
flex-direction: column;
padding: 20px;
gap: 20px;
row-gap: 40px; /* 行间距 */
column-gap: 20px; /* 列间距 */
}
.summary-title {
@@ -888,10 +889,10 @@ watch(selectedTab, async (val) => {
}
.summary-divider {
margin-top: 20px;
display: flex;
flex-direction: row;
gap: 20px;
row-gap: 40px; /* 行间距 */
column-gap: 20px; /* 列间距 */
width: 100%;
flex-wrap: wrap;
}

View File

@@ -29,6 +29,7 @@ import {
ApplicationMenu,
Search,
Copy,
CopyLink,
Loading,
Rss,
MessageEmoji,
@@ -111,6 +112,7 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('ApplicationMenu', ApplicationMenu)
nuxtApp.vueApp.component('SearchIcon', Search)
nuxtApp.vueApp.component('Copy', Copy)
nuxtApp.vueApp.component('CopyLink', CopyLink)
nuxtApp.vueApp.component('Loading', Loading)
nuxtApp.vueApp.component('Rss', Rss)
nuxtApp.vueApp.component('MessageEmoji', MessageEmoji)

View File

@@ -93,9 +93,8 @@ function getCircle(event) {
function withViewTransition(event, applyFn, direction = true) {
if (typeof document !== 'undefined' && document.startViewTransition) {
const transition = document.startViewTransition(async () => {
const transition = document.startViewTransition(() => {
applyFn()
await nextTick()
})
transition.ready
@@ -111,6 +110,7 @@ function withViewTransition(event, applyFn, direction = true) {
{
duration: 400,
easing: 'ease-in-out',
fill: 'both',
pseudoElement: direction
? '::view-transition-new(root)'
: '::view-transition-old(root)',

View File

@@ -18,51 +18,6 @@ server {
add_header X-Upstream $upstream_addr always;
}
location ^~ /api/ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# 升级所需
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 统一透传这些头(你在 /api/ 有,/api/ws 也要有)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
}
# 2) SockJS包含 /info、/iframe.html、/.../websocket 等)
location ^~ /api/sockjs {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
# 如要同源 iframe 回退,下面两行二选一(或者交给 Spring Security 的 sameOrigin
# proxy_hide_header X-Frame-Options;
# add_header X-Frame-Options "SAMEORIGIN" always;
}
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_http_version 1.1;
@@ -148,7 +103,7 @@ server {
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
location ^~ /websocket/ {
proxy_pass http://127.0.0.1:8082/;
proxy_pass http://127.0.0.1:8084/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -167,6 +122,7 @@ server {
add_header Cache-Control "no-store" always;
}
}
server {
listen 80;
server_name open-isle.com www.open-isle.com;