mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-07 15:41:02 +08:00
Compare commits
1 Commits
codex/upda
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5ec0348a9 |
23
.github/workflows/deploy-staging.yml
vendored
23
.github/workflows/deploy-staging.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Staging CI & CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: Deploy
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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-staging.sh
|
||||
|
||||
20
.github/workflows/deploy.yml
vendored
20
.github/workflows/deploy.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: CI & CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -13,6 +13,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# - uses: actions/setup-java@v4
|
||||
# with:
|
||||
# java-version: '17'
|
||||
# distribution: 'temurin'
|
||||
|
||||
# - run: mvn -B clean package -DskipTests
|
||||
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20'
|
||||
|
||||
# - run: |
|
||||
# cd open-isle-cli
|
||||
# npm ci
|
||||
# npm run build
|
||||
|
||||
- name: Deploy to Server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
|
||||
25
README.md
25
README.md
@@ -10,7 +10,7 @@
|
||||
|
||||
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
||||
|
||||
## 🚧 开发
|
||||
## 🚀 部署
|
||||
|
||||
### 后端
|
||||
|
||||
@@ -20,26 +20,9 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
|
||||
### 前端
|
||||
|
||||
1. 进入前端目录
|
||||
```bash
|
||||
cd frontend_nuxt
|
||||
```
|
||||
2. 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. 启动开发服务
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
生产版本使用如下命令编译:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
会在 `.output` 目录生成文件,配合线上网站方式部署
|
||||
1. `cd open-isle-cli`
|
||||
2. 执行 `npm install`
|
||||
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
|
||||
|
||||
## ✨ 项目特点
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.model.PointGood;
|
||||
import com.openisle.repository.PointGoodRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Initialize default point mall goods. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PointGoodInitializer implements CommandLineRunner {
|
||||
private final PointGoodRepository pointGoodRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (pointGoodRepository.count() == 0) {
|
||||
PointGood g1 = new PointGood();
|
||||
g1.setName("GPT Plus 1 个月");
|
||||
g1.setCost(20000);
|
||||
g1.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png");
|
||||
pointGoodRepository.save(g1);
|
||||
|
||||
PointGood g2 = new PointGood();
|
||||
g2.setName("奶茶");
|
||||
g2.setCost(5000);
|
||||
g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png");
|
||||
pointGoodRepository.save(g2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,16 +75,14 @@ public class SecurityConfig {
|
||||
cfg.setAllowedOrigins(List.of(
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://127.0.0.1",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost",
|
||||
"http://30.211.97.238:3000",
|
||||
"http://30.211.97.238",
|
||||
"http://192.168.7.98",
|
||||
"http://192.168.7.98:3000",
|
||||
"http://192.168.7.70",
|
||||
"http://192.168.7.70:8080",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
));
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.PointGoodDto;
|
||||
import com.openisle.dto.PointRedeemRequest;
|
||||
import com.openisle.mapper.PointGoodMapper;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.PointMallService;
|
||||
import com.openisle.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** REST controller for point mall. */
|
||||
@RestController
|
||||
@RequestMapping("/api/point-goods")
|
||||
@RequiredArgsConstructor
|
||||
public class PointMallController {
|
||||
private final PointMallService pointMallService;
|
||||
private final UserService userService;
|
||||
private final PointGoodMapper pointGoodMapper;
|
||||
|
||||
@GetMapping
|
||||
public List<PointGoodDto> list() {
|
||||
return pointMallService.listGoods().stream()
|
||||
.map(pointGoodMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/redeem")
|
||||
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
|
||||
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
||||
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
|
||||
return Map.of("point", point);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Point mall good info. */
|
||||
@Data
|
||||
public class PointGoodDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private int cost;
|
||||
private String image;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Request to redeem a point mall good. */
|
||||
@Data
|
||||
public class PointRedeemRequest {
|
||||
private Long goodId;
|
||||
private String contact;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.openisle.mapper;
|
||||
|
||||
import com.openisle.dto.PointGoodDto;
|
||||
import com.openisle.model.PointGood;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Mapper for point mall goods. */
|
||||
@Component
|
||||
public class PointGoodMapper {
|
||||
public PointGoodDto toDto(PointGood good) {
|
||||
PointGoodDto dto = new PointGoodDto();
|
||||
dto.setId(good.getId());
|
||||
dto.setName(good.getName());
|
||||
dto.setCost(good.getCost());
|
||||
dto.setImage(good.getImage());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Item available in the point mall. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "point_goods")
|
||||
public class PointGood {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int cost;
|
||||
|
||||
private String image;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.PointGood;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Repository for point mall goods. */
|
||||
public interface PointGoodRepository extends JpaRepository<PointGood, Long> {
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.model.PointGood;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PointGoodRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Service for point mall operations. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PointMallService {
|
||||
private final PointGoodRepository pointGoodRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public List<PointGood> listGoods() {
|
||||
return pointGoodRepository.findAll();
|
||||
}
|
||||
|
||||
public int redeem(User user, Long goodId, String contact) {
|
||||
PointGood good = pointGoodRepository.findById(goodId)
|
||||
.orElseThrow(() -> new NotFoundException("Good not found"));
|
||||
if (user.getPoint() < good.getCost()) {
|
||||
throw new FieldException("point", "Insufficient points");
|
||||
}
|
||||
user.setPoint(user.getPoint() - good.getCost());
|
||||
userRepository.save(user);
|
||||
notificationService.createActivityRedeemNotifications(user, good.getName() + ": " + contact);
|
||||
return user.getPoint();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE notifications
|
||||
MODIFY COLUMN type VARCHAR(50) NOT NULL;
|
||||
@@ -1,15 +1,6 @@
|
||||
; 本地部署后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
|
||||
; 预发环境
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-xxx.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
; 本地部署后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
|
||||
; 预发环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
@@ -15,77 +15,62 @@
|
||||
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||
<NuxtPage keepalive />
|
||||
</div>
|
||||
|
||||
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
</div>
|
||||
</div>
|
||||
<GlobalPopups />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script>
|
||||
import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||
import MenuComponent from '~/components/MenuComponent.vue'
|
||||
|
||||
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const menuVisible = ref(!isMobile.value)
|
||||
export default {
|
||||
name: 'App',
|
||||
components: { HeaderComponent, MenuComponent, GlobalPopups },
|
||||
setup() {
|
||||
const isMobile = useIsMobile()
|
||||
const menuVisible = ref(!isMobile.value)
|
||||
const hideMenu = computed(() => {
|
||||
return [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/404',
|
||||
'/signup-reason',
|
||||
'/github-callback',
|
||||
'/twitter-callback',
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
|
||||
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||
const header = useTemplateRef('header')
|
||||
|
||||
const hideMenu = computed(() => {
|
||||
return [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/404',
|
||||
'/signup-reason',
|
||||
'/github-callback',
|
||||
'/twitter-callback',
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
menuVisible.value = window.innerWidth > 768
|
||||
}
|
||||
})
|
||||
|
||||
const header = useTemplateRef('header')
|
||||
const handleMenuOutside = (event) => {
|
||||
const btn = header.value.$refs.menuBtn
|
||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
||||
return // 如果是菜单按钮的点击,不处理关闭
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
menuVisible.value = window.innerWidth > 768
|
||||
}
|
||||
})
|
||||
if (isMobile.value) {
|
||||
menuVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuOutside = (event) => {
|
||||
const btn = header.value.$refs.menuBtn
|
||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
||||
return // 如果是菜单按钮的点击,不处理关闭
|
||||
}
|
||||
|
||||
if (isMobile.value) {
|
||||
menuVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToNewPost = () => {
|
||||
navigateTo('/new-post', { replace: false })
|
||||
return { menuVisible, hideMenu, handleMenuOutside, header }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="~/assets/global.css"></style>
|
||||
<style>
|
||||
/* 页面过渡效果 */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: all 0.4s;
|
||||
}
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -118,24 +103,6 @@ const goToNewPost = () => {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-new-post-icon {
|
||||
background-color: var(--new-post-icon-color);
|
||||
color: white;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 20px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
backdrop-filter: var(--blur-5);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content,
|
||||
.content.menu-open {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/* Maple Mono - Thin 100 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-100-normal.woff2") format("woff2");
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Thin Italic 100 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-100-italic.woff2") format("woff2");
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraLight 200 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-200-normal.woff2") format("woff2");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraLight Italic 200 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-200-italic.woff2") format("woff2");
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Light 300 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-300-normal.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Light Italic 300 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-300-italic.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Regular 400 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-400-normal.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Italic 400 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-400-italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Medium 500 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-500-normal.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Medium Italic 500 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-500-italic.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - SemiBold 600 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-600-normal.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - SemiBold Italic 600 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-600-italic.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Bold 700 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-700-normal.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Bold Italic 700 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-700-italic.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraBold 800 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-800-normal.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraBold Italic 800 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-800-italic.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -2,35 +2,27 @@
|
||||
--primary-color-hover: rgb(9, 95, 105);
|
||||
--primary-color: rgb(10, 110, 120);
|
||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||
--header-height: 60px;
|
||||
--header-background-color: white;
|
||||
--header-border-color: lightgray;
|
||||
--header-text-color: black;
|
||||
--blur-1: blur(1px);
|
||||
--blur-2: blur(2px);
|
||||
--blur-4: blur(4px);
|
||||
--blur-5: blur(5px);
|
||||
--blur-10: blur(10px);
|
||||
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
|
||||
--app-menu-background-color: white;
|
||||
--menu-background-color: white;
|
||||
--background-color: white;
|
||||
--background-color-blur: rgba(255, 255, 255, 0.57);
|
||||
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
|
||||
--background-color-blur: var(--background-color);
|
||||
--menu-border-color: lightgray;
|
||||
--normal-border-color: lightgray;
|
||||
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||
--menu-text-color: black;
|
||||
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
||||
/* --normal-background-color: rgb(241, 241, 241); */
|
||||
--normal-background-color: white;
|
||||
--normal-background-color: rgb(241, 241, 241);
|
||||
--lottery-background-color: rgb(241, 241, 241);
|
||||
--code-highlight-background-color: rgb(241, 241, 241);
|
||||
--login-background-color: rgb(248, 248, 248);
|
||||
--login-background-color-hover: #e0e0e0;
|
||||
--text-color: black;
|
||||
--blockquote-text-color: #6a737d;
|
||||
--menu-width: 200px;
|
||||
--page-max-width: 1400px;
|
||||
--page-max-width: 1200px;
|
||||
--page-max-width-mobile: 900px;
|
||||
--article-info-background-color: #f0f0f0;
|
||||
--activity-card-background-color: #fafafa;
|
||||
@@ -41,9 +33,8 @@
|
||||
--header-border-color: #555;
|
||||
--primary-color: rgb(17, 182, 197);
|
||||
--primary-color-hover: rgb(13, 137, 151);
|
||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||
--header-text-color: white;
|
||||
--app-menu-background-color: #333;
|
||||
--menu-background-color: #333;
|
||||
--background-color: #333;
|
||||
/* --background-color-blur: #333333a4; */
|
||||
--background-color-blur: var(--background-color);
|
||||
@@ -51,10 +42,8 @@
|
||||
--normal-border-color: #555;
|
||||
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
||||
--menu-text-color: white;
|
||||
/* --normal-background-color: #000000; */
|
||||
--normal-background-color: #333;
|
||||
--normal-background-color: #000000;
|
||||
--lottery-background-color: #4e4e4e;
|
||||
--code-highlight-background-color: #262b35;
|
||||
--login-background-color: #575757;
|
||||
--login-background-color-hover: #717171;
|
||||
--text-color: #eee;
|
||||
@@ -63,15 +52,6 @@
|
||||
--activity-card-background-color: #585858;
|
||||
}
|
||||
|
||||
:root[data-frosted='off'] {
|
||||
--blur-1: none;
|
||||
--blur-2: none;
|
||||
--blur-4: none;
|
||||
--blur-5: none;
|
||||
--blur-10: none;
|
||||
--background-color-blur: var(--background-color);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -151,43 +131,13 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
display: flex;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
background-color: var(--normal-background-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-content-text pre .line-numbers {
|
||||
counter-reset: line-number 0;
|
||||
width: 2em;
|
||||
font-size: 13px;
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Maple Mono', monospace;
|
||||
margin: 1em 0;
|
||||
color: #888;
|
||||
border-right: 1px solid #888;
|
||||
box-sizing: border-box;
|
||||
padding-right: 0.5em;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.info-content-text pre .line-numbers .line-number::before {
|
||||
content: counter(line-number);
|
||||
counter-increment: line-number;
|
||||
}
|
||||
|
||||
.info-content-text code {
|
||||
font-family: 'Maple Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: no-wrap;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.copy-code-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -206,13 +156,20 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.about-content a,
|
||||
.info-content-text code {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--normal-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.info-content-text a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.about-content a:hover,
|
||||
.info-content-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -310,7 +267,7 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
line-height: 1.5;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
@@ -319,29 +276,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Transition API */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
[data-theme='dark']::view-transition-old(root) {
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
[data-theme='dark']::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* NProgress styles */
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
|
||||
@@ -41,8 +41,8 @@ export default {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: var(--blur-2);
|
||||
-webkit-backdrop-filter: var(--blur-2);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
.popup-content {
|
||||
position: relative;
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
export default {
|
||||
@@ -312,7 +312,7 @@ export default {
|
||||
border: none;
|
||||
outline: none;
|
||||
margin-left: 5px;
|
||||
background-color: var(--app-menu-background-color);
|
||||
background-color: var(--menu-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ export default {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--app-menu-background-color);
|
||||
background-color: var(--menu-background-color);
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,24 +3,22 @@
|
||||
<div class="dropdown-trigger" @click="toggle">
|
||||
<slot name="trigger"></slot>
|
||||
</div>
|
||||
<Transition name="dropdown-menu">
|
||||
<div v-if="visible" class="dropdown-menu-container">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="dropdown-item"
|
||||
:style="{ color: item.color || 'inherit' }"
|
||||
@click="handle(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
<div v-if="visible" class="dropdown-menu-container">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="dropdown-item"
|
||||
:style="{ color: item.color || 'inherit' }"
|
||||
@click="handle(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
export default {
|
||||
name: 'DropdownMenu',
|
||||
props: {
|
||||
@@ -63,28 +61,17 @@ export default {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-menu-enter-active,
|
||||
.dropdown-menu-leave-active {
|
||||
transition: all 0.4s;
|
||||
}
|
||||
.dropdown-menu-enter-from,
|
||||
.dropdown-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: var(--app-menu-background-color);
|
||||
background-color: var(--menu-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -95,9 +82,7 @@ export default {
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</button>
|
||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||
</div>
|
||||
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
|
||||
<NuxtLink class="logo-container" :to="`/`">
|
||||
<img
|
||||
alt="OpenIsle"
|
||||
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||
@@ -20,22 +20,11 @@
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<div class="header-content-right">
|
||||
<div v-if="isLogin" class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
</div>
|
||||
|
||||
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||
<div class="new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||
@@ -43,11 +32,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="!isLogin" class="auth-btns">
|
||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||
<div v-else class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
|
||||
@@ -60,13 +52,10 @@
|
||||
import { ClientOnly } from '#components'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import ToolTip from '~/components/ToolTip.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
|
||||
const props = defineProps({
|
||||
showMenuBtn: {
|
||||
type: Boolean,
|
||||
@@ -124,33 +113,12 @@ const goToLogout = () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
|
||||
const goToNewPost = () => {
|
||||
navigateTo('/new-post', { replace: false })
|
||||
}
|
||||
|
||||
const refrechData = async () => {
|
||||
await fetchUnreadCount()
|
||||
window.dispatchEvent(new Event('refresh-home'))
|
||||
}
|
||||
|
||||
const headerMenuItems = computed(() => [
|
||||
{ text: '设置', onClick: goToSettings },
|
||||
{ text: '个人主页', onClick: goToProfile },
|
||||
{ text: '退出', onClick: goToLogout },
|
||||
])
|
||||
|
||||
/** 其余逻辑保持不变 */
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
return 'fas fa-moon'
|
||||
case ThemeMode.LIGHT:
|
||||
return 'fas fa-sun'
|
||||
default:
|
||||
return 'fas fa-desktop'
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const updateAvatar = async () => {
|
||||
if (authState.loggedIn) {
|
||||
@@ -178,17 +146,25 @@ onMounted(async () => {
|
||||
await updateUnread()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => {
|
||||
if (userMenu.value) userMenu.value.close()
|
||||
showSearch.value = false
|
||||
},
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--header-height);
|
||||
background-color: var(--background-color-blur);
|
||||
backdrop-filter: var(--blur-10);
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--header-text-color);
|
||||
border-bottom: 1px solid var(--header-border-color);
|
||||
}
|
||||
@@ -207,10 +183,11 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: var(--page-max-width);
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.header-content-left {
|
||||
@@ -220,14 +197,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.header-content-right {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.auth-btns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -309,13 +278,7 @@ onMounted(async () => {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.search-icon,
|
||||
.theme-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-post-icon {
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const goLogin = () => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: var(--blur-4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,195 +1,168 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<nav v-if="visible" class="menu">
|
||||
<div class="menu-content">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="shouldShowStats"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="authState.loggedIn"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/points"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-coins"></i>
|
||||
<span class="menu-item-text">
|
||||
积分商城
|
||||
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="shouldShowStats"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="c in categoryData"
|
||||
:key="c.id"
|
||||
class="section-item"
|
||||
@click="gotoCategory(c)"
|
||||
>
|
||||
<template v-if="c.smallIcon || c.icon">
|
||||
<img
|
||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||
:src="c.smallIcon || c.icon"
|
||||
class="section-item-icon"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||
<div
|
||||
v-else
|
||||
v-for="c in categoryData"
|
||||
:key="c.id"
|
||||
class="section-item"
|
||||
@click="gotoCategory(c)"
|
||||
>
|
||||
<template v-if="c.smallIcon || c.icon">
|
||||
<img
|
||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||
:src="t.smallIcon || t.icon"
|
||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||
:src="c.smallIcon || c.icon"
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
</div>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 解决动态样式的水合错误 -->
|
||||
<ClientOnly v-if="!isMobile">
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else v-for="t in 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"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { authState } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: true },
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['item-click'])
|
||||
|
||||
const categoryOpen = ref(true)
|
||||
const tagOpen = ref(true)
|
||||
const myPoint = ref(null)
|
||||
const isLoadingCategory = ref(false)
|
||||
const isLoadingTag = ref(false)
|
||||
const categoryData = ref([])
|
||||
const tagData = ref([])
|
||||
|
||||
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||
const {
|
||||
data: categoryData,
|
||||
pending: isLoadingCategory,
|
||||
error: categoryError,
|
||||
} = await useAsyncData(
|
||||
// 稳定 key:避免 hydration 期误判
|
||||
'menu:categories',
|
||||
() => $fetch(`${API_BASE_URL}/api/categories`),
|
||||
{
|
||||
server: true, // SSR 预取
|
||||
default: () => [], // 初始默认值,减少空判断
|
||||
// 5 分钟内复用缓存,避免路由往返重复请求
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
)
|
||||
const fetchCategoryData = async () => {
|
||||
isLoadingCategory.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||
const data = await res.json()
|
||||
categoryData.value = data
|
||||
isLoadingCategory.value = false
|
||||
}
|
||||
|
||||
const {
|
||||
data: tagData,
|
||||
pending: isLoadingTag,
|
||||
error: tagError,
|
||||
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
|
||||
server: true,
|
||||
default: () => [],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const fetchTagData = async () => {
|
||||
isLoadingTag.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
|
||||
const data = await res.json()
|
||||
tagData.value = data
|
||||
isLoadingTag.value = false
|
||||
}
|
||||
|
||||
/** 其余逻辑保持不变 */
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
@@ -205,15 +178,6 @@ const unreadCount = computed(() => notificationState.unreadCount)
|
||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
||||
|
||||
const loadPoint = async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await fetchCurrentUser()
|
||||
myPoint.value = user ? user.point : null
|
||||
} else {
|
||||
myPoint.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const updateCount = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
@@ -223,15 +187,8 @@ const updateCount = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([updateCount(), loadPoint()])
|
||||
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
|
||||
watch(
|
||||
() => authState.loggedIn,
|
||||
() => {
|
||||
updateCount()
|
||||
loadPoint()
|
||||
},
|
||||
)
|
||||
await updateCount()
|
||||
watch(() => authState.loggedIn, updateCount)
|
||||
})
|
||||
|
||||
const handleItemClick = () => {
|
||||
@@ -254,35 +211,28 @@ const gotoTag = (t) => {
|
||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||
handleItemClick()
|
||||
}
|
||||
|
||||
await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu {
|
||||
position: sticky;
|
||||
top: var(--header-height);
|
||||
width: 220px;
|
||||
background-color: var(--app-menu-background-color);
|
||||
width: 200px;
|
||||
background-color: var(--menu-background-color);
|
||||
height: calc(100vh - 20px - var(--header-height));
|
||||
border-right: 1px solid var(--menu-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 10px 0 10px;
|
||||
.menu-item-container {
|
||||
}
|
||||
|
||||
/* .menu-item-container { */
|
||||
/**/
|
||||
/* } */
|
||||
|
||||
.menu-item {
|
||||
padding: 4px 10px;
|
||||
text-decoration: none;
|
||||
@@ -321,12 +271,6 @@ const gotoTag = (t) => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.point-count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
margin-right: 10px;
|
||||
opacity: 0.5;
|
||||
@@ -334,8 +278,10 @@ const gotoTag = (t) => {
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
position: relation;
|
||||
position: fixed;
|
||||
height: 30px;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -423,10 +369,6 @@ const gotoTag = (t) => {
|
||||
background-color: var(--background-color-blur);
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition:
|
||||
|
||||
@@ -40,22 +40,30 @@
|
||||
兑换
|
||||
</div>
|
||||
<div v-else class="redeem-button disabled">兑换</div>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeDialog"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
||||
<div class="redeem-dialog-content">
|
||||
<BaseInput
|
||||
textarea=""
|
||||
rows="5"
|
||||
v-model="contact"
|
||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
||||
/>
|
||||
<div class="redeem-actions">
|
||||
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
|
||||
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import LevelProgress from '~/components/LevelProgress.vue'
|
||||
import ProgressBar from '~/components/ProgressBar.vue'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -177,6 +185,56 @@ const submitRedeem = async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.redeem-dialog-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.redeem-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redeem-submit-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.redeem-submit-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.redeem-cancel-button {
|
||||
color: var(--primary-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-cancel-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.user-level-text {
|
||||
opacity: 0.8;
|
||||
font-size: 12px;
|
||||
@@ -189,5 +247,9 @@ const submitRedeem = async () => {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.redeem-dialog-content {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<BasePopup :visible="visible" @close="onClose">
|
||||
<div class="redeem-dialog-content">
|
||||
<BaseInput
|
||||
textarea
|
||||
rows="5"
|
||||
v-model="innerContact"
|
||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
||||
/>
|
||||
<div class="redeem-actions">
|
||||
<div class="redeem-submit-button" @click="submit" :disabled="loading">提交</div>
|
||||
<div class="redeem-cancel-button" @click="onClose">取消</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'close'])
|
||||
|
||||
const innerContact = ref(props.modelValue)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
innerContact.value = v
|
||||
},
|
||||
)
|
||||
watch(innerContact, (v) => emit('update:modelValue', v))
|
||||
|
||||
const submit = () => {
|
||||
emit('submit')
|
||||
}
|
||||
const onClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.redeem-dialog-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.redeem-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redeem-submit-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.redeem-submit-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.redeem-cancel-button {
|
||||
color: var(--primary-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-cancel-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.redeem-dialog-content {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -37,10 +37,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { ref, watch } from 'vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -110,10 +110,6 @@ watch(selected, (val) => {
|
||||
selected.value = null
|
||||
keyword.value = ''
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
toggle,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -135,7 +131,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.text-input {
|
||||
background-color: var(--app-menu-background-color);
|
||||
background-color: var(--menu-background-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
<template>
|
||||
<div class="tooltip-wrapper" ref="wrapperRef">
|
||||
<!-- 触发器 -->
|
||||
<div
|
||||
class="tooltip-trigger"
|
||||
:tabindex="focusable ? 0 : -1"
|
||||
:aria-describedby="visible ? ariaId : undefined"
|
||||
@mouseenter="onTriggerMouseEnter"
|
||||
@mouseleave="onTriggerMouseLeave"
|
||||
@click="onTriggerClick"
|
||||
@focus="onTriggerFocus"
|
||||
@blur="onTriggerBlur"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 提示内容(Teleport 到 body) -->
|
||||
<Teleport to="body" v-if="mounted">
|
||||
<Transition name="tooltip-fade">
|
||||
<div
|
||||
v-show="visible"
|
||||
:id="ariaId"
|
||||
ref="tooltipRef"
|
||||
class="tooltip-content"
|
||||
:class="[
|
||||
`tooltip-${currentPlacement}`,
|
||||
dark ? 'tooltip-dark' : 'tooltip-light',
|
||||
props.trigger === 'hover' ? 'tooltip-noninteractive' : '',
|
||||
]"
|
||||
:style="tooltipInlineStyle"
|
||||
role="tooltip"
|
||||
>
|
||||
<div class="tooltip-inner">
|
||||
<slot name="content">
|
||||
{{ content }}
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- 箭头 -->
|
||||
<div
|
||||
class="tooltip-arrow"
|
||||
:class="`tooltip-arrow-${currentPlacement}`"
|
||||
:style="arrowStyle"
|
||||
></div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
watch,
|
||||
defineProps,
|
||||
defineEmits,
|
||||
defineOptions,
|
||||
useId,
|
||||
} from 'vue'
|
||||
|
||||
defineOptions({ name: 'Tooltip' })
|
||||
|
||||
type Trigger = 'hover' | 'click' | 'focus' | 'manual'
|
||||
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
const props = defineProps({
|
||||
content: { type: String, default: '' },
|
||||
trigger: {
|
||||
type: String as () => Trigger,
|
||||
default: 'hover',
|
||||
validator: (v: string) => ['hover', 'click', 'focus', 'manual'].includes(v),
|
||||
},
|
||||
placement: {
|
||||
type: String as () => Placement,
|
||||
default: 'top',
|
||||
validator: (v: string) => ['top', 'bottom', 'left', 'right'].includes(v),
|
||||
},
|
||||
dark: { type: Boolean, default: false },
|
||||
delay: { type: Number, default: 100 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
focusable: { type: Boolean, default: true },
|
||||
offset: { type: Number, default: 8 },
|
||||
maxWidth: { type: [String, Number], default: '200px' },
|
||||
/** 隐藏延时(毫秒),hover 离开后等待一点点以防抖 */
|
||||
hideDelay: { type: Number, default: 80 },
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'show'): void
|
||||
(e: 'hide'): void
|
||||
}>()
|
||||
|
||||
const wrapperRef = ref<HTMLElement | null>(null)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
const visible = ref(false)
|
||||
const currentPlacement = ref<Placement>(props.placement)
|
||||
const ariaId = ref(`tooltip-${useId()}`)
|
||||
const mounted = ref(false)
|
||||
|
||||
let showTimer: number | null = null
|
||||
let hideTimer: number | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
let rafId: number | null = null
|
||||
|
||||
const maxWidthValue = computed(() => {
|
||||
return typeof props.maxWidth === 'number' ? `${props.maxWidth}px` : props.maxWidth
|
||||
})
|
||||
|
||||
const tooltipTransform = ref('translate3d(-9999px, -9999px, 0)')
|
||||
|
||||
const tooltipInlineStyle = computed(() => ({
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
zIndex: 2000,
|
||||
maxWidth: maxWidthValue.value,
|
||||
transform: tooltipTransform.value,
|
||||
}))
|
||||
|
||||
const arrowStyle = ref<Record<string, string>>({})
|
||||
|
||||
const clearTimers = () => {
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const show = async () => {
|
||||
if (props.disabled) return
|
||||
clearTimers()
|
||||
showTimer = window.setTimeout(async () => {
|
||||
visible.value = true
|
||||
emit('show')
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}, props.delay)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
clearTimers()
|
||||
hideTimer = window.setTimeout(() => {
|
||||
visible.value = false
|
||||
emit('hide')
|
||||
}, props.hideDelay)
|
||||
}
|
||||
|
||||
const showImmediately = async () => {
|
||||
if (props.disabled) return
|
||||
clearTimers()
|
||||
visible.value = true
|
||||
emit('show')
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}
|
||||
const hideImmediately = () => {
|
||||
clearTimers()
|
||||
visible.value = false
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
// 触发器事件
|
||||
const onTriggerMouseEnter = () => {
|
||||
if (props.trigger === 'hover') show()
|
||||
}
|
||||
const onTriggerMouseLeave = () => {
|
||||
// 关键修改:hover 模式下,离开触发区即开始隐藏计时,不再保持可交互
|
||||
if (props.trigger === 'hover') hide()
|
||||
}
|
||||
const onTriggerClick = () => {
|
||||
if (props.trigger !== 'click') return
|
||||
visible.value ? hideImmediately() : showImmediately()
|
||||
}
|
||||
const onTriggerFocus = () => {
|
||||
if (props.trigger === 'focus') showImmediately()
|
||||
}
|
||||
const onTriggerBlur = () => {
|
||||
if (props.trigger === 'focus') hideImmediately()
|
||||
}
|
||||
|
||||
// 点击外部关闭(只对 click 模式)
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (props.trigger !== 'click') return
|
||||
const w = wrapperRef.value
|
||||
const t = tooltipRef.value
|
||||
const target = e.target as Node
|
||||
if (w && !w.contains(target) && t && !t.contains(target)) {
|
||||
hideImmediately()
|
||||
}
|
||||
}
|
||||
|
||||
// 定位算法
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
function computeBasePosition(
|
||||
placement: Placement,
|
||||
triggerRect: DOMRect,
|
||||
tooltipRect: DOMRect,
|
||||
offset: number,
|
||||
) {
|
||||
const centerX = triggerRect.left + triggerRect.width / 2
|
||||
const centerY = triggerRect.top + triggerRect.height / 2
|
||||
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
return {
|
||||
top: triggerRect.top - tooltipRect.height - offset,
|
||||
left: centerX - tooltipRect.width / 2,
|
||||
}
|
||||
case 'bottom':
|
||||
return {
|
||||
top: triggerRect.bottom + offset,
|
||||
left: centerX - tooltipRect.width / 2,
|
||||
}
|
||||
case 'left':
|
||||
return {
|
||||
top: centerY - tooltipRect.height / 2,
|
||||
left: triggerRect.left - tooltipRect.width - offset,
|
||||
}
|
||||
case 'right':
|
||||
return {
|
||||
top: centerY - tooltipRect.height / 2,
|
||||
left: triggerRect.right + offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function positionWithSmartFlip(
|
||||
preferred: Placement,
|
||||
triggerRect: DOMRect,
|
||||
tooltipRect: DOMRect,
|
||||
offset: number,
|
||||
) {
|
||||
const padding = 8
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
|
||||
let placement: Placement = preferred
|
||||
let { top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!
|
||||
|
||||
const outTop = top < padding
|
||||
const outBottom = top + tooltipRect.height > vh - padding
|
||||
const outLeft = left < padding
|
||||
const outRight = left + tooltipRect.width > vw - padding
|
||||
|
||||
if (
|
||||
placement === 'top' &&
|
||||
outTop &&
|
||||
triggerRect.bottom + offset + tooltipRect.height <= vh - padding
|
||||
) {
|
||||
placement = 'bottom'
|
||||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||
} else if (
|
||||
placement === 'bottom' &&
|
||||
outBottom &&
|
||||
triggerRect.top - offset - tooltipRect.height >= padding
|
||||
) {
|
||||
placement = 'top'
|
||||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||
} else if (
|
||||
placement === 'left' &&
|
||||
outLeft &&
|
||||
triggerRect.right + offset + tooltipRect.width <= vw - padding
|
||||
) {
|
||||
placement = 'right'
|
||||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||
} else if (
|
||||
placement === 'right' &&
|
||||
outRight &&
|
||||
triggerRect.left - offset - tooltipRect.width >= padding
|
||||
) {
|
||||
placement = 'left'
|
||||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||
}
|
||||
|
||||
top = clamp(top, padding, vh - tooltipRect.height - padding)
|
||||
left = clamp(left, padding, vw - tooltipRect.width - padding)
|
||||
|
||||
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
||||
const triggerCenterY = triggerRect.top + triggerRect.height / 2
|
||||
const arrowLeft = clamp(triggerCenterX - left, 10, tooltipRect.width - 10)
|
||||
const arrowTop = clamp(triggerCenterY - top, 10, tooltipRect.height - 10)
|
||||
|
||||
return { placement, top, left, arrowLeft, arrowTop }
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!wrapperRef.value || !tooltipRef.value || !visible.value) return
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const triggerEl = wrapperRef.value!.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||
const tooltipEl = tooltipRef.value!
|
||||
if (!triggerEl) return
|
||||
|
||||
const triggerRect = triggerEl.getBoundingClientRect()
|
||||
const tooltipRect = tooltipEl.getBoundingClientRect()
|
||||
const { placement, top, left, arrowLeft, arrowTop } = positionWithSmartFlip(
|
||||
props.placement,
|
||||
triggerRect,
|
||||
tooltipRect,
|
||||
props.offset,
|
||||
)
|
||||
|
||||
currentPlacement.value = placement
|
||||
tooltipTransform.value = `translate3d(${Math.round(left)}px, ${Math.round(top)}px, 0)`
|
||||
if (placement === 'top' || placement === 'bottom') {
|
||||
arrowStyle.value = { '--arrow-left': `${Math.round(arrowLeft)}px` } as any
|
||||
} else {
|
||||
arrowStyle.value = { '--arrow-top': `${Math.round(arrowTop)}px` } as any
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onEnvChanged = () => {
|
||||
if (visible.value) updatePosition()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(v) => {
|
||||
if (v && visible.value) hideImmediately()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => props.placement,
|
||||
() => {
|
||||
if (visible.value) nextTick(updatePosition)
|
||||
},
|
||||
)
|
||||
watch(visible, (v) => {
|
||||
if (!mounted.value) return
|
||||
if (v) {
|
||||
if ('ResizeObserver' in window && !ro) {
|
||||
ro = new ResizeObserver(() => updatePosition())
|
||||
if (tooltipRef.value) ro.observe(tooltipRef.value)
|
||||
const triggerEl = wrapperRef.value?.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||
if (triggerEl) ro.observe(triggerEl)
|
||||
}
|
||||
updatePosition()
|
||||
} else {
|
||||
if (ro) {
|
||||
ro.disconnect()
|
||||
ro = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
mounted.value = true
|
||||
window.addEventListener('resize', onEnvChanged, { passive: true })
|
||||
window.addEventListener('scroll', onEnvChanged, { passive: true, capture: true })
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers()
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (ro) {
|
||||
ro.disconnect()
|
||||
ro = null
|
||||
}
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
window.removeEventListener('resize', onEnvChanged)
|
||||
window.removeEventListener('scroll', onEnvChanged, true)
|
||||
})
|
||||
|
||||
// 暴露给父组件(manual 可用)
|
||||
defineExpose({ show: showImmediately, hide: hideImmediately, updatePosition })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tooltip-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.tooltip-trigger {
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
}
|
||||
.tooltip-trigger:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
will-change: transform;
|
||||
pointer-events: auto; /* 默认允许交互(click/focus 模式) */
|
||||
}
|
||||
.tooltip-noninteractive {
|
||||
/* hover 模式下禁用指针事件,避免移入浮层导致保持显示 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tooltip-inner {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 主题 */
|
||||
.tooltip-light .tooltip-inner {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
border-color: var(--normal-border-color);
|
||||
}
|
||||
.tooltip-dark .tooltip-inner {
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 箭头(用 CSS 变量控制偏移) */
|
||||
.tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* 顶部 */
|
||||
.tooltip-top .tooltip-arrow-top {
|
||||
bottom: -6px;
|
||||
left: var(--arrow-left, 50%);
|
||||
transform: translateX(-50%);
|
||||
border-width: 6px 6px 0 6px;
|
||||
}
|
||||
.tooltip-light.tooltip-top .tooltip-arrow-top {
|
||||
border-color: var(--normal-border-color) transparent transparent transparent;
|
||||
}
|
||||
.tooltip-light.tooltip-top .tooltip-arrow-top::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: -6px;
|
||||
border-width: 6px 6px 0 6px;
|
||||
border-style: solid;
|
||||
border-color: var(--background-color) transparent transparent transparent;
|
||||
}
|
||||
.tooltip-dark.tooltip-top .tooltip-arrow-top {
|
||||
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.tooltip-bottom .tooltip-arrow-bottom {
|
||||
top: -6px;
|
||||
left: var(--arrow-left, 50%);
|
||||
transform: translateX(-50%);
|
||||
border-width: 0 6px 6px 6px;
|
||||
}
|
||||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom {
|
||||
border-color: transparent transparent var(--normal-border-color) transparent;
|
||||
}
|
||||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: -6px;
|
||||
border-width: 0 6px 6px 6px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent var(--background-color) transparent;
|
||||
}
|
||||
.tooltip-dark.tooltip-bottom .tooltip-arrow-bottom {
|
||||
border-color: transparent transparent rgba(0, 0, 0, 0.9) transparent;
|
||||
}
|
||||
|
||||
/* 左侧 */
|
||||
.tooltip-left .tooltip-arrow-left {
|
||||
right: -6px;
|
||||
top: var(--arrow-top, 50%);
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 0 6px 6px;
|
||||
}
|
||||
.tooltip-light.tooltip-left .tooltip-arrow-left {
|
||||
border-color: transparent transparent transparent var(--normal-border-color);
|
||||
}
|
||||
.tooltip-light.tooltip-left .tooltip-arrow-left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -7px;
|
||||
border-width: 6px 0 6px 6px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent transparent var(--background-color);
|
||||
}
|
||||
.tooltip-dark.tooltip-left .tooltip-arrow-left {
|
||||
border-color: transparent transparent transparent rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
/* 右侧 */
|
||||
.tooltip-right .tooltip-arrow-right {
|
||||
left: -6px;
|
||||
top: var(--arrow-top, 50%);
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 6px 6px 0;
|
||||
}
|
||||
.tooltip-light.tooltip-right .tooltip-arrow-right {
|
||||
border-color: transparent var(--normal-border-color) transparent transparent;
|
||||
}
|
||||
.tooltip-light.tooltip-right .tooltip-arrow-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 1px;
|
||||
border-width: 6px 6px 6px 0;
|
||||
border-style: solid;
|
||||
border-color: transparent var(--background-color) transparent transparent;
|
||||
}
|
||||
.tooltip-dark.tooltip-right .tooltip-arrow-right {
|
||||
border-color: transparent rgba(0, 0, 0, 0.9) transparent transparent;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.tooltip-fade-enter-active,
|
||||
.tooltip-fade-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
.tooltip-fade-enter-from,
|
||||
.tooltip-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 4px, 0) scale(0.98);
|
||||
}
|
||||
|
||||
/* 响应式微调 */
|
||||
@media (max-width: 768px) {
|
||||
.tooltip-inner {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1 @@
|
||||
export { toast } from './composables/useToast'
|
||||
|
||||
export const API_DOMAIN = 'https://www.open-isle.com'
|
||||
export const API_PORT = ''
|
||||
export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN
|
||||
|
||||
@@ -12,10 +12,9 @@ export default defineNuxtConfig({
|
||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||
},
|
||||
},
|
||||
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
// Ensure Vditor styles load before our overrides in global.css
|
||||
css: ['vditor/dist/index.css', '~/assets/global.css'],
|
||||
app: {
|
||||
pageTransition: { name: 'page', mode: 'out-in' },
|
||||
head: {
|
||||
script: [
|
||||
{
|
||||
@@ -27,31 +26,7 @@ export default defineNuxtConfig({
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
|
||||
let themeColor = '#fff';
|
||||
let themeStatus = 'default';
|
||||
if (theme === 'dark') {
|
||||
themeColor = '#333';
|
||||
themeStatus = 'black-translucent';
|
||||
} else {
|
||||
themeColor = '#ffffff';
|
||||
themeStatus = 'default';
|
||||
}
|
||||
|
||||
const androidMeta = document.createElement('meta');
|
||||
androidMeta.name = 'theme-color';
|
||||
androidMeta.content = themeColor;
|
||||
|
||||
const iosMeta = document.createElement('meta');
|
||||
iosMeta.name = 'apple-mobile-web-app-status-bar-style';
|
||||
iosMeta.content = themeStatus;
|
||||
|
||||
document.head.appendChild(androidMeta);
|
||||
document.head.appendChild(iosMeta);
|
||||
} catch (e) {
|
||||
console.warn('Theme initialization failed:', e);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
`,
|
||||
},
|
||||
@@ -85,27 +60,4 @@ export default defineNuxtConfig({
|
||||
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
build: {
|
||||
// increase warning limit and split large libraries into separate chunks
|
||||
chunkSizeWarningLimit: 1024,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('vditor')) {
|
||||
return 'vditor'
|
||||
}
|
||||
if (id.includes('echarts')) {
|
||||
return 'echarts'
|
||||
}
|
||||
if (id.includes('highlight.js')) {
|
||||
return 'highlight'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
4950
frontend_nuxt/package-lock.json
generated
4950
frontend_nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
<div class="forgot-page">
|
||||
<div class="forgot-content">
|
||||
<div class="forgot-title">找回密码</div>
|
||||
|
||||
<div v-if="step === 0" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
@@ -20,10 +19,6 @@
|
||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||
<div class="primary-button disabled" v-else>提交中...</div>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
使用 Google 注册的用户可使用对应的邮箱进行找回密码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,8 +26,6 @@
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -46,7 +39,6 @@ const passwordError = ref('')
|
||||
const isSending = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const isResetting = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.email) {
|
||||
@@ -145,21 +137,6 @@ const resetPassword = async () => {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.forgot-content .hint-message {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--blockquote-text-color);
|
||||
}
|
||||
|
||||
.hint-message i {
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div v-if="!isMobile" class="search-container">
|
||||
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
||||
<div class="search-title">一切可能,从此刻启航</div>
|
||||
<div class="search-subtitle">
|
||||
愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。
|
||||
</div>
|
||||
<SearchDropdown />
|
||||
</div>
|
||||
|
||||
@@ -113,7 +116,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import CategorySelect from '~/components/CategorySelect.vue'
|
||||
@@ -147,17 +150,9 @@ const categoryOptions = ref([])
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopicCookie = useCookie('homeTab')
|
||||
const selectedTopic = ref(
|
||||
selectedTopicCookie.value
|
||||
? selectedTopicCookie.value
|
||||
: route.query.view === 'ranking'
|
||||
? '排行榜'
|
||||
: route.query.view === 'latest'
|
||||
? '最新'
|
||||
: '最新回复',
|
||||
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
||||
)
|
||||
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||
const articles = ref([])
|
||||
const page = ref(0)
|
||||
const pageSize = 10
|
||||
@@ -183,11 +178,6 @@ onMounted(() => {
|
||||
const { category, tags } = route.query
|
||||
if (category) selectedCategorySet(category)
|
||||
if (tags) selectedTagsSet(tags)
|
||||
|
||||
const saved = localStorage.getItem('homeTab')
|
||||
if (saved) {
|
||||
selectedTopic.value = saved
|
||||
}
|
||||
})
|
||||
|
||||
/** 路由变更时同步筛选 **/
|
||||
@@ -205,7 +195,7 @@ watch(
|
||||
const loadOptions = async () => {
|
||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/`)
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
||||
if (res.ok) categoryOptions.value = [await res.json()]
|
||||
} catch {}
|
||||
}
|
||||
@@ -275,7 +265,7 @@ const {
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||
pinned: !!p.pinnedAt,
|
||||
type: p.type,
|
||||
}))
|
||||
},
|
||||
@@ -318,7 +308,7 @@ const fetchNextPage = async () => {
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||
pinned: !!p.pinnedAt,
|
||||
type: p.type,
|
||||
}))
|
||||
articles.value.push(...mapped)
|
||||
@@ -353,13 +343,9 @@ watch(
|
||||
watch([selectedCategory, selectedTags], () => {
|
||||
loadOptions()
|
||||
})
|
||||
watch(selectedTopic, (val) => {
|
||||
watch(selectedTopic, () => {
|
||||
// 仅当需要额外选项时加载
|
||||
loadOptions()
|
||||
selectedTopicCookie.value = val
|
||||
if (process.client) {
|
||||
localStorage.setItem('homeTab', val)
|
||||
}
|
||||
})
|
||||
|
||||
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||
@@ -368,8 +354,6 @@ if (import.meta.server) {
|
||||
}
|
||||
onMounted(() => {
|
||||
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||
|
||||
window.addEventListener('refresh-home', refreshFirst)
|
||||
})
|
||||
|
||||
/** 其他工具函数 **/
|
||||
@@ -387,8 +371,8 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-top: 32px;
|
||||
padding: 20px 20px 32px;
|
||||
margin-top: 100px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -400,6 +384,10 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.search-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -434,7 +422,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.topic-item-container {
|
||||
@@ -554,7 +541,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
display: -webkit-box;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -27,10 +27,6 @@
|
||||
>找回密码</a
|
||||
>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -263,11 +259,6 @@ const loginWithTwitter = () => {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.hint-message {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-page {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -505,26 +505,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import NotificationContainer from '~/components/NotificationContainer.vue'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import { markNotificationsRead, fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import {
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
isLoadingMessage,
|
||||
markRead,
|
||||
notifications,
|
||||
markAllRead,
|
||||
} from '~/utils/notification'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const route = useRoute()
|
||||
const notifications = ref([])
|
||||
const isLoadingMessage = ref(false)
|
||||
const selectedTab = ref(
|
||||
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
||||
)
|
||||
@@ -533,6 +528,234 @@ const filteredNotifications = computed(() =>
|
||||
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
|
||||
)
|
||||
|
||||
const markRead = async (id) => {
|
||||
if (!id) return
|
||||
const n = notifications.value.find((n) => n.id === id)
|
||||
if (!n || n.read) return
|
||||
n.read = true
|
||||
if (notificationState.unreadCount > 0) notificationState.unreadCount--
|
||||
const ok = await markNotificationsRead([id])
|
||||
if (!ok) {
|
||||
n.read = false
|
||||
notificationState.unreadCount++
|
||||
} else {
|
||||
fetchUnreadCount()
|
||||
}
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
// 除了 REGISTER_REQUEST 类型消息
|
||||
const idsToMark = notifications.value
|
||||
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
|
||||
.map((n) => n.id)
|
||||
if (idsToMark.length === 0) return
|
||||
notifications.value.forEach((n) => {
|
||||
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
||||
})
|
||||
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
|
||||
const ok = await markNotificationsRead(idsToMark)
|
||||
if (!ok) {
|
||||
notifications.value.forEach((n) => {
|
||||
if (idsToMark.includes(n.id)) n.read = false
|
||||
})
|
||||
await fetchUnreadCount()
|
||||
return
|
||||
}
|
||||
fetchUnreadCount()
|
||||
if (authState.role === 'ADMIN') {
|
||||
toast.success('已读所有消息(注册请求除外)')
|
||||
} else {
|
||||
toast.success('已读所有消息')
|
||||
}
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
POST_VIEWED: 'fas fa-eye',
|
||||
COMMENT_REPLY: 'fas fa-reply',
|
||||
POST_REVIEWED: 'fas fa-shield-alt',
|
||||
POST_REVIEW_REQUEST: 'fas fa-gavel',
|
||||
POST_UPDATED: 'fas fa-comment-dots',
|
||||
USER_ACTIVITY: 'fas fa-user',
|
||||
FOLLOWED_POST: 'fas fa-feather-alt',
|
||||
USER_FOLLOWED: 'fas fa-user-plus',
|
||||
USER_UNFOLLOWED: 'fas fa-user-minus',
|
||||
POST_SUBSCRIBED: 'fas fa-bookmark',
|
||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
}
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
isLoadingMessage.value = true
|
||||
notifications.value = []
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
isLoadingMessage.value = false
|
||||
if (!res.ok) {
|
||||
toast.error('获取通知失败')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
for (const n of data) {
|
||||
if (n.type === 'COMMENT_REPLY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REACTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
emoji: reactionEmojiMap[n.reactionType],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_VIEWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_WIN') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_DRAW') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_UPDATED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'USER_ACTIVITY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'MENTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'FOLLOWED_POST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REGISTER_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {},
|
||||
})
|
||||
} else {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPrefs = async () => {
|
||||
notificationPrefs.value = await fetchNotificationPreferences()
|
||||
}
|
||||
@@ -619,7 +842,7 @@ const formatType = (t) => {
|
||||
}
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
onMounted(() => {
|
||||
fetchNotifications()
|
||||
fetchPrefs()
|
||||
})
|
||||
@@ -636,8 +859,6 @@ onActivated(() => {
|
||||
.message-page {
|
||||
background-color: var(--background-color);
|
||||
overflow-x: hidden;
|
||||
height: calc(100vh - var(--header-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-page-header {
|
||||
@@ -649,7 +870,6 @@ onActivated(() => {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.message-page-header-right {
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script>
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
<template>
|
||||
<div class="point-mall-page">
|
||||
<section class="rules">
|
||||
<div class="section-title">🎉 积分规则</div>
|
||||
<div class="section-content">
|
||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span class="point-value">{{
|
||||
point
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="goods">
|
||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
{{ good.cost }} 积分
|
||||
</div>
|
||||
<div class="goods-item-button" @click="openRedeem(good)">兑换</div>
|
||||
</div>
|
||||
</section>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeRedeem"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const point = ref(null)
|
||||
|
||||
const pointRules = [
|
||||
'发帖:每天前两次,每次 30 积分',
|
||||
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
|
||||
'帖子被点赞:每次 10 积分',
|
||||
'评论被点赞:每次 10 积分',
|
||||
]
|
||||
|
||||
const goods = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const contact = ref('')
|
||||
const loading = ref(false)
|
||||
const selectedGood = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await fetchCurrentUser()
|
||||
point.value = user ? user.point : null
|
||||
}
|
||||
await loadGoods()
|
||||
})
|
||||
|
||||
const loadGoods = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
|
||||
if (res.ok) {
|
||||
goods.value = await res.json()
|
||||
}
|
||||
}
|
||||
|
||||
const openRedeem = (good) => {
|
||||
selectedGood.value = good
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const closeRedeem = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
const submitRedeem = async () => {
|
||||
if (!selectedGood.value || !contact.value) return
|
||||
loading.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-goods/redeem`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ goodId: selectedGood.value.id, contact: contact.value }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
point.value = data.point
|
||||
toast.success('兑换成功!')
|
||||
dialogVisible.value = false
|
||||
contact.value = ''
|
||||
} else {
|
||||
toast.error('兑换失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.point-mall-page {
|
||||
padding-left: 20px;
|
||||
max-width: var(--page-max-width);
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.point-info {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.point-value {
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.coin-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.rules,
|
||||
.goods {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.goods {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.goods-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goods-item-name {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.goods-item-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.goods-item-cost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.goods-item-button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
width: calc(100% - 40px);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.goods-item-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -232,7 +232,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||
import { useRoute } from 'vue-router'
|
||||
import CommentItem from '~/components/CommentItem.vue'
|
||||
@@ -268,6 +268,7 @@ const postReactions = ref([])
|
||||
const comments = ref([])
|
||||
const status = ref('PUBLISHED')
|
||||
const pinnedAt = ref(null)
|
||||
const isWaitingFetchingPost = ref(false)
|
||||
const isWaitingPostingComment = ref(false)
|
||||
const postTime = ref('')
|
||||
const postItems = ref([])
|
||||
@@ -391,7 +392,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
|
||||
avatar: c.author.avatar,
|
||||
text: c.content,
|
||||
reactions: c.reactions || [],
|
||||
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||||
pinned: !!c.pinnedAt,
|
||||
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
||||
openReplies: level === 0,
|
||||
src: c.author.avatar,
|
||||
@@ -454,41 +455,38 @@ const onCommentDeleted = (id) => {
|
||||
fetchComments()
|
||||
}
|
||||
|
||||
const {
|
||||
data: postData,
|
||||
pending: pendingPost,
|
||||
error: postError,
|
||||
refresh: refreshPost,
|
||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||
server: true,
|
||||
lazy: false,
|
||||
})
|
||||
|
||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||
|
||||
// 同步到现有的响应式字段
|
||||
watchEffect(() => {
|
||||
const data = postData.value
|
||||
if (!data) return
|
||||
postContent.value = data.content
|
||||
author.value = data.author
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
pinnedAt.value = data.pinnedAt
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
})
|
||||
|
||||
// 404 客户端跳转
|
||||
// if (postError.value?.statusCode === 404 && process.client) {
|
||||
// router.replace('/404')
|
||||
// }
|
||||
const fetchPost = async () => {
|
||||
try {
|
||||
isWaitingFetchingPost.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
})
|
||||
isWaitingFetchingPost.value = false
|
||||
if (!res.ok) {
|
||||
if (res.status === 404 && process.client) {
|
||||
router.replace('/404')
|
||||
}
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
postContent.value = data.content
|
||||
author.value = data.author
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
pinnedAt.value = data.pinnedAt
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
await nextTick()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPosts = computed(() => comments.value.length + 1)
|
||||
const lastReplyTime = computed(() =>
|
||||
@@ -609,7 +607,6 @@ const approvePost = async () => {
|
||||
if (res.ok) {
|
||||
status.value = 'PUBLISHED'
|
||||
toast.success('已通过审核')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -623,8 +620,8 @@ const pinPost = async () => {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
pinnedAt.value = new Date().toISOString()
|
||||
toast.success('已置顶')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -638,8 +635,8 @@ const unpinPost = async () => {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
pinnedAt.value = null
|
||||
toast.success('已取消置顶')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -677,7 +674,6 @@ const rejectPost = async () => {
|
||||
if (res.ok) {
|
||||
status.value = 'REJECTED'
|
||||
toast.success('已驳回')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -713,7 +709,7 @@ const joinLottery = async () => {
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('已参与抽奖')
|
||||
await refreshPost()
|
||||
await fetchPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -784,8 +780,9 @@ onMounted(async () => {
|
||||
window.addEventListener('scroll', updateCurrentIndex)
|
||||
jumpToHashComment()
|
||||
})
|
||||
</script>
|
||||
|
||||
await fetchPost()
|
||||
</script>
|
||||
<style>
|
||||
.post-page-container {
|
||||
background-color: var(--background-color);
|
||||
@@ -867,7 +864,6 @@ onMounted(async () => {
|
||||
direction: ltr;
|
||||
height: 300px;
|
||||
width: 2px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -36,13 +36,6 @@
|
||||
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
||||
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
||||
</div>
|
||||
<div class="form-row switch-row">
|
||||
<div class="setting-title">毛玻璃效果</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="frosted" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||
<h3>管理员设置</h3>
|
||||
@@ -72,13 +65,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const username = ref('')
|
||||
@@ -95,7 +87,6 @@ const aiFormatLimit = ref(3)
|
||||
const registerMode = ref('DIRECT')
|
||||
const isLoadingPage = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const frosted = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
isLoadingPage.value = true
|
||||
@@ -114,7 +105,6 @@ onMounted(async () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
isLoadingPage.value = false
|
||||
frosted.value = frostedState.enabled
|
||||
})
|
||||
|
||||
const onAvatarChange = (e) => {
|
||||
@@ -128,7 +118,6 @@ const onAvatarChange = (e) => {
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
watch(frosted, (val) => setFrosted(val))
|
||||
const onCropped = ({ file, url }) => {
|
||||
avatarFile.value = file
|
||||
avatar.value = url
|
||||
@@ -311,58 +300,6 @@ const save = async () => {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.2s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ const reason = ref('')
|
||||
const error = ref('')
|
||||
const isWaitingForRegister = ref(false)
|
||||
const token = ref('')
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
token.value = route.query.token || ''
|
||||
@@ -51,8 +50,8 @@ const submit = async () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: token.value,
|
||||
reason: reason.value,
|
||||
token: this.token,
|
||||
reason: this.reason,
|
||||
}),
|
||||
})
|
||||
isWaitingForRegister.value = false
|
||||
|
||||
@@ -35,12 +35,10 @@
|
||||
/>
|
||||
<div class="profile-level-target">
|
||||
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||
<ToolTip
|
||||
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||
placement="bottom"
|
||||
>
|
||||
<i class="fas fa-info-circle profile-exp-info"></i>
|
||||
</ToolTip>
|
||||
<i
|
||||
class="fas fa-info-circle profile-exp-info"
|
||||
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,89 +204,67 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||
<div class="timeline-tabs">
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
||||
@click="timelineFilter = 'all'"
|
||||
>
|
||||
全部
|
||||
</div>
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
||||
@click="timelineFilter = 'articles'"
|
||||
>
|
||||
文章
|
||||
</div>
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
||||
@click="timelineFilter = 'comments'"
|
||||
>
|
||||
评论和回复
|
||||
</div>
|
||||
</div>
|
||||
<BasePlaceholder
|
||||
v-if="filteredTimelineItems.length === 0"
|
||||
v-if="timelineItems.length === 0"
|
||||
text="暂无时间线"
|
||||
icon="fas fa-inbox"
|
||||
/>
|
||||
<div class="timeline-list">
|
||||
<BaseTimeline :items="filteredTimelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
发布了文章
|
||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'comment'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下评论了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'reply'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下对
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</router-link>
|
||||
回复了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'tag'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<BaseTimeline :items="timelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
发布了文章
|
||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<template v-else-if="item.type === 'comment'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下评论了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'reply'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下对
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</router-link>
|
||||
回复了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'tag'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||
@@ -348,15 +324,6 @@ const hotPosts = ref([])
|
||||
const hotReplies = ref([])
|
||||
const hotTags = ref([])
|
||||
const timelineItems = ref([])
|
||||
const timelineFilter = ref('all')
|
||||
const filteredTimelineItems = computed(() => {
|
||||
if (timelineFilter.value === 'articles') {
|
||||
return timelineItems.value.filter((item) => item.type === 'post')
|
||||
} else if (timelineFilter.value === 'comments') {
|
||||
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||
}
|
||||
return timelineItems.value
|
||||
})
|
||||
const followers = ref([])
|
||||
const followings = ref([])
|
||||
const medals = ref([])
|
||||
@@ -687,6 +654,12 @@ watch(selectedTab, async (val) => {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.profile-exp-info {
|
||||
margin-left: 4px;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -727,7 +700,6 @@ watch(selectedTab, async (val) => {
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
scrollbar-width: none;
|
||||
overflow-x: auto;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.profile-tabs-item {
|
||||
@@ -805,24 +777,8 @@ watch(selectedTab, async (val) => {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.timeline-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.timeline-tab-item {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-tab-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
.profile-timeline {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
import { initFrosted } from '~/utils/frosted'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
initFrosted()
|
||||
})
|
||||
9
frontend_nuxt/plugins/soft-manifest.client.ts
Normal file
9
frontend_nuxt/plugins/soft-manifest.client.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// 覆盖默认行为:收到 manifest 更新时,不立刻在路由切换里刷新
|
||||
nuxtApp.hooks.hook('app:manifest:update', () => {
|
||||
// todo 选择:弹个提示,让用户点击刷新;或延迟到页面隐藏时再刷新
|
||||
// 例如:document.addEventListener('visibilitychange', () => { if (document.hidden) location.reload() })
|
||||
})
|
||||
})
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,26 +0,0 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const FROSTED_KEY = 'frosted-glass'
|
||||
|
||||
export const frostedState = reactive({
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
function apply() {
|
||||
if (!import.meta.client) return
|
||||
document.documentElement.dataset.frosted = frostedState.enabled ? 'on' : 'off'
|
||||
}
|
||||
|
||||
export function initFrosted() {
|
||||
if (!import.meta.client) return
|
||||
const saved = localStorage.getItem(FROSTED_KEY)
|
||||
frostedState.enabled = saved !== 'false'
|
||||
apply()
|
||||
}
|
||||
|
||||
export function setFrosted(enabled) {
|
||||
if (!import.meta.client) return
|
||||
frostedState.enabled = enabled
|
||||
localStorage.setItem(FROSTED_KEY, enabled ? 'true' : 'false')
|
||||
apply()
|
||||
}
|
||||
@@ -24,7 +24,6 @@ export async function googleGetIdToken() {
|
||||
export function googleAuthorize() {
|
||||
const config = useRuntimeConfig()
|
||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||
return
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import hljs from 'highlight.js'
|
||||
if (typeof window !== 'undefined') {
|
||||
const theme =
|
||||
document.documentElement.dataset.theme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
if (theme === 'dark') {
|
||||
import('highlight.js/styles/atom-one-dark.css')
|
||||
} else {
|
||||
import('highlight.js/styles/atom-one-light.css')
|
||||
}
|
||||
}
|
||||
import 'highlight.js/styles/github.css'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { toast } from '../main'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
@@ -95,11 +86,7 @@ const md = new MarkdownIt({
|
||||
} else {
|
||||
code = hljs.highlightAuto(str).value
|
||||
}
|
||||
const lineNumbers = code
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(() => `<div class="line-number"></div>`)
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,32 +1,10 @@
|
||||
import { navigateTo, useRuntimeConfig } from 'nuxt/app'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { toast } from '~/composables/useToast'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
import { getToken } from './auth'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const notificationState = reactive({
|
||||
unreadCount: 0,
|
||||
})
|
||||
|
||||
const iconMap = {
|
||||
POST_VIEWED: 'fas fa-eye',
|
||||
COMMENT_REPLY: 'fas fa-reply',
|
||||
POST_REVIEWED: 'fas fa-shield-alt',
|
||||
POST_REVIEW_REQUEST: 'fas fa-gavel',
|
||||
POST_UPDATED: 'fas fa-comment-dots',
|
||||
USER_ACTIVITY: 'fas fa-user',
|
||||
FOLLOWED_POST: 'fas fa-feather-alt',
|
||||
USER_FOLLOWED: 'fas fa-user-plus',
|
||||
USER_UNFOLLOWED: 'fas fa-user-minus',
|
||||
POST_SUBSCRIBED: 'fas fa-bookmark',
|
||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
}
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
@@ -109,235 +87,3 @@ export async function updateNotificationPreference(type, enabled) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理信息的高阶函数
|
||||
* @returns
|
||||
*/
|
||||
function createFetchNotifications() {
|
||||
const notifications = ref([])
|
||||
const isLoadingMessage = ref(false)
|
||||
const fetchNotifications = async () => {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
if (isLoadingMessage && notifications && markRead) {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
isLoadingMessage.value = true
|
||||
notifications.value = []
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
isLoadingMessage.value = false
|
||||
if (!res.ok) {
|
||||
toast.error('获取通知失败')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
for (const n of data) {
|
||||
if (n.type === 'COMMENT_REPLY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REACTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
emoji: reactionEmojiMap[n.reactionType],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_VIEWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_WIN') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_DRAW') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_UPDATED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'USER_ACTIVITY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'MENTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'FOLLOWED_POST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REGISTER_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {},
|
||||
})
|
||||
} else {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id) => {
|
||||
if (!id) return
|
||||
const n = notifications.value.find((n) => n.id === id)
|
||||
if (!n || n.read) return
|
||||
n.read = true
|
||||
if (notificationState.unreadCount > 0) notificationState.unreadCount--
|
||||
const ok = await markNotificationsRead([id])
|
||||
if (!ok) {
|
||||
n.read = false
|
||||
notificationState.unreadCount++
|
||||
} else {
|
||||
fetchUnreadCount()
|
||||
}
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
// 除了 REGISTER_REQUEST 类型消息
|
||||
const idsToMark = notifications.value
|
||||
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
|
||||
.map((n) => n.id)
|
||||
if (idsToMark.length === 0) return
|
||||
notifications.value.forEach((n) => {
|
||||
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
||||
})
|
||||
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
|
||||
const ok = await markNotificationsRead(idsToMark)
|
||||
if (!ok) {
|
||||
notifications.value.forEach((n) => {
|
||||
if (idsToMark.includes(n.id)) n.read = false
|
||||
})
|
||||
await fetchUnreadCount()
|
||||
return
|
||||
}
|
||||
fetchUnreadCount()
|
||||
if (authState.role === 'ADMIN') {
|
||||
toast.success('已读所有消息(注册请求除外)')
|
||||
} else {
|
||||
toast.success('已读所有消息')
|
||||
}
|
||||
}
|
||||
return {
|
||||
fetchNotifications,
|
||||
markRead,
|
||||
notifications,
|
||||
isLoadingMessage,
|
||||
markRead,
|
||||
markAllRead,
|
||||
}
|
||||
}
|
||||
|
||||
export const { fetchNotifications, markRead, notifications, isLoadingMessage, markAllRead } =
|
||||
createFetchNotifications()
|
||||
|
||||
@@ -14,46 +14,19 @@ export const themeState = reactive({
|
||||
})
|
||||
|
||||
function apply(mode) {
|
||||
if (!import.meta.client) return
|
||||
if (!process.client) return
|
||||
const root = document.documentElement
|
||||
let newMode =
|
||||
mode === ThemeMode.SYSTEM
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: mode
|
||||
if (root.dataset.theme === newMode) return
|
||||
root.dataset.theme = newMode
|
||||
|
||||
// 更新 meta 标签
|
||||
const androidMeta = document.querySelector('meta[name="theme-color"]')
|
||||
const iosMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]')
|
||||
const themeColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--background-color')
|
||||
.trim()
|
||||
const themeStatus = newMode === 'dark' ? 'black-translucent' : 'default'
|
||||
|
||||
if (androidMeta) {
|
||||
androidMeta.content = themeColor
|
||||
if (mode === ThemeMode.SYSTEM) {
|
||||
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
} else {
|
||||
const newAndroidMeta = document.createElement('meta')
|
||||
newAndroidMeta.name = 'theme-color'
|
||||
newAndroidMeta.content = themeColor
|
||||
document.head.appendChild(newAndroidMeta)
|
||||
}
|
||||
|
||||
if (iosMeta) {
|
||||
iosMeta.content = themeStatus
|
||||
} else {
|
||||
const newIosMeta = document.createElement('meta')
|
||||
newIosMeta.name = 'apple-mobile-web-app-status-bar-style'
|
||||
newIosMeta.content = themeStatus
|
||||
document.head.appendChild(newIosMeta)
|
||||
root.dataset.theme = mode
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
if (!import.meta.client) return
|
||||
if (!process.client) return
|
||||
const saved = localStorage.getItem(THEME_KEY)
|
||||
if (saved && Object.values(ThemeMode).includes(saved)) {
|
||||
themeState.mode = saved
|
||||
@@ -62,117 +35,15 @@ export function initTheme() {
|
||||
}
|
||||
|
||||
export function setTheme(mode) {
|
||||
if (!import.meta.client) return
|
||||
if (!process.client) return
|
||||
if (!Object.values(ThemeMode).includes(mode)) return
|
||||
themeState.mode = mode
|
||||
localStorage.setItem(THEME_KEY, mode)
|
||||
apply(mode)
|
||||
}
|
||||
|
||||
function getCircle(event) {
|
||||
if (!import.meta.client) return undefined
|
||||
|
||||
let x, y
|
||||
if (event.touches?.length) {
|
||||
x = event.touches[0].clientX
|
||||
y = event.touches[0].clientY
|
||||
} else if (event.changedTouches?.length) {
|
||||
x = event.changedTouches[0].clientX
|
||||
y = event.changedTouches[0].clientY
|
||||
} else {
|
||||
x = event.clientX
|
||||
y = event.clientY
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
radius: Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)),
|
||||
}
|
||||
}
|
||||
|
||||
function withViewTransition(event, applyFn, direction = true) {
|
||||
if (typeof document !== 'undefined' && document.startViewTransition) {
|
||||
const transition = document.startViewTransition(async () => {
|
||||
applyFn()
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
transition.ready
|
||||
.then(() => {
|
||||
const { x, y, radius } = getCircle(event)
|
||||
|
||||
const clipPath = [`circle(0 at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`]
|
||||
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: direction ? clipPath : [...clipPath].reverse(),
|
||||
},
|
||||
{
|
||||
duration: 400,
|
||||
easing: 'ease-in-out',
|
||||
pseudoElement: direction
|
||||
? '::view-transition-new(root)'
|
||||
: '::view-transition-old(root)',
|
||||
},
|
||||
)
|
||||
})
|
||||
.catch(console.warn)
|
||||
} else {
|
||||
fallbackThemeTransition(applyFn)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackThemeTransition(applyFn) {
|
||||
if (!import.meta.client) return
|
||||
|
||||
const root = document.documentElement
|
||||
const computedStyle = getComputedStyle(root)
|
||||
|
||||
// 获取当前背景色用于过渡
|
||||
const currentBg = computedStyle.getPropertyValue('--background-color').trim()
|
||||
|
||||
// 创建过渡元素
|
||||
const transitionElement = document.createElement('div')
|
||||
transitionElement.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: ${currentBg};
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
backdrop-filter: var(--blur-1);
|
||||
`
|
||||
document.body.appendChild(transitionElement)
|
||||
|
||||
// 使用 Web Animations API 实现淡出动画
|
||||
const animation = transitionElement.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||
duration: 300,
|
||||
easing: 'ease-out',
|
||||
})
|
||||
|
||||
// 应用主题变更
|
||||
applyFn()
|
||||
|
||||
// 动画完成后清理
|
||||
animation.finished
|
||||
.then(() => {
|
||||
document.body.removeChild(transitionElement)
|
||||
})
|
||||
.catch(() => {
|
||||
// 降级处理
|
||||
document.body.removeChild(transitionElement)
|
||||
})
|
||||
}
|
||||
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function cycleTheme(event) {
|
||||
if (!import.meta.client) return
|
||||
export function cycleTheme() {
|
||||
if (!process.client) return
|
||||
const modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
|
||||
const index = modes.indexOf(themeState.mode)
|
||||
const next = modes[(index + 1) % modes.length]
|
||||
@@ -183,31 +54,10 @@ export function cycleTheme(event) {
|
||||
} else {
|
||||
toast.success('🌙 已经切换到暗色主题')
|
||||
}
|
||||
// 获取当前真实主题
|
||||
const currentTheme = themeState.mode === ThemeMode.SYSTEM ? getSystemTheme() : themeState.mode
|
||||
|
||||
// 获取新主题的真实表现
|
||||
const nextTheme = next === ThemeMode.SYSTEM ? getSystemTheme() : next
|
||||
|
||||
// 如果新旧主题相同,不用过渡动画
|
||||
if (currentTheme === nextTheme) {
|
||||
setTheme(next)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算新主题是否是暗色
|
||||
const newThemeIsDark = nextTheme === 'dark'
|
||||
|
||||
withViewTransition(
|
||||
event,
|
||||
() => {
|
||||
setTheme(next)
|
||||
},
|
||||
!newThemeIsDark,
|
||||
)
|
||||
setTheme(next)
|
||||
}
|
||||
|
||||
if (import.meta.client && window.matchMedia) {
|
||||
if (process.client && window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (themeState.mode === ThemeMode.SYSTEM) {
|
||||
apply(ThemeMode.SYSTEM)
|
||||
|
||||
Reference in New Issue
Block a user