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
+1
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@lehuo/contracts": "workspace:*",
"dompurify": "^3.4.13",
"exceljs": "4.4.0",
"mammoth": "^1.12.1",
"pinia": "^3.0.3",
"vue": "^3.5.20",
+11
View File
@@ -18,6 +18,7 @@ import StatisticsView from './views/StatisticsView.vue';
import DashboardView from './views/DashboardView.vue';
import AdminIcon from './components/AdminIcon.vue';
import FinancialDisclosuresView from './views/FinancialDisclosuresView.vue';
import OldItemsView from './views/OldItemsView.vue';
const auth = useAuthStore();
const form = reactive({ username: '', password: '' });
@@ -38,6 +39,7 @@ const activeView = ref<
| 'notifications'
| 'statistics'
| 'financial-disclosures'
| 'old-items'
>('dashboard');
onMounted(() => auth.restore());
@@ -141,6 +143,14 @@ async function logout(): Promise<void> {
<div><strong>乐活公益</strong><small>管理平台</small></div>
</div>
<nav>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'old-items' }]"
type="button"
:aria-current="activeView === 'old-items' ? 'page' : undefined"
@click="activeView = 'old-items'"
>
<AdminIcon name="content" />旧物善用管理
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'dashboard' }]"
type="button"
@@ -264,6 +274,7 @@ async function logout(): Promise<void> {
<ActivitiesView v-else-if="activeView === 'activities'" />
<ContentView v-else-if="activeView === 'content'" />
<FinancialDisclosuresView v-else-if="activeView === 'financial-disclosures'" />
<OldItemsView v-else-if="activeView === 'old-items'" />
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
<StatisticsView v-else />
</div>
@@ -0,0 +1,57 @@
import type {
ActivateOldItemRewardConfigRequest,
AdjustOldItemPointsRequest,
OldItemCertificateItem,
OldItemPointAccountItem,
OldItemPointEntryItem,
OldItemRewardConfig,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
import type { Page } from './old-items';
export const getOldItemRewardConfig = () =>
apiRequest<{ config: OldItemRewardConfig; usesDefault: boolean }>(
'/api/v1/admin/old-items/reward-config',
);
export const activateOldItemRewardConfig = (
input: Omit<ActivateOldItemRewardConfigRequest, 'clientRequestId'>,
) =>
apiRequest<{ config: OldItemRewardConfig }>('/api/v1/admin/old-items/reward-config/activate', {
method: 'POST',
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
});
export const listOldItemPointAccounts = () =>
apiRequest<Page<OldItemPointAccountItem>>(
'/api/v1/admin/old-items/reward-accounts?page=1&pageSize=100',
);
export const adjustOldItemPoints = (
userPublicId: string,
input: Omit<AdjustOldItemPointsRequest, 'clientRequestId'>,
) =>
apiRequest<{ entry: OldItemPointEntryItem }>(
`/api/v1/admin/old-items/reward-accounts/${userPublicId}/adjust`,
{
method: 'POST',
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
},
);
export const listOldItemCertificates = () =>
apiRequest<Page<OldItemCertificateItem>>(
'/api/v1/admin/old-items/certificates?page=1&pageSize=100',
);
export const issueOldItemCertificate = (donationPublicId: string) =>
apiRequest<{ certificate: OldItemCertificateItem }>(
`/api/v1/admin/old-items/donations/${donationPublicId}/certificate`,
{
method: 'POST',
body: JSON.stringify({ clientRequestId: crypto.randomUUID() }),
},
);
export const revokeOldItemCertificate = (publicId: string, reason: string) =>
apiRequest<{ certificate: OldItemCertificateItem }>(
`/api/v1/admin/old-items/certificates/${publicId}/revoke`,
{
method: 'POST',
body: JSON.stringify({ clientRequestId: crypto.randomUUID(), reason }),
},
);
+135
View File
@@ -0,0 +1,135 @@
import type {
CreateOldItemNeedRequest,
OldItemDonationItem,
OldItemDonationStatus,
OldItemAssigneeOption,
DownloadUrlResponse,
OldItemNeedItem,
OldItemNeedStatus,
OldItemStats,
OldItemDropOffPoint,
CreateOldItemInventoryRequest,
ImportOldItemInventoryRequest,
OldItemInventoryExportRow,
OldItemInventoryItem,
OldItemInventoryStatus,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
export interface Page<T> {
items: T[];
page: number;
pageSize: number;
total: number;
}
export const getOldItemStats = () => apiRequest<OldItemStats>('/api/v1/old-items/stats');
export const listOldItemDonations = (status = '') =>
apiRequest<Page<OldItemDonationItem>>(
`/api/v1/admin/old-items/donations?page=1&pageSize=100${status ? `&status=${status}` : ''}`,
);
export const getOldItemDonation = (id: string) =>
apiRequest<{ donation: OldItemDonationItem }>(`/api/v1/admin/old-items/donations/${id}`);
export const transitionOldItemDonation = (
id: string,
expectedRevision: number,
targetStatus: OldItemDonationStatus,
note?: string,
assistedPeople?: number,
assignedUserPublicId?: string,
) =>
apiRequest<{ donation: OldItemDonationItem }>(
`/api/v1/admin/old-items/donations/${id}/transition`,
{
method: 'POST',
body: JSON.stringify({
clientRequestId: crypto.randomUUID(),
expectedRevision,
targetStatus,
note,
assistedPeople,
assignedUserPublicId,
}),
},
);
export const listOldItemNeeds = () =>
apiRequest<Page<OldItemNeedItem>>('/api/v1/admin/old-items/needs?page=1&pageSize=100');
export const createOldItemNeed = (input: Omit<CreateOldItemNeedRequest, 'clientRequestId'>) =>
apiRequest<{ need: OldItemNeedItem }>('/api/v1/admin/old-items/needs', {
method: 'POST',
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
});
export const listOldItemAssignees = () =>
apiRequest<{ items: OldItemAssigneeOption[] }>('/api/v1/admin/old-items/assignees');
export const getOldItemImageUrl = (id: string) =>
apiRequest<DownloadUrlResponse>(`/api/v1/admin/old-items/media/${id}/download`);
export const listOldItemDropOffPoints = () =>
apiRequest<{ items: OldItemDropOffPoint[] }>('/api/v1/old-items/drop-off-points');
export const createOldItemDropOffPoint = (input: {
name: string;
regionText: string;
addressText: string;
openingHours: string;
sortOrder: number;
}) =>
apiRequest<{ point: OldItemDropOffPoint }>('/api/v1/admin/old-items/drop-off-points', {
method: 'POST',
body: JSON.stringify(input),
});
export const transitionOldItemNeed = (
id: string,
revision: number,
targetStatus: OldItemNeedStatus,
) =>
apiRequest<{ need: OldItemNeedItem }>(`/api/v1/admin/old-items/needs/${id}/transition`, {
method: 'POST',
body: JSON.stringify({
clientRequestId: crypto.randomUUID(),
expectedRevision: revision,
targetStatus,
}),
});
export const listOldItemInventory = (status = '') =>
apiRequest<Page<OldItemInventoryItem>>(
`/api/v1/admin/old-items/inventory?page=1&pageSize=100${status ? `&status=${status}` : ''}`,
);
export const getOldItemInventory = (id: string) =>
apiRequest<{ item: OldItemInventoryItem }>(`/api/v1/admin/old-items/inventory/${id}`);
export const createOldItemInventory = (
input: Omit<CreateOldItemInventoryRequest, 'clientRequestId'>,
) =>
apiRequest<{ item: OldItemInventoryItem }>('/api/v1/admin/old-items/inventory', {
method: 'POST',
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
});
export const importOldItemInventory = (
input: Omit<ImportOldItemInventoryRequest, 'clientRequestId'>,
) =>
apiRequest<{ importPublicId: string; items: OldItemInventoryItem[] }>(
'/api/v1/admin/old-items/inventory/import',
{
method: 'POST',
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
},
);
export const transitionOldItemInventory = (
id: string,
input: {
expectedRevision: number;
targetStatus: OldItemInventoryStatus;
eventAt?: string;
locationText?: string;
note?: string;
targetNeedPublicId?: string;
beneficiaryPublicId?: string;
destinationRegion?: string;
assistedPeople?: number;
},
) =>
apiRequest<{ item: OldItemInventoryItem }>(`/api/v1/admin/old-items/inventory/${id}/transition`, {
method: 'POST',
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
});
export const exportOldItemInventory = () =>
apiRequest<{ rows: OldItemInventoryExportRow[]; exportedAt: string }>(
'/api/v1/admin/old-items/inventory-export/file?page=1&pageSize=100',
);
@@ -0,0 +1,495 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import type { CellValue, Worksheet } from 'exceljs';
import {
OLD_ITEM_CATEGORIES,
OLD_ITEM_CATEGORY_LABELS,
OLD_ITEM_INVENTORY_STATUS_LABELS,
type OldItemCategory,
type OldItemDonationItem,
type OldItemInventoryExportRow,
type OldItemInventoryImportRow,
type OldItemInventoryItem,
type OldItemInventoryStatus,
type OldItemNeedItem,
type BeneficiaryListItem,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { fetchBeneficiaries } from '../api/case-management';
import {
createOldItemInventory,
exportOldItemInventory,
getOldItemInventory,
importOldItemInventory,
listOldItemInventory,
transitionOldItemInventory,
} from '../api/old-items';
const props = defineProps<{ donations: OldItemDonationItem[]; needs: OldItemNeedItem[] }>();
const items = ref<OldItemInventoryItem[]>([]),
selected = ref<OldItemInventoryItem | null>(null),
loading = ref(false),
message = ref(''),
importInput = ref<HTMLInputElement | null>(null);
const beneficiaries = ref<BeneficiaryListItem[]>([]),
outboundNeedPublicId = ref(''),
outboundBeneficiaryPublicId = ref(''),
outboundRegion = ref('');
const form = reactive({
sourceDonationPublicId: '',
category: 'CLOTHING' as OldItemCategory,
itemName: '',
quantity: 1,
unit: '件',
storageLocation: '',
targetNeedPublicId: '',
note: '',
});
const eligibleDonations = computed(() =>
props.donations.filter((x) => ['RECEIVED', 'PROCESSING', 'COMPLETED'].includes(x.status)),
);
const nextStatus: Record<OldItemInventoryStatus, OldItemInventoryStatus | null> = {
IN_STOCK: 'SORTED',
SORTED: 'DISINFECTED',
DISINFECTED: 'OUTBOUND',
OUTBOUND: 'DELIVERED',
DELIVERED: null,
};
const error = (e: unknown) =>
e instanceof ApiClientError
? `${e.message}${e.requestId ? `(编号:${e.requestId})` : ''}`
: e instanceof Error
? e.message
: '操作失败,请稍后重试';
async function createWorkbook() {
const { Workbook } = await import('exceljs');
return new Workbook();
}
async function load() {
loading.value = true;
try {
items.value = (await listOldItemInventory()).items;
} catch (e) {
message.value = error(e);
} finally {
loading.value = false;
}
}
async function loadBeneficiaries() {
try {
beneficiaries.value = (
await fetchBeneficiaries({ page: 1, pageSize: 100, status: 'ACTIVE' })
).items;
} catch {
beneficiaries.value = [];
}
}
async function add() {
message.value = '';
try {
await createOldItemInventory({
...form,
sourceDonationPublicId: form.sourceDonationPublicId || undefined,
targetNeedPublicId: form.targetNeedPublicId || undefined,
note: form.note || undefined,
});
Object.assign(form, {
sourceDonationPublicId: '',
category: 'CLOTHING',
itemName: '',
quantity: 1,
unit: '件',
storageLocation: '',
targetNeedPublicId: '',
note: '',
});
message.value = '物资已入库并写入首个流转节点';
await load();
} catch (e) {
message.value = error(e);
}
}
async function open(id: string) {
try {
selected.value = (await getOldItemInventory(id)).item;
outboundNeedPublicId.value = selected.value.targetNeed?.publicId ?? '';
outboundBeneficiaryPublicId.value = selected.value.beneficiaryPublicId ?? '';
outboundRegion.value = selected.value.destinationRegion ?? '';
} catch (e) {
message.value = error(e);
}
}
async function advance() {
if (!selected.value) return;
const targetStatus = nextStatus[selected.value.status];
if (!targetStatus) return;
const input: Parameters<typeof transitionOldItemInventory>[1] = {
expectedRevision: selected.value.revision,
targetStatus,
};
if (targetStatus === 'OUTBOUND') {
if (
!outboundRegion.value &&
!outboundNeedPublicId.value &&
!outboundBeneficiaryPublicId.value
) {
message.value = '出库前请至少绑定物资需求、受助对象或帮扶区域之一';
return;
}
input.destinationRegion = outboundRegion.value.trim() || undefined;
input.targetNeedPublicId = outboundNeedPublicId.value || undefined;
input.beneficiaryPublicId = outboundBeneficiaryPublicId.value || undefined;
}
if (targetStatus === 'DELIVERED') {
const people = Number(window.prompt('请输入实际帮扶人数', '0') ?? '');
if (!Number.isInteger(people) || people < 0) return;
input.assistedPeople = people;
}
const note = window.prompt(`补充说明(可选)`, '')?.trim();
if (note) input.note = note;
try {
selected.value = (await transitionOldItemInventory(selected.value.publicId, input)).item;
message.value = `已更新为${OLD_ITEM_INVENTORY_STATUS_LABELS[targetStatus]}`;
await load();
} catch (e) {
message.value = error(e);
}
}
function styleSheet(sheet: Worksheet, rowCount: number) {
sheet.views = [{ state: 'frozen', ySplit: 1, showGridLines: false }];
sheet.autoFilter = { from: 'A1', to: `J${Math.max(1, rowCount)}` };
sheet.getRow(1).height = 26;
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF6B35' } };
sheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' };
sheet.columns.forEach((column) => {
column.alignment = { vertical: 'middle', wrapText: true };
});
sheet.properties.defaultRowHeight = 22;
}
function download(buffer: ArrayBuffer, filename: string) {
const url = URL.createObjectURL(
new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function configureColumns(sheet: Worksheet) {
sheet.columns = [
{ header: '来源捐赠编号(可选)', key: 'sourceDonationNo', width: 24 },
{ header: '品类', key: 'category', width: 14 },
{ header: '物资名称', key: 'itemName', width: 24 },
{ header: '数量', key: 'quantity', width: 12 },
{ header: '单位', key: 'unit', width: 10 },
{ header: '入库位置', key: 'storageLocation', width: 24 },
{ header: '定向需求公开ID(可选)', key: 'targetNeedPublicId', width: 40 },
{ header: '备注(可选)', key: 'note', width: 30 },
{ header: '入库时间(可选)', key: 'inboundAt', width: 22 },
{ header: '填写说明', key: 'help', width: 36 },
];
}
async function template() {
const workbook = await createWorkbook();
const sheet = workbook.addWorksheet('物资入库模板');
configureColumns(sheet);
sheet.addRow({
sourceDonationNo: '',
category: '服饰',
itemName: '冬季外套',
quantity: 10,
unit: '件',
storageLocation: '一号仓 A-01',
targetNeedPublicId: '',
note: '已清洁装袋',
inboundAt: new Date(),
help: '品类仅限:服饰、书籍、生活用品、文体用品',
});
sheet.getColumn('quantity').numFmt = '0';
sheet.getColumn('inboundAt').numFmt = 'yyyy-mm-dd hh:mm';
for (let row = 2; row <= 501; row += 1) {
sheet.getCell(`B${row}`).dataValidation = {
type: 'list',
allowBlank: false,
formulae: ['"服饰,书籍,生活用品,文体用品"'],
showErrorMessage: true,
errorTitle: '品类无效',
error: '请选择模板提供的品类',
};
}
styleSheet(sheet, 2);
download((await workbook.xlsx.writeBuffer()) as ArrayBuffer, '旧物善用-物资入库模板.xlsx');
}
function value(cell: CellValue): string {
if (cell && typeof cell === 'object' && 'formula' in cell)
throw new Error('导入文件不能包含公式');
if (cell instanceof Date) return cell.toISOString();
if (cell && typeof cell === 'object' && 'text' in cell) return String(cell.text).trim();
return String(cell ?? '').trim();
}
const categoryByLabel = Object.fromEntries(
Object.entries(OLD_ITEM_CATEGORY_LABELS).map(([key, label]) => [label, key]),
) as Record<string, OldItemCategory>;
async function importFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const workbook = await createWorkbook();
await workbook.xlsx.load(await file.arrayBuffer());
const sheet = workbook.worksheets[0];
if (!sheet) throw new Error('Excel 中没有可读取的工作表');
const expected = [
'来源捐赠编号(可选)',
'品类',
'物资名称',
'数量',
'单位',
'入库位置',
'定向需求公开ID(可选)',
'备注(可选)',
'入库时间(可选)',
];
expected.forEach((header, index) => {
if (value(sheet.getCell(1, index + 1).value) !== header)
throw new Error(`第 ${index + 1} 列表头应为“${header}”`);
});
const rows: OldItemInventoryImportRow[] = [];
for (let index = 2; index <= sheet.actualRowCount; index += 1) {
const cells = Array.from({ length: 9 }, (_, i) => value(sheet.getCell(index, i + 1).value));
if (cells.every((x) => !x)) continue;
const category =
categoryByLabel[cells[1]!] ??
(OLD_ITEM_CATEGORIES.includes(cells[1] as OldItemCategory)
? (cells[1] as OldItemCategory)
: undefined);
const quantity = Number(cells[3]);
if (!category) throw new Error(`第 ${index} 行品类无效`);
if (!cells[2] || !Number.isInteger(quantity) || quantity < 1 || !cells[4] || !cells[5])
throw new Error(`第 ${index} 行名称、正整数数量、单位和入库位置为必填`);
rows.push({
sourceDonationNo: cells[0] || undefined,
category,
itemName: cells[2]!,
quantity,
unit: cells[4]!,
storageLocation: cells[5]!,
targetNeedPublicId: cells[6] || undefined,
note: cells[7] || undefined,
inboundAt: cells[8] ? new Date(cells[8]).toISOString() : undefined,
});
}
if (!rows.length) throw new Error('Excel 中没有可导入的数据行');
if (rows.length > 500) throw new Error('单次最多导入 500 行');
await importOldItemInventory({ originalFilename: file.name, rows });
message.value = `已原子导入 ${rows.length} 条物资,未产生部分成功数据`;
await load();
} catch (e) {
message.value = error(e);
} finally {
if (importInput.value) importInput.value.value = '';
}
}
async function exportExcel() {
try {
const response = await exportOldItemInventory();
const workbook = await createWorkbook();
const sheet = workbook.addWorksheet('物资全流程台账');
sheet.columns = [
{ header: '台账编号', key: 'inventoryNo', width: 28 },
{ header: '来源捐赠编号', key: 'sourceDonationNo', width: 24 },
{ header: '品类', key: 'category', width: 14 },
{ header: '物资名称', key: 'itemName', width: 24 },
{ header: '数量', key: 'quantity', width: 10 },
{ header: '单位', key: 'unit', width: 10 },
{ header: '当前位置', key: 'storageLocation', width: 24 },
{ header: '状态', key: 'status', width: 14 },
{ header: '定向需求', key: 'targetNeedTitle', width: 24 },
{ header: '帮扶地区', key: 'destinationRegion', width: 20 },
{ header: '落地时间', key: 'landingAt', width: 22 },
{ header: '入库时间', key: 'createdAt', width: 22 },
];
response.rows.forEach((row: OldItemInventoryExportRow) =>
sheet.addRow({
...row,
category: OLD_ITEM_CATEGORY_LABELS[row.category],
status: OLD_ITEM_INVENTORY_STATUS_LABELS[row.status],
landingAt: row.landingAt ? new Date(row.landingAt) : null,
createdAt: new Date(row.createdAt),
}),
);
sheet.getColumn('quantity').numFmt = '0';
sheet.getColumn('landingAt').numFmt = 'yyyy-mm-dd hh:mm';
sheet.getColumn('createdAt').numFmt = 'yyyy-mm-dd hh:mm';
styleSheet(sheet, response.rows.length + 1);
download(
(await workbook.xlsx.writeBuffer()) as ArrayBuffer,
`旧物善用-物资台账-${new Date().toISOString().slice(0, 10)}.xlsx`,
);
message.value = `已导出 ${response.rows.length} 条最小化台账,导出操作已审计`;
} catch (e) {
message.value = error(e);
}
}
function printLedger() {
window.print();
}
onMounted(() => {
void load();
void loadBeneficiaries();
});
</script>
<template>
<div class="inventory-panel">
<p v-if="message" class="form-message" role="status">{{ message }}</p>
<div class="inventory-toolbar no-print">
<button class="secondary-button" @click="template">下载 Excel 模板</button>
<button class="secondary-button" @click="importInput?.click()">批量导入</button>
<input ref="importInput" type="file" accept=".xlsx" hidden @change="importFile" />
<button class="secondary-button" @click="exportExcel">导出 Excel</button>
<button class="secondary-button" @click="printLedger">打印当前台账</button>
<button class="secondary-button" :disabled="loading" @click="load">刷新</button>
</div>
<div class="old-items-columns no-print">
<form class="panel compact-form" @submit.prevent="add">
<h2>新增物资入库</h2>
<label
>来源捐赠(可选)<select v-model="form.sourceDonationPublicId">
<option value="">独立入库</option>
<option v-for="d in eligibleDonations" :key="d.publicId" :value="d.publicId">
{{ d.donationNo }}
</option>
</select></label
>
<label
>品类<select v-model="form.category">
<option v-for="(label, key) in OLD_ITEM_CATEGORY_LABELS" :key="key" :value="key">
{{ label }}
</option>
</select></label
>
<label>物资名称<input v-model="form.itemName" required maxlength="160" /></label>
<label>数量<input v-model.number="form.quantity" required type="number" min="1" /></label>
<label>单位<input v-model="form.unit" required maxlength="20" /></label>
<label>入库位置<input v-model="form.storageLocation" required maxlength="160" /></label>
<label
>定向需求(可选)<select v-model="form.targetNeedPublicId">
<option value="">暂不绑定</option>
<option v-for="need in needs" :key="need.publicId" :value="need.publicId">
{{ need.title }}
</option>
</select></label
>
<label>备注<textarea v-model="form.note" maxlength="1000"></textarea></label>
<button class="primary-button">确认入库</button>
</form>
<aside class="panel inventory-help">
<h2>流程规则</h2>
<ol>
<li>在库</li>
<li>已分拣</li>
<li>已消毒</li>
<li>已出库</li>
<li>已落地</li>
</ol>
<p>
状态不可跳级或回退。出库必须绑定需求、受助对象或帮扶区域之一;公开端仅展示脱敏流转节点。
</p>
</aside>
</div>
<div class="panel print-ledger">
<div class="print-title">
<h2>旧物善用物资全流程台账</h2>
<small>打印时间:{{ new Date().toLocaleString('zh-CN') }}</small>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>台账编号</th>
<th>来源</th>
<th>物资</th>
<th>数量</th>
<th>位置/地区</th>
<th>状态</th>
<th class="no-print">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>{{ item.inventoryNo }}</td>
<td>{{ item.sourceDonation?.donationNo || '独立入库' }}</td>
<td>{{ OLD_ITEM_CATEGORY_LABELS[item.category] }} · {{ item.itemName }}</td>
<td>{{ item.quantity }}{{ item.unit }}</td>
<td>{{ item.destinationRegion || item.storageLocation }}</td>
<td>
<span class="status-pill">{{ OLD_ITEM_INVENTORY_STATUS_LABELS[item.status] }}</span>
</td>
<td class="no-print">
<button class="text-button" @click="open(item.publicId)">查看流转</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!items.length && !loading" class="empty-state">暂无物资台账</p>
</div>
<div v-if="selected" class="modal-backdrop no-print" @click.self="selected = null">
<section class="detail-modal">
<button class="modal-close" @click="selected = null">关闭</button>
<h2>{{ selected.inventoryNo }}</h2>
<p>
{{ OLD_ITEM_CATEGORY_LABELS[selected.category] }} · {{ selected.itemName }} ·
{{ selected.quantity }}{{ selected.unit }}
</p>
<p>当前状态:{{ OLD_ITEM_INVENTORY_STATUS_LABELS[selected.status] }}</p>
<p>当前位置:{{ selected.destinationRegion || selected.storageLocation }}</p>
<div v-if="selected.status === 'DISINFECTED'" class="compact-form inventory-outbound-form">
<label
>定向需求<select v-model="outboundNeedPublicId">
<option value="">不绑定</option>
<option v-for="need in needs" :key="need.publicId" :value="need.publicId">
{{ need.title }}
</option>
</select></label
>
<label v-if="beneficiaries.length"
>受助对象<select v-model="outboundBeneficiaryPublicId">
<option value="">不绑定个人对象</option>
<option
v-for="person in beneficiaries"
:key="person.publicId"
:value="person.publicId"
>
{{ person.displayName }}
</option>
</select></label
>
<label
>帮扶区域<input v-model="outboundRegion" maxlength="120" placeholder="可填写区县/街道"
/></label>
</div>
<button v-if="nextStatus[selected.status]" class="primary-button" @click="advance">
推进至{{ OLD_ITEM_INVENTORY_STATUS_LABELS[nextStatus[selected.status]!] }}
</button>
<ol class="trace-list">
<li v-for="event in selected.events" :key="event.eventAt">
<strong>{{ OLD_ITEM_INVENTORY_STATUS_LABELS[event.status] }}</strong
><span
>{{ new Date(event.eventAt).toLocaleString('zh-CN') }} ·
{{ event.operatorName }}</span
>
<p v-if="event.locationText">{{ event.locationText }}</p>
<p v-if="event.note">{{ event.note }}</p>
</li>
</ol>
</section>
</div>
</div>
</template>
@@ -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>
+328
View File
@@ -27,6 +27,334 @@
sans-serif;
}
.old-items-view .metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin: 18px 0;
}
.old-items-view .metric-grid article {
background: #fff;
border: 1px solid #eee8e1;
border-radius: 14px;
padding: 20px;
}
.old-items-view .metric-grid span {
display: block;
color: #716b65;
}
.old-items-view .metric-grid strong {
font-size: 30px;
color: #1f9d55;
margin-right: 5px;
}
.segmented {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.segmented button {
border: 1px solid #e6dfd8;
background: #fff;
padding: 10px 18px;
border-radius: 999px;
}
.segmented button.active {
background: #ff6b35;
color: #fff;
border-color: #ff6b35;
}
.old-items-columns {
display: grid;
grid-template-columns: minmax(280px, 360px) 1fr;
gap: 18px;
}
.compact-form {
display: grid;
gap: 12px;
}
.compact-form label {
display: grid;
gap: 6px;
}
.need-admin-card {
border-bottom: 1px solid #eee8e1;
padding: 16px 0;
}
.need-admin-card div,
.action-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.detail-modal {
width: min(680px, 92vw);
max-height: 86vh;
overflow: auto;
background: #fff;
border-radius: 18px;
padding: 24px;
position: relative;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: #0008;
display: grid;
place-items: center;
z-index: 30;
}
.modal-close {
position: absolute;
right: 18px;
top: 18px;
}
.detail-modal dl {
display: grid;
grid-template-columns: 90px 1fr;
gap: 10px;
}
.trace-list li {
margin: 12px 0;
}
.trace-list span {
color: #777;
margin-left: 12px;
}
.danger-button {
background: #fff0ed;
color: #c73b22;
border: 1px solid #ffc4b6;
border-radius: 8px;
padding: 10px 16px;
}
.old-item-admin-images {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 16px 0;
}
.old-item-admin-images img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 10px;
}
.old-item-assignee {
display: grid;
gap: 7px;
margin: 16px 0;
font-weight: 700;
}
.old-item-assignee select {
padding: 10px;
border: 1px solid #ddd4cc;
border-radius: 8px;
background: #fff;
}
.inventory-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.inventory-help ol {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
padding: 0;
list-style: none;
counter-reset: inventory-step;
}
.inventory-help li {
counter-increment: inventory-step;
padding: 14px 8px;
border-radius: 12px;
background: #fff3ec;
color: #8b3519;
text-align: center;
}
.inventory-help li::before {
content: counter(inventory-step);
display: block;
margin: 0 auto 6px;
width: 24px;
height: 24px;
line-height: 24px;
border-radius: 50%;
background: #ff6b35;
color: white;
font-weight: 700;
}
.print-title {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
}
.reward-boundary {
margin-bottom: 16px;
border-left: 4px solid #52c41a;
background: #f3fbef;
}
.reward-boundary p,
.reward-config-form small,
.panel-heading small {
color: #6f6964;
}
.reward-point-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.certificate-preview {
position: relative;
align-self: start;
overflow: hidden;
min-height: 420px;
padding: 44px;
border: 8px double #f4b15f;
background:
radial-gradient(circle at 100% 0, rgb(255 107 53 / 16%), transparent 34%),
linear-gradient(145deg, #fffdf9, #fff5e8);
text-align: center;
}
.certificate-preview::before,
.certificate-preview::after {
position: absolute;
width: 90px;
height: 90px;
border: 2px solid rgb(255 107 53 / 22%);
border-radius: 50%;
content: '';
}
.certificate-preview::before {
top: -45px;
left: -45px;
}
.certificate-preview::after {
right: -45px;
bottom: -45px;
}
.certificate-seal {
display: grid;
place-items: center;
width: 58px;
height: 58px;
margin: 0 auto 18px;
border-radius: 50%;
background: linear-gradient(135deg, #ff6b35, #ff9636);
box-shadow: 0 8px 24px rgb(255 107 53 / 24%);
color: white;
font-size: 28px;
}
.certificate-preview h2 {
margin: 12px 0 30px;
color: #8a351c;
font-size: 30px;
letter-spacing: 4px;
}
.certificate-preview > strong {
display: block;
margin-bottom: 14px;
color: #2f2b28;
font-size: 22px;
}
.certificate-preview > p {
min-height: 100px;
color: #574e48;
line-height: 2;
text-align: left;
}
.certificate-preview > div {
display: grid;
gap: 10px;
margin-top: 24px;
text-align: right;
}
.certificate-history {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
margin-top: 18px;
}
.certificate-history article {
padding: 16px;
border: 1px solid #eee7e1;
border-radius: 14px;
background: #fffdfa;
}
.certificate-history article > div {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.inventory-outbound-form,
.reward-config-form {
margin-bottom: 18px;
}
@media print {
body {
background: white;
}
body * {
visibility: hidden;
}
.print-ledger,
.print-ledger * {
visibility: visible;
}
.print-ledger {
position: absolute;
inset: 0;
box-shadow: none;
border: 0;
}
.no-print {
display: none !important;
}
.print-ledger table {
font-size: 10pt;
}
}
@media (max-width: 800px) {
.old-items-columns,
.old-items-view .metric-grid {
grid-template-columns: 1fr;
}
.reward-point-grid {
grid-template-columns: 1fr;
}
.certificate-preview {
min-height: 0;
padding: 28px 22px;
}
}
.financial-workspace {
display: grid;
gap: 20px;
+392
View File
@@ -0,0 +1,392 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
OLD_ITEM_CATEGORY_LABELS,
OLD_ITEM_DONATION_STATUS_LABELS,
PERMISSION_CODES,
type OldItemDonationItem,
type OldItemAssigneeOption,
type OldItemNeedItem,
type OldItemStats,
type OldItemDropOffPoint,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { useAuthStore } from '../stores/auth';
import OldItemInventoryPanel from '../components/OldItemInventoryPanel.vue';
import OldItemRewardsPanel from '../components/OldItemRewardsPanel.vue';
import {
createOldItemNeed,
getOldItemDonation,
getOldItemStats,
listOldItemDonations,
listOldItemAssignees,
getOldItemImageUrl,
listOldItemNeeds,
transitionOldItemDonation,
transitionOldItemNeed,
listOldItemDropOffPoints,
createOldItemDropOffPoint,
} from '../api/old-items';
const tab = ref<'donations' | 'inventory' | 'needs' | 'points' | 'rewards'>('donations'),
loading = ref(false),
message = ref('');
const auth = useAuthStore();
const canReadRewards = computed(() =>
auth.user?.permissions.includes(PERMISSION_CODES.OLD_ITEM_REWARDS_READ),
);
const donations = ref<OldItemDonationItem[]>([]),
needs = ref<OldItemNeedItem[]>([]),
selected = ref<OldItemDonationItem | null>(null),
stats = ref<OldItemStats>({ totalItems: 0, assistedPeople: 0, monthlyKindActs: 0 });
const assignees = ref<OldItemAssigneeOption[]>([]),
assigneePublicId = ref(''),
imageUrls = ref<string[]>([]);
const points = ref<OldItemDropOffPoint[]>([]);
const pointForm = reactive({
name: '',
regionText: '',
addressText: '',
openingHours: '',
sortOrder: 0,
});
const form = reactive({
title: '',
category: 'CLOTHING',
regionText: '',
beneficiaryGroup: '',
description: '',
requestedQuantity: 1,
priority: 50,
});
const error = (e: unknown) =>
e instanceof ApiClientError
? `${e.message}${e.requestId ? `(编号:${e.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load() {
loading.value = true;
message.value = '';
try {
const [d, n, s, a, p] = await Promise.all([
listOldItemDonations(),
listOldItemNeeds(),
getOldItemStats(),
listOldItemAssignees(),
listOldItemDropOffPoints(),
]);
donations.value = d.items;
needs.value = n.items;
stats.value = s;
assignees.value = a.items;
points.value = p.items;
} catch (e) {
message.value = error(e);
} finally {
loading.value = false;
}
}
async function open(id: string) {
try {
selected.value = (await getOldItemDonation(id)).donation;
assigneePublicId.value = '';
imageUrls.value = (
await Promise.all(
selected.value.imagePublicIds.map(async (fileId) => {
try {
return (await getOldItemImageUrl(fileId)).downloadUrl;
} catch {
return '';
}
}),
)
).filter(Boolean);
} catch (e) {
message.value = error(e);
}
}
async function move(target: OldItemDonationItem['status']) {
if (!selected.value) return;
let note: string | undefined, people: number | undefined;
let assignedUserPublicId: string | undefined;
if (target === 'REJECTED') {
note = window.prompt('请输入驳回原因')?.trim();
if (!note) return;
}
if (target === 'COMPLETED') {
people = Number(window.prompt('请输入本次实际帮扶人数', '0') ?? '');
if (!Number.isInteger(people) || people < 0) return;
}
if (target === 'WAITING_PICKUP') {
assignedUserPublicId = assigneePublicId.value || undefined;
if (!assignedUserPublicId) {
message.value = '上门取件审核通过前请选择工作人员';
return;
}
}
try {
selected.value = (
await transitionOldItemDonation(
selected.value.publicId,
selected.value.revision,
target,
note,
people,
assignedUserPublicId,
)
).donation;
await load();
} catch (e) {
message.value = error(e);
}
}
async function addNeed() {
try {
await createOldItemNeed({ ...form, category: form.category as 'CLOTHING' });
Object.assign(form, {
title: '',
category: 'CLOTHING',
regionText: '',
beneficiaryGroup: '',
description: '',
requestedQuantity: 1,
priority: 50,
});
await load();
} catch (e) {
message.value = error(e);
}
}
async function publish(n: OldItemNeedItem, target: 'PUBLISHED' | 'CLOSED') {
try {
await transitionOldItemNeed(n.publicId, n.revision, target);
await load();
} catch (e) {
message.value = error(e);
}
}
async function addPoint() {
try {
await createOldItemDropOffPoint(pointForm);
Object.assign(pointForm, {
name: '',
regionText: '',
addressText: '',
openingHours: '',
sortOrder: 0,
});
await load();
} catch (e) {
message.value = error(e);
}
}
onMounted(load);
</script>
<template>
<section class="workspace-view old-items-view">
<header class="view-header">
<div>
<p class="eyebrow">旧物善用管理</p>
<h1>闲置不浪费,旧物有温度</h1>
<p>审核捐赠、跟进取件与投递,并发布真实物资需求。</p>
</div>
<button class="secondary-button" :disabled="loading" @click="load">刷新</button>
</header>
<p v-if="message" class="form-message" role="alert">{{ message }}</p>
<div class="metric-grid">
<article>
<span>累计接收物资</span><strong>{{ stats.totalItems }}</strong
><small>件</small>
</article>
<article>
<span>累计帮扶</span><strong>{{ stats.assistedPeople }}</strong
><small>人</small>
</article>
<article>
<span>本月爱心人次</span><strong>{{ stats.monthlyKindActs }}</strong
><small>人次</small>
</article>
</div>
<div class="segmented">
<button :class="{ active: tab === 'donations' }" @click="tab = 'donations'">
捐赠订单审核</button
><button :class="{ active: tab === 'inventory' }" @click="tab = 'inventory'">物资台账</button
><button :class="{ active: tab === 'needs' }" @click="tab = 'needs'">物资需求</button
><button :class="{ active: tab === 'points' }" @click="tab = 'points'">投递点</button
><button
v-if="canReadRewards"
:class="{ active: tab === 'rewards' }"
@click="tab = 'rewards'"
>
证书积分
</button>
</div>
<div v-if="tab === 'donations'" class="panel">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>公益编号</th>
<th>品类/数量</th>
<th>方式</th>
<th>状态</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="d in donations" :key="d.publicId">
<td>{{ d.donationNo }}</td>
<td>{{ OLD_ITEM_CATEGORY_LABELS[d.category] }} · {{ d.quantity }}件</td>
<td>{{ d.method === 'PICKUP' ? '上门取件' : '定点投递' }}</td>
<td>
<span class="status-pill">{{ OLD_ITEM_DONATION_STATUS_LABELS[d.status] }}</span>
</td>
<td>{{ new Date(d.createdAt).toLocaleString('zh-CN') }}</td>
<td><button class="text-button" @click="open(d.publicId)">查看处理</button></td>
</tr>
</tbody>
</table>
</div>
<p v-if="!donations.length && !loading" class="empty-state">暂无捐赠订单</p>
</div>
<OldItemInventoryPanel v-else-if="tab === 'inventory'" :donations="donations" :needs="needs" />
<div v-else-if="tab === 'needs'" class="old-items-columns">
<form class="panel compact-form" @submit.prevent="addNeed">
<h2>新增需求草稿</h2>
<label>标题<input v-model="form.title" required maxlength="160" /></label
><label
>品类<select v-model="form.category">
<option v-for="(v, k) in OLD_ITEM_CATEGORY_LABELS" :key="k" :value="k">{{ v }}</option>
</select></label
><label>受助区域<input v-model="form.regionText" required /></label
><label>受助群体<input v-model="form.beneficiaryGroup" required /></label
><label>需求说明<textarea v-model="form.description" required></textarea></label
><label
>需求数量<input
v-model.number="form.requestedQuantity"
type="number"
min="1"
required /></label
><button class="primary-button">保存草稿</button>
</form>
<div class="panel">
<h2>需求列表</h2>
<article v-for="n in needs" :key="n.publicId" class="need-admin-card">
<strong>{{ n.title }}</strong>
<p>{{ n.regionText }} · {{ n.beneficiaryGroup }}</p>
<small
>{{ OLD_ITEM_CATEGORY_LABELS[n.category] }} 剩余 {{ n.remainingQuantity }}/{{
n.requestedQuantity
}}</small
>
<div>
<button
v-if="n.status === 'DRAFT'"
class="primary-button"
@click="publish(n, 'PUBLISHED')"
>
发布</button
><button
v-if="n.status === 'PUBLISHED'"
class="secondary-button"
@click="publish(n, 'CLOSED')"
>
下架</button
><span class="status-pill">{{ n.status }}</span>
</div>
</article>
</div>
</div>
<div v-else-if="tab === 'points'" class="old-items-columns">
<form class="panel compact-form" @submit.prevent="addPoint">
<h2>新增定点投递地址</h2>
<label>点位名称<input v-model="pointForm.name" required /></label>
<label>区域<input v-model="pointForm.regionText" required /></label>
<label>详细地址<input v-model="pointForm.addressText" required /></label>
<label>开放时间<input v-model="pointForm.openingHours" required /></label>
<label>排序<input v-model.number="pointForm.sortOrder" type="number" /></label>
<button class="primary-button">启用投递点</button>
</form>
<div class="panel">
<h2>已启用投递点</h2>
<article v-for="point in points" :key="point.publicId" class="need-admin-card">
<strong>{{ point.name }}</strong>
<p>{{ point.regionText }} · {{ point.addressText }}</p>
<small>{{ point.openingHours }}</small>
</article>
<p v-if="!points.length" class="empty-state">暂无启用投递点</p>
</div>
</div>
<OldItemRewardsPanel v-else :donations="donations" />
<div v-if="selected" class="modal-backdrop" @click.self="selected = null">
<section class="detail-modal">
<button class="modal-close" @click="selected = null">关闭</button>
<h2>{{ selected.donationNo }}</h2>
<dl>
<dt>物品</dt>
<dd>{{ OLD_ITEM_CATEGORY_LABELS[selected.category] }} · {{ selected.quantity }}件</dd>
<dt>描述</dt>
<dd>{{ selected.description }}</dd>
<dt>联系人</dt>
<dd>{{ selected.contactName }} {{ selected.mobileMasked }}</dd>
<dt v-if="selected.address">取件地址</dt>
<dd v-if="selected.address">{{ selected.address }}</dd>
<dt>状态</dt>
<dd>{{ OLD_ITEM_DONATION_STATUS_LABELS[selected.status] }}</dd>
</dl>
<div v-if="imageUrls.length" class="old-item-admin-images">
<img v-for="url in imageUrls" :key="url" :src="url" alt="用户上传的捐赠物品照片" />
</div>
<label
v-if="selected.status === 'SUBMITTED' && selected.method === 'PICKUP'"
class="old-item-assignee"
>取件工作人员<select v-model="assigneePublicId">
<option value="">请选择</option>
<option v-for="person in assignees" :key="person.publicId" :value="person.publicId">
{{ person.displayName }}
</option>
</select></label
>
<div class="action-row">
<button
v-if="selected.status === 'SUBMITTED'"
class="primary-button"
@click="move(selected.method === 'PICKUP' ? 'WAITING_PICKUP' : 'WAITING_DROP_OFF')"
>
审核通过</button
><button
v-if="selected.status === 'SUBMITTED'"
class="danger-button"
@click="move('REJECTED')"
>
驳回</button
><button
v-if="['WAITING_PICKUP', 'WAITING_DROP_OFF'].includes(selected.status)"
class="primary-button"
@click="move('RECEIVED')"
>
确认接收</button
><button
v-if="selected.status === 'RECEIVED'"
class="primary-button"
@click="move('PROCESSING')"
>
开始分拣</button
><button
v-if="selected.status === 'PROCESSING'"
class="primary-button"
@click="move('COMPLETED')"
>
完成帮扶
</button>
</div>
<ol class="trace-list">
<li v-for="h in selected.history" :key="h.createdAt">
<strong>{{ OLD_ITEM_DONATION_STATUS_LABELS[h.toStatus] }}</strong
><span>{{ new Date(h.createdAt).toLocaleString('zh-CN') }}</span>
<p v-if="h.note">{{ h.note }}</p>
</li>
</ol>
</section>
</div>
</section>
</template>
@@ -0,0 +1,102 @@
ALTER TABLE `files` MODIFY `business_type` ENUM('SERVICE_RECORD','PROJECT_COVER','ACTIVITY_COVER','CONTENT_COVER','OLD_ITEM_DONATION_IMAGE') NOT NULL;
CREATE TABLE `old_item_drop_off_points` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `public_id` CHAR(36) NOT NULL,
`name` VARCHAR(120) NOT NULL, `region_text` VARCHAR(120) NOT NULL,
`address_text` VARCHAR(300) NOT NULL, `opening_hours` VARCHAR(200) NOT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true, `sort_order` INTEGER NOT NULL DEFAULT 0,
`created_by` BIGINT UNSIGNED NOT NULL, `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL, UNIQUE INDEX `old_item_drop_off_points_public_id_key` (`public_id`),
INDEX `old_item_drop_off_points_active_sort_idx` (`is_active`,`sort_order`), PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_needs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `public_id` CHAR(36) NOT NULL,
`client_request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
`title` VARCHAR(160) NOT NULL, `category` ENUM('CLOTHING','BOOKS','HOUSEHOLD','SPORTS') NOT NULL,
`region_text` VARCHAR(120) NOT NULL, `beneficiary_group` VARCHAR(160) NOT NULL, `description` TEXT NOT NULL,
`requested_quantity` INTEGER UNSIGNED NOT NULL, `fulfilled_quantity` INTEGER UNSIGNED NOT NULL DEFAULT 0,
`priority` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0,
`status` ENUM('DRAFT','PUBLISHED','FULFILLED','CLOSED') NOT NULL DEFAULT 'DRAFT',
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, `published_at` DATETIME(3) NULL,
`created_by` BIGINT UNSIGNED NOT NULL, `updated_by` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `old_item_needs_public_id_key` (`public_id`),
UNIQUE INDEX `old_item_needs_creator_request_key` (`created_by`,`client_request_id`),
INDEX `old_item_needs_public_list_idx` (`status`,`category`,`priority`,`sort_order`), PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_need_status_history` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `need_id` BIGINT UNSIGNED NOT NULL,
`request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
`from_status` ENUM('DRAFT','PUBLISHED','FULFILLED','CLOSED') NULL,
`to_status` ENUM('DRAFT','PUBLISHED','FULFILLED','CLOSED') NOT NULL,
`operator_user_id` BIGINT UNSIGNED NOT NULL, `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `old_item_need_status_history_request_id_key` (`request_id`),
INDEX `old_item_need_history_need_created_idx` (`need_id`,`created_at`), PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_donations` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `public_id` CHAR(36) NOT NULL, `donation_no` VARCHAR(32) NOT NULL,
`donor_user_id` BIGINT UNSIGNED NOT NULL, `client_request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
`category` ENUM('CLOTHING','BOOKS','HOUSEHOLD','SPORTS') NOT NULL,
`item_condition` ENUM('NEW','LIKE_NEW','GOOD','FAIR') NOT NULL, `quantity` INTEGER UNSIGNED NOT NULL,
`description_encrypted` TEXT NOT NULL, `method` ENUM('PICKUP','DROP_OFF') NOT NULL,
`contact_name_encrypted` VARCHAR(512) NOT NULL, `mobile_encrypted` VARCHAR(512) NOT NULL, `mobile_hash` CHAR(64) NOT NULL,
`address_encrypted` TEXT NULL, `appointment_at` DATETIME(3) NULL, `drop_off_point_id` BIGINT UNSIGNED NULL,
`target_need_id` BIGINT UNSIGNED NULL, `assigned_user_id` BIGINT UNSIGNED NULL,
`rejection_reason_encrypted` TEXT NULL,
`status` ENUM('SUBMITTED','REJECTED','WAITING_PICKUP','WAITING_DROP_OFF','RECEIVED','PROCESSING','COMPLETED','CANCELLED') NOT NULL DEFAULT 'SUBMITTED',
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, `assisted_people` INTEGER UNSIGNED NOT NULL DEFAULT 0,
`received_at` DATETIME(3) NULL, `completed_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `old_item_donations_public_id_key` (`public_id`), UNIQUE INDEX `old_item_donations_donation_no_key` (`donation_no`),
UNIQUE INDEX `old_item_donations_donor_request_key` (`donor_user_id`,`client_request_id`),
INDEX `old_item_donations_donor_status_created_idx` (`donor_user_id`,`status`,`created_at`),
INDEX `old_item_donations_admin_list_idx` (`status`,`method`,`created_at`),
INDEX `old_item_donations_need_status_idx` (`target_need_id`,`status`), PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_donation_status_history` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `donation_id` BIGINT UNSIGNED NOT NULL,
`request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
`from_status` ENUM('SUBMITTED','REJECTED','WAITING_PICKUP','WAITING_DROP_OFF','RECEIVED','PROCESSING','COMPLETED','CANCELLED') NULL,
`to_status` ENUM('SUBMITTED','REJECTED','WAITING_PICKUP','WAITING_DROP_OFF','RECEIVED','PROCESSING','COMPLETED','CANCELLED') NOT NULL,
`note_encrypted` TEXT NULL, `operator_user_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `old_item_donation_status_history_request_id_key` (`request_id`),
INDEX `old_item_donation_history_donation_created_idx` (`donation_id`,`created_at`), PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_donation_files` (
`donation_id` BIGINT UNSIGNED NOT NULL, `file_id` BIGINT UNSIGNED NOT NULL,
`sort_order` INTEGER NOT NULL DEFAULT 0, `attached_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `old_item_donation_files_file_idx` (`file_id`), PRIMARY KEY (`donation_id`,`file_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `old_item_drop_off_points` ADD CONSTRAINT `old_item_drop_off_points_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_needs` ADD CONSTRAINT `old_item_needs_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_needs` ADD CONSTRAINT `old_item_needs_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_need_status_history` ADD CONSTRAINT `old_item_need_history_need_fkey` FOREIGN KEY (`need_id`) REFERENCES `old_item_needs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_need_status_history` ADD CONSTRAINT `old_item_need_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_donor_fkey` FOREIGN KEY (`donor_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_assignee_fkey` FOREIGN KEY (`assigned_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_drop_off_point_fkey` FOREIGN KEY (`drop_off_point_id`) REFERENCES `old_item_drop_off_points`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_target_need_fkey` FOREIGN KEY (`target_need_id`) REFERENCES `old_item_needs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_donation_status_history` ADD CONSTRAINT `old_item_donation_history_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_donation_status_history` ADD CONSTRAINT `old_item_donation_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_donation_files` ADD CONSTRAINT `old_item_donation_files_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_donation_files` ADD CONSTRAINT `old_item_donation_files_file_fkey` FOREIGN KEY (`file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
INSERT INTO `permissions` (`code`,`name`,`description`) VALUES
('old_items:read','查看旧物善用','查看旧物捐赠、需求与统计'),
('old_items:manage','管理旧物善用','审核、分配并更新旧物捐赠状态'),
('old_item_needs:manage','管理旧物需求','发布、编辑和下架物资需求');
INSERT INTO `role_permissions` (`role_id`,`permission_id`)
SELECT r.id,p.id FROM `roles` r CROSS JOIN `permissions` p
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN ('old_items:read','old_items:manage','old_item_needs:manage');
INSERT INTO `role_permissions` (`role_id`,`permission_id`)
SELECT r.id,p.id FROM `roles` r CROSS JOIN `permissions` p
WHERE r.code IN ('STAFF','PROJECT_MANAGER') AND p.code IN ('old_items:read','old_items:manage')
ON DUPLICATE KEY UPDATE `role_id`=`role_id`;
@@ -0,0 +1,89 @@
CREATE TABLE `old_item_inventory_imports` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`public_id` CHAR(36) NOT NULL,
`client_request_id` CHAR(36) NOT NULL,
`request_fingerprint` CHAR(64) NOT NULL,
`original_filename_encrypted` VARCHAR(512) NOT NULL,
`row_count` INTEGER UNSIGNED NOT NULL,
`created_by` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `old_item_inventory_imports_public_id_key` (`public_id`),
UNIQUE INDEX `old_item_inventory_import_creator_request_key` (`created_by`, `client_request_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_inventory_items` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`public_id` CHAR(36) NOT NULL,
`inventory_no` VARCHAR(32) NOT NULL,
`source_donation_id` BIGINT UNSIGNED NULL,
`import_id` BIGINT UNSIGNED NULL,
`category` ENUM('CLOTHING','BOOKS','HOUSEHOLD','SPORTS') NOT NULL,
`item_name` VARCHAR(160) NOT NULL,
`quantity` INTEGER UNSIGNED NOT NULL,
`unit` VARCHAR(20) NOT NULL DEFAULT '件',
`storage_location` VARCHAR(160) NOT NULL,
`status` ENUM('IN_STOCK','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NOT NULL DEFAULT 'IN_STOCK',
`target_need_id` BIGINT UNSIGNED NULL,
`beneficiary_id` BIGINT UNSIGNED NULL,
`destination_region` VARCHAR(120) NULL,
`note_encrypted` TEXT NULL,
`landing_at` DATETIME(3) NULL,
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
`created_by` BIGINT UNSIGNED NOT NULL,
`updated_by` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `old_item_inventory_items_public_id_key` (`public_id`),
UNIQUE INDEX `old_item_inventory_items_inventory_no_key` (`inventory_no`),
UNIQUE INDEX `old_item_inventory_items_source_donation_id_key` (`source_donation_id`),
INDEX `old_item_inventory_status_category_created_idx` (`status`, `category`, `created_at`),
INDEX `old_item_inventory_need_status_idx` (`target_need_id`, `status`),
INDEX `old_item_inventory_beneficiary_status_idx` (`beneficiary_id`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_inventory_events` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`inventory_item_id` BIGINT UNSIGNED NOT NULL,
`request_id` CHAR(36) NOT NULL,
`request_fingerprint` CHAR(64) NOT NULL,
`event_type` ENUM('INBOUND','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NOT NULL,
`from_status` ENUM('IN_STOCK','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NULL,
`to_status` ENUM('IN_STOCK','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NOT NULL,
`location_text` VARCHAR(160) NULL,
`note_encrypted` TEXT NULL,
`event_at` DATETIME(3) NOT NULL,
`operator_user_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `old_item_inventory_events_request_id_key` (`request_id`),
INDEX `old_item_inventory_events_item_event_idx` (`inventory_item_id`, `event_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `old_item_inventory_imports` ADD CONSTRAINT `old_item_inventory_imports_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_source_donation_fkey` FOREIGN KEY (`source_donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_import_fkey` FOREIGN KEY (`import_id`) REFERENCES `old_item_inventory_imports`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_target_need_fkey` FOREIGN KEY (`target_need_id`) REFERENCES `old_item_needs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_beneficiary_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_inventory_events` ADD CONSTRAINT `old_item_inventory_events_item_fkey` FOREIGN KEY (`inventory_item_id`) REFERENCES `old_item_inventory_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_inventory_events` ADD CONSTRAINT `old_item_inventory_events_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
('old_item_inventory:read', '查看旧物台账', '查看旧物物资全流程台账'),
('old_item_inventory:manage', '管理旧物台账', '入库并更新旧物物资流转状态'),
('old_item_inventory:import', '导入旧物台账', '从标准 Excel 模板批量导入物资'),
('old_item_inventory:export', '导出旧物台账', '导出和打印最小化旧物台账');
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN (
'old_item_inventory:read','old_item_inventory:manage','old_item_inventory:import','old_item_inventory:export'
);
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
WHERE r.code IN ('STAFF','PROJECT_MANAGER') AND p.code IN ('old_item_inventory:read','old_item_inventory:manage')
ON DUPLICATE KEY UPDATE `role_id` = `role_id`;
@@ -0,0 +1,113 @@
CREATE TABLE `old_item_reward_configs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`public_id` CHAR(36) NOT NULL,
`client_request_id` CHAR(36) NOT NULL,
`request_fingerprint` CHAR(64) NOT NULL,
`version` INTEGER UNSIGNED NOT NULL,
`active_key` VARCHAR(16) NULL,
`certificate_title` VARCHAR(120) NOT NULL,
`certificate_message` VARCHAR(1000) NOT NULL,
`issuer_name` VARCHAR(120) NOT NULL,
`legal_notice` VARCHAR(500) NOT NULL,
`clothing_points` INTEGER UNSIGNED NOT NULL,
`books_points` INTEGER UNSIGNED NOT NULL,
`household_points` INTEGER UNSIGNED NOT NULL,
`sports_points` INTEGER UNSIGNED NOT NULL,
`max_points_per_donation` INTEGER UNSIGNED NOT NULL,
`created_by` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `old_item_reward_configs_public_id_key` (`public_id`),
UNIQUE INDEX `old_item_reward_configs_client_request_id_key` (`client_request_id`),
UNIQUE INDEX `old_item_reward_configs_version_key` (`version`),
UNIQUE INDEX `old_item_reward_configs_active_key_key` (`active_key`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_certificates` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`public_id` CHAR(36) NOT NULL,
`certificate_no` VARCHAR(40) NOT NULL,
`donation_id` BIGINT UNSIGNED NOT NULL,
`config_id` BIGINT UNSIGNED NULL,
`issue_request_id` CHAR(36) NOT NULL,
`issue_fingerprint` CHAR(64) NOT NULL,
`active_key` VARCHAR(16) NULL,
`status` ENUM('ISSUED','REVOKED') NOT NULL DEFAULT 'ISSUED',
`recipient_name_encrypted` VARCHAR(512) NOT NULL,
`title_snapshot` VARCHAR(120) NOT NULL,
`message_snapshot` VARCHAR(1000) NOT NULL,
`issuer_snapshot` VARCHAR(120) NOT NULL,
`legal_notice_snapshot` VARCHAR(500) NOT NULL,
`issued_by` BIGINT UNSIGNED NOT NULL,
`issued_at` DATETIME(3) NOT NULL,
`revoked_by` BIGINT UNSIGNED NULL,
`revoked_request_id` CHAR(36) NULL,
`revoked_fingerprint` CHAR(64) NULL,
`revoke_reason_encrypted` TEXT NULL,
`revoked_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `old_item_certificates_public_id_key` (`public_id`),
UNIQUE INDEX `old_item_certificates_certificate_no_key` (`certificate_no`),
UNIQUE INDEX `old_item_certificates_issue_request_id_key` (`issue_request_id`),
UNIQUE INDEX `old_item_certificates_revoked_request_id_key` (`revoked_request_id`),
UNIQUE INDEX `old_item_certificates_donation_active_key` (`donation_id`, `active_key`),
INDEX `old_item_certificates_donation_issued_idx` (`donation_id`, `issued_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_point_accounts` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`public_id` CHAR(36) NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL,
`balance` INTEGER UNSIGNED NOT NULL DEFAULT 0,
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `old_item_point_accounts_public_id_key` (`public_id`),
UNIQUE INDEX `old_item_point_accounts_user_id_key` (`user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `old_item_point_entries` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`public_id` CHAR(36) NOT NULL,
`account_id` BIGINT UNSIGNED NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL,
`donation_id` BIGINT UNSIGNED NULL,
`config_id` BIGINT UNSIGNED NULL,
`request_id` CHAR(36) NOT NULL,
`request_fingerprint` CHAR(64) NOT NULL,
`source_key` VARCHAR(100) NOT NULL,
`entry_type` ENUM('EARNED','ADJUSTMENT') NOT NULL,
`points_delta` INTEGER NOT NULL,
`balance_after` INTEGER UNSIGNED NOT NULL,
`note_encrypted` TEXT NULL,
`operator_user_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `old_item_point_entries_public_id_key` (`public_id`),
UNIQUE INDEX `old_item_point_entries_request_id_key` (`request_id`),
UNIQUE INDEX `old_item_point_entries_source_key_key` (`source_key`),
INDEX `old_item_point_entries_user_created_idx` (`user_id`, `created_at`),
INDEX `old_item_point_entries_donation_idx` (`donation_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `old_item_reward_configs` ADD CONSTRAINT `old_item_reward_configs_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_config_fkey` FOREIGN KEY (`config_id`) REFERENCES `old_item_reward_configs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_issued_by_fkey` FOREIGN KEY (`issued_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_revoked_by_fkey` FOREIGN KEY (`revoked_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_point_accounts` ADD CONSTRAINT `old_item_point_accounts_user_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_account_fkey` FOREIGN KEY (`account_id`) REFERENCES `old_item_point_accounts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_user_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_config_fkey` FOREIGN KEY (`config_id`) REFERENCES `old_item_reward_configs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
('old_item_rewards:read', '查看旧物证书积分', '查看旧物善用证书、积分账户和规则'),
('old_item_rewards:manage', '管理旧物证书积分', '配置规则、补发撤销证书和调整公益积分');
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN ('old_item_rewards:read','old_item_rewards:manage');
+529 -133
View File
@@ -153,6 +153,70 @@ enum FileBusinessType {
PROJECT_COVER
ACTIVITY_COVER
CONTENT_COVER
OLD_ITEM_DONATION_IMAGE
}
enum OldItemCategory {
CLOTHING
BOOKS
HOUSEHOLD
SPORTS
}
enum OldItemCondition {
NEW
LIKE_NEW
GOOD
FAIR
}
enum OldItemDonationMethod {
PICKUP
DROP_OFF
}
enum OldItemDonationStatus {
SUBMITTED
REJECTED
WAITING_PICKUP
WAITING_DROP_OFF
RECEIVED
PROCESSING
COMPLETED
CANCELLED
}
enum OldItemNeedStatus {
DRAFT
PUBLISHED
FULFILLED
CLOSED
}
enum OldItemInventoryStatus {
IN_STOCK
SORTED
DISINFECTED
OUTBOUND
DELIVERED
}
enum OldItemInventoryEventType {
INBOUND
SORTED
DISINFECTED
OUTBOUND
DELIVERED
}
enum OldItemCertificateStatus {
ISSUED
REVOKED
}
enum OldItemPointEntryType {
EARNED
ADJUSTMENT
}
enum ContentPostType {
@@ -261,70 +325,87 @@ enum FollowUpSource {
}
model User {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
status UserStatus @default(ACTIVE)
userType UserType @map("user_type")
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
mobileHash String? @unique @map("mobile_hash") @db.Char(64)
nickname String? @db.VarChar(100)
avatarUrl String? @map("avatar_url") @db.VarChar(512)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
wechatIdentities WechatIdentity[]
staffProfile StaffProfile?
adminCredential AdminCredential?
sessions AuthSession[]
userRoles UserRole[]
auditLogs AuditLog[] @relation("AuditActor")
assignedRoles UserRole[] @relation("UserRoleAssigner")
serviceApplications ServiceApplication[] @relation("ApplicationApplicant")
applicationStatusChanges ServiceApplicationStatusHistory[] @relation("ApplicationStatusOperator")
consents Consent[]
createdBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryCreator")
updatedBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryUpdater")
ownedServiceCases ServiceCase[] @relation("CaseOwner")
createdServiceCases ServiceCase[] @relation("CaseCreator")
updatedServiceCases ServiceCase[] @relation("CaseUpdater")
caseAssignments CaseAssignee[]
caseStatusChanges ServiceCaseStatusHistory[] @relation("CaseStatusOperator")
createdServiceRecords ServiceRecord[] @relation("ServiceRecordCreator")
updatedServiceRecords ServiceRecord[] @relation("ServiceRecordUpdater")
uploadedFiles File[] @relation("FileUploader")
assignedBeneficiaryTags BeneficiaryTag[] @relation("BeneficiaryTagAssigner")
assignedCaseMembers CaseAssignee[] @relation("CaseAssigneeAssigner")
revisedServiceRecords ServiceRecordRevision[] @relation("ServiceRecordReviser")
ownedFollowUpTasks FollowUpTask[] @relation("FollowUpOwner")
createdFollowUpTasks FollowUpTask[] @relation("FollowUpCreator")
updatedFollowUpTasks FollowUpTask[] @relation("FollowUpUpdater")
followUpStatusChanges FollowUpStatusHistory[] @relation("FollowUpStatusOperator")
volunteerProfile Volunteer?
reviewedVolunteers Volunteer[] @relation("VolunteerReviewer")
createdVolunteers Volunteer[] @relation("VolunteerCreator")
updatedVolunteers Volunteer[] @relation("VolunteerUpdater")
volunteerStatusChanges VolunteerStatusHistory[] @relation("VolunteerStatusOperator")
managedProjects Project[] @relation("ProjectManager")
createdProjects Project[] @relation("ProjectCreator")
updatedProjects Project[] @relation("ProjectUpdater")
projectStatusChanges ProjectStatusHistory[] @relation("ProjectStatusOperator")
createdActivities Activity[] @relation("ActivityCreator")
updatedActivities Activity[] @relation("ActivityUpdater")
activityStatusChanges ActivityStatusHistory[] @relation("ActivityStatusOperator")
activityRegistrations ActivityRegistration[] @relation("ActivityRegistrationUser")
reviewedRegistrations ActivityRegistration[] @relation("RegistrationReviewer")
checkedInRegistrations ActivityRegistration[] @relation("RegistrationCheckInOperator")
recordedServiceMinutes ActivityRegistration[] @relation("RegistrationMinutesOperator")
registrationChanges ActivityRegistrationHistory[] @relation("RegistrationHistoryOperator")
createdContentPosts ContentPost[] @relation("ContentCreator")
updatedContentPosts ContentPost[] @relation("ContentUpdater")
reviewedContentPosts ContentPost[] @relation("ContentReviewer")
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
notifications Notification[]
notificationPreferences NotificationPreference[]
createdFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureCreator")
updatedFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureUpdater")
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
status UserStatus @default(ACTIVE)
userType UserType @map("user_type")
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
mobileHash String? @unique @map("mobile_hash") @db.Char(64)
nickname String? @db.VarChar(100)
avatarUrl String? @map("avatar_url") @db.VarChar(512)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
wechatIdentities WechatIdentity[]
staffProfile StaffProfile?
adminCredential AdminCredential?
sessions AuthSession[]
userRoles UserRole[]
auditLogs AuditLog[] @relation("AuditActor")
assignedRoles UserRole[] @relation("UserRoleAssigner")
serviceApplications ServiceApplication[] @relation("ApplicationApplicant")
applicationStatusChanges ServiceApplicationStatusHistory[] @relation("ApplicationStatusOperator")
consents Consent[]
createdBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryCreator")
updatedBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryUpdater")
ownedServiceCases ServiceCase[] @relation("CaseOwner")
createdServiceCases ServiceCase[] @relation("CaseCreator")
updatedServiceCases ServiceCase[] @relation("CaseUpdater")
caseAssignments CaseAssignee[]
caseStatusChanges ServiceCaseStatusHistory[] @relation("CaseStatusOperator")
createdServiceRecords ServiceRecord[] @relation("ServiceRecordCreator")
updatedServiceRecords ServiceRecord[] @relation("ServiceRecordUpdater")
uploadedFiles File[] @relation("FileUploader")
assignedBeneficiaryTags BeneficiaryTag[] @relation("BeneficiaryTagAssigner")
assignedCaseMembers CaseAssignee[] @relation("CaseAssigneeAssigner")
revisedServiceRecords ServiceRecordRevision[] @relation("ServiceRecordReviser")
ownedFollowUpTasks FollowUpTask[] @relation("FollowUpOwner")
createdFollowUpTasks FollowUpTask[] @relation("FollowUpCreator")
updatedFollowUpTasks FollowUpTask[] @relation("FollowUpUpdater")
followUpStatusChanges FollowUpStatusHistory[] @relation("FollowUpStatusOperator")
volunteerProfile Volunteer?
reviewedVolunteers Volunteer[] @relation("VolunteerReviewer")
createdVolunteers Volunteer[] @relation("VolunteerCreator")
updatedVolunteers Volunteer[] @relation("VolunteerUpdater")
volunteerStatusChanges VolunteerStatusHistory[] @relation("VolunteerStatusOperator")
managedProjects Project[] @relation("ProjectManager")
createdProjects Project[] @relation("ProjectCreator")
updatedProjects Project[] @relation("ProjectUpdater")
projectStatusChanges ProjectStatusHistory[] @relation("ProjectStatusOperator")
createdActivities Activity[] @relation("ActivityCreator")
updatedActivities Activity[] @relation("ActivityUpdater")
activityStatusChanges ActivityStatusHistory[] @relation("ActivityStatusOperator")
activityRegistrations ActivityRegistration[] @relation("ActivityRegistrationUser")
reviewedRegistrations ActivityRegistration[] @relation("RegistrationReviewer")
checkedInRegistrations ActivityRegistration[] @relation("RegistrationCheckInOperator")
recordedServiceMinutes ActivityRegistration[] @relation("RegistrationMinutesOperator")
registrationChanges ActivityRegistrationHistory[] @relation("RegistrationHistoryOperator")
createdContentPosts ContentPost[] @relation("ContentCreator")
updatedContentPosts ContentPost[] @relation("ContentUpdater")
reviewedContentPosts ContentPost[] @relation("ContentReviewer")
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
notifications Notification[]
notificationPreferences NotificationPreference[]
createdFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureCreator")
updatedFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureUpdater")
financialDisclosureStatusChanges FinancialDisclosureStatusHistory[] @relation("FinancialDisclosureStatusOperator")
oldItemDonations OldItemDonation[] @relation("OldItemDonor")
assignedOldItemDonations OldItemDonation[] @relation("OldItemAssignee")
oldItemDonationStatusChanges OldItemDonationStatusHistory[] @relation("OldItemDonationStatusOperator")
createdOldItemNeeds OldItemNeed[] @relation("OldItemNeedCreator")
updatedOldItemNeeds OldItemNeed[] @relation("OldItemNeedUpdater")
oldItemNeedStatusChanges OldItemNeedStatusHistory[] @relation("OldItemNeedStatusOperator")
createdOldItemDropOffPoints OldItemDropOffPoint[] @relation("OldItemDropOffPointCreator")
createdOldItemInventoryItems OldItemInventoryItem[] @relation("OldItemInventoryCreator")
updatedOldItemInventoryItems OldItemInventoryItem[] @relation("OldItemInventoryUpdater")
oldItemInventoryEvents OldItemInventoryEvent[] @relation("OldItemInventoryEventOperator")
oldItemInventoryImports OldItemInventoryImport[] @relation("OldItemInventoryImportCreator")
createdOldItemRewardConfigs OldItemRewardConfig[] @relation("OldItemRewardConfigCreator")
issuedOldItemCertificates OldItemCertificate[] @relation("OldItemCertificateIssuer")
revokedOldItemCertificates OldItemCertificate[] @relation("OldItemCertificateRevoker")
oldItemPointAccount OldItemPointAccount?
oldItemPointEntries OldItemPointEntry[] @relation("OldItemPointOwner")
operatedOldItemPointEntries OldItemPointEntry[] @relation("OldItemPointOperator")
@@map("users")
}
@@ -552,27 +633,27 @@ model ServiceApplicationStatusHistory {
}
model BeneficiaryProfile {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
displayNameEncrypted String @map("display_name_encrypted") @db.VarChar(512)
displayNameHash String @map("display_name_hash") @db.Char(64)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
displayNameEncrypted String @map("display_name_encrypted") @db.VarChar(512)
displayNameHash String @map("display_name_hash") @db.Char(64)
gender BeneficiaryGender?
birthYear Int? @map("birth_year") @db.UnsignedSmallInt
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
mobileHash String? @map("mobile_hash") @db.Char(64)
regionCode String @map("region_code") @db.VarChar(100)
addressDetailEncrypted String? @map("address_detail_encrypted") @db.VarChar(1024)
primaryCategory ServiceCategory @map("primary_category")
keyCareFlag Boolean @default(false) @map("key_care_flag")
isMinor Boolean @default(false) @map("is_minor")
status BeneficiaryStatus @default(ACTIVE)
notesEncrypted String? @map("notes_encrypted") @db.Text
createdById BigInt @map("created_by") @db.UnsignedBigInt
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
creator User @relation("BeneficiaryCreator", fields: [createdById], references: [id], onDelete: Restrict)
updater User @relation("BeneficiaryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
birthYear Int? @map("birth_year") @db.UnsignedSmallInt
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
mobileHash String? @map("mobile_hash") @db.Char(64)
regionCode String @map("region_code") @db.VarChar(100)
addressDetailEncrypted String? @map("address_detail_encrypted") @db.VarChar(1024)
primaryCategory ServiceCategory @map("primary_category")
keyCareFlag Boolean @default(false) @map("key_care_flag")
isMinor Boolean @default(false) @map("is_minor")
status BeneficiaryStatus @default(ACTIVE)
notesEncrypted String? @map("notes_encrypted") @db.Text
createdById BigInt @map("created_by") @db.UnsignedBigInt
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
creator User @relation("BeneficiaryCreator", fields: [createdById], references: [id], onDelete: Restrict)
updater User @relation("BeneficiaryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
contacts BeneficiaryContact[]
beneficiaryTags BeneficiaryTag[]
applications ServiceApplication[]
@@ -580,6 +661,7 @@ model BeneficiaryProfile {
serviceRecords ServiceRecord[]
consents Consent[]
followUpTasks FollowUpTask[]
oldItemInventoryItems OldItemInventoryItem[]
@@index([displayNameHash], map: "beneficiary_profiles_display_name_hash_idx")
@@index([mobileHash], map: "beneficiary_profiles_mobile_hash_idx")
@@ -769,29 +851,30 @@ model ServiceRecordRevision {
}
model File {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
caseId BigInt? @map("case_id") @db.UnsignedBigInt
clientRequestId String @map("client_request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
storageProvider String @map("storage_provider") @db.VarChar(50)
bucket String @db.VarChar(255)
objectKey String @unique @map("object_key") @db.VarChar(512)
originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(1024)
mimeType String @map("mime_type") @db.VarChar(100)
size BigInt @db.UnsignedBigInt
sha256 String? @db.Char(64)
businessType FileBusinessType @map("business_type")
uploaderUserId BigInt @map("uploader_user_id") @db.UnsignedBigInt
status FileStatus @default(UPLOAD_PENDING)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict)
uploader User @relation("FileUploader", fields: [uploaderUserId], references: [id], onDelete: Restrict)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
caseId BigInt? @map("case_id") @db.UnsignedBigInt
clientRequestId String @map("client_request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
storageProvider String @map("storage_provider") @db.VarChar(50)
bucket String @db.VarChar(255)
objectKey String @unique @map("object_key") @db.VarChar(512)
originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(1024)
mimeType String @map("mime_type") @db.VarChar(100)
size BigInt @db.UnsignedBigInt
sha256 String? @db.Char(64)
businessType FileBusinessType @map("business_type")
uploaderUserId BigInt @map("uploader_user_id") @db.UnsignedBigInt
status FileStatus @default(UPLOAD_PENDING)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict)
uploader User @relation("FileUploader", fields: [uploaderUserId], references: [id], onDelete: Restrict)
serviceRecords ServiceRecordFile[]
projectCover Project? @relation("ProjectCover")
activityCover Activity? @relation("ActivityCover")
contentCover ContentPost? @relation("ContentCover")
projectCover Project? @relation("ProjectCover")
activityCover Activity? @relation("ActivityCover")
contentCover ContentPost? @relation("ContentCover")
oldItemDonations OldItemDonationFile[]
@@unique([uploaderUserId, clientRequestId], map: "files_uploader_client_request_key")
@@index([caseId, status, createdAt], map: "files_case_status_created_at_idx")
@@ -799,6 +882,319 @@ model File {
@@map("files")
}
model OldItemDropOffPoint {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
name String @db.VarChar(120)
regionText String @map("region_text") @db.VarChar(120)
addressText String @map("address_text") @db.VarChar(300)
openingHours String @map("opening_hours") @db.VarChar(200)
isActive Boolean @default(true) @map("is_active")
sortOrder Int @default(0) @map("sort_order")
createdById BigInt @map("created_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
creator User @relation("OldItemDropOffPointCreator", fields: [createdById], references: [id], onDelete: Restrict)
donations OldItemDonation[]
@@index([isActive, sortOrder], map: "old_item_drop_off_points_active_sort_idx")
@@map("old_item_drop_off_points")
}
model OldItemNeed {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
clientRequestId String @map("client_request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
title String @db.VarChar(160)
category OldItemCategory
regionText String @map("region_text") @db.VarChar(120)
beneficiaryGroup String @map("beneficiary_group") @db.VarChar(160)
description String @db.Text
requestedQuantity Int @map("requested_quantity") @db.UnsignedInt
fulfilledQuantity Int @default(0) @map("fulfilled_quantity") @db.UnsignedInt
priority Int @default(0)
sortOrder Int @default(0) @map("sort_order")
status OldItemNeedStatus @default(DRAFT)
revision Int @default(1) @db.UnsignedInt
publishedAt DateTime? @map("published_at") @db.DateTime(3)
createdById BigInt @map("created_by") @db.UnsignedBigInt
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
creator User @relation("OldItemNeedCreator", fields: [createdById], references: [id], onDelete: Restrict)
updater User @relation("OldItemNeedUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
donations OldItemDonation[]
history OldItemNeedStatusHistory[]
inventoryItems OldItemInventoryItem[]
@@unique([createdById, clientRequestId], map: "old_item_needs_creator_request_key")
@@index([status, category, priority, sortOrder], map: "old_item_needs_public_list_idx")
@@map("old_item_needs")
}
model OldItemNeedStatusHistory {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
needId BigInt @map("need_id") @db.UnsignedBigInt
requestId String @unique @map("request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
fromStatus OldItemNeedStatus? @map("from_status")
toStatus OldItemNeedStatus @map("to_status")
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
need OldItemNeed @relation(fields: [needId], references: [id], onDelete: Restrict)
operator User @relation("OldItemNeedStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
@@index([needId, createdAt], map: "old_item_need_history_need_created_idx")
@@map("old_item_need_status_history")
}
model OldItemDonation {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
donationNo String @unique @map("donation_no") @db.VarChar(32)
donorUserId BigInt @map("donor_user_id") @db.UnsignedBigInt
clientRequestId String @map("client_request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
category OldItemCategory
itemCondition OldItemCondition @map("item_condition")
quantity Int @db.UnsignedInt
descriptionEncrypted String @map("description_encrypted") @db.Text
method OldItemDonationMethod
contactNameEncrypted String @map("contact_name_encrypted") @db.VarChar(512)
mobileEncrypted String @map("mobile_encrypted") @db.VarChar(512)
mobileHash String @map("mobile_hash") @db.Char(64)
addressEncrypted String? @map("address_encrypted") @db.Text
appointmentAt DateTime? @map("appointment_at") @db.DateTime(3)
dropOffPointId BigInt? @map("drop_off_point_id") @db.UnsignedBigInt
targetNeedId BigInt? @map("target_need_id") @db.UnsignedBigInt
assignedUserId BigInt? @map("assigned_user_id") @db.UnsignedBigInt
rejectionReasonEncrypted String? @map("rejection_reason_encrypted") @db.Text
status OldItemDonationStatus @default(SUBMITTED)
revision Int @default(1) @db.UnsignedInt
assistedPeople Int @default(0) @map("assisted_people") @db.UnsignedInt
receivedAt DateTime? @map("received_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
donor User @relation("OldItemDonor", fields: [donorUserId], references: [id], onDelete: Restrict)
assignee User? @relation("OldItemAssignee", fields: [assignedUserId], references: [id], onDelete: Restrict)
dropOffPoint OldItemDropOffPoint? @relation(fields: [dropOffPointId], references: [id], onDelete: Restrict)
targetNeed OldItemNeed? @relation(fields: [targetNeedId], references: [id], onDelete: Restrict)
history OldItemDonationStatusHistory[]
files OldItemDonationFile[]
inventoryItems OldItemInventoryItem[]
certificates OldItemCertificate[]
pointEntries OldItemPointEntry[]
@@unique([donorUserId, clientRequestId], map: "old_item_donations_donor_request_key")
@@index([donorUserId, status, createdAt], map: "old_item_donations_donor_status_created_idx")
@@index([status, method, createdAt], map: "old_item_donations_admin_list_idx")
@@index([targetNeedId, status], map: "old_item_donations_need_status_idx")
@@map("old_item_donations")
}
model OldItemRewardConfig {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
clientRequestId String @unique @map("client_request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
version Int @unique @db.UnsignedInt
activeKey String? @unique @map("active_key") @db.VarChar(16)
certificateTitle String @map("certificate_title") @db.VarChar(120)
certificateMessage String @map("certificate_message") @db.VarChar(1000)
issuerName String @map("issuer_name") @db.VarChar(120)
legalNotice String @map("legal_notice") @db.VarChar(500)
clothingPoints Int @map("clothing_points") @db.UnsignedInt
booksPoints Int @map("books_points") @db.UnsignedInt
householdPoints Int @map("household_points") @db.UnsignedInt
sportsPoints Int @map("sports_points") @db.UnsignedInt
maxPointsPerDonation Int @map("max_points_per_donation") @db.UnsignedInt
createdById BigInt @map("created_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
creator User @relation("OldItemRewardConfigCreator", fields: [createdById], references: [id], onDelete: Restrict)
certificates OldItemCertificate[]
pointEntries OldItemPointEntry[]
@@map("old_item_reward_configs")
}
model OldItemCertificate {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
certificateNo String @unique @map("certificate_no") @db.VarChar(40)
donationId BigInt @map("donation_id") @db.UnsignedBigInt
configId BigInt? @map("config_id") @db.UnsignedBigInt
issueRequestId String @unique @map("issue_request_id") @db.Char(36)
issueFingerprint String @map("issue_fingerprint") @db.Char(64)
activeKey String? @map("active_key") @db.VarChar(16)
status OldItemCertificateStatus @default(ISSUED)
recipientNameEncrypted String @map("recipient_name_encrypted") @db.VarChar(512)
titleSnapshot String @map("title_snapshot") @db.VarChar(120)
messageSnapshot String @map("message_snapshot") @db.VarChar(1000)
issuerSnapshot String @map("issuer_snapshot") @db.VarChar(120)
legalNoticeSnapshot String @map("legal_notice_snapshot") @db.VarChar(500)
issuedById BigInt @map("issued_by") @db.UnsignedBigInt
issuedAt DateTime @map("issued_at") @db.DateTime(3)
revokedById BigInt? @map("revoked_by") @db.UnsignedBigInt
revokedRequestId String? @unique @map("revoked_request_id") @db.Char(36)
revokedFingerprint String? @map("revoked_fingerprint") @db.Char(64)
revokeReasonEncrypted String? @map("revoke_reason_encrypted") @db.Text
revokedAt DateTime? @map("revoked_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
donation OldItemDonation @relation(fields: [donationId], references: [id], onDelete: Restrict)
config OldItemRewardConfig? @relation(fields: [configId], references: [id], onDelete: Restrict)
issuer User @relation("OldItemCertificateIssuer", fields: [issuedById], references: [id], onDelete: Restrict)
revoker User? @relation("OldItemCertificateRevoker", fields: [revokedById], references: [id], onDelete: Restrict)
@@unique([donationId, activeKey], map: "old_item_certificates_donation_active_key")
@@index([donationId, issuedAt], map: "old_item_certificates_donation_issued_idx")
@@map("old_item_certificates")
}
model OldItemPointAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
userId BigInt @unique @map("user_id") @db.UnsignedBigInt
balance Int @default(0) @db.UnsignedInt
revision Int @default(1) @db.UnsignedInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
entries OldItemPointEntry[]
@@map("old_item_point_accounts")
}
model OldItemPointEntry {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
accountId BigInt @map("account_id") @db.UnsignedBigInt
userId BigInt @map("user_id") @db.UnsignedBigInt
donationId BigInt? @map("donation_id") @db.UnsignedBigInt
configId BigInt? @map("config_id") @db.UnsignedBigInt
requestId String @unique @map("request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
sourceKey String @unique @map("source_key") @db.VarChar(100)
entryType OldItemPointEntryType @map("entry_type")
pointsDelta Int @map("points_delta")
balanceAfter Int @map("balance_after") @db.UnsignedInt
noteEncrypted String? @map("note_encrypted") @db.Text
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
account OldItemPointAccount @relation(fields: [accountId], references: [id], onDelete: Restrict)
user User @relation("OldItemPointOwner", fields: [userId], references: [id], onDelete: Restrict)
donation OldItemDonation? @relation(fields: [donationId], references: [id], onDelete: Restrict)
config OldItemRewardConfig? @relation(fields: [configId], references: [id], onDelete: Restrict)
operator User @relation("OldItemPointOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
@@index([userId, createdAt], map: "old_item_point_entries_user_created_idx")
@@index([donationId], map: "old_item_point_entries_donation_idx")
@@map("old_item_point_entries")
}
model OldItemInventoryImport {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
clientRequestId String @map("client_request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(512)
rowCount Int @map("row_count") @db.UnsignedInt
createdById BigInt @map("created_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
creator User @relation("OldItemInventoryImportCreator", fields: [createdById], references: [id], onDelete: Restrict)
items OldItemInventoryItem[]
@@unique([createdById, clientRequestId], map: "old_item_inventory_import_creator_request_key")
@@map("old_item_inventory_imports")
}
model OldItemInventoryItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
inventoryNo String @unique @map("inventory_no") @db.VarChar(32)
sourceDonationId BigInt? @unique @map("source_donation_id") @db.UnsignedBigInt
importId BigInt? @map("import_id") @db.UnsignedBigInt
category OldItemCategory
itemName String @map("item_name") @db.VarChar(160)
quantity Int @db.UnsignedInt
unit String @default("件") @db.VarChar(20)
storageLocation String @map("storage_location") @db.VarChar(160)
status OldItemInventoryStatus @default(IN_STOCK)
targetNeedId BigInt? @map("target_need_id") @db.UnsignedBigInt
beneficiaryId BigInt? @map("beneficiary_id") @db.UnsignedBigInt
destinationRegion String? @map("destination_region") @db.VarChar(120)
noteEncrypted String? @map("note_encrypted") @db.Text
landingAt DateTime? @map("landing_at") @db.DateTime(3)
revision Int @default(1) @db.UnsignedInt
createdById BigInt @map("created_by") @db.UnsignedBigInt
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
sourceDonation OldItemDonation? @relation(fields: [sourceDonationId], references: [id], onDelete: Restrict)
import OldItemInventoryImport? @relation(fields: [importId], references: [id], onDelete: Restrict)
targetNeed OldItemNeed? @relation(fields: [targetNeedId], references: [id], onDelete: Restrict)
beneficiary BeneficiaryProfile? @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
creator User @relation("OldItemInventoryCreator", fields: [createdById], references: [id], onDelete: Restrict)
updater User @relation("OldItemInventoryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
events OldItemInventoryEvent[]
@@index([status, category, createdAt], map: "old_item_inventory_status_category_created_idx")
@@index([targetNeedId, status], map: "old_item_inventory_need_status_idx")
@@index([beneficiaryId, status], map: "old_item_inventory_beneficiary_status_idx")
@@map("old_item_inventory_items")
}
model OldItemInventoryEvent {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
inventoryItemId BigInt @map("inventory_item_id") @db.UnsignedBigInt
requestId String @unique @map("request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
eventType OldItemInventoryEventType @map("event_type")
fromStatus OldItemInventoryStatus? @map("from_status")
toStatus OldItemInventoryStatus @map("to_status")
locationText String? @map("location_text") @db.VarChar(160)
noteEncrypted String? @map("note_encrypted") @db.Text
eventAt DateTime @map("event_at") @db.DateTime(3)
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
inventoryItem OldItemInventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Restrict)
operator User @relation("OldItemInventoryEventOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
@@index([inventoryItemId, eventAt], map: "old_item_inventory_events_item_event_idx")
@@map("old_item_inventory_events")
}
model OldItemDonationStatusHistory {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
donationId BigInt @map("donation_id") @db.UnsignedBigInt
requestId String @unique @map("request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
fromStatus OldItemDonationStatus? @map("from_status")
toStatus OldItemDonationStatus @map("to_status")
noteEncrypted String? @map("note_encrypted") @db.Text
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
donation OldItemDonation @relation(fields: [donationId], references: [id], onDelete: Restrict)
operator User @relation("OldItemDonationStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
@@index([donationId, createdAt], map: "old_item_donation_history_donation_created_idx")
@@map("old_item_donation_status_history")
}
model OldItemDonationFile {
donationId BigInt @map("donation_id") @db.UnsignedBigInt
fileId BigInt @map("file_id") @db.UnsignedBigInt
sortOrder Int @default(0) @map("sort_order")
attachedAt DateTime @default(now()) @map("attached_at") @db.DateTime(3)
donation OldItemDonation @relation(fields: [donationId], references: [id], onDelete: Restrict)
file File @relation(fields: [fileId], references: [id], onDelete: Restrict)
@@id([donationId, fileId])
@@index([fileId], map: "old_item_donation_files_file_idx")
@@map("old_item_donation_files")
}
model ServiceRecordFile {
serviceRecordId BigInt @map("service_record_id") @db.UnsignedBigInt
fileId BigInt @map("file_id") @db.UnsignedBigInt
@@ -1158,24 +1554,24 @@ model ContentPostStatusHistory {
}
model FinancialDisclosure {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
clientRequestId String @map("client_request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
title String @db.VarChar(200)
summary String? @db.VarChar(500)
richText String @map("rich_text") @db.MediumText
status FinancialDisclosureStatus @default(DRAFT)
isPinned Boolean @default(false) @map("is_pinned")
sortOrder Int @default(0) @map("sort_order")
revision Int @default(1) @db.UnsignedInt
publishedAt DateTime? @map("published_at") @db.DateTime(3)
createdById BigInt @map("created_by") @db.UnsignedBigInt
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
creator User @relation("FinancialDisclosureCreator", fields: [createdById], references: [id], onDelete: Restrict)
updater User @relation("FinancialDisclosureUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
clientRequestId String @map("client_request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
title String @db.VarChar(200)
summary String? @db.VarChar(500)
richText String @map("rich_text") @db.MediumText
status FinancialDisclosureStatus @default(DRAFT)
isPinned Boolean @default(false) @map("is_pinned")
sortOrder Int @default(0) @map("sort_order")
revision Int @default(1) @db.UnsignedInt
publishedAt DateTime? @map("published_at") @db.DateTime(3)
createdById BigInt @map("created_by") @db.UnsignedBigInt
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
creator User @relation("FinancialDisclosureCreator", fields: [createdById], references: [id], onDelete: Restrict)
updater User @relation("FinancialDisclosureUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
statusHistory FinancialDisclosureStatusHistory[]
@@unique([createdById, clientRequestId], map: "financial_disclosures_creator_request_key")
@@ -1185,16 +1581,16 @@ model FinancialDisclosure {
}
model FinancialDisclosureStatusHistory {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
financialDisclosureId BigInt @map("financial_disclosure_id") @db.UnsignedBigInt
requestId String @unique @map("request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
fromStatus FinancialDisclosureStatus? @map("from_status")
toStatus FinancialDisclosureStatus @map("to_status")
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
financialDisclosure FinancialDisclosure @relation(fields: [financialDisclosureId], references: [id], onDelete: Restrict)
operator User @relation("FinancialDisclosureStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
financialDisclosureId BigInt @map("financial_disclosure_id") @db.UnsignedBigInt
requestId String @unique @map("request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
fromStatus FinancialDisclosureStatus? @map("from_status")
toStatus FinancialDisclosureStatus @map("to_status")
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
financialDisclosure FinancialDisclosure @relation(fields: [financialDisclosureId], references: [id], onDelete: Restrict)
operator User @relation("FinancialDisclosureStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
@@index([financialDisclosureId, createdAt], map: "financial_disclosure_history_document_idx")
@@index([operatorUserId, createdAt], map: "financial_disclosure_history_operator_idx")
+2
View File
@@ -26,6 +26,7 @@ import { RequestMetricsService } from './common/request-metrics.service';
import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
import { SystemMetricsController } from './common/system-metrics.controller';
import { FinancialDisclosuresModule } from './financial-disclosures/financial-disclosures.module';
import { OldItemsModule } from './old-items/old-items.module';
@Module({
imports: [
@@ -44,6 +45,7 @@ import { FinancialDisclosuresModule } from './financial-disclosures/financial-di
ContentNotificationsModule,
StatisticsModule,
FinancialDisclosuresModule,
OldItemsModule,
],
controllers: [HealthController, SystemMetricsController],
providers: [
+191
View File
@@ -0,0 +1,191 @@
import { Type } from 'class-transformer';
import {
IsArray,
ArrayMaxSize,
ArrayMinSize,
IsDateString,
IsIn,
IsInt,
IsMobilePhone,
IsOptional,
IsString,
IsUUID,
Length,
Max,
Matches,
Min,
ValidateNested,
} from 'class-validator';
import {
OLD_ITEM_CATEGORIES,
OLD_ITEM_CONDITIONS,
OLD_ITEM_DONATION_METHODS,
OLD_ITEM_DONATION_STATUSES,
OLD_ITEM_NEED_STATUSES,
OLD_ITEM_INVENTORY_STATUSES,
} from '@lehuo/contracts';
import { PaginationDto } from '../../case-management/dto/case-management.dto';
export class OldItemDonationQueryDto extends PaginationDto {
@IsOptional() @IsIn(OLD_ITEM_DONATION_STATUSES) status?: string;
@IsOptional() @IsIn(OLD_ITEM_DONATION_METHODS) method?: string;
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
}
export class OldItemNeedQueryDto extends PaginationDto {
@IsOptional() @IsIn(OLD_ITEM_CATEGORIES) category?: string;
@IsOptional() @IsIn(OLD_ITEM_NEED_STATUSES) status?: string;
}
export class CreateOldItemDonationDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsIn(OLD_ITEM_CONDITIONS) itemCondition!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(9999) quantity!: number;
@IsString() @Length(2, 1000) description!: string;
@IsIn(OLD_ITEM_DONATION_METHODS) method!: 'PICKUP' | 'DROP_OFF';
@IsString() @Length(2, 50) contactName!: string;
@IsMobilePhone('zh-CN') mobile!: string;
@IsOptional() @IsString() @Length(5, 500) address?: string;
@IsOptional() @IsDateString() appointmentAt?: string;
@IsOptional() @IsUUID('4') dropOffPointPublicId?: string;
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
@IsOptional() @IsArray() @IsUUID('4', { each: true }) imagePublicIds?: string[];
}
export class CreateOldItemMediaUploadIntentDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['OLD_ITEM_DONATION_IMAGE']) businessType!: 'OLD_ITEM_DONATION_IMAGE';
@IsString() @Length(1, 255) filename!: string;
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!:
'image/jpeg' | 'image/png' | 'image/webp';
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
}
export class TransitionOldItemDonationDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsIn(OLD_ITEM_DONATION_STATUSES) targetStatus!: string;
@IsOptional() @IsString() @Length(1, 500) note?: string;
@IsOptional() @IsUUID('4') assignedUserPublicId?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(999999) assistedPeople?: number;
}
export class CreateOldItemNeedDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(2, 160) title!: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsString() @Length(2, 120) regionText!: string;
@IsString() @Length(2, 160) beneficiaryGroup!: string;
@IsString() @Length(2, 2000) description!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(999999) requestedQuantity!: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) priority?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
}
export class UpdateOldItemNeedDto {
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsString() @Length(2, 160) title!: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsString() @Length(2, 120) regionText!: string;
@IsString() @Length(2, 160) beneficiaryGroup!: string;
@IsString() @Length(2, 2000) description!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(999999) requestedQuantity!: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) priority?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
}
export class TransitionOldItemNeedDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsIn(OLD_ITEM_NEED_STATUSES) targetStatus!: string;
}
export class CreateOldItemDropOffPointDto {
@IsString() @Length(2, 120) name!: string;
@IsString() @Length(2, 120) regionText!: string;
@IsString() @Length(5, 300) addressText!: string;
@IsString() @Length(2, 200) openingHours!: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
}
export class OldItemInventoryQueryDto extends PaginationDto {
@IsOptional() @IsIn(OLD_ITEM_INVENTORY_STATUSES) status?: string;
@IsOptional() @IsIn(OLD_ITEM_CATEGORIES) category?: string;
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
}
export class CreateOldItemInventoryDto {
@IsUUID('4') clientRequestId!: string;
@IsOptional() @IsUUID('4') sourceDonationPublicId?: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsString() @Length(1, 160) itemName!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(999999) quantity!: number;
@IsString() @Length(1, 20) unit!: string;
@IsString() @Length(2, 160) storageLocation!: string;
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
@IsOptional() @IsString() @Length(1, 1000) note?: string;
@IsOptional() @IsDateString() inboundAt?: string;
}
export class OldItemInventoryImportRowDto {
@IsOptional() @IsString() @Length(1, 32) sourceDonationNo?: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsString() @Length(1, 160) itemName!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(999999) quantity!: number;
@IsString() @Length(1, 20) unit!: string;
@IsString() @Length(2, 160) storageLocation!: string;
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
@IsOptional() @IsString() @Length(1, 1000) note?: string;
@IsOptional() @IsDateString() inboundAt?: string;
}
export class ImportOldItemInventoryDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(1, 255) originalFilename!: string;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => OldItemInventoryImportRowDto)
rows!: OldItemInventoryImportRowDto[];
}
export class TransitionOldItemInventoryDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsIn(OLD_ITEM_INVENTORY_STATUSES) targetStatus!: string;
@IsOptional() @IsDateString() eventAt?: string;
@IsOptional() @IsString() @Length(2, 160) locationText?: string;
@IsOptional() @IsString() @Length(1, 1000) note?: string;
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
@IsOptional() @IsUUID('4') beneficiaryPublicId?: string;
@IsOptional() @IsString() @Length(2, 120) destinationRegion?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(999999) assistedPeople?: number;
}
export class ActivateOldItemRewardConfigDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(2, 120) certificateTitle!: string;
@IsString() @Length(10, 1000) certificateMessage!: string;
@IsString() @Length(2, 120) issuerName!: string;
@IsString() @Length(10, 500) legalNotice!: string;
@Type(() => Number) @IsInt() @Min(0) @Max(1000) clothingPoints!: number;
@Type(() => Number) @IsInt() @Min(0) @Max(1000) booksPoints!: number;
@Type(() => Number) @IsInt() @Min(0) @Max(1000) householdPoints!: number;
@Type(() => Number) @IsInt() @Min(0) @Max(1000) sportsPoints!: number;
@Type(() => Number) @IsInt() @Min(0) @Max(100000) maxPointsPerDonation!: number;
}
export class IssueOldItemCertificateDto {
@IsUUID('4') clientRequestId!: string;
}
export class RevokeOldItemCertificateDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(2, 500) reason!: string;
}
export class AdjustOldItemPointsDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@Type(() => Number) @IsInt() @Min(-100000) @Max(100000) pointsDelta!: number;
@IsString() @Length(2, 500) reason!: string;
}
export class OldItemPointAccountQueryDto extends PaginationDto {
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
}
@@ -0,0 +1,634 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { API_ERROR_CODES, PERMISSION_CODES, type OldItemInventoryItem } from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
CreateOldItemInventoryDto,
ImportOldItemInventoryDto,
OldItemInventoryImportRowDto,
OldItemInventoryQueryDto,
TransitionOldItemInventoryDto,
} from './dto/old-items.dto';
import { OldItemRewardsService } from './old-item-rewards.service';
const TRANSITIONS: Record<string, string[]> = {
IN_STOCK: ['SORTED'],
SORTED: ['DISINFECTED'],
DISINFECTED: ['OUTBOUND'],
OUTBOUND: ['DELIVERED'],
DELIVERED: [],
};
const EVENT_BY_STATUS = {
IN_STOCK: 'INBOUND',
SORTED: 'SORTED',
DISINFECTED: 'DISINFECTED',
OUTBOUND: 'OUTBOUND',
DELIVERED: 'DELIVERED',
} as const;
const INCLUDE = {
sourceDonation: { select: { publicId: true, donationNo: true, status: true, revision: true } },
targetNeed: { select: { publicId: true, title: true } },
beneficiary: { select: { publicId: true } },
events: {
include: { operator: { include: { staffProfile: true } } },
orderBy: { eventAt: 'asc' as const },
},
} satisfies Prisma.OldItemInventoryItemInclude;
type InventoryWithRelations = Prisma.OldItemInventoryItemGetPayload<{ include: typeof INCLUDE }>;
@Injectable()
export class OldItemInventoryService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
) {}
async list(q: OldItemInventoryQueryDto) {
const where: Prisma.OldItemInventoryItemWhereInput = {
...(q.status ? { status: q.status as never } : {}),
...(q.category ? { category: q.category as never } : {}),
...(q.keyword
? {
OR: [
{ inventoryNo: { contains: q.keyword } },
{ itemName: { contains: q.keyword } },
{ sourceDonation: { donationNo: { contains: q.keyword } } },
],
}
: {}),
};
const [total, items] = await this.prisma.$transaction([
this.prisma.oldItemInventoryItem.count({ where }),
this.prisma.oldItemInventoryItem.findMany({
where,
include: INCLUDE,
orderBy: { createdAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: items.map((item) => this.map(item)),
page: q.page,
pageSize: q.pageSize,
total,
};
}
async get(publicId: string, actor: AuthContext, req: RequestContext) {
const item = await this.find(publicId);
await this.audit.record(req, {
action: 'OLD_ITEM_INVENTORY_VIEWED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY',
resourcePublicId: publicId,
});
return { item: this.map(item) };
}
async create(input: CreateOldItemInventoryDto, actor: AuthContext, req: RequestContext) {
const fp = this.fingerprint('old-item-inventory-create', input);
const replay = await this.prisma.oldItemInventoryEvent.findUnique({
where: { requestId: input.clientRequestId },
include: { inventoryItem: { include: INCLUDE } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { item: this.map(replay.inventoryItem), idempotentReplay: true };
}
const created = await this.prisma.$transaction(async (tx) => {
const links = await this.resolveLinks(
tx,
input.sourceDonationPublicId,
undefined,
input.targetNeedPublicId,
);
const eventAt = this.eventAt(input.inboundAt);
const item = await tx.oldItemInventoryItem.create({
data: {
publicId: randomUUID(),
inventoryNo: this.inventoryNo(),
sourceDonationId: links.donation?.id,
category: input.category as never,
itemName: input.itemName.trim(),
quantity: input.quantity,
unit: input.unit.trim(),
storageLocation: input.storageLocation.trim(),
targetNeedId: links.need?.id,
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await tx.oldItemInventoryEvent.create({
data: {
inventoryItemId: item.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
eventType: 'INBOUND',
toStatus: 'IN_STOCK',
locationText: input.storageLocation.trim(),
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
eventAt,
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_INVENTORY_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY',
resourcePublicId: item.publicId,
metadata: { category: input.category, quantity: input.quantity, source: 'MANUAL' },
},
tx,
);
return tx.oldItemInventoryItem.findUniqueOrThrow({
where: { id: item.id },
include: INCLUDE,
});
});
return { item: this.map(created), idempotentReplay: false };
}
async import(input: ImportOldItemInventoryDto, actor: AuthContext, req: RequestContext) {
const fp = this.fingerprint('old-item-inventory-import', input);
const replay = await this.prisma.oldItemInventoryImport.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
include: { items: { include: INCLUDE, orderBy: { createdAt: 'asc' } } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return {
importPublicId: replay.publicId,
items: replay.items.map((x) => this.map(x)),
idempotentReplay: true,
};
}
this.validateImportDuplicates(input.rows);
const created = await this.prisma.$transaction(async (tx) => {
const batch = await tx.oldItemInventoryImport.create({
data: {
publicId: randomUUID(),
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
originalFilenameEncrypted: this.sensitive.encrypt(input.originalFilename.trim()),
rowCount: input.rows.length,
createdById: actor.userId,
},
});
const items: InventoryWithRelations[] = [];
for (const [index, row] of input.rows.entries()) {
const links = await this.resolveLinks(
tx,
undefined,
row.sourceDonationNo,
row.targetNeedPublicId,
);
const item = await this.createImportRow(
tx,
batch.id,
row,
index,
links,
actor.userId,
input.clientRequestId,
fp,
);
items.push(item);
}
await this.audit.record(
req,
{
action: 'OLD_ITEM_INVENTORY_IMPORTED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY_IMPORT',
resourcePublicId: batch.publicId,
metadata: { rowCount: input.rows.length },
},
tx,
);
return { batch, items };
});
return {
importPublicId: created.batch.publicId,
items: created.items.map((x) => this.map(x)),
idempotentReplay: false,
};
}
async transition(
publicId: string,
input: TransitionOldItemInventoryDto,
actor: AuthContext,
req: RequestContext,
) {
const fp = this.fingerprint('old-item-inventory-transition', { publicId, ...input });
const replay = await this.prisma.oldItemInventoryEvent.findUnique({
where: { requestId: input.clientRequestId },
include: { inventoryItem: { include: INCLUDE } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { item: this.map(replay.inventoryItem), idempotentReplay: true };
}
const current = await this.find(publicId);
if (current.revision !== input.expectedRevision) throw this.conflict();
if (!TRANSITIONS[current.status]?.includes(input.targetStatus)) throw this.invalid();
if (
input.targetStatus === 'OUTBOUND' &&
!input.destinationRegion &&
!input.targetNeedPublicId &&
!input.beneficiaryPublicId
)
throw this.validation('出库时必须绑定物资需求、受助对象或帮扶区域之一');
if (input.targetStatus === 'DELIVERED' && input.assistedPeople === undefined)
throw this.validation('帮扶落地时必须填写实际帮扶人数');
const eventAt = this.eventAt(input.eventAt);
const previousAt = current.events.at(-1)?.eventAt;
if (previousAt && eventAt < previousAt) throw this.validation('流转时间不能早于上一节点');
const updated = await this.prisma.$transaction(async (tx) => {
const links = await this.resolveDestination(tx, current, input, actor);
const changed = await tx.oldItemInventoryItem.updateMany({
where: { id: current.id, revision: input.expectedRevision, status: current.status },
data: {
status: input.targetStatus as never,
revision: { increment: 1 },
updatedById: actor.userId,
...(input.locationText ? { storageLocation: input.locationText.trim() } : {}),
...(input.targetStatus === 'OUTBOUND'
? {
targetNeedId: links.needId,
beneficiaryId: links.beneficiaryId,
destinationRegion: input.destinationRegion?.trim() ?? current.destinationRegion,
}
: {}),
...(input.targetStatus === 'DELIVERED' ? { landingAt: eventAt } : {}),
...(input.note ? { noteEncrypted: this.sensitive.encrypt(input.note.trim()) } : {}),
},
});
if (changed.count !== 1) throw this.conflict();
await tx.oldItemInventoryEvent.create({
data: {
inventoryItemId: current.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
eventType: EVENT_BY_STATUS[input.targetStatus as keyof typeof EVENT_BY_STATUS],
fromStatus: current.status,
toStatus: input.targetStatus as never,
locationText:
input.locationText?.trim() ??
(input.targetStatus === 'OUTBOUND' ? input.destinationRegion?.trim() : null),
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
eventAt,
operatorUserId: actor.userId,
},
});
await this.syncDonation(tx, current, input, actor, req, fp, eventAt);
await this.audit.record(
req,
{
action: 'OLD_ITEM_INVENTORY_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY',
resourcePublicId: publicId,
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
},
tx,
);
return tx.oldItemInventoryItem.findUniqueOrThrow({
where: { id: current.id },
include: INCLUDE,
});
});
return { item: this.map(updated), idempotentReplay: false };
}
async export(q: OldItemInventoryQueryDto, actor: AuthContext, req: RequestContext) {
const result = await this.list({
...q,
page: 1,
pageSize: Math.min(5000, Math.max(q.pageSize, 5000)),
});
await this.audit.record(req, {
action: 'OLD_ITEM_INVENTORY_EXPORTED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY_EXPORT',
metadata: {
rowCount: result.items.length,
status: q.status ?? null,
category: q.category ?? null,
},
});
return {
rows: result.items.map((x) => ({
inventoryNo: x.inventoryNo,
sourceDonationNo: x.sourceDonation?.donationNo ?? '',
category: x.category,
itemName: x.itemName,
quantity: x.quantity,
unit: x.unit,
storageLocation: x.storageLocation,
status: x.status,
targetNeedTitle: x.targetNeed?.title ?? '',
beneficiaryPublicId: x.beneficiaryPublicId ?? '',
destinationRegion: x.destinationRegion ?? '',
landingAt: x.landingAt ?? '',
createdAt: x.createdAt,
})),
exportedAt: new Date().toISOString(),
};
}
private async createImportRow(
tx: Prisma.TransactionClient,
importId: bigint,
row: OldItemInventoryImportRowDto,
index: number,
links: { donation: { id: bigint } | null; need: { id: bigint } | null },
actorId: bigint,
requestId: string,
fp: string,
) {
const item = await tx.oldItemInventoryItem.create({
data: {
publicId: randomUUID(),
inventoryNo: this.inventoryNo(),
sourceDonationId: links.donation?.id,
importId,
category: row.category as never,
itemName: row.itemName.trim(),
quantity: row.quantity,
unit: row.unit.trim(),
storageLocation: row.storageLocation.trim(),
targetNeedId: links.need?.id,
noteEncrypted: row.note ? this.sensitive.encrypt(row.note.trim()) : null,
createdById: actorId,
updatedById: actorId,
},
});
await tx.oldItemInventoryEvent.create({
data: {
inventoryItemId: item.id,
requestId: this.derivedRequestId(requestId, index),
requestFingerprint: fp,
eventType: 'INBOUND',
toStatus: 'IN_STOCK',
locationText: row.storageLocation.trim(),
noteEncrypted: row.note ? this.sensitive.encrypt(row.note.trim()) : null,
eventAt: this.eventAt(row.inboundAt),
operatorUserId: actorId,
},
});
return tx.oldItemInventoryItem.findUniqueOrThrow({ where: { id: item.id }, include: INCLUDE });
}
private async resolveLinks(
tx: Prisma.TransactionClient,
donationPublicId?: string,
donationNo?: string,
needPublicId?: string,
) {
const [donation, need] = await Promise.all([
donationPublicId || donationNo
? tx.oldItemDonation.findFirst({
where: donationPublicId ? { publicId: donationPublicId } : { donationNo },
})
: null,
needPublicId ? tx.oldItemNeed.findUnique({ where: { publicId: needPublicId } }) : null,
]);
if ((donationPublicId || donationNo) && !donation) throw this.validation('来源捐赠编号不存在');
if (donation && !['RECEIVED', 'PROCESSING', 'COMPLETED'].includes(donation.status))
throw this.validation('来源捐赠尚未接收,不能入库');
if (donation) {
const existing = await tx.oldItemInventoryItem.findUnique({
where: { sourceDonationId: donation.id },
select: { publicId: true },
});
if (existing) throw this.validation('来源捐赠已完成入库,不能重复绑定');
}
if (needPublicId && !need) throw this.validation('定向物资需求不存在');
return { donation, need };
}
private async resolveDestination(
tx: Prisma.TransactionClient,
current: InventoryWithRelations,
input: TransitionOldItemInventoryDto,
actor: AuthContext,
) {
const need = input.targetNeedPublicId
? await tx.oldItemNeed.findUnique({ where: { publicId: input.targetNeedPublicId } })
: null;
if (
input.beneficiaryPublicId &&
!actor.permissions.includes(PERMISSION_CODES.BENEFICIARIES_READ) &&
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
)
throw new ApplicationError(
API_ERROR_CODES.FORBIDDEN,
'无权绑定受助对象',
HttpStatus.FORBIDDEN,
);
const beneficiary = input.beneficiaryPublicId
? await tx.beneficiaryProfile.findFirst({
where: { publicId: input.beneficiaryPublicId, status: 'ACTIVE' },
})
: null;
if (input.targetNeedPublicId && !need) throw this.validation('物资需求不存在');
if (input.beneficiaryPublicId && !beneficiary) throw this.validation('受助对象不存在或不可用');
return {
needId: need?.id ?? (input.targetNeedPublicId ? null : current.targetNeedId),
beneficiaryId: beneficiary?.id ?? (input.beneficiaryPublicId ? null : current.beneficiaryId),
};
}
private async syncDonation(
tx: Prisma.TransactionClient,
current: InventoryWithRelations,
input: TransitionOldItemInventoryDto,
actor: AuthContext,
req: RequestContext,
fp: string,
eventAt: Date,
) {
const donation = current.sourceDonation;
if (
input.targetStatus === 'DELIVERED' &&
current.targetNeedId &&
(!donation || donation.status !== 'COMPLETED')
) {
const need = await tx.oldItemNeed.findUniqueOrThrow({
where: { id: current.targetNeedId },
});
const fulfilled = Math.min(need.requestedQuantity, need.fulfilledQuantity + current.quantity);
await tx.oldItemNeed.update({
where: { id: need.id },
data: {
fulfilledQuantity: fulfilled,
...(fulfilled >= need.requestedQuantity && need.status === 'PUBLISHED'
? { status: 'FULFILLED', revision: { increment: 1 } }
: {}),
},
});
}
if (!donation) return;
if (input.targetStatus === 'SORTED' && donation.status === 'RECEIVED') {
await tx.oldItemDonation.update({
where: { id: current.sourceDonationId! },
data: { status: 'PROCESSING', revision: { increment: 1 } },
});
await tx.oldItemDonationStatusHistory.create({
data: {
donationId: current.sourceDonationId!,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: 'RECEIVED',
toStatus: 'PROCESSING',
operatorUserId: actor.userId,
createdAt: eventAt,
},
});
}
if (input.targetStatus === 'DELIVERED' && donation.status !== 'COMPLETED') {
const allowed = donation.status === 'PROCESSING' || donation.status === 'RECEIVED';
if (!allowed) throw this.invalid();
await tx.oldItemDonation.update({
where: { id: current.sourceDonationId! },
data: {
status: 'COMPLETED',
revision: { increment: 1 },
completedAt: eventAt,
assistedPeople: input.assistedPeople ?? 0,
},
});
await tx.oldItemDonationStatusHistory.create({
data: {
donationId: current.sourceDonationId!,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: donation.status,
toStatus: 'COMPLETED',
operatorUserId: actor.userId,
createdAt: eventAt,
},
});
await this.rewards.grantOnCompletion(
tx,
current.sourceDonationId!,
input.clientRequestId,
eventAt,
actor,
req,
);
}
}
private async find(publicId: string) {
const item = await this.prisma.oldItemInventoryItem.findUnique({
where: { publicId },
include: INCLUDE,
});
if (!item)
throw new ApplicationError(
API_ERROR_CODES.RESOURCE_NOT_FOUND,
'未找到该物资台账',
HttpStatus.NOT_FOUND,
);
return item;
}
private map(x: InventoryWithRelations): OldItemInventoryItem {
return {
publicId: x.publicId,
inventoryNo: x.inventoryNo,
sourceDonation: x.sourceDonation
? { publicId: x.sourceDonation.publicId, donationNo: x.sourceDonation.donationNo }
: null,
category: x.category,
itemName: x.itemName,
quantity: x.quantity,
unit: x.unit,
storageLocation: x.storageLocation,
status: x.status,
targetNeed: x.targetNeed,
beneficiaryPublicId: x.beneficiary?.publicId ?? null,
destinationRegion: x.destinationRegion,
note: x.noteEncrypted ? this.sensitive.decrypt(x.noteEncrypted) : null,
landingAt: x.landingAt?.toISOString() ?? null,
revision: x.revision,
createdAt: x.createdAt.toISOString(),
events: x.events.map((e) => ({
eventType: e.eventType,
fromStatus: e.fromStatus,
status: e.toStatus,
locationText: e.locationText,
note: e.noteEncrypted ? this.sensitive.decrypt(e.noteEncrypted) : null,
operatorName: e.operator.staffProfile?.displayName ?? e.operator.nickname ?? '工作人员',
eventAt: e.eventAt.toISOString(),
})),
};
}
private validateImportDuplicates(rows: OldItemInventoryImportRowDto[]) {
const numbers = rows.map((x) => x.sourceDonationNo).filter((x): x is string => Boolean(x));
if (new Set(numbers).size !== numbers.length)
throw this.validation('同一导入文件不能重复绑定来源捐赠编号');
}
private eventAt(value?: string) {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime()) || date.getTime() > Date.now() + 300_000)
throw this.validation('流转时间无效或晚于当前时间');
return date;
}
private inventoryNo() {
return `INV-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}-${randomBytes(5).toString('hex').toUpperCase()}`;
}
private derivedRequestId(requestId: string, index: number) {
const hex = createHash('sha256').update(`${requestId}:${index}`).digest('hex').slice(0, 32);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
private fingerprint(domain: string, value: object) {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private validation(message: string) {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private duplicate() {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
private conflict() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'台账已被更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private invalid() {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
'当前台账状态不能执行该操作',
HttpStatus.CONFLICT,
);
}
}
@@ -0,0 +1,658 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
OLD_ITEM_CATEGORY_LABELS,
type OldItemCertificateItem,
type OldItemPointAccountItem,
type OldItemPointEntryItem,
type OldItemRewardConfig,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { OldItemCategory, Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
ActivateOldItemRewardConfigDto,
AdjustOldItemPointsDto,
IssueOldItemCertificateDto,
OldItemPointAccountQueryDto,
RevokeOldItemCertificateDto,
} from './dto/old-items.dto';
const DEFAULT_CONFIG: OldItemRewardConfig = {
publicId: null,
version: 0,
certificateTitle: '爱心捐赠纪念证书',
certificateMessage:
'感谢 {recipient} 完成公益编号 {donationNo} 的旧物捐赠,共捐赠 {quantity} 件{category}物资。您的善意让闲置物品重新拥有温度。',
issuerName: '乐活公益',
legalNotice: '本证书仅用于记录公益参与,不作为捐赠收据、税务凭证或任何财产权利证明。',
clothingPoints: 1,
booksPoints: 1,
householdPoints: 1,
sportsPoints: 1,
maxPointsPerDonation: 500,
createdAt: null,
};
const ALLOWED_PLACEHOLDERS = new Set([
'recipient',
'donationNo',
'quantity',
'category',
'completedDate',
]);
const CERTIFICATE_INCLUDE = { donation: true } satisfies Prisma.OldItemCertificateInclude;
type CertificateWithDonation = Prisma.OldItemCertificateGetPayload<{
include: typeof CERTIFICATE_INCLUDE;
}>;
@Injectable()
export class OldItemRewardsService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
) {}
async config() {
const active = await this.prisma.oldItemRewardConfig.findUnique({
where: { activeKey: 'ACTIVE' },
});
return { config: active ? this.mapConfig(active) : DEFAULT_CONFIG, usesDefault: !active };
}
async activateConfig(
input: ActivateOldItemRewardConfigDto,
actor: AuthContext,
req: RequestContext,
) {
this.validateTemplate(input.certificateMessage);
const fp = this.fingerprint('old-item-reward-config', input);
const replay = await this.prisma.oldItemRewardConfig.findUnique({
where: { clientRequestId: input.clientRequestId },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { config: this.mapConfig(replay), idempotentReplay: true };
}
const config = await this.prisma.$transaction(async (tx) => {
const latest = await tx.oldItemRewardConfig.aggregate({ _max: { version: true } });
await tx.oldItemRewardConfig.updateMany({
where: { activeKey: 'ACTIVE' },
data: { activeKey: null },
});
const created = await tx.oldItemRewardConfig.create({
data: {
publicId: randomUUID(),
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
version: (latest._max.version ?? 0) + 1,
activeKey: 'ACTIVE',
certificateTitle: input.certificateTitle.trim(),
certificateMessage: input.certificateMessage.trim(),
issuerName: input.issuerName.trim(),
legalNotice: input.legalNotice.trim(),
clothingPoints: input.clothingPoints,
booksPoints: input.booksPoints,
householdPoints: input.householdPoints,
sportsPoints: input.sportsPoints,
maxPointsPerDonation: input.maxPointsPerDonation,
createdById: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_REWARD_CONFIG_ACTIVATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_REWARD_CONFIG',
resourcePublicId: created.publicId,
metadata: { version: created.version },
},
tx,
);
return created;
});
return { config: this.mapConfig(config), idempotentReplay: false };
}
async grantOnCompletion(
tx: Prisma.TransactionClient,
donationId: bigint,
completionRequestId: string,
completedAt: Date,
actor: AuthContext,
req: RequestContext,
) {
const donation = await tx.oldItemDonation.findUniqueOrThrow({
where: { id: donationId },
include: { donor: true },
});
const config = await tx.oldItemRewardConfig.findUnique({ where: { activeKey: 'ACTIVE' } });
const view = config ? this.mapConfig(config) : DEFAULT_CONFIG;
const activeCertificate = await tx.oldItemCertificate.findFirst({
where: { donationId, activeKey: 'ACTIVE' },
});
if (!activeCertificate) {
const certificate = await this.createCertificate(
tx,
donation,
config,
view,
this.derivedRequestId(completionRequestId, 'certificate'),
actor.userId,
completedAt,
);
await this.audit.record(
req,
{
action: 'OLD_ITEM_CERTIFICATE_ISSUED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_CERTIFICATE',
resourcePublicId: certificate.publicId,
metadata: { source: 'DONATION_COMPLETED' },
},
tx,
);
}
const sourceKey = `DONATION:${donation.publicId}`;
const existingPoints = await tx.oldItemPointEntry.findUnique({ where: { sourceKey } });
const points = this.points(view, donation.category, donation.quantity);
if (!existingPoints && points > 0) {
const account = await this.lockAccount(tx, donation.donorUserId);
const balanceAfter = account.balance + points;
await tx.oldItemPointAccount.update({
where: { id: account.id },
data: { balance: balanceAfter, revision: { increment: 1 } },
});
const entry = await tx.oldItemPointEntry.create({
data: {
publicId: randomUUID(),
accountId: account.id,
userId: donation.donorUserId,
donationId,
configId: config?.id,
requestId: this.derivedRequestId(completionRequestId, 'points'),
requestFingerprint: this.fingerprint('old-item-points-earned', {
donationPublicId: donation.publicId,
points,
}),
sourceKey,
entryType: 'EARNED',
pointsDelta: points,
balanceAfter,
noteEncrypted: this.sensitive.encrypt('旧物捐赠完成自动获得公益荣誉积分'),
operatorUserId: actor.userId,
createdAt: completedAt,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_POINTS_EARNED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_POINT_ENTRY',
resourcePublicId: entry.publicId,
metadata: { points, source: 'DONATION_COMPLETED' },
},
tx,
);
}
}
async myRewards(actor: AuthContext) {
const [account, certificates, entries] = await this.prisma.$transaction([
this.prisma.oldItemPointAccount.findUnique({ where: { userId: actor.userId } }),
this.prisma.oldItemCertificate.findMany({
where: { donation: { donorUserId: actor.userId } },
include: CERTIFICATE_INCLUDE,
orderBy: { issuedAt: 'desc' },
take: 50,
}),
this.prisma.oldItemPointEntry.findMany({
where: { userId: actor.userId },
include: { donation: { select: { donationNo: true } } },
orderBy: { createdAt: 'desc' },
take: 100,
}),
]);
return {
balance: account?.balance ?? 0,
certificates: certificates.map((x) => this.mapCertificate(x, true)),
entries: entries.map((x) => this.mapPointEntry(x, true)),
};
}
async myCertificate(publicId: string, actor: AuthContext) {
const certificate = await this.prisma.oldItemCertificate.findFirst({
where: { publicId, donation: { donorUserId: actor.userId } },
include: CERTIFICATE_INCLUDE,
});
if (!certificate) throw this.notFound('未找到该电子证书');
return { certificate: this.mapCertificate(certificate, true) };
}
async accounts(q: OldItemPointAccountQueryDto) {
const where: Prisma.OldItemPointAccountWhereInput = q.keyword
? {
OR: [
{ publicId: { contains: q.keyword } },
{ user: { publicId: { contains: q.keyword } } },
{ user: { nickname: { contains: q.keyword } } },
],
}
: {};
const [total, accounts] = await this.prisma.$transaction([
this.prisma.oldItemPointAccount.count({ where }),
this.prisma.oldItemPointAccount.findMany({
where,
include: { user: true },
orderBy: { updatedAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: accounts.map((x): OldItemPointAccountItem => ({
publicId: x.publicId,
userPublicId: x.user.publicId,
displayName: x.user.nickname ?? '爱心用户',
balance: x.balance,
revision: x.revision,
updatedAt: x.updatedAt.toISOString(),
})),
page: q.page,
pageSize: q.pageSize,
total,
};
}
async certificates(q: OldItemPointAccountQueryDto) {
const where: Prisma.OldItemCertificateWhereInput = q.keyword
? {
OR: [
{ certificateNo: { contains: q.keyword } },
{ donation: { donationNo: { contains: q.keyword } } },
],
}
: {};
const [total, certificates] = await this.prisma.$transaction([
this.prisma.oldItemCertificate.count({ where }),
this.prisma.oldItemCertificate.findMany({
where,
include: CERTIFICATE_INCLUDE,
orderBy: { issuedAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: certificates.map((x) => this.mapCertificate(x, false)),
page: q.page,
pageSize: q.pageSize,
total,
};
}
async issue(
donationPublicId: string,
input: IssueOldItemCertificateDto,
actor: AuthContext,
req: RequestContext,
) {
const fp = this.fingerprint('old-item-certificate-issue', { donationPublicId, ...input });
const replay = await this.prisma.oldItemCertificate.findUnique({
where: { issueRequestId: input.clientRequestId },
include: CERTIFICATE_INCLUDE,
});
if (replay) {
if (replay.issueFingerprint !== fp) throw this.duplicate();
return { certificate: this.mapCertificate(replay, false), idempotentReplay: true };
}
const donation = await this.prisma.oldItemDonation.findUnique({
where: { publicId: donationPublicId },
include: { donor: true },
});
if (!donation) throw this.notFound('未找到该捐赠记录');
if (donation.status !== 'COMPLETED' || !donation.completedAt)
throw this.invalid('捐赠完成后才能补发证书');
const certificate = await this.prisma.$transaction(async (tx) => {
const config = await tx.oldItemRewardConfig.findUnique({ where: { activeKey: 'ACTIVE' } });
const active = await tx.oldItemCertificate.findFirst({
where: { donationId: donation.id, activeKey: 'ACTIVE' },
});
if (active) {
await tx.oldItemCertificate.update({
where: { id: active.id },
data: {
activeKey: null,
status: 'REVOKED',
revokedById: actor.userId,
revokedRequestId: this.derivedRequestId(input.clientRequestId, 'reissue-revoke'),
revokedFingerprint: fp,
revokeReasonEncrypted: this.sensitive.encrypt('因后台补发新证书自动撤销旧证书'),
revokedAt: new Date(),
},
});
}
const created = await this.createCertificate(
tx,
donation,
config,
config ? this.mapConfig(config) : DEFAULT_CONFIG,
input.clientRequestId,
actor.userId,
new Date(),
fp,
);
await this.audit.record(
req,
{
action: active ? 'OLD_ITEM_CERTIFICATE_REISSUED' : 'OLD_ITEM_CERTIFICATE_ISSUED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_CERTIFICATE',
resourcePublicId: created.publicId,
metadata: { donationPublicId, replacedCertificate: Boolean(active) },
},
tx,
);
return tx.oldItemCertificate.findUniqueOrThrow({
where: { id: created.id },
include: CERTIFICATE_INCLUDE,
});
});
return { certificate: this.mapCertificate(certificate, false), idempotentReplay: false };
}
async revoke(
publicId: string,
input: RevokeOldItemCertificateDto,
actor: AuthContext,
req: RequestContext,
) {
const fp = this.fingerprint('old-item-certificate-revoke', { publicId, ...input });
const replay = await this.prisma.oldItemCertificate.findUnique({
where: { revokedRequestId: input.clientRequestId },
include: CERTIFICATE_INCLUDE,
});
if (replay) {
if (replay.revokedFingerprint !== fp) throw this.duplicate();
return { certificate: this.mapCertificate(replay, false), idempotentReplay: true };
}
const current = await this.prisma.oldItemCertificate.findUnique({
where: { publicId },
include: CERTIFICATE_INCLUDE,
});
if (!current) throw this.notFound('未找到该电子证书');
if (current.status !== 'ISSUED') throw this.invalid('该证书已撤销');
const certificate = await this.prisma.$transaction(async (tx) => {
const changed = await tx.oldItemCertificate.updateMany({
where: { id: current.id, status: 'ISSUED' },
data: {
activeKey: null,
status: 'REVOKED',
revokedById: actor.userId,
revokedRequestId: input.clientRequestId,
revokedFingerprint: fp,
revokeReasonEncrypted: this.sensitive.encrypt(input.reason.trim()),
revokedAt: new Date(),
},
});
if (changed.count !== 1) throw this.conflict();
const updated = await tx.oldItemCertificate.findUniqueOrThrow({
where: { id: current.id },
include: CERTIFICATE_INCLUDE,
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_CERTIFICATE_REVOKED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_CERTIFICATE',
resourcePublicId: publicId,
},
tx,
);
return updated;
});
return { certificate: this.mapCertificate(certificate, false), idempotentReplay: false };
}
async adjust(
userPublicId: string,
input: AdjustOldItemPointsDto,
actor: AuthContext,
req: RequestContext,
) {
if (input.pointsDelta === 0) throw this.validation('积分调整值不能为 0');
const fp = this.fingerprint('old-item-points-adjust', { userPublicId, ...input });
const replay = await this.prisma.oldItemPointEntry.findUnique({
where: { requestId: input.clientRequestId },
include: { donation: { select: { donationNo: true } } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { entry: this.mapPointEntry(replay, false), idempotentReplay: true };
}
const user = await this.prisma.user.findUnique({ where: { publicId: userPublicId } });
if (!user) throw this.notFound('未找到该用户');
const entry = await this.prisma.$transaction(async (tx) => {
const account = await this.lockAccount(tx, user.id);
if (account.revision !== input.expectedRevision) throw this.conflict();
const balanceAfter = account.balance + input.pointsDelta;
if (balanceAfter < 0) throw this.validation('扣减后积分不能小于 0');
const changed = await tx.oldItemPointAccount.updateMany({
where: { id: account.id, revision: input.expectedRevision },
data: { balance: balanceAfter, revision: { increment: 1 } },
});
if (changed.count !== 1) throw this.conflict();
const created = await tx.oldItemPointEntry.create({
data: {
publicId: randomUUID(),
accountId: account.id,
userId: user.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
sourceKey: `ADJUSTMENT:${input.clientRequestId}`,
entryType: 'ADJUSTMENT',
pointsDelta: input.pointsDelta,
balanceAfter,
noteEncrypted: this.sensitive.encrypt(input.reason.trim()),
operatorUserId: actor.userId,
},
include: { donation: { select: { donationNo: true } } },
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_POINTS_ADJUSTED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_POINT_ENTRY',
resourcePublicId: created.publicId,
metadata: { pointsDelta: input.pointsDelta },
},
tx,
);
return created;
});
return { entry: this.mapPointEntry(entry, false), idempotentReplay: false };
}
private async createCertificate(
tx: Prisma.TransactionClient,
donation: Prisma.OldItemDonationGetPayload<{ include: { donor: true } }>,
config: Prisma.OldItemRewardConfigGetPayload<object> | null,
view: OldItemRewardConfig,
requestId: string,
actorId: bigint,
issuedAt: Date,
fingerprint?: string,
) {
const recipient = donation.donor.nickname?.trim() || '爱心用户';
return tx.oldItemCertificate.create({
data: {
publicId: randomUUID(),
certificateNo: this.certificateNo(),
donationId: donation.id,
configId: config?.id,
issueRequestId: requestId,
issueFingerprint:
fingerprint ??
this.fingerprint('old-item-certificate-auto', {
donationPublicId: donation.publicId,
requestId,
}),
activeKey: 'ACTIVE',
recipientNameEncrypted: this.sensitive.encrypt(recipient),
titleSnapshot: view.certificateTitle,
messageSnapshot: this.render(view.certificateMessage, {
recipient,
donationNo: donation.donationNo,
quantity: String(donation.quantity),
category: OLD_ITEM_CATEGORY_LABELS[donation.category],
completedDate: (donation.completedAt ?? issuedAt).toISOString().slice(0, 10),
}),
issuerSnapshot: view.issuerName,
legalNoticeSnapshot: view.legalNotice,
issuedById: actorId,
issuedAt,
},
});
}
private async lockAccount(tx: Prisma.TransactionClient, userId: bigint) {
await tx.oldItemPointAccount.upsert({
where: { userId },
update: {},
create: { publicId: randomUUID(), userId },
});
await tx.$queryRaw`SELECT id FROM old_item_point_accounts WHERE user_id = ${userId} FOR UPDATE`;
return tx.oldItemPointAccount.findUniqueOrThrow({ where: { userId } });
}
private points(config: OldItemRewardConfig, category: OldItemCategory, quantity: number) {
const perItem = {
CLOTHING: config.clothingPoints,
BOOKS: config.booksPoints,
HOUSEHOLD: config.householdPoints,
SPORTS: config.sportsPoints,
}[category];
return Math.min(config.maxPointsPerDonation, perItem * quantity);
}
private mapConfig(x: Prisma.OldItemRewardConfigGetPayload<object>): OldItemRewardConfig {
return {
publicId: x.publicId,
version: x.version,
certificateTitle: x.certificateTitle,
certificateMessage: x.certificateMessage,
issuerName: x.issuerName,
legalNotice: x.legalNotice,
clothingPoints: x.clothingPoints,
booksPoints: x.booksPoints,
householdPoints: x.householdPoints,
sportsPoints: x.sportsPoints,
maxPointsPerDonation: x.maxPointsPerDonation,
createdAt: x.createdAt.toISOString(),
};
}
private mapCertificate(
x: CertificateWithDonation,
revealRecipient: boolean,
): OldItemCertificateItem {
return {
publicId: x.publicId,
certificateNo: x.certificateNo,
donationPublicId: x.donation.publicId,
donationNo: x.donation.donationNo,
status: x.status,
recipientName: revealRecipient
? this.sensitive.decrypt(x.recipientNameEncrypted)
: '爱心用户',
title: x.titleSnapshot,
message: x.messageSnapshot,
issuerName: x.issuerSnapshot,
legalNotice: x.legalNoticeSnapshot,
issuedAt: x.issuedAt.toISOString(),
revokedAt: x.revokedAt?.toISOString() ?? null,
revokeReason:
revealRecipient && x.revokeReasonEncrypted
? this.sensitive.decrypt(x.revokeReasonEncrypted)
: null,
};
}
private mapPointEntry(
x: Prisma.OldItemPointEntryGetPayload<{
include: { donation: { select: { donationNo: true } } };
}>,
revealNote: boolean,
): OldItemPointEntryItem {
return {
publicId: x.publicId,
entryType: x.entryType,
pointsDelta: x.pointsDelta,
balanceAfter: x.balanceAfter,
donationNo: x.donation?.donationNo ?? null,
note: revealNote && x.noteEncrypted ? this.sensitive.decrypt(x.noteEncrypted) : null,
createdAt: x.createdAt.toISOString(),
};
}
private validateTemplate(template: string) {
for (const match of template.matchAll(/\{([^{}]+)\}/g)) {
if (!ALLOWED_PLACEHOLDERS.has(match[1]!))
throw this.validation(`证书模板包含不支持的占位符:{${match[1]}}`);
}
}
private render(template: string, values: Record<string, string>) {
return template.replace(/\{([^{}]+)\}/g, (all, key: string) => values[key] ?? all);
}
private certificateNo() {
return `CERT-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}-${randomBytes(6).toString('hex').toUpperCase()}`;
}
private derivedRequestId(requestId: string, domain: string) {
const hex = createHash('sha256').update(`${domain}:${requestId}`).digest('hex').slice(0, 32);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
private fingerprint(domain: string, value: object) {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private validation(message: string) {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private duplicate() {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
private conflict() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'积分账户已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private invalid(message: string) {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
message,
HttpStatus.CONFLICT,
);
}
private notFound(message: string) {
return new ApplicationError(API_ERROR_CODES.RESOURCE_NOT_FOUND, message, HttpStatus.NOT_FOUND);
}
}
@@ -0,0 +1,331 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Query,
Req,
ValidationPipe,
HttpStatus,
} from '@nestjs/common';
import {
API_ERROR_CODES,
PERMISSION_CODES,
type ConfirmUploadResponse,
type CreateUploadIntentResponse,
} from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import { ApplicationError } from '../common/application-error';
import { Public } from '../common/decorators/public.decorator';
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import { MediaService } from '../participation/media.service';
import {
CreateOldItemDonationDto,
CreateOldItemMediaUploadIntentDto,
CreateOldItemDropOffPointDto,
CreateOldItemNeedDto,
OldItemDonationQueryDto,
OldItemNeedQueryDto,
TransitionOldItemDonationDto,
TransitionOldItemNeedDto,
UpdateOldItemNeedDto,
CreateOldItemInventoryDto,
ImportOldItemInventoryDto,
OldItemInventoryQueryDto,
TransitionOldItemInventoryDto,
ActivateOldItemRewardConfigDto,
AdjustOldItemPointsDto,
IssueOldItemCertificateDto,
OldItemPointAccountQueryDto,
RevokeOldItemCertificateDto,
} from './dto/old-items.dto';
import { OldItemInventoryService } from './old-item-inventory.service';
import { OldItemRewardsService } from './old-item-rewards.service';
import { OldItemsService } from './old-items.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1/old-items')
export class OldItemsController {
constructor(
@Inject(OldItemsService) private readonly service: OldItemsService,
@Inject(MediaService) private readonly media: MediaService,
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
) {}
@Get('stats') @Public() stats() {
return this.service.stats();
}
@Get('needs') @Public() needs(@Query(strict(OldItemNeedQueryDto)) q: OldItemNeedQueryDto) {
return this.service.listNeeds(q);
}
@Get('needs/:publicId') @Public() need(@Param('publicId') id: string) {
return this.service.getNeed(id);
}
@Get('drop-off-points') @Public() points() {
return this.service.dropOffPoints();
}
@Post('media/upload-intents') @RequireSessionChannel(SessionChannel.WECHAT) mediaIntent(
@Body(strict(CreateOldItemMediaUploadIntentDto)) input: CreateOldItemMediaUploadIntentDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
): Promise<CreateUploadIntentResponse> {
if (input.businessType !== 'OLD_ITEM_DONATION_IMAGE')
throw new ApplicationError(
API_ERROR_CODES.VALIDATION_ERROR,
'图片业务类型无效',
HttpStatus.BAD_REQUEST,
);
return this.media.createIntent(input, actor, req);
}
@Post('media/:publicId/confirm') @RequireSessionChannel(SessionChannel.WECHAT) confirm(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
): Promise<ConfirmUploadResponse> {
return this.media.confirm(id, actor, req);
}
@Post('donations') @RequireSessionChannel(SessionChannel.WECHAT) create(
@Body(strict(CreateOldItemDonationDto)) input: CreateOldItemDonationDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.createDonation(input, actor, req);
}
@Get('me/donations') @RequireSessionChannel(SessionChannel.WECHAT) mine(
@Query(strict(OldItemDonationQueryDto)) q: OldItemDonationQueryDto,
@CurrentAuth() actor: AuthContext,
) {
return this.service.myDonations(q, actor);
}
@Get('me/donations/:publicId') @RequireSessionChannel(SessionChannel.WECHAT) mineOne(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
) {
return this.service.myDonation(id, actor);
}
@Get('me/media/:publicId/download')
@RequireSessionChannel(SessionChannel.WECHAT)
image(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.imageDownload(id, actor, req);
}
@Get('me/rewards') @RequireSessionChannel(SessionChannel.WECHAT) myRewards(
@CurrentAuth() actor: AuthContext,
) {
return this.rewards.myRewards(actor);
}
@Get('me/certificates/:publicId')
@RequireSessionChannel(SessionChannel.WECHAT)
myCertificate(@Param('publicId') id: string, @CurrentAuth() actor: AuthContext) {
return this.rewards.myCertificate(id, actor);
}
}
@Controller('api/v1/admin/old-items')
@RequireSessionChannel(SessionChannel.ADMIN)
export class AdminOldItemsController {
constructor(
@Inject(OldItemsService) private readonly service: OldItemsService,
@Inject(OldItemInventoryService) private readonly inventory: OldItemInventoryService,
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
) {}
@Get('assignees') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) assignees() {
return this.service.assignees();
}
@Get('media/:publicId/download') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) image(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.imageDownload(id, actor, req, true);
}
@Get('donations') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) donations(
@Query(strict(OldItemDonationQueryDto)) q: OldItemDonationQueryDto,
) {
return this.service.adminDonations(q);
}
@Get('donations/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) donation(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.adminDonation(id, actor, req);
}
@Post('donations/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.OLD_ITEMS_MANAGE)
transition(
@Param('publicId') id: string,
@Body(strict(TransitionOldItemDonationDto)) input: TransitionOldItemDonationDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.transitionDonation(id, input, actor, req);
}
@Get('needs') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) needs(
@Query(strict(OldItemNeedQueryDto)) q: OldItemNeedQueryDto,
) {
return this.service.listNeeds(q, true);
}
@Get('needs/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) need(
@Param('publicId') id: string,
) {
return this.service.getNeed(id, true);
}
@Post('needs') @RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE) createNeed(
@Body(strict(CreateOldItemNeedDto)) input: CreateOldItemNeedDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.createNeed(input, actor, req);
}
@Patch('needs/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE) updateNeed(
@Param('publicId') id: string,
@Body(strict(UpdateOldItemNeedDto)) input: UpdateOldItemNeedDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.updateNeed(id, input, actor, req);
}
@Post('needs/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE)
transitionNeed(
@Param('publicId') id: string,
@Body(strict(TransitionOldItemNeedDto)) input: TransitionOldItemNeedDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.transitionNeed(id, input, actor, req);
}
@Post('drop-off-points') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_MANAGE) createPoint(
@Body(strict(CreateOldItemDropOffPointDto)) input: CreateOldItemDropOffPointDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.createDropOffPoint(input, actor, req);
}
@Get('inventory')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_READ)
inventoryList(@Query(strict(OldItemInventoryQueryDto)) q: OldItemInventoryQueryDto) {
return this.inventory.list(q);
}
@Get('inventory/:publicId')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_READ)
inventoryOne(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.get(id, actor, req);
}
@Post('inventory')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_MANAGE)
inventoryCreate(
@Body(strict(CreateOldItemInventoryDto)) input: CreateOldItemInventoryDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.create(input, actor, req);
}
@Post('inventory/import')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_IMPORT)
inventoryImport(
@Body(strict(ImportOldItemInventoryDto)) input: ImportOldItemInventoryDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.import(input, actor, req);
}
@Post('inventory/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_MANAGE)
inventoryTransition(
@Param('publicId') id: string,
@Body(strict(TransitionOldItemInventoryDto)) input: TransitionOldItemInventoryDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.transition(id, input, actor, req);
}
@Get('inventory-export/file')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_EXPORT)
inventoryExport(
@Query(strict(OldItemInventoryQueryDto)) q: OldItemInventoryQueryDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.export(q, actor, req);
}
@Get('reward-config')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
rewardConfig() {
return this.rewards.config();
}
@Post('reward-config/activate')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
activateRewardConfig(
@Body(strict(ActivateOldItemRewardConfigDto)) input: ActivateOldItemRewardConfigDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.rewards.activateConfig(input, actor, req);
}
@Get('reward-accounts')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
rewardAccounts(@Query(strict(OldItemPointAccountQueryDto)) q: OldItemPointAccountQueryDto) {
return this.rewards.accounts(q);
}
@Post('reward-accounts/:userPublicId/adjust')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
adjustRewardPoints(
@Param('userPublicId') userPublicId: string,
@Body(strict(AdjustOldItemPointsDto)) input: AdjustOldItemPointsDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.rewards.adjust(userPublicId, input, actor, req);
}
@Get('certificates')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
certificates(@Query(strict(OldItemPointAccountQueryDto)) q: OldItemPointAccountQueryDto) {
return this.rewards.certificates(q);
}
@Post('donations/:publicId/certificate')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
issueCertificate(
@Param('publicId') publicId: string,
@Body(strict(IssueOldItemCertificateDto)) input: IssueOldItemCertificateDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.rewards.issue(publicId, input, actor, req);
}
@Post('certificates/:publicId/revoke')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
revokeCertificate(
@Param('publicId') publicId: string,
@Body(strict(RevokeOldItemCertificateDto)) input: RevokeOldItemCertificateDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.rewards.revoke(publicId, input, actor, req);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { ParticipationModule } from '../participation/participation.module';
import { FilesModule } from '../files/files.module';
import { AdminOldItemsController, OldItemsController } from './old-items.controller';
import { OldItemsService } from './old-items.service';
import { OldItemInventoryService } from './old-item-inventory.service';
import { OldItemRewardsService } from './old-item-rewards.service';
@Module({
imports: [AuditModule, ParticipationModule, FilesModule],
controllers: [OldItemsController, AdminOldItemsController],
providers: [OldItemsService, OldItemInventoryService, OldItemRewardsService],
})
export class OldItemsModule {}
+763
View File
@@ -0,0 +1,763 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
type OldItemDonationItem,
type OldItemNeedItem,
type OldItemStats,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
import type { OldItemNeed, Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
CreateOldItemDonationDto,
CreateOldItemDropOffPointDto,
CreateOldItemNeedDto,
OldItemDonationQueryDto,
OldItemNeedQueryDto,
TransitionOldItemDonationDto,
TransitionOldItemNeedDto,
UpdateOldItemNeedDto,
} from './dto/old-items.dto';
import { OldItemRewardsService } from './old-item-rewards.service';
const DONATION_TRANSITIONS: Record<string, string[]> = {
SUBMITTED: ['REJECTED', 'WAITING_PICKUP', 'WAITING_DROP_OFF', 'CANCELLED'],
WAITING_PICKUP: ['RECEIVED', 'CANCELLED'],
WAITING_DROP_OFF: ['RECEIVED', 'CANCELLED'],
RECEIVED: ['PROCESSING'],
PROCESSING: ['COMPLETED'],
REJECTED: [],
COMPLETED: [],
CANCELLED: [],
};
const NEED_TRANSITIONS: Record<string, string[]> = {
DRAFT: ['PUBLISHED', 'CLOSED'],
PUBLISHED: ['FULFILLED', 'CLOSED'],
FULFILLED: ['CLOSED'],
CLOSED: [],
};
const DONATION_INCLUDE = {
history: { orderBy: { createdAt: 'asc' as const } },
targetNeed: true,
dropOffPoint: true,
assignee: true,
donor: true,
files: { include: { file: true }, orderBy: { sortOrder: 'asc' as const } },
inventoryItems: {
include: { events: { orderBy: { eventAt: 'asc' as const } } },
orderBy: { createdAt: 'asc' as const },
},
} satisfies Prisma.OldItemDonationInclude;
type DonationWithRelations = Prisma.OldItemDonationGetPayload<{
include: typeof DONATION_INCLUDE;
}>;
@Injectable()
export class OldItemsService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
) {}
async assignees() {
const users = await this.prisma.user.findMany({
where: { userType: 'STAFF', status: 'ACTIVE' },
include: { staffProfile: true },
orderBy: { createdAt: 'asc' },
});
return {
items: users.map((user) => ({
publicId: user.publicId,
displayName: user.staffProfile?.displayName ?? user.nickname ?? '工作人员',
})),
};
}
async imageDownload(
publicId: string,
actor: AuthContext,
request: RequestContext,
admin = false,
) {
if (!this.storage.configured)
throw new ApplicationError(
API_ERROR_CODES.STORAGE_UNAVAILABLE,
'图片服务尚未配置或暂时不可用',
HttpStatus.SERVICE_UNAVAILABLE,
);
const file = await this.prisma.file.findFirst({
where: { publicId, status: 'AVAILABLE', businessType: 'OLD_ITEM_DONATION_IMAGE' },
include: { oldItemDonations: { include: { donation: true } } },
});
const donation = file?.oldItemDonations[0]?.donation;
if (!file || !donation || (!admin && donation.donorUserId !== actor.userId))
throw this.notFound('未找到该捐赠图片');
const expiresIn = 300;
let downloadUrl: string;
try {
downloadUrl = await this.storage.createDownloadUrl(file.objectKey, expiresIn);
} catch {
throw new ApplicationError(
API_ERROR_CODES.STORAGE_UNAVAILABLE,
'图片服务暂时不可用',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
await this.audit.record(request, {
action: 'OLD_ITEM_PRIVATE_IMAGE_URL_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'FILE',
resourcePublicId: publicId,
});
return { downloadUrl, expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() };
}
async stats(): Promise<OldItemStats> {
const month = new Date();
month.setDate(1);
month.setHours(0, 0, 0, 0);
const [items, people, acts] = await this.prisma.$transaction([
this.prisma.oldItemDonation.aggregate({
_sum: { quantity: true },
where: { status: { in: ['RECEIVED', 'PROCESSING', 'COMPLETED'] } },
}),
this.prisma.oldItemDonation.aggregate({
_sum: { assistedPeople: true },
where: { status: 'COMPLETED' },
}),
this.prisma.oldItemDonation.count({ where: { receivedAt: { gte: month } } }),
]);
return {
totalItems: items._sum.quantity ?? 0,
assistedPeople: people._sum.assistedPeople ?? 0,
monthlyKindActs: acts,
};
}
async dropOffPoints() {
return {
items: await this.prisma.oldItemDropOffPoint.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }],
select: {
publicId: true,
name: true,
regionText: true,
addressText: true,
openingHours: true,
},
}),
};
}
async listNeeds(q: OldItemNeedQueryDto, admin = false) {
const where: Prisma.OldItemNeedWhereInput = {
...(admin ? {} : { status: 'PUBLISHED' }),
...(q.status ? { status: q.status as never } : {}),
...(q.category ? { category: q.category as never } : {}),
};
const [total, items] = await this.prisma.$transaction([
this.prisma.oldItemNeed.count({ where }),
this.prisma.oldItemNeed.findMany({
where,
orderBy: [{ priority: 'desc' }, { sortOrder: 'desc' }, { publishedAt: 'desc' }],
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return { items: items.map((x) => this.need(x)), page: q.page, pageSize: q.pageSize, total };
}
async getNeed(publicId: string, admin = false) {
const x = await this.prisma.oldItemNeed.findUnique({ where: { publicId } });
if (!x || (!admin && x.status !== 'PUBLISHED')) throw this.notFound('未找到该物资需求');
return { need: this.need(x) };
}
async createDonation(input: CreateOldItemDonationDto, actor: AuthContext, req: RequestContext) {
this.validateMethod(input);
const fp = this.fingerprint('old-item-donation-create', input);
const replay = await this.prisma.oldItemDonation.findUnique({
where: {
donorUserId_clientRequestId: {
donorUserId: actor.userId,
clientRequestId: input.clientRequestId,
},
},
include: DONATION_INCLUDE,
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { donation: this.mapDonation(replay, true), idempotentReplay: true };
}
const [point, need, files] = await Promise.all([
input.dropOffPointPublicId
? this.prisma.oldItemDropOffPoint.findFirst({
where: { publicId: input.dropOffPointPublicId, isActive: true },
})
: null,
input.targetNeedPublicId
? this.prisma.oldItemNeed.findFirst({
where: { publicId: input.targetNeedPublicId, status: 'PUBLISHED' },
})
: null,
input.imagePublicIds?.length
? this.prisma.file.findMany({
where: {
publicId: { in: input.imagePublicIds },
uploaderUserId: actor.userId,
status: 'AVAILABLE',
businessType: 'OLD_ITEM_DONATION_IMAGE',
caseId: null,
},
})
: [],
]);
if (input.method === 'DROP_OFF' && !point) throw this.validation('请选择有效的定点投递地址');
if (input.targetNeedPublicId && !need) throw this.validation('定向需求已下架或不存在');
if ((input.imagePublicIds?.length ?? 0) !== files.length || files.length > 6)
throw this.validation('捐赠图片无效、未确认或超过 6 张');
const created = await this.prisma.$transaction(async (tx) => {
const donation = await tx.oldItemDonation.create({
data: {
publicId: randomUUID(),
donationNo: this.donationNo(),
donorUserId: actor.userId,
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
category: input.category as never,
itemCondition: input.itemCondition as never,
quantity: input.quantity,
descriptionEncrypted: this.sensitive.encrypt(input.description.trim()),
method: input.method,
contactNameEncrypted: this.sensitive.encrypt(input.contactName.trim()),
mobileEncrypted: this.sensitive.encrypt(input.mobile),
mobileHash: this.sensitive.lookupHash('old-item-mobile', input.mobile),
addressEncrypted: input.address ? this.sensitive.encrypt(input.address.trim()) : null,
appointmentAt: input.appointmentAt ? new Date(input.appointmentAt) : null,
dropOffPointId: point?.id,
targetNeedId: need?.id,
files: { create: files.map((f, i) => ({ fileId: f.id, sortOrder: i })) },
},
});
await tx.oldItemDonationStatusHistory.create({
data: {
donationId: donation.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
toStatus: 'SUBMITTED',
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_DONATION_SUBMITTED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_DONATION',
resourcePublicId: donation.publicId,
metadata: { category: input.category, method: input.method, quantity: input.quantity },
},
tx,
);
return tx.oldItemDonation.findUniqueOrThrow({
where: { id: donation.id },
include: DONATION_INCLUDE,
});
});
return { donation: this.mapDonation(created, true), idempotentReplay: false };
}
async myDonations(q: OldItemDonationQueryDto, actor: AuthContext) {
return this.listDonations({ ...q }, { donorUserId: actor.userId }, true);
}
async myDonation(publicId: string, actor: AuthContext) {
const x = await this.findDonation(publicId);
if (x.donorUserId !== actor.userId) throw this.notFound('未找到该捐赠记录');
return { donation: this.mapDonation(x, true) };
}
async adminDonations(q: OldItemDonationQueryDto) {
const where: Prisma.OldItemDonationWhereInput = {
...(q.status ? { status: q.status as never } : {}),
...(q.method ? { method: q.method as never } : {}),
...(q.keyword
? {
OR: [
{ donationNo: { contains: q.keyword } },
{ donor: { nickname: { contains: q.keyword } } },
],
}
: {}),
};
return this.listDonations(q, where, false);
}
async adminDonation(publicId: string, actor: AuthContext, req: RequestContext) {
const x = await this.findDonation(publicId);
await this.audit.record(req, {
action: 'OLD_ITEM_DONATION_SENSITIVE_VIEWED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_DONATION',
resourcePublicId: publicId,
});
return { donation: this.mapDonation(x, true) };
}
async transitionDonation(
publicId: string,
input: TransitionOldItemDonationDto,
actor: AuthContext,
req: RequestContext,
) {
const current = await this.findDonation(publicId),
fp = this.fingerprint('old-item-donation-transition', { publicId, ...input });
const replay = await this.prisma.oldItemDonationStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { donation: { include: DONATION_INCLUDE } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { donation: this.mapDonation(replay.donation, true), idempotentReplay: true };
}
if (current.revision !== input.expectedRevision) throw this.conflict();
if (!DONATION_TRANSITIONS[current.status]?.includes(input.targetStatus))
throw this.invalid('当前捐赠状态不能执行该操作');
if (input.targetStatus === 'WAITING_PICKUP' && current.method !== 'PICKUP')
throw this.invalid('投递订单不能进入待取件');
if (input.targetStatus === 'WAITING_DROP_OFF' && current.method !== 'DROP_OFF')
throw this.invalid('取件订单不能进入待投递');
if (input.targetStatus === 'REJECTED' && !input.note)
throw this.validation('驳回时必须填写原因');
const assignee = input.assignedUserPublicId
? await this.prisma.user.findFirst({
where: { publicId: input.assignedUserPublicId, userType: 'STAFF', status: 'ACTIVE' },
})
: null;
if (input.assignedUserPublicId && !assignee) throw this.validation('分配人员无效');
if (input.targetStatus === 'WAITING_PICKUP' && !assignee && !current.assignedUserId)
throw this.validation('上门取件审核通过时必须分配工作人员');
const updated = await this.prisma.$transaction(async (tx) => {
const transitionedAt = new Date();
const result = await tx.oldItemDonation.updateMany({
where: { id: current.id, revision: input.expectedRevision, status: current.status },
data: {
status: input.targetStatus as never,
revision: { increment: 1 },
...(assignee ? { assignedUserId: assignee.id } : {}),
...(input.targetStatus === 'REJECTED'
? { rejectionReasonEncrypted: this.sensitive.encrypt(input.note!) }
: {}),
...(input.targetStatus === 'RECEIVED' ? { receivedAt: transitionedAt } : {}),
...(input.targetStatus === 'COMPLETED'
? { completedAt: transitionedAt, assistedPeople: input.assistedPeople ?? 0 }
: {}),
},
});
if (result.count !== 1) throw this.conflict();
await tx.oldItemDonationStatusHistory.create({
data: {
donationId: current.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: current.status,
toStatus: input.targetStatus as never,
noteEncrypted: input.note ? this.sensitive.encrypt(input.note) : null,
operatorUserId: actor.userId,
},
});
if (input.targetStatus === 'COMPLETED' && current.targetNeedId) {
const need = await tx.oldItemNeed.findUniqueOrThrow({
where: { id: current.targetNeedId },
});
const fulfilled = Math.min(
need.requestedQuantity,
need.fulfilledQuantity + current.quantity,
);
await tx.oldItemNeed.update({
where: { id: need.id },
data: {
fulfilledQuantity: fulfilled,
...(fulfilled >= need.requestedQuantity && need.status === 'PUBLISHED'
? { status: 'FULFILLED', revision: { increment: 1 } }
: {}),
},
});
}
if (input.targetStatus === 'COMPLETED') {
await this.rewards.grantOnCompletion(
tx,
current.id,
input.clientRequestId,
transitionedAt,
actor,
req,
);
}
await this.audit.record(
req,
{
action: 'OLD_ITEM_DONATION_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_DONATION',
resourcePublicId: publicId,
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
},
tx,
);
return tx.oldItemDonation.findUniqueOrThrow({
where: { id: current.id },
include: DONATION_INCLUDE,
});
});
return { donation: this.mapDonation(updated, true), idempotentReplay: false };
}
async createNeed(input: CreateOldItemNeedDto, actor: AuthContext, req: RequestContext) {
const fp = this.fingerprint('old-item-need-create', input);
const replay = await this.prisma.oldItemNeed.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { need: this.need(replay), idempotentReplay: true };
}
const x = await this.prisma.$transaction(async (tx) => {
const n = await tx.oldItemNeed.create({
data: {
publicId: randomUUID(),
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
title: input.title.trim(),
category: input.category as never,
regionText: input.regionText.trim(),
beneficiaryGroup: input.beneficiaryGroup.trim(),
description: input.description.trim(),
requestedQuantity: input.requestedQuantity,
priority: input.priority ?? 0,
sortOrder: input.sortOrder ?? 0,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await tx.oldItemNeedStatusHistory.create({
data: {
needId: n.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
toStatus: 'DRAFT',
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_NEED_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_NEED',
resourcePublicId: n.publicId,
},
tx,
);
return n;
});
return { need: this.need(x), idempotentReplay: false };
}
async updateNeed(
publicId: string,
input: UpdateOldItemNeedDto,
actor: AuthContext,
req: RequestContext,
) {
const n = await this.findNeed(publicId);
if (n.status !== 'DRAFT' || n.revision !== input.expectedRevision) throw this.conflict();
const x = await this.prisma.$transaction(async (tx) => {
const r = await tx.oldItemNeed.updateMany({
where: { id: n.id, status: 'DRAFT', revision: input.expectedRevision },
data: {
title: input.title.trim(),
category: input.category as never,
regionText: input.regionText.trim(),
beneficiaryGroup: input.beneficiaryGroup.trim(),
description: input.description.trim(),
requestedQuantity: input.requestedQuantity,
priority: input.priority ?? 0,
sortOrder: input.sortOrder ?? 0,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (r.count !== 1) throw this.conflict();
await this.audit.record(
req,
{
action: 'OLD_ITEM_NEED_UPDATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_NEED',
resourcePublicId: publicId,
},
tx,
);
return tx.oldItemNeed.findUniqueOrThrow({ where: { id: n.id } });
});
return { need: this.need(x) };
}
async transitionNeed(
publicId: string,
input: TransitionOldItemNeedDto,
actor: AuthContext,
req: RequestContext,
) {
const n = await this.findNeed(publicId),
fp = this.fingerprint('old-item-need-transition', { publicId, ...input });
const replay = await this.prisma.oldItemNeedStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { need: true },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { need: this.need(replay.need), idempotentReplay: true };
}
if (
n.revision !== input.expectedRevision ||
!NEED_TRANSITIONS[n.status]?.includes(input.targetStatus)
)
throw this.invalid('当前需求状态不能执行该操作');
const x = await this.prisma.$transaction(async (tx) => {
const r = await tx.oldItemNeed.updateMany({
where: { id: n.id, status: n.status, revision: n.revision },
data: {
status: input.targetStatus as never,
updatedById: actor.userId,
revision: { increment: 1 },
...(input.targetStatus === 'PUBLISHED' ? { publishedAt: new Date() } : {}),
},
});
if (r.count !== 1) throw this.conflict();
await tx.oldItemNeedStatusHistory.create({
data: {
needId: n.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: n.status,
toStatus: input.targetStatus as never,
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_NEED_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_NEED',
resourcePublicId: publicId,
metadata: { fromStatus: n.status, toStatus: input.targetStatus },
},
tx,
);
return tx.oldItemNeed.findUniqueOrThrow({ where: { id: n.id } });
});
return { need: this.need(x), idempotentReplay: false };
}
async createDropOffPoint(
input: CreateOldItemDropOffPointDto,
actor: AuthContext,
req: RequestContext,
) {
const x = await this.prisma.$transaction(async (tx) => {
const p = await tx.oldItemDropOffPoint.create({
data: {
publicId: randomUUID(),
...input,
sortOrder: input.sortOrder ?? 0,
createdById: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_DROP_OFF_POINT_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_DROP_OFF_POINT',
resourcePublicId: p.publicId,
},
tx,
);
return p;
});
return {
point: {
publicId: x.publicId,
name: x.name,
regionText: x.regionText,
addressText: x.addressText,
openingHours: x.openingHours,
},
};
}
private async listDonations(
q: OldItemDonationQueryDto,
where: Prisma.OldItemDonationWhereInput,
sensitive: boolean,
) {
const [total, items] = await this.prisma.$transaction([
this.prisma.oldItemDonation.count({ where }),
this.prisma.oldItemDonation.findMany({
where,
include: DONATION_INCLUDE,
orderBy: { createdAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: items.map((x) => this.mapDonation(x, sensitive)),
page: q.page,
pageSize: q.pageSize,
total,
};
}
private async findDonation(publicId: string) {
const x = await this.prisma.oldItemDonation.findUnique({
where: { publicId },
include: DONATION_INCLUDE,
});
if (!x) throw this.notFound('未找到该捐赠记录');
return x;
}
private async findNeed(publicId: string) {
const x = await this.prisma.oldItemNeed.findUnique({ where: { publicId } });
if (!x) throw this.notFound('未找到该物资需求');
return x;
}
private need(n: OldItemNeed): OldItemNeedItem {
return {
publicId: n.publicId,
title: n.title,
category: n.category,
regionText: n.regionText,
beneficiaryGroup: n.beneficiaryGroup,
description: n.description,
requestedQuantity: n.requestedQuantity,
fulfilledQuantity: n.fulfilledQuantity,
remainingQuantity: Math.max(0, n.requestedQuantity - n.fulfilledQuantity),
priority: n.priority,
status: n.status,
revision: n.revision,
publishedAt: n.publishedAt?.toISOString() ?? null,
};
}
private mapDonation(d: DonationWithRelations, sensitive: boolean): OldItemDonationItem {
return {
publicId: d.publicId,
donationNo: d.donationNo,
category: d.category,
itemCondition: d.itemCondition,
quantity: d.quantity,
description: sensitive ? this.sensitive.decrypt(d.descriptionEncrypted) : '物品捐赠',
method: d.method,
status: d.status,
targetNeed: d.targetNeed
? { publicId: d.targetNeed.publicId, title: d.targetNeed.title }
: null,
dropOffPoint: d.dropOffPoint
? {
publicId: d.dropOffPoint.publicId,
name: d.dropOffPoint.name,
regionText: d.dropOffPoint.regionText,
addressText: d.dropOffPoint.addressText,
openingHours: d.dropOffPoint.openingHours,
}
: null,
appointmentAt: d.appointmentAt?.toISOString() ?? null,
contactName: sensitive ? this.sensitive.decrypt(d.contactNameEncrypted) : '爱心用户',
mobileMasked: this.maskMobile(this.sensitive.decrypt(d.mobileEncrypted)),
address: sensitive && d.addressEncrypted ? this.sensitive.decrypt(d.addressEncrypted) : null,
assigneeName: d.assignee?.nickname ?? null,
rejectionReason:
sensitive && d.rejectionReasonEncrypted
? this.sensitive.decrypt(d.rejectionReasonEncrypted)
: null,
assistedPeople: d.assistedPeople,
revision: d.revision,
createdAt: d.createdAt.toISOString(),
history: d.history.map((h) => ({
fromStatus: h.fromStatus,
toStatus: h.toStatus,
note: sensitive && h.noteEncrypted ? this.sensitive.decrypt(h.noteEncrypted) : null,
createdAt: h.createdAt.toISOString(),
})),
imagePublicIds: d.files.map((item) => item.file.publicId),
inventoryTrace: d.inventoryItems
.flatMap((item) =>
item.events.map((event) => ({
status: event.toStatus,
locationText: ['OUTBOUND', 'DELIVERED'].includes(event.toStatus)
? event.locationText
: null,
eventAt: event.eventAt.toISOString(),
})),
)
.sort((a, b) => a.eventAt.localeCompare(b.eventAt)),
};
}
private validateMethod(i: CreateOldItemDonationDto) {
if (i.method === 'PICKUP' && (!i.address || !i.appointmentAt))
throw this.validation('上门取件需填写地址和预约时间');
if (i.method === 'DROP_OFF' && !i.dropOffPointPublicId)
throw this.validation('定点投递需选择投递点');
if (i.appointmentAt && new Date(i.appointmentAt) <= new Date())
throw this.validation('预约时间必须晚于当前时间');
}
private fingerprint(domain: string, value: object) {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private donationNo() {
return `OI-${new Date().toISOString().slice(0, 7).replace('-', '')}-${randomUUID().slice(0, 8).toUpperCase()}`;
}
private maskMobile(v: string) {
return v.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
}
private validation(m: string) {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, m, HttpStatus.BAD_REQUEST);
}
private notFound(m: string) {
return new ApplicationError(API_ERROR_CODES.RESOURCE_NOT_FOUND, m, HttpStatus.NOT_FOUND);
}
private invalid(m: string) {
return new ApplicationError(API_ERROR_CODES.INVALID_STATE_TRANSITION, m, HttpStatus.CONFLICT);
}
private conflict() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'数据已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private duplicate() {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
}
@@ -163,7 +163,8 @@ export class CreateMediaUploadIntentDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['PROJECT_COVER', 'ACTIVITY_COVER']) businessType!: 'PROJECT_COVER' | 'ACTIVITY_COVER';
@IsString() @Length(1, 255) filename!: string;
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!: string;
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!:
'image/jpeg' | 'image/png' | 'image/webp';
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
}
+10 -4
View File
@@ -5,6 +5,7 @@ import {
API_ERROR_CODES,
type ConfirmUploadResponse,
type CreateUploadIntentResponse,
type CreateMediaUploadIntentRequest,
type ServiceRecordAttachment,
} from '@lehuo/contracts';
@@ -14,7 +15,6 @@ import { FilesRepository } from '../files/files.repository';
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
import type { File, FileBusinessType } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type { CreateMediaUploadIntentDto } from './dto/participation.dto';
const EXTENSIONS: Record<string, string> = {
'image/jpeg': 'jpg',
@@ -31,7 +31,7 @@ export class MediaService {
) {}
async createIntent(
input: CreateMediaUploadIntentDto,
input: CreateMediaUploadIntentRequest,
actor: AuthContext,
request: RequestContext,
): Promise<CreateUploadIntentResponse> {
@@ -56,7 +56,9 @@ export class MediaService {
return this.uploadResponse(previous, true);
}
const now = new Date();
const objectKey = `public-covers/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
const prefix =
input.businessType === 'OLD_ITEM_DONATION_IMAGE' ? 'private-old-items' : 'public-covers';
const objectKey = `${prefix}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
const file = await this.files.createPending(
{
publicId: randomUUID(),
@@ -86,7 +88,11 @@ export class MediaService {
const file = await this.files.findByPublicId(publicId);
if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId)
throw this.notFound();
if (file.businessType !== 'PROJECT_COVER' && file.businessType !== 'ACTIVITY_COVER') {
if (
file.businessType !== 'PROJECT_COVER' &&
file.businessType !== 'ACTIVITY_COVER' &&
file.businessType !== 'OLD_ITEM_DONATION_IMAGE'
) {
throw this.notFound();
}
if (file.status === 'AVAILABLE') return { file: this.attachment(file) };
@@ -19,5 +19,6 @@ import { VolunteersService } from './volunteers.service';
AdminParticipationController,
],
providers: [VolunteersService, ProjectsService, ActivitiesService, MediaService],
exports: [MediaService],
})
export class ParticipationModule {}
+495
View File
@@ -0,0 +1,495 @@
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type {
ApiSuccessResponse,
OldItemDonationItem,
OldItemNeedItem,
OldItemStats,
OldItemInventoryItem,
OldItemPointAccountItem,
OldItemRewardsSummary,
WechatLoginResponse,
} from '@lehuo/contracts';
import { AppModule } from '../src/app.module';
import { PasswordService } from '../src/auth/password.service';
import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants';
import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter';
import { PrismaService } from '../src/database/prisma.service';
class OldItemsWechatAdapter implements WechatAuthAdapter {
exchangeCode(code: string) {
return Promise.resolve({
openid: `old-items-openid-${code}`,
unionid: `old-items-unionid-${code}`,
});
}
}
const data = <T>(r: { body: unknown }) => (r.body as ApiSuccessResponse<T>).data;
function cookie(r: { headers: Record<string, unknown> }) {
const raw = r.headers['set-cookie'];
const values = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : [];
const item = values.find((x) => x.startsWith(`${ADMIN_SESSION_COOKIE}=`));
if (!item) throw new Error('Admin cookie missing');
return item.split(';')[0]!;
}
describe('old item reuse Phases 1-3', () => {
let app: INestApplication, prisma: PrismaService, passwords: PasswordService;
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(WECHAT_AUTH_ADAPTER)
.useValue(new OldItemsWechatAdapter())
.compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
);
await app.init();
prisma = app.get(PrismaService);
passwords = app.get(PasswordService);
});
beforeEach(async () => {
await prisma.oldItemPointEntry.deleteMany();
await prisma.oldItemPointAccount.deleteMany();
await prisma.oldItemCertificate.deleteMany();
await prisma.oldItemRewardConfig.deleteMany();
await prisma.oldItemInventoryEvent.deleteMany();
await prisma.oldItemInventoryItem.deleteMany();
await prisma.oldItemInventoryImport.deleteMany();
await prisma.oldItemDonationFile.deleteMany();
await prisma.oldItemDonationStatusHistory.deleteMany();
await prisma.oldItemDonation.deleteMany();
await prisma.oldItemNeedStatusHistory.deleteMany();
await prisma.oldItemNeed.deleteMany();
await prisma.oldItemDropOffPoint.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.authSession.deleteMany();
await prisma.userRole.deleteMany();
await prisma.adminCredential.deleteMany();
await prisma.staffProfile.deleteMany();
await prisma.wechatIdentity.deleteMany();
});
afterAll(async () => {
await prisma.oldItemPointEntry.deleteMany();
await prisma.oldItemPointAccount.deleteMany();
await prisma.oldItemCertificate.deleteMany();
await prisma.oldItemRewardConfig.deleteMany();
await prisma.oldItemInventoryEvent.deleteMany();
await prisma.oldItemInventoryItem.deleteMany();
await prisma.oldItemInventoryImport.deleteMany();
await prisma.oldItemDonationFile.deleteMany();
await prisma.oldItemDonationStatusHistory.deleteMany();
await prisma.oldItemDonation.deleteMany();
await prisma.oldItemNeedStatusHistory.deleteMany();
await prisma.oldItemNeed.deleteMany();
await prisma.oldItemDropOffPoint.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.authSession.deleteMany();
await prisma.userRole.deleteMany();
await prisma.adminCredential.deleteMany();
await prisma.staffProfile.deleteMany();
await prisma.wechatIdentity.deleteMany();
await app.close();
});
async function admin() {
const username = `old.${crypto.randomUUID()}`,
password = 'correct horse battery staple',
role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } }),
user = await prisma.user.create({
data: {
publicId: crypto.randomUUID(),
userType: 'STAFF',
nickname: '旧物管理员',
staffProfile: { create: { displayName: '旧物管理员' } },
adminCredential: { create: { username, passwordHash: await passwords.hash(password) } },
},
});
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
return {
publicId: user.publicId,
cookie: cookie(
await request(app.getHttpServer() as Server)
.post('/api/v1/admin/auth/login')
.set('origin', 'http://localhost:5173')
.send({ username, password })
.expect(201),
),
};
}
async function wechat(code: string) {
const r = await request(app.getHttpServer() as Server)
.post('/api/v1/auth/wechat/login')
.send({ code })
.expect(201);
return data<WechatLoginResponse>(r).accessToken;
}
it('publishes a need and completes an auditable directional pickup donation', async () => {
const adminSession = await admin(),
adminCookie = adminSession.cookie;
const rewardConfigRequestId = crypto.randomUUID();
const rewardConfigPayload = {
clientRequestId: rewardConfigRequestId,
certificateTitle: '旧物善用爱心证书',
certificateMessage:
'感谢 {recipient} 捐赠 {quantity} 件{category}物资,公益编号 {donationNo}。',
issuerName: '乐活公益',
legalNotice: '本证书仅记录公益参与,不作为捐赠票据、税务凭证或财产权利证明。',
clothingPoints: 1,
booksPoints: 3,
householdPoints: 1,
sportsPoints: 1,
maxPointsPerDonation: 10,
};
await request(app.getHttpServer() as Server)
.post('/api/v1/admin/old-items/reward-config/activate')
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send(rewardConfigPayload)
.expect(201);
expect(
data<{ idempotentReplay: boolean }>(
await request(app.getHttpServer() as Server)
.post('/api/v1/admin/old-items/reward-config/activate')
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send(rewardConfigPayload)
.expect(201),
).idempotentReplay,
).toBe(true);
const createNeedResponse = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/old-items/needs')
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send({
clientRequestId: crypto.randomUUID(),
title: '社区儿童学习用品需求',
category: 'BOOKS',
regionText: '丹阳市云阳街道',
beneficiaryGroup: '困境儿童',
description: '需要内容健康、无严重破损的课外读物',
requestedQuantity: 20,
priority: 80,
sortOrder: 10,
});
if (createNeedResponse.status !== 201)
throw new Error(
`create need failed: ${createNeedResponse.status} ${JSON.stringify(createNeedResponse.body)}`,
);
const createdNeed = data<{ need: OldItemNeedItem }>(createNeedResponse).need;
const published = data<{ need: OldItemNeedItem }>(
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/old-items/needs/${createdNeed.publicId}/transition`)
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send({
clientRequestId: crypto.randomUUID(),
expectedRevision: createdNeed.revision,
targetStatus: 'PUBLISHED',
})
.expect(201),
).need;
expect(
data<{ items: OldItemNeedItem[] }>(
await request(app.getHttpServer() as Server)
.get('/api/v1/old-items/needs?page=1&pageSize=20')
.expect(200),
).items,
).toHaveLength(1);
const token = await wechat('donor-one'),
requestId = crypto.randomUUID(),
appointmentAt = new Date(Date.now() + 86_400_000).toISOString();
const payload = {
clientRequestId: requestId,
category: 'BOOKS',
itemCondition: 'GOOD',
quantity: 6,
description: '六本适龄儿童读物,页面完整',
method: 'PICKUP',
contactName: '爱心用户',
mobile: '13812345678',
address: '丹阳市测试路 1 号,仅供集成测试',
appointmentAt,
targetNeedPublicId: published.publicId,
imagePublicIds: [],
};
const created = data<{ donation: OldItemDonationItem }>(
await request(app.getHttpServer() as Server)
.post('/api/v1/old-items/donations')
.set('authorization', `Bearer ${token}`)
.send(payload)
.expect(201),
).donation;
const replay = data<{ donation: OldItemDonationItem; idempotentReplay: boolean }>(
await request(app.getHttpServer() as Server)
.post('/api/v1/old-items/donations')
.set('authorization', `Bearer ${token}`)
.send(payload)
.expect(201),
);
expect(created.donationNo).toMatch(/^OI-/);
expect(replay.idempotentReplay).toBe(true);
let current = created;
for (const step of [
{ targetStatus: 'WAITING_PICKUP', assignedUserPublicId: adminSession.publicId },
{ targetStatus: 'RECEIVED' },
{ targetStatus: 'PROCESSING' },
{ targetStatus: 'COMPLETED', assistedPeople: 3 },
]) {
current = data<{ donation: OldItemDonationItem }>(
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/old-items/donations/${created.publicId}/transition`)
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send({
clientRequestId: crypto.randomUUID(),
expectedRevision: current.revision,
...step,
})
.expect(201),
).donation;
}
expect(current.status).toBe('COMPLETED');
expect(current.history).toHaveLength(5);
const stats = data<OldItemStats>(
await request(app.getHttpServer() as Server)
.get('/api/v1/old-items/stats')
.expect(200),
);
expect(stats.totalItems).toBe(6);
expect(stats.assistedPeople).toBe(3);
const rewards = data<OldItemRewardsSummary>(
await request(app.getHttpServer() as Server)
.get('/api/v1/old-items/me/rewards')
.set('authorization', `Bearer ${token}`)
.expect(200),
);
expect(rewards.balance).toBe(10);
expect(rewards.certificates).toHaveLength(1);
expect(rewards.certificates[0]?.title).toBe('旧物善用爱心证书');
expect(rewards.entries[0]?.pointsDelta).toBe(10);
await request(app.getHttpServer() as Server)
.get(`/api/v1/old-items/me/certificates/${rewards.certificates[0]!.publicId}`)
.set('authorization', `Bearer ${token}`)
.expect(200);
const otherToken = await wechat('other-donor');
await request(app.getHttpServer() as Server)
.get(`/api/v1/old-items/me/certificates/${rewards.certificates[0]!.publicId}`)
.set('authorization', `Bearer ${otherToken}`)
.expect(404);
const accountPage = data<{ items: OldItemPointAccountItem[] }>(
await request(app.getHttpServer() as Server)
.get('/api/v1/admin/old-items/reward-accounts?page=1&pageSize=20')
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.expect(200),
);
expect(accountPage.items).toHaveLength(1);
const account = accountPage.items[0]!;
const adjustment = {
clientRequestId: crypto.randomUUID(),
expectedRevision: account.revision,
pointsDelta: 5,
reason: '集成测试人工校正',
};
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`)
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send(adjustment)
.expect(201);
expect(
data<{ idempotentReplay: boolean }>(
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`)
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send(adjustment)
.expect(201),
).idempotentReplay,
).toBe(true);
expect(
data<OldItemRewardsSummary>(
await request(app.getHttpServer() as Server)
.get('/api/v1/old-items/me/rewards')
.set('authorization', `Bearer ${token}`)
.expect(200),
).balance,
).toBe(15);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`)
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send({
clientRequestId: crypto.randomUUID(),
expectedRevision: account.revision + 1,
pointsDelta: -16,
reason: '不允许出现负积分',
})
.expect(400);
const reissued = data<{ certificate: { publicId: string }; idempotentReplay: boolean }>(
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/old-items/donations/${created.publicId}/certificate`)
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send({ clientRequestId: crypto.randomUUID() })
.expect(201),
);
expect(reissued.idempotentReplay).toBe(false);
expect(await prisma.oldItemCertificate.count({ where: { status: 'REVOKED' } })).toBe(1);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/old-items/certificates/${reissued.certificate.publicId}/revoke`)
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send({ clientRequestId: crypto.randomUUID(), reason: '集成测试撤销' })
.expect(201);
expect(await prisma.oldItemCertificate.count({ where: { status: 'ISSUED' } })).toBe(0);
expect(
await prisma.auditLog.count({
where: {
resourceType: {
in: [
'OLD_ITEM_DONATION',
'OLD_ITEM_NEED',
'OLD_ITEM_REWARD_CONFIG',
'OLD_ITEM_CERTIFICATE',
'OLD_ITEM_POINT_ENTRY',
],
},
},
}),
).toBeGreaterThanOrEqual(12);
});
it('runs the inventory ledger state machine and atomically imports and exports rows', async () => {
const adminCookie = (await admin()).cookie;
const requestId = crypto.randomUUID();
const payload = {
clientRequestId: requestId,
category: 'CLOTHING',
itemName: '冬季外套',
quantity: 12,
unit: '件',
storageLocation: '测试仓 A-01',
note: '集成测试入库',
};
const createdResponse = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/old-items/inventory')
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send(payload)
.expect(201);
let current = data<{ item: OldItemInventoryItem }>(createdResponse).item;
expect(current.inventoryNo).toMatch(/^INV-/);
expect(current.events).toHaveLength(1);
expect(
data<{ idempotentReplay: boolean }>(
await request(app.getHttpServer() as Server)
.post('/api/v1/admin/old-items/inventory')
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send(payload)
.expect(201),
).idempotentReplay,
).toBe(true);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/old-items/inventory/${current.publicId}/transition`)
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send({
clientRequestId: crypto.randomUUID(),
expectedRevision: current.revision,
targetStatus: 'DISINFECTED',
})
.expect(409);
for (const step of [
{ targetStatus: 'SORTED' },
{ targetStatus: 'DISINFECTED' },
{ targetStatus: 'OUTBOUND', destinationRegion: '丹阳市云阳街道' },
{ targetStatus: 'DELIVERED', assistedPeople: 5 },
]) {
current = data<{ item: OldItemInventoryItem }>(
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/old-items/inventory/${current.publicId}/transition`)
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send({
clientRequestId: crypto.randomUUID(),
expectedRevision: current.revision,
...step,
})
.expect(201),
).item;
}
expect(current.status).toBe('DELIVERED');
expect(current.events.map((x) => x.status)).toEqual([
'IN_STOCK',
'SORTED',
'DISINFECTED',
'OUTBOUND',
'DELIVERED',
]);
const importId = crypto.randomUUID();
const importPayload = {
clientRequestId: importId,
originalFilename: '物资入库.xlsx',
rows: [
{
category: 'BOOKS',
itemName: '儿童读物',
quantity: 20,
unit: '本',
storageLocation: '测试仓 B-01',
},
{
category: 'SPORTS',
itemName: '跳绳',
quantity: 8,
unit: '根',
storageLocation: '测试仓 B-02',
},
],
};
const imported = data<{ items: OldItemInventoryItem[]; idempotentReplay: boolean }>(
await request(app.getHttpServer() as Server)
.post('/api/v1/admin/old-items/inventory/import')
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send(importPayload)
.expect(201),
);
expect(imported.items).toHaveLength(2);
expect(
data<{ idempotentReplay: boolean }>(
await request(app.getHttpServer() as Server)
.post('/api/v1/admin/old-items/inventory/import')
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.send(importPayload)
.expect(201),
).idempotentReplay,
).toBe(true);
const exported = data<{ rows: unknown[] }>(
await request(app.getHttpServer() as Server)
.get('/api/v1/admin/old-items/inventory-export/file?page=1&pageSize=100')
.set('origin', 'http://localhost:5173')
.set('cookie', adminCookie)
.expect(200),
);
expect(exported.rows).toHaveLength(3);
expect(await prisma.oldItemInventoryEvent.count()).toBe(7);
expect(
await prisma.auditLog.count({
where: { resourceType: { startsWith: 'OLD_ITEM_INVENTORY' } },
}),
).toBeGreaterThanOrEqual(7);
});
});
+9 -1
View File
@@ -19,7 +19,15 @@
"pages/content-detail/content-detail",
"pages/notifications/notifications",
"pages/financial-disclosures/financial-disclosures",
"pages/financial-disclosure-detail/financial-disclosure-detail"
"pages/financial-disclosure-detail/financial-disclosure-detail",
"pages/old-item-home/old-item-home",
"pages/old-item-donate/old-item-donate",
"pages/old-item-records/old-item-records",
"pages/old-item-detail/old-item-detail",
"pages/old-item-rewards/old-item-rewards",
"pages/old-item-certificate/old-item-certificate",
"pages/old-item-needs/old-item-needs",
"pages/old-item-guide/old-item-guide"
],
"window": {
"backgroundColor": "#F7F5F2",
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+208
View File
@@ -0,0 +1,208 @@
.old-page {
min-height: 100vh;
padding: 24rpx;
background: #f7f5f2;
box-sizing: border-box;
}
.old-hero {
padding: 38rpx 32rpx;
border-radius: 28rpx;
color: #fff;
background: linear-gradient(135deg, #ff8c4a, #f05a28);
box-shadow: 0 16rpx 36rpx rgba(240, 90, 40, 0.2);
}
.old-hero__title {
display: block;
font-size: 40rpx;
font-weight: 800;
}
.old-hero__copy {
display: block;
margin-top: 10rpx;
font-size: 25rpx;
opacity: 0.92;
}
.old-card {
margin-top: 22rpx;
padding: 24rpx;
border: 2rpx solid #eee7df;
border-radius: 24rpx;
background: #fff;
box-shadow: 0 10rpx 24rpx rgba(67, 50, 36, 0.05);
}
.old-title {
display: block;
font-size: 31rpx;
font-weight: 800;
}
.old-muted {
display: block;
margin-top: 8rpx;
color: #77716b;
font-size: 24rpx;
line-height: 1.6;
}
.old-primary,
.old-secondary {
width: 100%;
min-height: 88rpx;
margin-top: 18rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
}
.old-primary {
color: #fff;
background: linear-gradient(90deg, #ff702f, #f05a28);
}
.old-secondary {
color: #e95220;
background: #fff2ec;
border: 2rpx solid #ffd3c2;
}
.old-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
}
.old-stat {
padding: 22rpx 8rpx;
text-align: center;
border-radius: 20rpx;
background: #fff;
}
.old-stat strong,
.old-stat text {
display: block;
}
.old-stat strong {
color: #2eaa4f;
font-size: 34rpx;
}
.old-stat text {
color: #77716b;
font-size: 21rpx;
}
.old-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-top: 20rpx;
}
.old-grid button {
min-height: 140rpx;
margin: 0;
padding: 22rpx;
border-radius: 22rpx;
background: #fff;
text-align: left;
}
.old-grid strong,
.old-grid text {
display: block;
}
.old-grid strong {
font-size: 28rpx;
}
.old-grid text {
margin-top: 8rpx;
color: #77716b;
font-size: 22rpx;
}
.old-field {
display: block;
margin-top: 22rpx;
font-size: 25rpx;
font-weight: 700;
}
.old-field input,
.old-field textarea,
.old-field picker {
display: block;
margin-top: 10rpx;
padding: 20rpx;
border: 2rpx solid #e8e1da;
border-radius: 16rpx;
background: #fff;
font-weight: 400;
}
.old-options {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 12rpx;
}
.old-options button {
margin: 0;
padding: 12rpx 20rpx;
border: 2rpx solid #e6dfd8;
border-radius: 16rpx;
background: #fff;
font-size: 24rpx;
}
.old-options button.active {
color: #f05a28;
border-color: #f05a28;
background: #fff4ef;
}
.old-record {
display: block;
width: 100%;
margin-top: 18rpx;
padding: 24rpx;
border-radius: 22rpx;
background: #fff;
text-align: left;
}
.old-record__head {
display: flex;
justify-content: space-between;
}
.old-record__status {
color: #f05a28;
}
.old-trace {
margin-top: 22rpx;
padding-left: 28rpx;
border-left: 4rpx solid #ffd4c3;
}
.old-trace view {
position: relative;
margin: 0 0 26rpx;
}
.old-trace view::before {
position: absolute;
left: -38rpx;
top: 8rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #f05a28;
content: '';
}
.old-empty {
padding: 80rpx 20rpx;
text-align: center;
color: #77716b;
}
.old-photo-grid {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
margin-top: 12rpx;
}
.old-photo {
width: 150rpx;
height: 150rpx;
border-radius: 16rpx;
}
.old-remove {
margin: 0;
color: #b33;
background: #fff;
}
.old-guide text {
display: block;
margin: 14rpx 0;
line-height: 1.7;
}
@@ -23,7 +23,7 @@ Page({
async loadApplication() {
if (!this.data.publicId) {
this.setData({ status: 'error', errorMessage: '申请信息不完整,请返回重试' });
this.setData({ status: 'error', errorMessage: '帮扶信息不完整,请返回重试' });
return;
}
this.setData({ status: 'loading', errorMessage: '' });
@@ -3,12 +3,12 @@
<view wx:elif="{{status === 'error'}}" class="state-card"><text class="state-title">暂时无法读取服务进度</text><text class="state-text">{{errorMessage}}</text><button class="secondary-button" bindtap="retryLoad">重新加载</button></view>
<block wx:else>
<view class="detail-summary">
<view><text class="eyebrow">{{application.serviceCategoryLabel}}</text><text class="detail-title">{{application.beneficiaryName}}的服务申请</text></view>
<view><text class="eyebrow">{{application.serviceCategoryLabel}}</text><text class="detail-title">{{application.beneficiaryName}}的帮扶服务</text></view>
<text class="status-pill">{{application.statusLabel}}</text>
<text class="application-meta">服务编号:{{application.applicationNo}}</text>
</view>
<view class="detail-card">
<text class="form-title">申请信息</text>
<text class="form-title">帮扶信息</text>
<view class="summary-row"><text>申请关系</text><text>{{application.applyModeLabel}}</text></view>
<view class="summary-row"><text>联系电话</text><text>{{application.contactMobileMasked}}</text></view>
<view class="summary-row"><text>所在地区</text><text>{{application.regionCode}}</text></view>
@@ -1,3 +1,3 @@
{
"navigationBarTitleText": "服务申请"
"navigationBarTitleText": "帮扶服务"
}
@@ -20,14 +20,29 @@ import { createClientRequestId } from '../../utils/request-id';
const TOTAL_STEPS = 5;
const LOCAL_SERVICE_CATEGORIES: ServiceCategoryOption[] = [
const CATEGORY_IMAGES: Record<ServiceCategory, string> = {
OLD_AGE: '/assets/images/brand-elder-care.jpg',
WOMEN: '/assets/images/service-women-support-v2.jpg',
CHILD: '/assets/images/service-child-care-v2.jpg',
DISABILITY: '/assets/images/brand-disability-support.jpg',
LIFE_DIFFICULTY: '/assets/images/service-life-support-v2.jpg',
OTHER: '/assets/images/service-other-support-v2.jpg',
};
type ServiceCategoryView = ServiceCategoryOption & { image: string };
const LOCAL_SERVICE_CATEGORY_OPTIONS = [
{ code: 'OLD_AGE', label: '关爱老人', description: '生活照料、陪伴关怀与健康支持' },
{ code: 'WOMEN', label: '困境妇女', description: '困境支持、权益关爱与资源链接' },
{ code: 'CHILD', label: '儿童关爱', description: '成长陪伴、家庭支持与儿童关爱' },
{ code: 'DISABILITY', label: '残障帮扶', description: '生活协助、康复支持与无障碍关怀' },
{ code: 'LIFE_DIFFICULTY', label: '生活困难', description: '生活支持、资源链接与持续关爱' },
{ code: 'OTHER', label: '其他服务', description: '由工作人员进一步了解并协助判断' },
];
] satisfies ServiceCategoryOption[];
const LOCAL_SERVICE_CATEGORIES: ServiceCategoryView[] = LOCAL_SERVICE_CATEGORY_OPTIONS.map(
(item) => ({ ...item, image: CATEGORY_IMAGES[item.code] }),
);
function isServiceCategory(value: string): value is ServiceCategory {
return (SERVICE_CATEGORIES as readonly string[]).includes(value);
@@ -83,7 +98,13 @@ Page({
this.setData({ loading: true, errorMessage: '' });
try {
const result = await fetchServiceCategories();
this.setData({ categories: result.items, loading: false });
this.setData({
categories: result.items.map((item) => ({
...item,
image: CATEGORY_IMAGES[item.code],
})),
loading: false,
});
} catch (error) {
this.setData({
loading: false,
@@ -29,10 +29,7 @@
bindtap="selectCategory"
aria-label="{{item.label}}:{{item.description}}"
>
<image wx:if="{{item.code === 'OLD_AGE'}}" class="selection-photo" src="/assets/images/brand-elder-care.jpg" mode="aspectFill" />
<image wx:elif="{{item.code === 'WOMEN_CHILDREN'}}" class="selection-photo" src="/assets/images/brand-women-support.jpg" mode="aspectFill" />
<image wx:elif="{{item.code === 'DISABILITY'}}" class="selection-photo" src="/assets/images/brand-disability-support.jpg" mode="aspectFill" />
<view wx:else class="selection-photo selection-photo--fallback">公益服务</view>
<image class="selection-photo" src="{{item.image}}" mode="aspectFill" />
<view class="selection-content">
<text class="selection-title">{{item.label}}</text>
<text class="selection-description">{{item.description}}</text>
@@ -86,7 +83,7 @@
</view>
<view wx:else class="form-card">
<text class="form-title">确认申请信息</text>
<text class="form-title">确认帮扶信息</text>
<text class="form-hint">提交前请确认,联系方式仅供授权工作人员提供服务使用。</text>
<view class="summary-list">
<view class="summary-row"><text>服务类型</text><text>{{serviceCategoryLabel}}</text></view>
@@ -99,7 +96,7 @@
<checkbox-group bindchange="updatePrivacyConsent">
<label class="consent-row">
<checkbox value="accepted" checked="{{privacyConsent}}" color="#FF6B35" />
<text>我已阅读并同意本次服务申请的隐私授权,确认所填信息用于公益服务联系、评估和必要留痕。</text>
<text>我已阅读并同意本次帮扶服务的隐私授权,确认所填信息用于公益服务联系、评估和必要留痕。</text>
</label>
</checkbox-group>
</view>
@@ -108,7 +105,7 @@
<view class="form-actions">
<button wx:if="{{step > 1}}" class="secondary-button action-button" disabled="{{submitting}}" bindtap="previousStep">上一步</button>
<button wx:if="{{step < totalSteps}}" class="primary-button action-button" bindtap="nextStep">下一步</button>
<button wx:else class="primary-button action-button" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submitApplication">{{submitting ? '正在提交…' : '提交申请'}}</button>
<button wx:else class="primary-button action-button" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submitApplication">{{submitting ? '正在提交…' : '提交帮扶需求'}}</button>
</view>
</block>
</view>
@@ -21,7 +21,7 @@ Page({
async loadApplication() {
if (!this.data.publicId) {
this.setData({ loading: false, errorMessage: '申请信息不完整,请前往“我的申请”查看' });
this.setData({ loading: false, errorMessage: '帮扶信息不完整,请前往“我的帮扶服务”查看' });
return;
}
this.setData({ loading: true, errorMessage: '' });
@@ -40,7 +40,7 @@ Page({
? `${error.message}(编号:${error.requestId})`
: error instanceof Error
? error.message
: '暂时无法读取申请信息';
: '暂时无法读取帮扶信息';
this.setData({ loading: false, errorMessage: message });
}
},
@@ -1,12 +1,12 @@
<view class="page success-page">
<view wx:if="{{loading}}" class="state-card"><view class="skeleton-card"></view><text class="state-text">正在确认提交结果…</text></view>
<view wx:elif="{{errorMessage}}" class="state-card">
<text class="state-title">暂时无法读取申请结果</text><text class="state-text">{{errorMessage}}</text>
<text class="state-title">暂时无法读取帮扶服务结果</text><text class="state-text">{{errorMessage}}</text>
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
</view>
<view wx:else class="success-card">
<view class="success-mark" aria-hidden="true">✓</view>
<text class="success-title">申请已提交</text>
<text class="success-title">帮扶需求已提交</text>
<text class="success-message">乐活公益工作人员将根据实际情况与您联系,请留意来电。</text>
<view class="result-list">
<view><text>服务编号</text><text class="result-value">{{application.applicationNo}}</text></view>
@@ -8,12 +8,6 @@ Page({
message: '正在安全登录,请稍候…',
title: MINI_PROGRAM_TITLE,
canCollaborate: false,
serviceCategories: [
{ code: 'OLD_AGE', label: '关爱老人', symbol: '老', tone: 'orange' },
{ code: 'WOMEN_CHILDREN', label: '困境儿童', symbol: '童', tone: 'green' },
{ code: 'WOMEN_CHILDREN', label: '妇女帮扶', symbol: '女', tone: 'rose' },
{ code: 'DISABILITY', label: '残障救助', symbol: '助', tone: 'blue' },
],
},
onLoad() {
@@ -53,13 +47,6 @@ Page({
void wx.navigateTo({ url: '/pages/application-form/application-form?mode=FAMILY' });
},
applyForCategory(event: WechatMiniprogram.BaseEvent) {
const category = event.currentTarget.dataset['code'] as string;
void wx.navigateTo({
url: `/pages/application-form/application-form?mode=SELF&category=${category}`,
});
},
viewMyApplications() {
void wx.navigateTo({ url: '/pages/my-applications/my-applications' });
},
@@ -82,6 +69,12 @@ Page({
viewContent() {
void wx.navigateTo({ url: '/pages/content/content' });
},
viewFinancialDisclosures() {
void wx.navigateTo({ url: '/pages/financial-disclosures/financial-disclosures' });
},
viewOldItems() {
void wx.navigateTo({ url: '/pages/old-item-home/old-item-home' });
},
viewOrganization() {
void wx.navigateTo({ url: '/pages/content/content?type=ORGANIZATION_INTRO' });
},
@@ -8,19 +8,23 @@
<view class="hero-glow hero-glow--one"></view><view class="hero-glow hero-glow--two"></view>
<text class="hero-title">用爱连接 让爱传递</text>
<text class="hero-copy">乐于助人 · 助人为乐 · 服务社会</text>
<button class="hero-action" bindtap="apply">立即申请服务 <text>›</text></button>
<button class="hero-action" bindtap="apply">申请帮扶服务 <text>›</text></button>
</view>
<view wx:if="{{authStatus === 'loading'}}" class="state-card"><view class="skeleton-line skeleton-line--wide"></view><text class="state-text">正在安全登录,请稍候…</text></view>
<view wx:elif="{{authStatus === 'error'}}" class="state-card"><text class="state-title">暂时无法登录</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="retryLogin">重新登录</button></view>
<block wx:else>
<view class="quick-grid">
<button wx:for="{{serviceCategories}}" wx:key="label" class="quick-item" data-code="{{item.code}}" bindtap="applyForCategory"><view class="quick-icon quick-icon--{{item.tone}}"><text>{{item.symbol}}</text></view><text>{{item.label}}</text></button>
<button class="quick-item" bindtap="apply"><view class="quick-icon quick-icon--blue"><text>申</text></view><text>服务申请</text></button>
<button class="quick-item" bindtap="viewVolunteerCenter"><view class="quick-icon quick-icon--gold"><text>愿</text></view><text>志愿服务</text></button>
<button class="quick-item" bindtap="viewProjects"><view class="quick-icon quick-icon--green"><text>益</text></view><text>公益项目</text></button>
<button class="quick-item" bindtap="viewContent"><view class="quick-icon quick-icon--rose"><text>讯</text></view><text>公益动态</text></button>
<view class="home-services">
<button class="help-entry" bindtap="apply"><view class="help-entry__icon"><text>帮</text></view><view class="help-entry__content"><text class="help-entry__title">帮扶服务</text><text class="help-entry__copy">为自己或家人提交需求,工作人员将与您联系</text></view><text class="help-entry__arrow">›</text></button>
<view class="quick-grid">
<button class="quick-item" bindtap="viewVolunteerCenter"><view class="quick-icon quick-icon--gold"><text>愿</text></view><text>志愿服务</text></button>
<button class="quick-item" bindtap="viewProjects"><view class="quick-icon quick-icon--green"><text>益</text></view><text>公益项目</text></button>
<button class="quick-item" bindtap="viewContent"><view class="quick-icon quick-icon--rose"><text>讯</text></view><text>公益动态</text></button>
<button class="quick-item" bindtap="viewOldItems"><view class="quick-icon quick-icon--gold"><text>物</text></view><text>旧物善用</text></button>
<button class="quick-item" bindtap="viewFinancialDisclosures"><view class="quick-icon quick-icon--green"><text>财</text></view><text>财务公开</text></button>
<button class="quick-item" bindtap="viewMyApplications"><view class="quick-icon quick-icon--blue"><text>进</text></view><text>服务进度</text></button>
</view>
</view>
<button class="notice-strip" bindtap="viewContent"><text class="notice-badge">公告</text><text class="notice-text">查看乐活公益最新服务动态与温馨提示</text><text>›</text></button>
@@ -33,7 +37,6 @@
<view class="section-heading"><text class="section-title">便捷入口</text></view>
<view class="service-list">
<button bindtap="viewMyApplications"><view class="list-mark list-mark--orange">进</view><view><text class="list-title">服务进度</text><text class="list-copy">查看申请受理与跟进情况</text></view><text>›</text></button>
<button wx:if="{{canCollaborate}}" bindtap="viewAssignedCases"><view class="list-mark list-mark--blue">协</view><view><text class="list-title">协作任务</text><text class="list-copy">查看已授权的服务工单</text></view><text>›</text></button>
<button bindtap="viewActivities"><view class="list-mark list-mark--green">活</view><view><text class="list-title">公益活动</text><text class="list-copy">浏览活动并提交报名</text></view><text>›</text></button>
</view>
@@ -101,24 +101,74 @@
font-size: 25rpx;
font-weight: 700;
}
.quick-grid {
display: grid;
.home-services {
margin-top: 24rpx;
padding: 26rpx 8rpx 18rpx;
grid-template-columns: repeat(4, 1fr);
gap: 28rpx 10rpx;
padding: 20rpx;
border: 2rpx solid var(--color-border);
border-radius: 28rpx;
background: #fff;
box-shadow: var(--shadow-card);
}
.help-entry {
display: grid;
width: 100%;
min-height: 128rpx;
margin: 0;
padding: 22rpx 24rpx;
grid-template-columns: 76rpx 1fr auto;
align-items: center;
gap: 20rpx;
border-radius: 24rpx;
color: #7a3219;
background: linear-gradient(120deg, #fff0e7, #fff8f3);
text-align: left;
}
.help-entry__icon {
display: flex;
width: 72rpx;
height: 72rpx;
align-items: center;
justify-content: center;
border-radius: 24rpx;
color: #fff;
background: linear-gradient(145deg, #ff7c43, #f05a28);
font-weight: 800;
}
.help-entry__content,
.help-entry__title,
.help-entry__copy {
display: block;
}
.help-entry__title {
font-size: 32rpx;
font-weight: 800;
}
.help-entry__copy {
margin-top: 5rpx;
color: #795d50;
font-size: 24rpx;
line-height: 1.5;
}
.help-entry__arrow {
color: var(--color-primary);
font-size: 42rpx;
}
.quick-grid {
display: grid;
margin-top: 24rpx;
padding: 0 4rpx 4rpx;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx 16rpx;
}
.quick-item {
display: flex;
min-height: 112rpx;
min-width: 0;
margin: 0;
padding: 0;
padding: 8rpx 0;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
color: var(--color-text);
background: transparent;
@@ -1,3 +1,3 @@
{
"navigationBarTitleText": "我的申请"
"navigationBarTitleText": "我的帮扶服务"
}
@@ -1,8 +1,8 @@
<view class="page list-page">
<view class="page-heading"><text class="page-title">我的服务申请</text><text class="page-subtitle">查看已提交的需求和工作人员处理进度</text></view>
<view class="page-heading"><text class="page-title">我的帮扶服务</text><text class="page-subtitle">查看已提交的帮扶需求和工作人员处理进度</text></view>
<view wx:if="{{status === 'loading'}}" class="application-list"><view wx:for="{{[1, 2, 3]}}" wx:key="*this" class="skeleton-card"></view></view>
<view wx:elif="{{status === 'empty'}}" class="state-card">
<text class="state-title">您还没有提交过服务申请</text><text class="state-text">如果您或家人需要帮助,可以从这里开始。</text>
<text class="state-title">您还没有提交过帮扶需求</text><text class="state-text">如果您或家人需要帮助,可以从这里开始。</text>
<button class="primary-button" bindtap="startApplication">开始申请</button>
</view>
<view wx:elif="{{status === 'error'}}" class="state-card">
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "电子荣誉证书"
}
@@ -0,0 +1,33 @@
import type { OldItemCertificateItem } from '@lehuo/contracts';
import { getMyOldItemCertificate } from '../../services/old-items';
Page({
data: {
loading: true,
error: '',
certificate: null as (OldItemCertificateItem & { issuedDate: string }) | null,
},
onLoad(options) {
const id = options['id'];
if (!id) {
this.setData({ loading: false, error: '缺少证书参数' });
return;
}
void this.load(id);
},
async load(id: string) {
try {
const certificate = (await getMyOldItemCertificate(id)).certificate;
this.setData({
certificate: {
...certificate,
issuedDate: new Date(certificate.issuedAt).toLocaleDateString('zh-CN'),
},
});
} catch (error) {
this.setData({ error: error instanceof Error ? error.message : '加载失败' });
} finally {
this.setData({ loading: false });
}
},
});
@@ -0,0 +1,9 @@
<view class="old-page">
<view wx:if="{{loading}}" class="old-card"><text class="old-muted">正在加载证书…</text></view>
<view wx:elif="{{error}}" class="old-card">{{error}}</view>
<block wx:elif="{{certificate}}">
<view wx:if="{{certificate.status==='REVOKED'}}" class="certificate-revoked">该证书已撤销{{certificate.revokeReason?':'+certificate.revokeReason:''}}</view>
<view class="old-card certificate"><text class="certificate-title">{{certificate.title}}</text><text class="certificate-name">{{certificate.recipientName}}</text><text class="certificate-message">{{certificate.message}}</text><text class="certificate-issuer">{{certificate.issuerName}}</text><text class="certificate-date">{{certificate.issuedDate}}</text><text class="certificate-no">证书编号:{{certificate.certificateNo}}</text></view>
<view class="old-card"><text class="old-title">使用说明</text><text class="old-muted">{{certificate.legalNotice}}</text></view>
</block>
</view>
@@ -0,0 +1,62 @@
@import '../../old-items.wxss';
.certificate {
position: relative;
padding: 64rpx 40rpx;
overflow: hidden;
border: 6rpx double #ef9c72;
text-align: center;
}
.certificate::before {
position: absolute;
top: -60rpx;
right: -30rpx;
color: rgba(240, 90, 40, 0.08);
content: '♥';
font-size: 260rpx;
}
.certificate-title {
display: block;
color: #b8421d;
font-size: 48rpx;
font-weight: 800;
letter-spacing: 6rpx;
}
.certificate-name {
display: block;
margin-top: 46rpx;
font-size: 40rpx;
font-weight: 700;
}
.certificate-message {
display: block;
margin-top: 28rpx;
color: #554b44;
line-height: 1.9;
text-align: left;
}
.certificate-issuer {
display: block;
margin-top: 50rpx;
font-weight: 700;
text-align: right;
}
.certificate-date {
display: block;
color: #687078;
font-size: 26rpx;
text-align: right;
}
.certificate-no {
display: block;
margin-top: 40rpx;
color: #918a83;
font-size: 24rpx;
}
.certificate-revoked {
margin-bottom: 24rpx;
padding: 20rpx;
color: #b42318;
background: #fff0ef;
border-radius: 18rpx;
}
@@ -0,0 +1 @@
{ "navigationBarTitleText": "捐赠溯源详情" }
@@ -0,0 +1,80 @@
import type { OldItemDonationItem } from '@lehuo/contracts';
import { getMyOldItemDonation, getMyOldItemImageUrl } from '../../services/old-items';
const labels: Record<string, string> = {
SUBMITTED: '已提交,等待审核',
REJECTED: '审核未通过',
WAITING_PICKUP: '审核通过,等待取件',
WAITING_DROP_OFF: '审核通过,等待投递',
RECEIVED: '物资已接收',
PROCESSING: '物资正在分拣消毒',
COMPLETED: '帮扶已完成',
CANCELLED: '捐赠已取消',
};
const inventoryLabels: Record<string, string> = {
IN_STOCK: '物资已入库',
SORTED: '物资已完成分拣',
DISINFECTED: '物资已完成消毒',
OUTBOUND: '物资已出库',
DELIVERED: '物资已送达帮扶地',
};
Page({
data: {
loading: true,
error: '',
donation: null as
| (OldItemDonationItem & {
statusLabel: string;
historyView: Array<{ label: string; time: string; note: string | null }>;
inventoryView: Array<{ label: string; time: string; location: string | null }>;
})
| null,
imageUrls: [] as string[],
},
onLoad(o) {
if (!o['id']) {
this.setData({ loading: false, error: '缺少捐赠记录参数' });
return;
}
void this.load(o['id']);
},
async load(id: string) {
try {
const d = (await getMyOldItemDonation(id)).donation;
const imageUrls = (
await Promise.all(
d.imagePublicIds.map(async (fileId) => {
try {
return (await getMyOldItemImageUrl(fileId)).downloadUrl;
} catch {
return '';
}
}),
)
).filter(Boolean);
this.setData({
imageUrls,
donation: {
...d,
statusLabel: labels[d.status] ?? d.status,
historyView: d.history.map((h) => ({
label: labels[h.toStatus] ?? h.toStatus,
time: new Date(h.createdAt).toLocaleString('zh-CN'),
note: h.note,
})),
inventoryView: d.inventoryTrace.map((event) => ({
label: inventoryLabels[event.status] ?? event.status,
time: new Date(event.eventAt).toLocaleString('zh-CN'),
location: event.locationText,
})),
},
});
} catch (e) {
this.setData({ error: e instanceof Error ? e.message : '加载失败' });
} finally {
this.setData({ loading: false });
}
},
rewards() {
void wx.navigateTo({ url: '/pages/old-item-rewards/old-item-rewards' });
},
});
@@ -0,0 +1 @@
<view class="old-page"><view wx:if="{{error}}" class="old-card">{{error}}</view><block wx:elif="{{donation}}"><view class="old-hero"><text class="old-hero__copy">公益编号 {{donation.donationNo}}</text><text class="old-hero__title">{{donation.statusLabel}}</text></view><view class="old-card"><text class="old-title">捐赠信息</text><text class="old-muted">数量:{{donation.quantity}} 件</text><text class="old-muted">说明:{{donation.description}}</text><text class="old-muted">方式:{{donation.method==='PICKUP'?'上门取件':'定点投递'}}</text><text wx:if="{{donation.targetNeed}}" class="old-muted">定向需求:{{donation.targetNeed.title}}</text><text wx:if="{{donation.rejectionReason}}" class="old-muted">驳回原因:{{donation.rejectionReason}}</text><view wx:if="{{imageUrls.length}}" class="old-photo-grid"><image wx:for="{{imageUrls}}" wx:key="*this" class="old-photo" src="{{item}}" mode="aspectFill"></image></view></view><view class="old-card"><text class="old-title">捐赠处理记录</text><view class="old-trace"><view wx:for="{{donation.historyView}}" wx:key="time"><text class="old-title">{{item.label}}</text><text class="old-muted">{{item.time}}</text><text wx:if="{{item.note}}" class="old-muted">{{item.note}}</text></view></view></view><view wx:if="{{donation.inventoryView.length}}" class="old-card"><text class="old-title">物资全流程溯源</text><view class="old-trace"><view wx:for="{{donation.inventoryView}}" wx:key="time"><text class="old-title">{{item.label}}</text><text class="old-muted">{{item.time}}</text><text wx:if="{{item.location}}" class="old-muted">帮扶地区:{{item.location}}</text></view></view></view><view class="old-card"><text class="old-title">公益荣誉凭证</text><text class="old-muted">捐赠完成后会生成电子荣誉证书并记入公益荣誉积分。证书不作为捐赠票据、税务凭证或财产权利证明。</text><button class="old-secondary" bindtap="rewards">查看证书与积分</button></view></block></view>
@@ -0,0 +1 @@
@import '../../old-items.wxss';
@@ -0,0 +1 @@
{ "navigationBarTitleText": "捐赠旧物" }
@@ -0,0 +1,154 @@
import type {
OldItemCategory,
OldItemCondition,
OldItemDonationMethod,
OldItemDropOffPoint,
} from '@lehuo/contracts';
import {
createOldItemDonation,
cryptoId,
getOldItemDropOffPoints,
uploadOldItemImage,
} from '../../services/old-items';
const categories = [
{ code: 'CLOTHING', label: '服饰' },
{ code: 'BOOKS', label: '书籍' },
{ code: 'HOUSEHOLD', label: '生活用品' },
{ code: 'SPORTS', label: '文体用品' },
];
const conditions = [
{ code: 'NEW', label: '全新' },
{ code: 'LIKE_NEW', label: '九成新' },
{ code: 'GOOD', label: '使用良好' },
{ code: 'FAIR', label: '可正常使用' },
];
const isCategory = (value: string): value is OldItemCategory =>
categories.some((item) => item.code === value);
const isCondition = (value: string): value is OldItemCondition =>
conditions.some((item) => item.code === value);
const isMethod = (value: string): value is OldItemDonationMethod =>
value === 'PICKUP' || value === 'DROP_OFF';
Page({
data: {
categories,
conditions,
category: 'CLOTHING',
itemCondition: 'GOOD',
quantity: 1,
description: '',
method: 'PICKUP',
contactName: '',
mobile: '',
address: '',
appointmentAt: '',
points: [] as OldItemDropOffPoint[],
pointIndex: -1,
localImages: [] as string[],
imagePublicIds: [] as string[],
targetNeedPublicId: '',
submitting: false,
error: '',
},
onLoad(options) {
this.setData({
targetNeedPublicId: options['need'] || '',
category: isCategory(options['category'] || '') ? options['category'] : 'CLOTHING',
});
void this.loadPoints();
},
async loadPoints() {
try {
this.setData({ points: (await getOldItemDropOffPoints()).items });
} catch {
this.setData({ points: [] });
}
},
setCategory(e: WechatMiniprogram.BaseEvent) {
const value = String(e.currentTarget.dataset['value']);
if (isCategory(value)) this.setData({ category: value });
},
setCondition(e: WechatMiniprogram.BaseEvent) {
const value = String(e.currentTarget.dataset['value']);
if (isCondition(value)) this.setData({ itemCondition: value });
},
setMethod(e: WechatMiniprogram.BaseEvent) {
const value = String(e.currentTarget.dataset['value']);
if (isMethod(value)) this.setData({ method: value });
},
input(e: WechatMiniprogram.Input) {
const field = String(e.currentTarget.dataset['field']);
this.setData({ [field]: e.detail.value });
},
pointChange(e: WechatMiniprogram.CustomEvent) {
this.setData({ pointIndex: Number(e.detail.value) });
},
async chooseImages() {
const remain = 6 - this.data.localImages.length;
if (remain <= 0) return;
try {
const picked = await wx.chooseMedia({
count: remain,
mediaType: ['image'],
sourceType: ['album', 'camera'],
sizeType: ['compressed'],
});
const paths = picked.tempFiles.map((x) => x.tempFilePath);
this.setData({ submitting: true, error: '正在安全上传图片…' });
const ids = [] as string[];
for (const path of paths) ids.push(await uploadOldItemImage(path));
this.setData({
localImages: [...this.data.localImages, ...paths],
imagePublicIds: [...this.data.imagePublicIds, ...ids],
error: '',
});
} catch (e) {
this.setData({ error: e instanceof Error ? e.message : '图片上传失败' });
} finally {
this.setData({ submitting: false });
}
},
removeImage(e: WechatMiniprogram.BaseEvent) {
const index = Number(e.currentTarget.dataset['index']);
this.setData({
localImages: this.data.localImages.filter((_, i) => i !== index),
imagePublicIds: this.data.imagePublicIds.filter((_, i) => i !== index),
});
},
async submit() {
if (this.data.submitting) return;
const point = this.data.points[this.data.pointIndex];
this.setData({ submitting: true, error: '' });
try {
const result = await createOldItemDonation({
clientRequestId: cryptoId(),
category: this.data.category as OldItemCategory,
itemCondition: this.data.itemCondition as OldItemCondition,
quantity: Number(this.data.quantity),
description: this.data.description,
method: this.data.method as OldItemDonationMethod,
contactName: this.data.contactName,
mobile: this.data.mobile,
address: this.data.method === 'PICKUP' ? this.data.address : undefined,
appointmentAt:
this.data.method === 'PICKUP'
? new Date(this.data.appointmentAt.replace(' ', 'T')).toISOString()
: undefined,
dropOffPointPublicId: this.data.method === 'DROP_OFF' ? point?.publicId : undefined,
targetNeedPublicId: this.data.targetNeedPublicId || undefined,
imagePublicIds: this.data.imagePublicIds,
});
await wx.showModal({
title: '提交成功',
content: `公益编号:${result.donation.donationNo}`,
showCancel: false,
});
void wx.redirectTo({
url: `/pages/old-item-detail/old-item-detail?id=${result.donation.publicId}`,
});
} catch (e) {
this.setData({ error: e instanceof Error ? e.message : '提交失败,请检查填写内容' });
} finally {
this.setData({ submitting: false });
}
},
});
@@ -0,0 +1 @@
<view class="old-page"><view class="old-card old-guide"><text class="old-title">捐赠须知</text><text>可捐:干净完好、功能正常的服饰、书籍、生活及文体用品。</text><text>禁捐:贴身衣物、破损霉变物品、药品、食品及存在安全风险的用品。</text></view><view class="old-card"><text class="old-title">1. 选择品类</text><view class="old-options"><button wx:for="{{categories}}" wx:key="code" class="{{category===item.code?'active':''}}" data-value="{{item.code}}" bindtap="setCategory">{{item.label}}</button></view><text class="old-field">新旧程度</text><view class="old-options"><button wx:for="{{conditions}}" wx:key="code" class="{{itemCondition===item.code?'active':''}}" data-value="{{item.code}}" bindtap="setCondition">{{item.label}}</button></view><label class="old-field">数量<input type="number" value="{{quantity}}" data-field="quantity" bindinput="input"/></label><label class="old-field">物品描述<textarea value="{{description}}" maxlength="1000" placeholder="请说明尺寸、使用情况等" data-field="description" bindinput="input"></textarea></label><text class="old-field">物品图片(最多 6 张)</text><view class="old-photo-grid"><view wx:for="{{localImages}}" wx:key="*this"><image class="old-photo" src="{{item}}" mode="aspectFill"></image><button class="old-remove" data-index="{{index}}" bindtap="removeImage">移除</button></view></view><button class="old-secondary" bindtap="chooseImages" disabled="{{submitting||localImages.length>=6}}">选择图片</button></view><view class="old-card"><text class="old-title">2. 捐赠方式</text><view class="old-options"><button class="{{method==='PICKUP'?'active':''}}" data-value="PICKUP" bindtap="setMethod">上门取件</button><button class="{{method==='DROP_OFF'?'active':''}}" data-value="DROP_OFF" bindtap="setMethod">定点投递</button></view><label class="old-field">联系人<input value="{{contactName}}" data-field="contactName" bindinput="input"/></label><label class="old-field">手机号<input type="number" maxlength="11" value="{{mobile}}" data-field="mobile" bindinput="input"/></label><block wx:if="{{method==='PICKUP'}}"><label class="old-field">取件地址<textarea value="{{address}}" data-field="address" bindinput="input"></textarea></label><label class="old-field">预约时间<input value="{{appointmentAt}}" placeholder="如 2026-08-20 14:00" data-field="appointmentAt" bindinput="input"/></label></block><block wx:else><label class="old-field">投递点<picker range="{{points}}" range-key="name" value="{{pointIndex}}" bindchange="pointChange"><view>{{pointIndex>=0?points[pointIndex].name:'请选择投递点'}}</view></picker></label><text wx:if="{{!points.length}}" class="old-muted">暂无启用投递点,请改用上门取件或稍后再试。</text></block></view><text wx:if="{{error}}" class="old-muted">{{error}}</text><button class="old-primary" disabled="{{submitting}}" bindtap="submit">{{submitting?'处理中…':'提交审核'}}</button></view>
@@ -0,0 +1 @@
@import '../../old-items.wxss';
@@ -0,0 +1 @@
{ "navigationBarTitleText": "捐赠规范指南" }
@@ -0,0 +1 @@
Page({});
@@ -0,0 +1 @@
<view class="old-page"><view class="old-hero"><text class="old-hero__title">让善意安全抵达</text><text class="old-hero__copy">提交前请确认物品符合以下规范</text></view><view class="old-card old-guide"><text class="old-title">可以捐赠</text><text>✓ 清洗干净、无明显污渍破损的四季衣物</text><text>✓ 内容健康、无严重缺页涂画的书籍</text><text>✓ 功能正常、配件齐全的生活用品</text><text>✓ 安全完好、可继续使用的文体用品</text></view><view class="old-card old-guide"><text class="old-title">禁止捐赠</text><text>× 贴身衣物、药品、食品、化妆品</text><text>× 霉变、虫蛀、严重破损或无法清洁的物品</text><text>× 易燃易爆、尖锐危险及来源不明物品</text><text>× 涉及盗版、违法或不适合公益流转的内容</text></view><view class="old-card old-guide"><text class="old-title">整理与交接</text><text>请分类装袋或装箱,易碎品做好保护并在描述中注明。上门取件以后台确认的预约安排为准;定点投递请在开放时间内送达并出示公益编号。</text></view></view>
@@ -0,0 +1 @@
@import '../../old-items.wxss';
@@ -0,0 +1 @@
{ "navigationBarTitleText": "旧物善用" }
@@ -0,0 +1,47 @@
import { getOldItemNeeds, getOldItemStats } from '../../services/old-items';
Page({
data: {
loading: true,
error: '',
stats: { totalItems: 0, assistedPeople: 0, monthlyKindActs: 0 },
needs: [] as Array<{
publicId: string;
title: string;
regionText: string;
remainingQuantity: number;
}>,
},
onShow() {
void this.load();
},
async load() {
this.setData({ loading: true, error: '' });
try {
const [stats, needs] = await Promise.all([getOldItemStats(), getOldItemNeeds()]);
this.setData({ stats, needs: needs.items.slice(0, 3) });
} catch (error) {
this.setData({ error: error instanceof Error ? error.message : '加载失败' });
} finally {
this.setData({ loading: false });
}
},
donate() {
void wx.navigateTo({ url: '/pages/old-item-donate/old-item-donate' });
},
records() {
void wx.navigateTo({ url: '/pages/old-item-records/old-item-records' });
},
rewards() {
void wx.navigateTo({ url: '/pages/old-item-rewards/old-item-rewards' });
},
needs() {
void wx.navigateTo({ url: '/pages/old-item-needs/old-item-needs' });
},
news() {
void wx.navigateTo({ url: '/pages/content/content?type=PUBLIC_WELFARE_NEWS' });
},
guide() {
void wx.navigateTo({ url: '/pages/old-item-guide/old-item-guide' });
},
});
@@ -0,0 +1,10 @@
<view class="old-page">
<view class="old-hero"><text class="old-hero__title">闲置不浪费,旧物有温度</text><text class="old-hero__copy">让每一件闲置物品,抵达真正需要的人身边</text></view>
<view wx:if="{{error}}" class="old-card"><text>{{error}}</text><button class="old-secondary" bindtap="load">重试</button></view>
<block wx:else>
<view class="old-card"><button class="old-primary" bindtap="donate">我要捐赠旧物</button><button class="old-secondary" bindtap="records">我的捐赠记录</button><button class="old-secondary" bindtap="rewards">我的证书与荣誉积分</button></view>
<view class="old-card old-stats"><view class="old-stat"><text>{{stats.totalItems}}</text><text>累计捐赠件数</text></view><view class="old-stat"><text>{{stats.assistedPeople}}</text><text>累计帮扶人数</text></view><view class="old-stat"><text>{{stats.monthlyKindActs}}</text><text>本月爱心人次</text></view></view>
<view class="old-grid"><button bindtap="needs"><text>物资需求公示</text><text>精准帮扶</text></button><button bindtap="records"><text>捐赠进度溯源</text><text>全程透明</text></button><button bindtap="news"><text>公益动态资讯</text><text>项目进展</text></button><button bindtap="guide"><text>捐赠规范指南</text><text>可捐 / 禁捐说明</text></button></view>
<view class="old-card"><text class="old-title">最新物资需求</text><view wx:for="{{needs}}" wx:key="publicId"><text class="old-muted">{{item.title}} · {{item.regionText}} · 尚需 {{item.remainingQuantity}} 件</text></view><text wx:if="{{!needs.length}}" class="old-muted">当前暂无已发布需求,请稍后查看。</text></view>
</block>
</view>
@@ -0,0 +1 @@
@import '../../old-items.wxss';
@@ -0,0 +1 @@
{ "navigationBarTitleText": "物资需求公示" }
@@ -0,0 +1,48 @@
import type { OldItemNeedItem } from '@lehuo/contracts';
import { getOldItemNeeds } from '../../services/old-items';
const labels: Record<string, string> = {
CLOTHING: '过冬衣物',
BOOKS: '学习用品',
HOUSEHOLD: '生活用品',
SPORTS: '文体用品',
};
Page({
data: {
loading: true,
error: '',
category: '',
tabs: [
{ value: '', label: '全部' },
{ value: 'CLOTHING', label: '过冬衣物' },
{ value: 'BOOKS', label: '学习用品' },
{ value: 'HOUSEHOLD', label: '生活用品' },
{ value: 'SPORTS', label: '文体用品' },
],
items: [] as Array<OldItemNeedItem & { categoryLabel: string }>,
},
onLoad() {
void this.load();
},
async load() {
this.setData({ loading: true, error: '' });
try {
const r = await getOldItemNeeds(this.data.category);
this.setData({
items: r.items.map((x) => ({ ...x, categoryLabel: labels[x.category] ?? x.category })),
});
} catch (e) {
this.setData({ error: e instanceof Error ? e.message : '加载失败' });
} finally {
this.setData({ loading: false });
}
},
filter(e: WechatMiniprogram.BaseEvent) {
this.setData({ category: String(e.currentTarget.dataset['category'] ?? '') });
void this.load();
},
donate(e: WechatMiniprogram.BaseEvent) {
void wx.navigateTo({
url: `/pages/old-item-donate/old-item-donate?need=${String(e.currentTarget.dataset['id'])}&category=${String(e.currentTarget.dataset['category'])}`,
});
},
});
@@ -0,0 +1 @@
<view class="old-page"><view class="old-hero"><text class="old-hero__title">精准物资需求</text><text class="old-hero__copy">需求经后台发布,捐赠可直接定向关联</text></view><view class="old-options"><button wx:for="{{tabs}}" wx:key="value" class="{{category===item.value?'active':''}}" data-category="{{item.value}}" bindtap="filter">{{item.label}}</button></view><view wx:if="{{error}}" class="old-card">{{error}}</view><view wx:for="{{items}}" wx:key="publicId" class="old-card"><text class="old-title">{{item.title}}</text><text class="old-muted">{{item.regionText}} · {{item.beneficiaryGroup}}</text><text class="old-muted">{{item.description}}</text><text class="old-muted">{{item.categoryLabel}} · 尚需 {{item.remainingQuantity}} / {{item.requestedQuantity}} 件</text><button class="old-primary" data-id="{{item.publicId}}" data-category="{{item.category}}" bindtap="donate">定向捐赠</button></view><view wx:if="{{!loading&&!error&&!items.length}}" class="old-empty">当前暂无已发布需求</view></view>
@@ -0,0 +1 @@
@import '../../old-items.wxss';
@@ -0,0 +1 @@
{ "navigationBarTitleText": "我的捐赠记录" }
@@ -0,0 +1,65 @@
import type { OldItemDonationItem } from '@lehuo/contracts';
import { getMyOldItemDonations } from '../../services/old-items';
const labels: Record<string, string> = {
SUBMITTED: '待审核',
REJECTED: '已驳回',
WAITING_PICKUP: '待取件',
WAITING_DROP_OFF: '待投递',
RECEIVED: '已接收',
PROCESSING: '分拣中',
COMPLETED: '已完成',
CANCELLED: '已取消',
};
const categoryLabels: Record<string, string> = {
CLOTHING: '服饰',
BOOKS: '书籍',
HOUSEHOLD: '生活用品',
SPORTS: '文体用品',
};
Page({
data: {
loading: true,
error: '',
status: '',
tabs: [
{ value: '', label: '全部' },
{ value: 'SUBMITTED', label: '待审核' },
{ value: 'WAITING_PICKUP', label: '待取件' },
{ value: 'COMPLETED', label: '已完成' },
{ value: 'REJECTED', label: '已驳回' },
],
items: [] as Array<
OldItemDonationItem & { statusLabel: string; categoryLabel: string; dateText: string }
>,
},
onShow() {
void this.load();
},
async load() {
this.setData({ loading: true, error: '' });
try {
const r = await getMyOldItemDonations(this.data.status);
this.setData({
items: r.items.map((x) => ({
...x,
statusLabel: labels[x.status] ?? x.status,
categoryLabel: categoryLabels[x.category] ?? x.category,
dateText: new Date(x.createdAt).toLocaleDateString('zh-CN'),
})),
});
} catch (e) {
this.setData({ error: e instanceof Error ? e.message : '加载失败' });
} finally {
this.setData({ loading: false });
}
},
filter(e: WechatMiniprogram.BaseEvent) {
this.setData({ status: String(e.currentTarget.dataset['status'] ?? '') });
void this.load();
},
open(e: WechatMiniprogram.BaseEvent) {
void wx.navigateTo({
url: `/pages/old-item-detail/old-item-detail?id=${String(e.currentTarget.dataset['id'])}`,
});
},
});
@@ -0,0 +1 @@
<view class="old-page"><view class="old-options"><button wx:for="{{tabs}}" wx:key="value" class="{{status===item.value?'active':''}}" data-status="{{item.value}}" bindtap="filter">{{item.label}}</button></view><view wx:if="{{error}}" class="old-card"><text>{{error}}</text><button class="old-secondary" bindtap="load">重试</button></view><button wx:for="{{items}}" wx:key="publicId" class="old-record" data-id="{{item.publicId}}" bindtap="open"><view class="old-record__head"><text class="old-title">{{item.categoryLabel}} · {{item.quantity}}件</text><text class="old-record__status">{{item.statusLabel}}</text></view><text class="old-muted">{{item.donationNo}}</text><text class="old-muted">{{item.dateText}} · {{item.method==='PICKUP'?'上门取件':'定点投递'}}</text></button><view wx:if="{{!loading&&!error&&!items.length}}" class="old-empty">暂无符合条件的捐赠记录</view></view>
@@ -0,0 +1 @@
@import '../../old-items.wxss';
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "证书与荣誉积分"
}
@@ -0,0 +1,47 @@
import type { OldItemRewardsSummary } from '@lehuo/contracts';
import { getMyOldItemRewards } from '../../services/old-items';
Page({
data: {
loading: true,
error: '',
summary: null as
| (OldItemRewardsSummary & {
certificateView: Array<
OldItemRewardsSummary['certificates'][number] & { issuedDate: string }
>;
entryView: Array<OldItemRewardsSummary['entries'][number] & { createdDate: string }>;
})
| null,
},
onShow() {
void this.load();
},
async load() {
this.setData({ loading: true, error: '' });
try {
const summary = await getMyOldItemRewards();
this.setData({
summary: {
...summary,
certificateView: summary.certificates.map((item) => ({
...item,
issuedDate: new Date(item.issuedAt).toLocaleDateString('zh-CN'),
})),
entryView: summary.entries.map((item) => ({
...item,
createdDate: new Date(item.createdAt).toLocaleString('zh-CN'),
})),
},
});
} catch (error) {
this.setData({ error: error instanceof Error ? error.message : '加载失败' });
} finally {
this.setData({ loading: false });
}
},
openCertificate(event: WechatMiniprogram.BaseEvent) {
const id = event.currentTarget.dataset['id'] as string;
void wx.navigateTo({ url: `/pages/old-item-certificate/old-item-certificate?id=${id}` });
},
});
@@ -0,0 +1,10 @@
<view class="old-page">
<view class="old-hero"><text class="old-hero__copy">旧物善用 · 公益荣誉</text><text class="old-hero__title">每一份善意,都值得被记住</text></view>
<view wx:if="{{loading}}" class="old-card"><text class="old-muted">正在加载荣誉记录…</text></view>
<view wx:elif="{{error}}" class="old-card"><text>{{error}}</text><button class="old-secondary" bindtap="load">重试</button></view>
<block wx:elif="{{summary}}">
<view class="old-card reward-balance"><text class="reward-number">{{summary.balance}}</text><text class="reward-label">公益荣誉积分</text><text class="old-muted">仅用于记录公益参与,不可兑换、转让、提现或购买商品</text></view>
<view class="old-card"><text class="old-title">我的电子证书</text><view wx:for="{{summary.certificateView}}" wx:key="publicId" class="reward-row" data-id="{{item.publicId}}" bindtap="openCertificate"><view class="reward-content"><text class="old-title">{{item.title}}</text><text class="old-muted">{{item.certificateNo}} · {{item.issuedDate}}</text><text wx:if="{{item.status==='REVOKED'}}" class="reward-status">已撤销</text></view><text>查看 ›</text></view><text wx:if="{{!summary.certificates.length}}" class="old-muted">完成旧物捐赠后,将在这里看到电子荣誉证书。</text></view>
<view class="old-card"><text class="old-title">积分记录</text><view wx:for="{{summary.entryView}}" wx:key="publicId" class="reward-row"><view class="reward-content"><text>{{item.entryType==='EARNED'?'完成旧物捐赠':'后台校正'}}</text><text class="old-muted">{{item.donationNo || item.note}} · {{item.createdDate}}</text><text class="old-muted">结余 {{item.balanceAfter}}</text></view><text class="reward-delta {{item.pointsDelta<0?'reward-delta--negative':''}}">{{item.pointsDelta>0?'+':''}}{{item.pointsDelta}}</text></view><text wx:if="{{!summary.entries.length}}" class="old-muted">暂无积分记录。</text></view>
</block>
</view>
@@ -0,0 +1,43 @@
@import '../../old-items.wxss';
.reward-balance {
text-align: center;
}
.reward-number {
display: block;
color: #e85220;
font-size: 72rpx;
font-weight: 800;
}
.reward-label {
color: #687078;
font-size: 26rpx;
}
.reward-row {
display: flex;
padding: 24rpx 0;
border-bottom: 2rpx solid #f0f1f2;
justify-content: space-between;
gap: 20rpx;
}
.reward-row:last-child {
border-bottom: 0;
}
.reward-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.reward-delta {
color: #2f9b58;
font-size: 34rpx;
font-weight: 700;
}
.reward-delta--negative {
color: #b42318;
}
.reward-status {
color: #b42318;
font-size: 25rpx;
}
@@ -46,4 +46,7 @@ Page({
organization() {
void wx.navigateTo({ url: '/pages/content/content?type=ORGANIZATION_INTRO' });
},
oldItems() {
void wx.navigateTo({ url: '/pages/old-item-home/old-item-home' });
},
});
@@ -3,9 +3,9 @@
<view wx:elif="{{error}}" class="state-card"><text class="state-title">暂时无法打开个人中心</text><text class="state-text">{{error}}</text><button class="secondary-button" bindtap="load">重新加载</button></view>
<block wx:else>
<view class="profile-hero"><view class="avatar">乐</view><view><text class="profile-name">{{user.displayName || '乐活用户'}}</text><text class="profile-id">账号已安全登录</text></view><text class="profile-badge">{{isVolunteer ? '认证志愿者' : '公益伙伴'}}</text><view class="profile-trust"><view><text class="profile-trust__title">隐私保护</text><text>最少采集</text></view><view><text class="profile-trust__title">服务可查</text><text>进度留痕</text></view><view><text class="profile-trust__title">消息可达</text><text>及时通知</text></view></view></view>
<view class="summary-card"><button bindtap="applications"><text class="summary-card__icon orange">服</text><text class="summary-card__label">我的申请</text></button><button bindtap="volunteer"><text class="summary-card__icon green">愿</text><text class="summary-card__label">志愿服务</text></button><button bindtap="notifications"><text class="summary-card__icon blue">信</text><text class="summary-card__label">我的消息</text></button></view>
<view class="summary-card"><button bindtap="applications"><text class="summary-card__icon orange">帮</text><text class="summary-card__label">帮扶服务</text></button><button bindtap="volunteer"><text class="summary-card__icon green">愿</text><text class="summary-card__label">志愿服务</text></button><button bindtap="notifications"><text class="summary-card__icon blue">信</text><text class="summary-card__label">我的消息</text></button></view>
<view class="section-heading"><text class="section-title">我的服务</text></view>
<view class="menu-card"><button bindtap="applications"><text class="menu-icon orange">申</text><view><text class="menu-title">我的服务申请</text><text class="menu-copy">查看需求受理与服务进度</text></view><text class="menu-arrow">›</text></button><button bindtap="volunteer"><text class="menu-icon green">愿</text><view><text class="menu-title">志愿者中心</text><text class="menu-copy">资料认证、活动报名与服务时长</text></view><text class="menu-arrow">›</text></button><button wx:if="{{canCollaborate}}" bindtap="assignedCases"><text class="menu-icon blue">协</text><view><text class="menu-title">我的协作任务</text><text class="menu-copy">查看被指派的必要服务信息</text></view><text class="menu-arrow">›</text></button></view>
<view class="menu-card"><button bindtap="applications"><text class="menu-icon orange">帮</text><view><text class="menu-title">我的帮扶服务</text><text class="menu-copy">查看需求受理与服务进度</text></view><text class="menu-arrow">›</text></button><button bindtap="oldItems"><text class="menu-icon orange">物</text><view><text class="menu-title">我的旧物善用</text><text class="menu-copy">捐赠闲置物品并查看全程溯源</text></view><text class="menu-arrow">›</text></button><button bindtap="volunteer"><text class="menu-icon green">愿</text><view><text class="menu-title">志愿者中心</text><text class="menu-copy">资料认证、活动报名与服务时长</text></view><text class="menu-arrow">›</text></button><button wx:if="{{canCollaborate}}" bindtap="assignedCases"><text class="menu-icon blue">协</text><view><text class="menu-title">我的协作任务</text><text class="menu-copy">查看被指派的必要服务信息</text></view><text class="menu-arrow">›</text></button></view>
<view class="section-heading"><text class="section-title">消息与帮助</text></view>
<view class="menu-card"><button bindtap="notifications"><text class="menu-icon blue">信</text><view><text class="menu-title">我的消息</text><text class="menu-copy">服务进度、报名结果与通知设置</text></view><text class="menu-arrow">›</text></button><button bindtap="content"><text class="menu-icon purple">问</text><view><text class="menu-title">公益资讯与常见问题</text><text class="menu-copy">查看经审核发布的帮助内容</text></view><text class="menu-arrow">›</text></button><button bindtap="organization"><text class="menu-icon orange">乐</text><view><text class="menu-title">关于乐活公益</text><text class="menu-copy">了解机构与服务说明</text></view><text class="menu-arrow">›</text></button></view>
<view class="privacy-footnote">您的服务资料仅用于提供公益服务,并按最小权限原则保护。</view>
@@ -9,12 +9,19 @@ Page({
tone: 'orange',
},
{
code: 'WOMEN_CHILDREN',
label: '妇女儿童帮扶',
description: '困境支持、成长陪伴与权益关爱',
icon: '爱',
code: 'WOMEN',
label: '困境妇女',
description: '困境支持、权益关爱与资源链接',
icon: '女',
tone: 'rose',
},
{
code: 'CHILD',
label: '儿童关爱',
description: '成长陪伴、家庭支持与儿童关爱',
icon: '童',
tone: 'orange',
},
{
code: 'DISABILITY',
label: '残障帮扶',
@@ -23,9 +30,9 @@ Page({
tone: 'blue',
},
{
code: 'DIFFICULT_FAMILY',
label: '困难家庭服务',
description: '生活困难、资源链接与持续关爱',
code: 'LIFE_DIFFICULTY',
label: '生活困难',
description: '生活支持、资源链接与持续关爱',
icon: '家',
tone: 'green',
},
@@ -1,7 +1,7 @@
<view class="page">
<view class="service-hero"><text class="service-hero__eyebrow">乐活暖心服务</text><text class="service-hero__title">有困难,您可以告诉我们</text><text class="service-hero__copy">填写基本情况后,工作人员会根据实际服务能力与您联系。</text></view>
<view class="quick-actions"><button bindtap="applyForSelf"><text class="quick-actions__icon">♡</text><text class="quick-actions__title">我需要帮助</text><text class="quick-actions__copy">为自己申请</text></button><button bindtap="applyForFamily"><text class="quick-actions__icon green">♧</text><text class="quick-actions__title">帮家人申请</text><text class="quick-actions__copy">为他人代办</text></button></view>
<button class="progress-entry" bindtap="applications"><view><text class="progress-entry__title">我的服务申请</text><text class="progress-entry__copy">查看受理进度和历史记录</text></view><text>›</text></button>
<button class="progress-entry" bindtap="applications"><view><text class="progress-entry__title">我的帮扶服务</text><text class="progress-entry__copy">查看需求受理进度和历史记录</text></view><text>›</text></button>
<view class="section-heading"><text class="section-title">选择服务类型</text><text class="section-note">不知道选什么也没关系</text></view>
<button wx:for="{{categories}}" wx:key="code" class="category-card" data-code="{{item.code}}" bindtap="applyCategory"><text class="category-card__icon {{item.tone}}">{{item.icon}}</text><view><text class="category-card__title">{{item.label}}</text><text class="category-card__copy">{{item.description}}</text></view><text class="category-card__arrow">›</text></button>
<view class="safety-note"><text class="safety-note__title">温馨提示</text><text>平台不承诺即时救援。如遇危及生命安全的紧急情况,请及时联系 110 或 120。</text></view>
@@ -0,0 +1,95 @@
import type {
CreateOldItemDonationRequest,
CreateUploadIntentResponse,
OldItemDonationItem,
OldItemDropOffPoint,
OldItemNeedItem,
OldItemCertificateItem,
OldItemRewardsSummary,
OldItemStats,
DownloadUrlResponse,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
export interface Page<T> {
items: T[];
page: number;
pageSize: number;
total: number;
}
export const getOldItemStats = () => apiRequest<OldItemStats>('/api/v1/old-items/stats');
export const getOldItemNeeds = (category = '') =>
apiRequest<Page<OldItemNeedItem>>(
`/api/v1/old-items/needs?page=1&pageSize=50${category ? `&category=${category}` : ''}`,
);
export const getOldItemDropOffPoints = () =>
apiRequest<{ items: OldItemDropOffPoint[] }>('/api/v1/old-items/drop-off-points');
export const createOldItemDonation = (input: CreateOldItemDonationRequest) =>
apiRequest<{ donation: OldItemDonationItem }>('/api/v1/old-items/donations', {
method: 'POST',
data: input,
});
export const getMyOldItemDonations = (status = '') =>
apiRequest<Page<OldItemDonationItem>>(
`/api/v1/old-items/me/donations?page=1&pageSize=50${status ? `&status=${status}` : ''}`,
);
export const getMyOldItemDonation = (id: string) =>
apiRequest<{ donation: OldItemDonationItem }>(`/api/v1/old-items/me/donations/${id}`);
export const getMyOldItemRewards = () =>
apiRequest<OldItemRewardsSummary>('/api/v1/old-items/me/rewards');
export const getMyOldItemCertificate = (id: string) =>
apiRequest<{ certificate: OldItemCertificateItem }>(`/api/v1/old-items/me/certificates/${id}`);
export const getMyOldItemImageUrl = (id: string) =>
apiRequest<DownloadUrlResponse>(`/api/v1/old-items/me/media/${id}/download`);
function fileInfo(path: string): Promise<{ size: number }> {
return new Promise((resolve, reject) =>
wx.getFileInfo({ filePath: path, success: resolve, fail: reject }),
);
}
function read(path: string): Promise<ArrayBuffer> {
return new Promise((resolve, reject) =>
wx
.getFileSystemManager()
.readFile({ filePath: path, success: (r) => resolve(r.data as ArrayBuffer), fail: reject }),
);
}
function put(url: string, data: ArrayBuffer, headers: Record<string, string>): Promise<void> {
return new Promise((resolve, reject) =>
wx.request({
url,
method: 'PUT',
data,
header: headers,
success: (r) =>
r.statusCode >= 200 && r.statusCode < 300 ? resolve() : reject(new Error('图片上传失败')),
fail: reject,
}),
);
}
export async function uploadOldItemImage(path: string) {
const info = await fileInfo(path);
const ext = (path.split('.').pop() || 'jpg').toLowerCase();
const mimeType = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
const intent = await apiRequest<CreateUploadIntentResponse>(
'/api/v1/old-items/media/upload-intents',
{
method: 'POST',
data: {
clientRequestId: cryptoId(),
businessType: 'OLD_ITEM_DONATION_IMAGE',
filename: `donation.${ext === 'jpeg' ? 'jpg' : ext}`,
mimeType,
size: info.size,
},
},
);
await put(intent.uploadUrl, await read(path), intent.uploadHeaders);
await apiRequest(`/api/v1/old-items/media/${intent.file.publicId}/confirm`, { method: 'POST' });
return intent.file.publicId;
}
export function cryptoId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0,
v = c === 'x' ? r : (r & 3) | 8;
return v.toString(16);
});
}