271 lines
9.5 KiB
Vue
271 lines
9.5 KiB
Vue
<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>
|