feat(old-items): complete reuse workflow and refine miniapp home

This commit is contained in:
2026-08-15 16:38:30 +08:00
parent 65d23eab4a
commit f3737c80be
94 changed files with 7880 additions and 215 deletions
@@ -0,0 +1,270 @@
<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>