Compare commits

...

7 Commits

Author SHA1 Message Date
Tim
adfc05b9b2 feat: add admin point grants and history UI 2025-09-30 20:11:45 +08:00
Tim
3b92bdaf2a Merge pull request #1038 from smallclover/main
修改按钮样式
2025-09-30 10:46:07 +08:00
smallclover
f872a32410 修改按钮样式
1. 文字变为白色
2. 按钮样式和其他按钮统一
2025-09-29 21:47:12 +09:00
tim
0119605649 feat: 先把每日定时构件给注释掉 2025-09-29 01:14:50 +08:00
Tim
774611f3a8 Merge pull request #1033 from nagisa77/feature/open_search
Feature: Open Search
2025-09-28 19:19:21 +08:00
Tim
61f6e7c90a Merge pull request #1034 from smallclover/main
UI调整
2025-09-28 10:06:28 +08:00
smallclover
892aa6a7c6 UI调整
https://github.com/nagisa77/OpenIsle/issues/855
2025-09-27 08:59:11 +09:00
10 changed files with 238 additions and 11 deletions

View File

@@ -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:

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -13,4 +13,5 @@ public enum PointHistoryType {
REDEEM, REDEEM,
LOTTERY_JOIN, LOTTERY_JOIN,
LOTTERY_REWARD, LOTTERY_REWARD,
ADMIN_GRANT,
} }

View File

@@ -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) {

View File

@@ -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);
} }

View File

@@ -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;
} }

View File

@@ -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)
} }
}, },
) )

View File

@@ -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 () => {

View File

@@ -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;