feat(old-items): complete reuse workflow and refine miniapp home
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user