mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-03 18:40:46 +08:00
Compare commits
7 Commits
feature/op
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adfc05b9b2 | ||
|
|
3b92bdaf2a | ||
|
|
f872a32410 | ||
|
|
0119605649 | ||
|
|
774611f3a8 | ||
|
|
61f6e7c90a | ||
|
|
892aa6a7c6 |
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -2,8 +2,8 @@ name: CI & CD
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
# schedule:
|
||||||
- cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
# - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
|
|||||||
@@ -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<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -13,4 +13,5 @@ public enum PointHistoryType {
|
|||||||
REDEEM,
|
REDEEM,
|
||||||
LOTTERY_JOIN,
|
LOTTERY_JOIN,
|
||||||
LOTTERY_REWARD,
|
LOTTERY_REWARD,
|
||||||
|
ADMIN_GRANT,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,22 @@ public class PointService {
|
|||||||
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
|
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) {
|
public void processLotteryJoin(User participant, LotteryPost post) {
|
||||||
int cost = post.getPointCost();
|
int cost = post.getPointCost();
|
||||||
if (cost > 0) {
|
if (cost > 0) {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export default {
|
|||||||
|
|
||||||
.cropper-btn {
|
.cropper-btn {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 10px;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -128,7 +128,7 @@ export default {
|
|||||||
|
|
||||||
.cropper-btn.primary {
|
.cropper-btn.primary {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
color: var(--text-color);
|
color: #ffff;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ export default {
|
|||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -315,8 +316,9 @@ export default {
|
|||||||
right: 0;
|
right: 0;
|
||||||
background: var(--background-color);
|
background: var(--background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 5px;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
max-height: 200px;
|
max-height: 300px;
|
||||||
min-width: 350px;
|
min-width: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ export default {
|
|||||||
label: '隐私政策',
|
label: '隐私政策',
|
||||||
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
|
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'points',
|
||||||
|
label: '积分说明',
|
||||||
|
content: `# 积分说明
|
||||||
|
|
||||||
|
- 积分可用于兑换商品、参与抽奖等社区玩法。
|
||||||
|
- 管理员可以通过后台新增的积分模块为用户发放奖励积分。
|
||||||
|
- 每次发放都会记录在积分历史中,方便你查看积分来源。
|
||||||
|
`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'api',
|
key: 'api',
|
||||||
label: 'API与调试',
|
label: 'API与调试',
|
||||||
@@ -88,11 +98,21 @@ export default {
|
|||||||
return `${token.value.slice(0, 20)}...${token.value.slice(-10)}`
|
return `${token.value.slice(0, 20)}...${token.value.slice(-10)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadContent = async (file) => {
|
const loadContent = async (tab) => {
|
||||||
if (!file) return
|
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 {
|
try {
|
||||||
isFetching.value = true
|
isFetching.value = true
|
||||||
const res = await fetch(file)
|
const res = await fetch(tab.file)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
content.value = await res.text()
|
content.value = await res.text()
|
||||||
} else {
|
} else {
|
||||||
@@ -110,15 +130,15 @@ export default {
|
|||||||
if (initTab && tabs.find((t) => t.key === initTab)) {
|
if (initTab && tabs.find((t) => t.key === initTab)) {
|
||||||
selectedTab.value = initTab
|
selectedTab.value = initTab
|
||||||
const tab = tabs.find((t) => t.key === initTab)
|
const tab = tabs.find((t) => t.key === initTab)
|
||||||
if (tab && tab.file) loadContent(tab.file)
|
if (tab) loadContent(tab)
|
||||||
} else {
|
} else {
|
||||||
loadContent(tabs[0].file)
|
loadContent(tabs[0])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedTab, (name) => {
|
watch(selectedTab, (name) => {
|
||||||
const tab = tabs.find((t) => t.key === 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 } })
|
router.replace({ query: { ...route.query, tab: name } })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,6 +147,8 @@ export default {
|
|||||||
(name) => {
|
(name) => {
|
||||||
if (name && name !== selectedTab.value && tabs.find((t) => t.key === name)) {
|
if (name && name !== selectedTab.value && tabs.find((t) => t.key === name)) {
|
||||||
selectedTab.value = name
|
selectedTab.value = name
|
||||||
|
const tab = tabs.find((t) => t.key === name)
|
||||||
|
if (tab) loadContent(tab)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -184,6 +184,16 @@
|
|||||||
}}</NuxtLink>
|
}}</NuxtLink>
|
||||||
参与,获得 {{ item.amount }} 积分
|
参与,获得 {{ item.amount }} 积分
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'ADMIN_GRANT' && item.fromUserId">
|
||||||
|
管理员
|
||||||
|
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||||
|
item.fromUserName
|
||||||
|
}}</NuxtLink>
|
||||||
|
赠送了 {{ item.amount }} 积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'ADMIN_GRANT'">
|
||||||
|
管理员赠送了 {{ item.amount }} 积分
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||||
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
||||||
</div>
|
</div>
|
||||||
@@ -229,6 +239,7 @@ const pointRules = [
|
|||||||
'评论被点赞:每次 10 积分',
|
'评论被点赞:每次 10 积分',
|
||||||
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
|
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
|
||||||
'文章被收录至精选:每次 500 积分',
|
'文章被收录至精选:每次 500 积分',
|
||||||
|
'管理员赠送:特殊活动可由管理员手动赠送积分',
|
||||||
]
|
]
|
||||||
|
|
||||||
const goods = ref([])
|
const goods = ref([])
|
||||||
@@ -250,6 +261,7 @@ const iconMap = {
|
|||||||
LOTTERY_REWARD: 'fireworks',
|
LOTTERY_REWARD: 'fireworks',
|
||||||
POST_LIKE_CANCELLED: 'clear-icon',
|
POST_LIKE_CANCELLED: 'clear-icon',
|
||||||
COMMENT_LIKE_CANCELLED: 'clear-icon',
|
COMMENT_LIKE_CANCELLED: 'clear-icon',
|
||||||
|
ADMIN_GRANT: 'paper-money-two',
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadTrend = async () => {
|
const loadTrend = async () => {
|
||||||
|
|||||||
@@ -65,6 +65,35 @@
|
|||||||
<div class="setting-title">注册模式</div>
|
<div class="setting-title">注册模式</div>
|
||||||
<Dropdown v-model="registerMode" :fetch-options="fetchRegisterModes" />
|
<Dropdown v-model="registerMode" :fetch-options="fetchRegisterModes" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row grant-row">
|
||||||
|
<div class="setting-title">发放积分</div>
|
||||||
|
<div class="grant-form">
|
||||||
|
<BaseInput
|
||||||
|
v-model="grantUsername"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
class="grant-input"
|
||||||
|
@input="grantError = ''"
|
||||||
|
/>
|
||||||
|
<BaseInput
|
||||||
|
v-model="grantAmount"
|
||||||
|
type="number"
|
||||||
|
placeholder="积分数量"
|
||||||
|
class="grant-input amount"
|
||||||
|
@input="grantError = ''"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grant-button"
|
||||||
|
:class="{ disabled: isGrantingPoints }"
|
||||||
|
:disabled="isGrantingPoints"
|
||||||
|
@click="grantPoint"
|
||||||
|
>
|
||||||
|
{{ isGrantingPoints ? '发放中...' : '发放' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="grantError" class="grant-error-message">{{ grantError }}</div>
|
||||||
|
<div class="setting-description">积分会立即发放给目标用户,并记录在积分历史中</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<div v-if="isSaving" class="save-button disabled">保存中...</div>
|
<div v-if="isSaving" class="save-button disabled">保存中...</div>
|
||||||
@@ -102,6 +131,10 @@ const registerMode = ref('DIRECT')
|
|||||||
const isLoadingPage = ref(false)
|
const isLoadingPage = ref(false)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const frosted = ref(true)
|
const frosted = ref(true)
|
||||||
|
const grantUsername = ref('')
|
||||||
|
const grantAmount = ref('')
|
||||||
|
const grantError = ref('')
|
||||||
|
const isGrantingPoints = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isLoadingPage.value = true
|
isLoadingPage.value = true
|
||||||
@@ -184,6 +217,55 @@ const loadAdminConfig = async () => {
|
|||||||
// ignore
|
// 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 () => {
|
const save = async () => {
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
|
|
||||||
@@ -323,6 +405,51 @@ const save = async () => {
|
|||||||
max-width: 200px;
|
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 {
|
.switch-row {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user