Files
lehuo/apps/admin-web/src/components/OldItemRewardsPanel.vue
T

271 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import type {
OldItemCertificateItem,
OldItemDonationItem,
OldItemPointAccountItem,
OldItemRewardConfig,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
activateOldItemRewardConfig,
adjustOldItemPoints,
getOldItemRewardConfig,
issueOldItemCertificate,
listOldItemCertificates,
listOldItemPointAccounts,
revokeOldItemCertificate,
} from '../api/old-item-rewards';
const props = defineProps<{ donations: OldItemDonationItem[] }>();
const config = ref<OldItemRewardConfig | null>(null),
accounts = ref<OldItemPointAccountItem[]>([]),
certificates = ref<OldItemCertificateItem[]>([]),
loading = ref(false),
message = ref(''),
usesDefault = ref(true);
const form = reactive({
certificateTitle: '',
certificateMessage: '',
issuerName: '',
legalNotice: '',
clothingPoints: 1,
booksPoints: 1,
householdPoints: 1,
sportsPoints: 1,
maxPointsPerDonation: 500,
});
const completedDonations = computed(() => props.donations.filter((x) => x.status === 'COMPLETED'));
const activeDonationIds = computed(
() =>
new Set(certificates.value.filter((x) => x.status === 'ISSUED').map((x) => x.donationPublicId)),
);
const error = (e: unknown) =>
e instanceof ApiClientError
? `${e.message}${e.requestId ? `(编号:${e.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load() {
loading.value = true;
try {
const [configured, accountPage, certificatePage] = await Promise.all([
getOldItemRewardConfig(),
listOldItemPointAccounts(),
listOldItemCertificates(),
]);
config.value = configured.config;
usesDefault.value = configured.usesDefault;
accounts.value = accountPage.items;
certificates.value = certificatePage.items;
Object.assign(form, configured.config);
} catch (e) {
message.value = error(e);
} finally {
loading.value = false;
}
}
async function saveConfig() {
try {
const response = await activateOldItemRewardConfig({ ...form });
config.value = response.config;
usesDefault.value = false;
message.value = `规则版本 v${response.config.version} 已启用,仅影响之后完成的捐赠`;
} catch (e) {
message.value = error(e);
}
}
async function issue(donation: OldItemDonationItem) {
const alreadyIssued = activeDonationIds.value.has(donation.publicId);
if (
alreadyIssued &&
!window.confirm('补发新证书会自动撤销当前有效证书,但历史仍会保留。确定继续吗?')
)
return;
try {
await issueOldItemCertificate(donation.publicId);
message.value = alreadyIssued ? '新证书已补发,旧证书已保留为撤销状态' : '证书已补发';
await load();
} catch (e) {
message.value = error(e);
}
}
async function revoke(certificate: OldItemCertificateItem) {
const reason = window.prompt('请输入撤销原因(会保留在审计与证书历史中)')?.trim();
if (!reason) return;
try {
await revokeOldItemCertificate(certificate.publicId, reason);
message.value = '证书已撤销,历史记录未删除';
await load();
} catch (e) {
message.value = error(e);
}
}
async function adjust(account: OldItemPointAccountItem) {
const delta = Number(window.prompt('请输入积分调整值,扣减请输入负数', '0') ?? '');
if (!Number.isInteger(delta) || delta === 0) return;
const reason = window.prompt('请输入调整原因(必填)')?.trim();
if (!reason) return;
try {
await adjustOldItemPoints(account.userPublicId, {
expectedRevision: account.revision,
pointsDelta: delta,
reason,
});
message.value = `已${delta > 0 ? '增加' : '扣减'} ${Math.abs(delta)} 公益积分`;
await load();
} catch (e) {
message.value = error(e);
}
}
onMounted(load);
</script>
<template>
<div class="rewards-admin">
<p v-if="message" class="form-message" role="status">{{ message }}</p>
<div class="reward-boundary panel">
<strong>公益荣誉积分边界</strong>
<p>积分只记录公益参与,不可购买、兑换、转让、提现或抵扣;证书不是捐赠票据或税务凭证。</p>
</div>
<div class="old-items-columns">
<form class="panel compact-form reward-config-form" @submit.prevent="saveConfig">
<div class="panel-heading">
<div>
<h2>证书与积分规则</h2>
<small>{{
usesDefault ? '当前使用系统默认规则' : `当前版本 v${config?.version}`
}}</small>
</div>
</div>
<label>证书标题<input v-model="form.certificateTitle" required maxlength="120" /></label>
<label
>证书正文<textarea v-model="form.certificateMessage" required maxlength="1000"></textarea>
</label>
<small>支持:{recipient}、{donationNo}、{quantity}、{category}、{completedDate}</small>
<label>签发机构<input v-model="form.issuerName" required maxlength="120" /></label>
<label
>法律说明<textarea v-model="form.legalNotice" required maxlength="500"></textarea>
</label>
<div class="reward-point-grid">
<label
>服饰/件<input v-model.number="form.clothingPoints" type="number" min="0" max="1000"
/></label>
<label
>书籍/件<input v-model.number="form.booksPoints" type="number" min="0" max="1000"
/></label>
<label
>生活用品/件<input
v-model.number="form.householdPoints"
type="number"
min="0"
max="1000"
/></label>
<label
>文体用品/件<input v-model.number="form.sportsPoints" type="number" min="0" max="1000"
/></label>
</div>
<label
>单次积分上限<input
v-model.number="form.maxPointsPerDonation"
type="number"
min="0"
max="100000"
/></label>
<button class="primary-button">启用为新版本</button>
</form>
<article class="certificate-preview panel" aria-label="电子证书预览">
<span class="certificate-seal">♥</span>
<small>电子证书预览</small>
<h2>{{ form.certificateTitle }}</h2>
<strong>爱心用户</strong>
<p>
{{
form.certificateMessage
.replace('{recipient}', '爱心用户')
.replace('{donationNo}', 'OI-DEMO')
.replace('{quantity}', '10')
.replace('{category}', '服饰')
.replace('{completedDate}', new Date().toISOString().slice(0, 10))
}}
</p>
<div>
<b>{{ form.issuerName }}</b
><small>{{ form.legalNotice }}</small>
</div>
</article>
</div>
<div class="panel">
<div class="panel-heading">
<h2>证书发放管理</h2>
<small>完成捐赠时自动发放;历史完成记录可手动补发</small>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>公益编号</th>
<th>完成时间</th>
<th>当前证书</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="donation in completedDonations" :key="donation.publicId">
<td>{{ donation.donationNo }}</td>
<td>{{ new Date(donation.createdAt).toLocaleDateString('zh-CN') }}</td>
<td>{{ activeDonationIds.has(donation.publicId) ? '有效' : '未发放/已撤销' }}</td>
<td>
<button class="text-button" @click="issue(donation)">
{{ activeDonationIds.has(donation.publicId) ? '补发' : '发放' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!completedDonations.length" class="empty-state">暂无已完成捐赠</p>
<div class="certificate-history">
<article v-for="item in certificates" :key="item.publicId">
<div>
<strong>{{ item.certificateNo }}</strong
><span class="status-pill">{{ item.status === 'ISSUED' ? '有效' : '已撤销' }}</span>
</div>
<p>{{ item.donationNo }} · {{ new Date(item.issuedAt).toLocaleString('zh-CN') }}</p>
<button v-if="item.status === 'ISSUED'" class="danger-button" @click="revoke(item)">
撤销证书
</button>
</article>
</div>
</div>
<div class="panel">
<div class="panel-heading">
<h2>公益积分账户</h2>
<small>所有调整形成只追加流水,余额不得小于 0</small>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>用户</th>
<th>当前积分</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="account in accounts" :key="account.publicId">
<td>{{ account.displayName }}</td>
<td>
<strong>{{ account.balance }}</strong>
</td>
<td>{{ new Date(account.updatedAt).toLocaleString('zh-CN') }}</td>
<td><button class="text-button" @click="adjust(account)">调整积分</button></td>
</tr>
</tbody>
</table>
</div>
<p v-if="!accounts.length && !loading" class="empty-state">暂无积分账户</p>
</div>
</div>
</template>