Compare commits

...

7 Commits

Author SHA1 Message Date
Tim
8d98c876d2 feat: add point mall redemption 2025-08-17 02:06:47 +08:00
tim
df4df1933a feat: 积分页面ui 2025-08-17 01:57:42 +08:00
Tim
7507f1bb03 Merge pull request #604 from nagisa77/codex/add-hni7s1
feat: add point rules and products to points mall
2025-08-17 01:32:59 +08:00
Tim
9b4c36c76a feat: add point rules and products 2025-08-17 01:32:26 +08:00
Tim
edfc81aeb0 Merge pull request #603 from nagisa77/codex/add
feat: add point mall module
2025-08-17 01:24:06 +08:00
Tim
7bd1225b27 feat: add point mall module 2025-08-17 01:23:47 +08:00
Tim
2dd56e27af Merge pull request #599 from nagisa77/feature/daily_bugfix_0816
Feature/daily bugfix 0816
2025-08-17 01:13:39 +08:00
13 changed files with 533 additions and 76 deletions

View File

@@ -0,0 +1,31 @@
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);
}
}
}

View File

@@ -0,0 +1,39 @@
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);
}
}

View File

@@ -0,0 +1,12 @@
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;
}

View File

@@ -0,0 +1,10 @@
package com.openisle.dto;
import lombok.Data;
/** Request to redeem a point mall good. */
@Data
public class PointRedeemRequest {
private Long goodId;
private String contact;
}

View File

@@ -0,0 +1,18 @@
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;
}
}

View File

@@ -0,0 +1,26 @@
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;
}

View File

@@ -0,0 +1,8 @@
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> {
}

View File

@@ -0,0 +1,37 @@
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();
}
}

View File

@@ -56,6 +56,19 @@
<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-section">
@@ -130,7 +143,7 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { authState } from '~/utils/auth'
import { authState, fetchCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
@@ -147,6 +160,7 @@ const emit = defineEmits(['item-click'])
const categoryOpen = ref(true)
const tagOpen = ref(true)
const myPoint = ref(null)
/** ✅ 用 useAsyncData 替换原生 fetch避免 SSR+CSR 二次请求 */
const {
@@ -191,6 +205,15 @@ 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()
@@ -200,9 +223,15 @@ const updateCount = async () => {
}
onMounted(async () => {
await updateCount()
// 登录态变化时再拉一次未读数;与 useAsyncData 无关
watch(() => authState.loggedIn, updateCount)
await Promise.all([updateCount(), loadPoint()])
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
watch(
() => authState.loggedIn,
() => {
updateCount()
loadPoint()
},
)
})
const handleItemClick = () => {
@@ -292,6 +321,12 @@ 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;

View File

@@ -40,30 +40,22 @@
兑换
</div>
<div v-else class="redeem-button disabled">兑换</div>
<BasePopup :visible="dialogVisible" @close="closeDialog">
<div class="redeem-dialog-content">
<BaseInput
textarea=""
rows="5"
v-model="contact"
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
/>
<div class="redeem-actions">
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
</div>
</div>
</BasePopup>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeDialog"
@submit="submitRedeem"
/>
</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
@@ -185,56 +177,6 @@ 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;
@@ -247,9 +189,5 @@ const submitRedeem = async () => {
align-items: flex-start;
gap: 10px;
}
.redeem-dialog-content {
min-width: 300px;
}
}
</style>

View File

@@ -0,0 +1,103 @@
<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>

View File

@@ -0,0 +1,199 @@
<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>

View File

@@ -28,6 +28,7 @@ const reason = ref('')
const error = ref('')
const isWaitingForRegister = ref(false)
const token = ref('')
const route = useRoute()
onMounted(async () => {
token.value = route.query.token || ''
@@ -50,8 +51,8 @@ const submit = async () => {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: this.token,
reason: this.reason,
token: token.value,
reason: reason.value,
}),
})
isWaitingForRegister.value = false