diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5bfb41eb7..28c013027 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,8 +2,8 @@ name: CI & CD on: workflow_dispatch: - schedule: - - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点 + # schedule: + # - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点 jobs: build-and-deploy: diff --git a/backend/src/main/java/com/openisle/controller/AdminPointController.java b/backend/src/main/java/com/openisle/controller/AdminPointController.java new file mode 100644 index 000000000..5352391aa --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/AdminPointController.java @@ -0,0 +1,35 @@ +package com.openisle.controller; + +import com.openisle.dto.AdminGrantPointRequest; +import com.openisle.service.PointService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/points") +@RequiredArgsConstructor +public class AdminPointController { + + private final PointService pointService; + + @PostMapping("/grant") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Grant points", description = "Grant points to a user as administrator") + @ApiResponse(responseCode = "200", description = "Points granted") + public Map grant( + @RequestBody AdminGrantPointRequest request, + Authentication auth + ) { + String username = request.getUsername(); + int balance = pointService.grantPointByAdmin(auth.getName(), username, request.getAmount()); + return Map.of("username", username.trim(), "point", balance); + } +} diff --git a/backend/src/main/java/com/openisle/dto/AdminGrantPointRequest.java b/backend/src/main/java/com/openisle/dto/AdminGrantPointRequest.java new file mode 100644 index 000000000..c4503f0db --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/AdminGrantPointRequest.java @@ -0,0 +1,12 @@ +package com.openisle.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AdminGrantPointRequest { + + private String username; + private int amount; +} diff --git a/backend/src/main/java/com/openisle/model/PointHistoryType.java b/backend/src/main/java/com/openisle/model/PointHistoryType.java index 689f73c9c..ed71dc461 100644 --- a/backend/src/main/java/com/openisle/model/PointHistoryType.java +++ b/backend/src/main/java/com/openisle/model/PointHistoryType.java @@ -13,4 +13,5 @@ public enum PointHistoryType { REDEEM, LOTTERY_JOIN, LOTTERY_REWARD, + ADMIN_GRANT, } diff --git a/backend/src/main/java/com/openisle/service/PointService.java b/backend/src/main/java/com/openisle/service/PointService.java index 0a8349a53..029cab314 100644 --- a/backend/src/main/java/com/openisle/service/PointService.java +++ b/backend/src/main/java/com/openisle/service/PointService.java @@ -43,6 +43,22 @@ public class PointService { return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null); } + public int grantPointByAdmin(String adminName, String targetUsername, int amount) { + if (amount <= 0) { + throw new FieldException("amount", "积分必须为正数"); + } + if (targetUsername == null || targetUsername.isBlank()) { + throw new FieldException("username", "用户名不能为空"); + } + String normalizedUsername = targetUsername.trim(); + User admin = userRepository.findByUsername(adminName).orElseThrow(); + User target = userRepository + .findByUsername(normalizedUsername) + .orElseThrow(() -> new FieldException("username", "用户不存在")); + addPoint(target, amount, PointHistoryType.ADMIN_GRANT, null, null, admin); + return target.getPoint(); + } + public void processLotteryJoin(User participant, LotteryPost post) { int cost = post.getPointCost(); if (cost > 0) { diff --git a/frontend_nuxt/components/AvatarCropper.vue b/frontend_nuxt/components/AvatarCropper.vue index 2cf260fd7..538ebe5cb 100644 --- a/frontend_nuxt/components/AvatarCropper.vue +++ b/frontend_nuxt/components/AvatarCropper.vue @@ -119,7 +119,7 @@ export default { .cropper-btn { padding: 6px 12px; - border-radius: 4px; + border-radius: 10px; color: var(--primary-color); border: none; background: transparent; @@ -128,7 +128,7 @@ export default { .cropper-btn.primary { background: var(--primary-color); - color: var(--text-color); + color: #ffff; border-color: var(--primary-color); } diff --git a/frontend_nuxt/pages/about/index.vue b/frontend_nuxt/pages/about/index.vue index 5d669c2de..be77273b6 100644 --- a/frontend_nuxt/pages/about/index.vue +++ b/frontend_nuxt/pages/about/index.vue @@ -71,6 +71,16 @@ export default { label: '隐私政策', file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md', }, + { + key: 'points', + label: '积分说明', + content: `# 积分说明 + +- 积分可用于兑换商品、参与抽奖等社区玩法。 +- 管理员可以通过后台新增的积分模块为用户发放奖励积分。 +- 每次发放都会记录在积分历史中,方便你查看积分来源。 +`, + }, { key: 'api', label: 'API与调试', @@ -88,11 +98,21 @@ export default { return `${token.value.slice(0, 20)}...${token.value.slice(-10)}` }) - const loadContent = async (file) => { - if (!file) return + const loadContent = async (tab) => { + if (!tab || tab.key === 'api') return + if (tab.content) { + isFetching.value = false + content.value = tab.content + return + } + if (!tab.file) { + isFetching.value = false + content.value = '' + return + } try { isFetching.value = true - const res = await fetch(file) + const res = await fetch(tab.file) if (res.ok) { content.value = await res.text() } else { @@ -110,15 +130,15 @@ export default { if (initTab && tabs.find((t) => t.key === initTab)) { selectedTab.value = initTab const tab = tabs.find((t) => t.key === initTab) - if (tab && tab.file) loadContent(tab.file) + if (tab) loadContent(tab) } else { - loadContent(tabs[0].file) + loadContent(tabs[0]) } }) watch(selectedTab, (name) => { const tab = tabs.find((t) => t.key === name) - if (tab && tab.file) loadContent(tab.file) + if (tab) loadContent(tab) router.replace({ query: { ...route.query, tab: name } }) }) @@ -127,6 +147,8 @@ export default { (name) => { if (name && name !== selectedTab.value && tabs.find((t) => t.key === name)) { selectedTab.value = name + const tab = tabs.find((t) => t.key === name) + if (tab) loadContent(tab) } }, ) diff --git a/frontend_nuxt/pages/points.vue b/frontend_nuxt/pages/points.vue index 1aace6a27..203faaa4e 100644 --- a/frontend_nuxt/pages/points.vue +++ b/frontend_nuxt/pages/points.vue @@ -184,6 +184,16 @@ }} 参与,获得 {{ item.amount }} 积分 + + 你目前的积分是 {{ item.balance }} @@ -229,6 +239,7 @@ const pointRules = [ '评论被点赞:每次 10 积分', '邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册', '文章被收录至精选:每次 500 积分', + '管理员赠送:特殊活动可由管理员手动赠送积分', ] const goods = ref([]) @@ -250,6 +261,7 @@ const iconMap = { LOTTERY_REWARD: 'fireworks', POST_LIKE_CANCELLED: 'clear-icon', COMMENT_LIKE_CANCELLED: 'clear-icon', + ADMIN_GRANT: 'paper-money-two', } const loadTrend = async () => { diff --git a/frontend_nuxt/pages/settings.vue b/frontend_nuxt/pages/settings.vue index 6da7c4fbe..15347d98f 100644 --- a/frontend_nuxt/pages/settings.vue +++ b/frontend_nuxt/pages/settings.vue @@ -65,6 +65,35 @@
注册模式
+
+
发放积分
+
+ + + +
+
{{ grantError }}
+
积分会立即发放给目标用户,并记录在积分历史中
+
保存中...
@@ -102,6 +131,10 @@ const registerMode = ref('DIRECT') const isLoadingPage = ref(false) const isSaving = ref(false) const frosted = ref(true) +const grantUsername = ref('') +const grantAmount = ref('') +const grantError = ref('') +const isGrantingPoints = ref(false) onMounted(async () => { isLoadingPage.value = true @@ -184,6 +217,55 @@ const loadAdminConfig = async () => { // ignore } } + +const grantPoint = async () => { + if (isGrantingPoints.value) return + const username = grantUsername.value.trim() + if (!username) { + grantError.value = '用户名不能为空' + toast.error(grantError.value) + return + } + const amount = Number(grantAmount.value) + if (!Number.isInteger(amount) || amount <= 0) { + grantError.value = '积分数量必须为正整数' + toast.error(grantError.value) + return + } + isGrantingPoints.value = true + try { + const token = getToken() + const res = await fetch(`${API_BASE_URL}/api/admin/points/grant`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ username, amount }), + }) + let data = null + try { + data = await res.json() + } catch (e) { + // ignore body parse errors + } + if (res.ok) { + toast.success(`已为 ${username} 发放 ${amount} 积分`) + grantUsername.value = '' + grantAmount.value = '' + grantError.value = '' + } else { + const message = data?.error || '发放失败' + grantError.value = message + toast.error(message) + } + } catch (e) { + grantError.value = '发放失败,请稍后再试' + toast.error(grantError.value) + } finally { + isGrantingPoints.value = false + } +} const save = async () => { isSaving.value = true @@ -323,6 +405,51 @@ const save = async () => { max-width: 200px; } +.grant-row { + max-width: 100%; +} + +.grant-form { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.grant-input { + flex: 1 1 180px; +} + +.grant-input.amount { + max-width: 140px; +} + +.grant-button { + background-color: var(--primary-color); + color: #fff; + border: none; + border-radius: 8px; + padding: 8px 16px; + cursor: pointer; + transition: background-color 0.2s ease-in-out; +} + +.grant-button.disabled, +.grant-button:disabled { + cursor: not-allowed; + background-color: var(--primary-color-disabled); +} + +.grant-button:not(.disabled):hover { + background-color: var(--primary-color-hover); +} + +.grant-error-message { + color: red; + font-size: 14px; + margin-top: 8px; +} + .switch-row { flex-direction: row; align-items: center;