Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad3e42ed6 | ||
|
|
7e71455c6b | ||
|
|
7aae913c9e | ||
|
|
65eac893d8 | ||
|
|
0a91cafc4e | ||
|
|
f3737c80be | ||
|
|
65d23eab4a | ||
|
|
39a2e53e61 | ||
|
|
58787e738a | ||
|
|
9622d249be |
@@ -1,9 +1,17 @@
|
|||||||
.git
|
.git
|
||||||
.github
|
.github
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
|
dist
|
||||||
**/dist
|
**/dist
|
||||||
coverage
|
coverage
|
||||||
.env
|
**/coverage
|
||||||
*.log
|
*.log
|
||||||
docs
|
apps/miniprogram/.miniprogram
|
||||||
*.png
|
apps/miniprogram/miniprogram_npm
|
||||||
|
mysql-data
|
||||||
|
minio-data
|
||||||
|
redis-data
|
||||||
|
docs/ui_reference.png
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.pnpm-store/
|
||||||
|
.workbuddy/
|
||||||
|
# 微信开发者工具在仓库根目录误生成的工程配置(本地产物,与 apps/miniprogram 下的同名文件重复)
|
||||||
|
/project.config.json
|
||||||
|
/project.private.config.json
|
||||||
|
# 微信开发者工具私人配置,含本机路径与调试开关,不入库
|
||||||
|
apps/miniprogram/project.private.config.json
|
||||||
apps/miniprogram/miniprogram_npm/
|
apps/miniprogram/miniprogram_npm/
|
||||||
apps/miniprogram/.miniprogram/
|
apps/miniprogram/.miniprogram/
|
||||||
apps/api/src/generated/prisma/
|
apps/api/src/generated/prisma/
|
||||||
|
|||||||
@@ -35,3 +35,9 @@ pnpm build
|
|||||||
集成测试只允许连接数据库名为 `lehuo_test` 的 MySQL。可先运行 `docker compose --profile test up -d mysql-test`,再设置 `TEST_DATABASE_URL` 后执行测试。
|
集成测试只允许连接数据库名为 `lehuo_test` 的 MySQL。可先运行 `docker compose --profile test up -d mysql-test`,再设置 `TEST_DATABASE_URL` 后执行测试。
|
||||||
|
|
||||||
项目约束、当前阶段和决策分别见 `docs/01_PROJECT_CONSTITUTION.md`、`docs/10_TASK_STATE.md`、`docs/11_DECISIONS.md`。
|
项目约束、当前阶段和决策分别见 `docs/01_PROJECT_CONSTITUTION.md`、`docs/10_TASK_STATE.md`、`docs/11_DECISIONS.md`。
|
||||||
|
|
||||||
|
## Ubuntu 26.04 云服务器部署
|
||||||
|
|
||||||
|
Ubuntu 26.04 LTS 单机部署包位于 `infra/ubuntu/`,提供 Docker 官方源安装、生产环境密钥生成、双域名 TLS、私有 MySQL/Redis/MinIO、API/管理后台镜像、migration、备份恢复、冒烟和回滚脚本。完整步骤见 `infra/ubuntu/README.md`。
|
||||||
|
|
||||||
|
该部署包不替代 staging、UAT、微信合法域名/真实 AppID、隐私法务、告警接收与异地备份验收;外部项未完成前不得宣称已生产上线。
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:24.13.0-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/admin-web/package.json apps/admin-web/package.json
|
||||||
|
COPY packages/contracts/package.json packages/contracts/package.json
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY apps/admin-web apps/admin-web
|
||||||
|
COPY packages/contracts packages/contracts
|
||||||
|
ARG VITE_API_BASE_URL
|
||||||
|
RUN test -n "$VITE_API_BASE_URL"
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
RUN pnpm --filter @lehuo/admin-web build
|
||||||
|
|
||||||
|
FROM nginx:1.28.0-alpine AS runtime
|
||||||
|
COPY --from=build /app/apps/admin-web/dist /usr/share/nginx/html
|
||||||
|
COPY infra/ubuntu/nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
RUN rm -f /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80 443
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@lehuo/admin-web",
|
"name": "@lehuo/admin-web",
|
||||||
"version": "0.1.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lehuo/contracts": "workspace:*",
|
"@lehuo/contracts": "workspace:*",
|
||||||
|
"dompurify": "^3.4.13",
|
||||||
|
"exceljs": "4.4.0",
|
||||||
|
"mammoth": "^1.12.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.20",
|
"vue": "^3.5.20",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import ActivitiesView from './views/ActivitiesView.vue';
|
|||||||
import ContentView from './views/ContentView.vue';
|
import ContentView from './views/ContentView.vue';
|
||||||
import NotificationDeliveriesView from './views/NotificationDeliveriesView.vue';
|
import NotificationDeliveriesView from './views/NotificationDeliveriesView.vue';
|
||||||
import StatisticsView from './views/StatisticsView.vue';
|
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 auth = useAuthStore();
|
||||||
const form = reactive({ username: '', password: '' });
|
const form = reactive({ username: '', password: '' });
|
||||||
@@ -22,7 +26,10 @@ const errors = reactive<{ username?: string; password?: string }>({});
|
|||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const activeView = ref<
|
const activeView = ref<ViewKey>('dashboard');
|
||||||
|
|
||||||
|
type ViewKey =
|
||||||
|
| 'dashboard'
|
||||||
| 'applications'
|
| 'applications'
|
||||||
| 'beneficiaries'
|
| 'beneficiaries'
|
||||||
| 'cases'
|
| 'cases'
|
||||||
@@ -33,7 +40,24 @@ const activeView = ref<
|
|||||||
| 'content'
|
| 'content'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'statistics'
|
| 'statistics'
|
||||||
>('applications');
|
| 'financial-disclosures'
|
||||||
|
| 'old-items';
|
||||||
|
|
||||||
|
const NAV_ITEMS: ReadonlyArray<{ key: ViewKey; label: string; icon: string }> = [
|
||||||
|
{ key: 'dashboard', label: '工作概览', icon: 'dashboard' },
|
||||||
|
{ key: 'applications', label: '服务申请', icon: 'application' },
|
||||||
|
{ key: 'cases', label: '服务工单', icon: 'case' },
|
||||||
|
{ key: 'follow-ups', label: '回访工作台', icon: 'phone' },
|
||||||
|
{ key: 'beneficiaries', label: '服务对象', icon: 'people' },
|
||||||
|
{ key: 'volunteers', label: '志愿者审核', icon: 'volunteer' },
|
||||||
|
{ key: 'projects', label: '公益项目', icon: 'flag' },
|
||||||
|
{ key: 'activities', label: '活动与报名', icon: 'calendar' },
|
||||||
|
{ key: 'content', label: '内容发布', icon: 'content' },
|
||||||
|
{ key: 'financial-disclosures', label: '财务公开', icon: 'coins' },
|
||||||
|
{ key: 'notifications', label: '通知投递', icon: 'notification' },
|
||||||
|
{ key: 'statistics', label: '服务统计', icon: 'chart' },
|
||||||
|
{ key: 'old-items', label: '旧物善用管理', icon: 'recycle' },
|
||||||
|
];
|
||||||
|
|
||||||
onMounted(() => auth.restore());
|
onMounted(() => auth.restore());
|
||||||
|
|
||||||
@@ -137,87 +161,17 @@ async function logout(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
<button
|
<button
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'applications' }]"
|
v-for="item in NAV_ITEMS"
|
||||||
|
:key="item.key"
|
||||||
|
:class="['nav-item', { 'nav-item--active': activeView === item.key }]"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-current="activeView === 'applications' ? 'page' : undefined"
|
:aria-current="activeView === item.key ? 'page' : undefined"
|
||||||
@click="activeView = 'applications'"
|
@click="activeView = item.key"
|
||||||
>
|
>
|
||||||
服务申请
|
<AdminIcon :name="item.icon" />{{ item.label }}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'statistics' }]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="activeView === 'statistics' ? 'page' : undefined"
|
|
||||||
@click="activeView = 'statistics'"
|
|
||||||
>
|
|
||||||
服务统计
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'content' }]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="activeView === 'content' ? 'page' : undefined"
|
|
||||||
@click="activeView = 'content'"
|
|
||||||
>
|
|
||||||
内容发布
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'notifications' }]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="activeView === 'notifications' ? 'page' : undefined"
|
|
||||||
@click="activeView = 'notifications'"
|
|
||||||
>
|
|
||||||
通知投递
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'beneficiaries' }]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="activeView === 'beneficiaries' ? 'page' : undefined"
|
|
||||||
@click="activeView = 'beneficiaries'"
|
|
||||||
>
|
|
||||||
服务对象
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'cases' }]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="activeView === 'cases' ? 'page' : undefined"
|
|
||||||
@click="activeView = 'cases'"
|
|
||||||
>
|
|
||||||
服务工单
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'follow-ups' }]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="activeView === 'follow-ups' ? 'page' : undefined"
|
|
||||||
@click="activeView = 'follow-ups'"
|
|
||||||
>
|
|
||||||
回访工作台
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'volunteers' }]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="activeView === 'volunteers' ? 'page' : undefined"
|
|
||||||
@click="activeView = 'volunteers'"
|
|
||||||
>
|
|
||||||
志愿者审核
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'projects' }]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="activeView === 'projects' ? 'page' : undefined"
|
|
||||||
@click="activeView = 'projects'"
|
|
||||||
>
|
|
||||||
公益项目
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { 'nav-item--active': activeView === 'activities' }]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="activeView === 'activities' ? 'page' : undefined"
|
|
||||||
@click="activeView = 'activities'"
|
|
||||||
>
|
|
||||||
活动与报名
|
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<p class="phase-note">Phase 6:内容审核、站内消息与微信订阅通知</p>
|
<p class="phase-note">仅限授权人员访问,关键业务操作均记录审计。</p>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="admin-main">
|
<div class="admin-main">
|
||||||
<header class="admin-topbar">
|
<header class="admin-topbar">
|
||||||
@@ -230,7 +184,11 @@ async function logout(): Promise<void> {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<p v-if="message" class="global-message" role="alert">{{ message }}</p>
|
<p v-if="message" class="global-message" role="alert">{{ message }}</p>
|
||||||
<ServiceApplicationsView v-if="activeView === 'applications'" />
|
<DashboardView
|
||||||
|
v-if="activeView === 'dashboard'"
|
||||||
|
@navigate="activeView = $event as typeof activeView"
|
||||||
|
/>
|
||||||
|
<ServiceApplicationsView v-else-if="activeView === 'applications'" />
|
||||||
<BeneficiariesView v-else-if="activeView === 'beneficiaries'" />
|
<BeneficiariesView v-else-if="activeView === 'beneficiaries'" />
|
||||||
<ServiceCasesView v-else-if="activeView === 'cases'" />
|
<ServiceCasesView v-else-if="activeView === 'cases'" />
|
||||||
<FollowUpsView v-else-if="activeView === 'follow-ups'" />
|
<FollowUpsView v-else-if="activeView === 'follow-ups'" />
|
||||||
@@ -238,8 +196,10 @@ async function logout(): Promise<void> {
|
|||||||
<ProjectsView v-else-if="activeView === 'projects'" />
|
<ProjectsView v-else-if="activeView === 'projects'" />
|
||||||
<ActivitiesView v-else-if="activeView === 'activities'" />
|
<ActivitiesView v-else-if="activeView === 'activities'" />
|
||||||
<ContentView v-else-if="activeView === 'content'" />
|
<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'" />
|
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
|
||||||
<StatisticsView v-else />
|
<StatisticsView v-else-if="activeView === 'statistics'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type {
|
||||||
|
CreateFinancialDisclosureRequest,
|
||||||
|
FinancialDisclosureDetailResponse,
|
||||||
|
PaginatedFinancialDisclosuresResponse,
|
||||||
|
TransitionFinancialDisclosureRequest,
|
||||||
|
UpdateFinancialDisclosureRequest,
|
||||||
|
} from '@lehuo/contracts';
|
||||||
|
import { apiRequest } from './api-client';
|
||||||
|
const query = (value: Record<string, string | number | undefined>) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(value).forEach(
|
||||||
|
([key, item]) => item !== undefined && params.set(key, String(item)),
|
||||||
|
);
|
||||||
|
return params.toString();
|
||||||
|
};
|
||||||
|
export const fetchFinancialDisclosures = (value: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
status?: string;
|
||||||
|
}) =>
|
||||||
|
apiRequest<PaginatedFinancialDisclosuresResponse>(
|
||||||
|
`/api/v1/admin/financial-disclosures?${query(value)}`,
|
||||||
|
);
|
||||||
|
export const fetchFinancialDisclosure = (id: string) =>
|
||||||
|
apiRequest<FinancialDisclosureDetailResponse>(`/api/v1/admin/financial-disclosures/${id}`);
|
||||||
|
export const createFinancialDisclosure = (input: CreateFinancialDisclosureRequest) =>
|
||||||
|
apiRequest<FinancialDisclosureDetailResponse>('/api/v1/admin/financial-disclosures', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
export const updateFinancialDisclosure = (id: string, input: UpdateFinancialDisclosureRequest) =>
|
||||||
|
apiRequest<FinancialDisclosureDetailResponse>(`/api/v1/admin/financial-disclosures/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
export const transitionFinancialDisclosure = (
|
||||||
|
id: string,
|
||||||
|
input: TransitionFinancialDisclosureRequest,
|
||||||
|
) =>
|
||||||
|
apiRequest<FinancialDisclosureDetailResponse>(
|
||||||
|
`/api/v1/admin/financial-disclosures/${id}/transition`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(input) },
|
||||||
|
);
|
||||||
@@ -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,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ name: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg class="admin-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
v-if="name === 'dashboard'"
|
||||||
|
d="M4 4h6v6H4V4Zm10 0h6v9h-6V4ZM4 14h6v6H4v-6Zm10 3h6v3h-6v-3Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else-if="name === 'application'"
|
||||||
|
d="M7 3h10v3h3v15H4V6h3V3Zm2 2v2h6V5H9Zm0 6v2h6v-2H9Zm0 4v2h6v-2H9Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else-if="name === 'people'"
|
||||||
|
d="M8 11a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm8-1a3 3 0 1 1 0-6 3 3 0 0 1 0 6ZM2 21v-3c0-3.2 2.7-5 6-5s6 1.8 6 5v3H2Zm13 0v-3.2c0-1.4-.5-2.7-1.4-3.7.8-.4 1.6-.6 2.4-.6 3 0 5 1.6 5 4.5v3h-6Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else-if="name === 'case'"
|
||||||
|
d="M9 3h6l1 3h5v14H3V6h5l1-3Zm1.4 3h3.2l-.3-1h-2.6l-.3 1ZM7 11v2h10v-2H7Zm0 4v2h7v-2H7Z"
|
||||||
|
/>
|
||||||
|
<path v-else-if="name === 'chart'" d="M4 20V9h4v11H4Zm6 0V4h4v16h-4Zm6 0v-7h4v7h-4Z" />
|
||||||
|
<path
|
||||||
|
v-else-if="name === 'content'"
|
||||||
|
d="M4 3h16v18H4V3Zm3 4v2h10V7H7Zm0 4v2h10v-2H7Zm0 4v2h7v-2H7Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else-if="name === 'coins'"
|
||||||
|
d="M12 4c4.4 0 8 1.1 8 2.5S16.4 9 12 9 4 7.9 4 6.5 7.6 4 12 4Zm8 5v2.5c0 1.4-3.6 2.5-8 2.5s-8-1.1-8-2.5V9c0 1.4 3.6 2.5 8 2.5S20 10.4 20 9Zm0 4.5V16c0 1.4-3.6 2.5-8 2.5S4 17.4 4 16v-2.5c0 1.4 3.6 2.5 8 2.5s8-1.1 8-2.5Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else-if="name === 'phone'"
|
||||||
|
d="M6.6 10.8c1.5 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.4.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.4 0 .7-.2 1l-2.3 2.2Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else-if="name === 'volunteer'"
|
||||||
|
d="M9 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-3.3 0-7 1.7-7 4v3h9.5a6 6 0 0 1-.5-2c0-1.9.9-3.6 2.3-4.7-1.3-.2-2.8-.3-4.3-.3Zm12-3.2-5.2 5.2-2-2L12.4 15.4 15.8 19l6.6-6.6-1.4-1.6Z"
|
||||||
|
/>
|
||||||
|
<path v-else-if="name === 'flag'" d="M5 3h2v18H5V3Zm4 1h10l-2.5 4L19 12H9V4Z" />
|
||||||
|
<path
|
||||||
|
v-else-if="name === 'calendar'"
|
||||||
|
d="M7 2h2v2h6V2h2v2h3v18H4V4h3V2ZM6 9v11h12V9H6Zm2 3h3v3H8v-3Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else-if="name === 'recycle'"
|
||||||
|
d="M7 7h8l-2.5-2.5L14 3l5 5-5 5-1.5-1.5L15 9H7a3 3 0 0 0-3 3H2a5 5 0 0 1 5-5Zm10 10H9l2.5 2.5L10 21l-5-5 5-5 1.5 1.5L9 15h8a3 3 0 0 0 3-3h2a5 5 0 0 1-5 5Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else
|
||||||
|
d="M12 2a7 7 0 0 1 7 7v4l2 3v1H3v-1l2-3V9a7 7 0 0 1 7-7Zm-3 17h6a3 3 0 0 1-6 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { defaultFinancialSummary, htmlToPlainText } from './financial-document';
|
||||||
|
describe('financial document helpers', () => {
|
||||||
|
it('creates a short readable summary from imported HTML', () => {
|
||||||
|
expect(htmlToPlainText('<h1>年度报告</h1><p>公益项目执行情况</p>')).toBe(
|
||||||
|
'年度报告公益项目执行情况',
|
||||||
|
);
|
||||||
|
expect(defaultFinancialSummary(`<p>${'财'.repeat(130)}</p>`)).toHaveLength(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function htmlToPlainText(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<br\s*\/?\s*>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/ | /gi, ' ')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'|'/gi, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultFinancialSummary(html: string): string {
|
||||||
|
return htmlToPlainText(html).slice(0, 120);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
declare module 'mammoth/mammoth.browser' {
|
||||||
|
export interface ConversionMessage {
|
||||||
|
type: 'warning' | 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export interface ConversionResult {
|
||||||
|
value: string;
|
||||||
|
messages: ConversionMessage[];
|
||||||
|
}
|
||||||
|
export function convertToHtml(input: { arrayBuffer: ArrayBuffer }): Promise<ConversionResult>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
--color-primary: #ff6b35;
|
--primitive-orange-500: #ff6b35;
|
||||||
|
--primitive-orange-600: #e95a28;
|
||||||
|
--primitive-navy-900: #202c38;
|
||||||
|
--primitive-navy-800: #293746;
|
||||||
|
--primitive-gray-50: #f5f7f9;
|
||||||
|
--primitive-gray-200: #e4e8ec;
|
||||||
|
--primitive-white: #fff;
|
||||||
|
--color-primary: var(--primitive-orange-500);
|
||||||
--color-primary-hover: #e95a28;
|
--color-primary-hover: #e95a28;
|
||||||
--color-primary-soft: #fff1eb;
|
--color-primary-soft: #fff1eb;
|
||||||
--color-text: #1f2329;
|
--color-text: #1f2329;
|
||||||
@@ -8,10 +15,11 @@
|
|||||||
--color-success: #237a2b;
|
--color-success: #237a2b;
|
||||||
--color-warning: #8a5a00;
|
--color-warning: #8a5a00;
|
||||||
--color-info: #145ea8;
|
--color-info: #145ea8;
|
||||||
--color-surface: #fff;
|
--color-surface: var(--primitive-white);
|
||||||
--color-background: #f5f5f5;
|
--color-background: var(--primitive-gray-50);
|
||||||
--color-border: #d9dce1;
|
--color-border: var(--primitive-gray-200);
|
||||||
--color-sidebar: #263342;
|
--color-sidebar: var(--primitive-navy-900);
|
||||||
|
--shadow-card: 0 3px 12px rgb(0 0 0 / 4%);
|
||||||
--focus-ring: 0 0 0 3px rgb(255 107 53 / 28%);
|
--focus-ring: 0 0 0 3px rgb(255 107 53 / 28%);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
@@ -20,6 +28,485 @@
|
|||||||
sans-serif;
|
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;
|
||||||
|
}
|
||||||
|
.financial-editor-panel {
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.financial-editor-head,
|
||||||
|
.rich-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.financial-editor-head {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.import-button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 38px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.import-button input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.financial-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 140px;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.financial-fields label:first-child,
|
||||||
|
.financial-fields label:nth-child(2) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.inline-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.inline-check input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.rich-toolbar {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.rich-toolbar button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid #d8dee8;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #344054;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rich-editor {
|
||||||
|
min-height: 480px;
|
||||||
|
padding: 36px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
outline: none;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.rich-editor:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgb(240 90 40 / 12%);
|
||||||
|
}
|
||||||
|
.rich-editor table,
|
||||||
|
.public-rich-text table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.rich-editor th,
|
||||||
|
.rich-editor td,
|
||||||
|
.public-rich-text th,
|
||||||
|
.public-rich-text td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #d9dde4;
|
||||||
|
}
|
||||||
|
.mobile-preview {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
min-height: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 30px 24px;
|
||||||
|
border: 10px solid #25282d;
|
||||||
|
border-radius: 34px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 48px rgb(23 31 45 / 18%);
|
||||||
|
}
|
||||||
|
.preview-label {
|
||||||
|
margin: -12px 0 24px;
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.preview-summary {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff5ee;
|
||||||
|
color: #6d4b3b;
|
||||||
|
}
|
||||||
|
.public-rich-text {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #30343b;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.public-rich-text img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.status-chip {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff0e8;
|
||||||
|
color: #e65322;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.financial-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.financial-fields label {
|
||||||
|
grid-column: 1 !important;
|
||||||
|
}
|
||||||
|
.rich-editor {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -208,17 +695,33 @@ input[aria-invalid='true'] {
|
|||||||
}
|
}
|
||||||
.admin-sidebar nav {
|
.admin-sidebar nav {
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.admin-sidebar nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.nav-item {
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 48px;
|
min-height: 42px;
|
||||||
padding: 10px 14px;
|
padding: 8px 14px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #e8edf2;
|
color: #e8edf2;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.admin-icon {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
fill: currentColor;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.nav-item--active {
|
.nav-item--active {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -227,7 +730,7 @@ input[aria-invalid='true'] {
|
|||||||
}
|
}
|
||||||
.admin-sidebar nav {
|
.admin-sidebar nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.inline-actions {
|
.inline-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -239,6 +742,7 @@ input[aria-invalid='true'] {
|
|||||||
}
|
}
|
||||||
.phase-note {
|
.phase-note {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #cbd3dc;
|
color: #cbd3dc;
|
||||||
@@ -246,6 +750,234 @@ input[aria-invalid='true'] {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
.page-kicker {
|
||||||
|
margin: 0 0 6px !important;
|
||||||
|
color: var(--color-primary) !important;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.dashboard-summary .summary-card {
|
||||||
|
border: 0;
|
||||||
|
border-top: 3px solid var(--color-primary);
|
||||||
|
box-shadow: 0 5px 18px rgb(28 39 51 / 7%);
|
||||||
|
}
|
||||||
|
.dashboard-summary .summary-card:nth-child(2) {
|
||||||
|
border-color: #378fe8;
|
||||||
|
}
|
||||||
|
.dashboard-summary .summary-card:nth-child(3) {
|
||||||
|
border-color: #42af57;
|
||||||
|
}
|
||||||
|
.dashboard-summary .summary-card:nth-child(4) {
|
||||||
|
border-color: #ffb13b;
|
||||||
|
}
|
||||||
|
.dashboard-charts {
|
||||||
|
display: grid;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.chart-panel {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.panel-heading h2 {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
}
|
||||||
|
.panel-heading p,
|
||||||
|
.panel-heading > span {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.bar-chart {
|
||||||
|
display: flex;
|
||||||
|
height: 220px;
|
||||||
|
padding: 18px 10px 0;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#eef1f4 0,
|
||||||
|
#eef1f4 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 55px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.bar-column {
|
||||||
|
display: flex;
|
||||||
|
min-width: 36px;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.bar-values {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: block;
|
||||||
|
width: min(18px, 35%);
|
||||||
|
min-height: 5px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
.bar--application {
|
||||||
|
background: #4b98ee;
|
||||||
|
}
|
||||||
|
.bar--completed {
|
||||||
|
background: #ff7848;
|
||||||
|
}
|
||||||
|
.bar-column small {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.chart-legend {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 18px;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.legend-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
margin-right: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.legend-dot--blue {
|
||||||
|
background: #4b98ee;
|
||||||
|
}
|
||||||
|
.legend-dot--orange {
|
||||||
|
background: #ff7848;
|
||||||
|
}
|
||||||
|
.distribution-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 19px;
|
||||||
|
}
|
||||||
|
.distribution-list > div > div {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.distribution-list progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.quick-panel {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.quick-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.quick-actions button {
|
||||||
|
display: flex;
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 14px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.quick-actions b {
|
||||||
|
display: grid;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
.quick-actions .blue {
|
||||||
|
background: #378fe8;
|
||||||
|
}
|
||||||
|
.quick-actions .green {
|
||||||
|
background: #42af57;
|
||||||
|
}
|
||||||
|
.quick-actions .gold {
|
||||||
|
background: #e8a22c;
|
||||||
|
}
|
||||||
|
.muted-text {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.case-stepper {
|
||||||
|
display: grid;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.case-stepper div {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.case-stepper div:not(:last-child)::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
top: 14px;
|
||||||
|
left: calc(50% + 20px);
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
height: 2px;
|
||||||
|
content: '';
|
||||||
|
background: #dde2e6;
|
||||||
|
}
|
||||||
|
.case-stepper i {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid #cbd2d8;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #7c858d;
|
||||||
|
background: #fff;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.case-stepper .done {
|
||||||
|
color: #268948;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.case-stepper .done i {
|
||||||
|
border-color: #3daf5c;
|
||||||
|
color: #fff;
|
||||||
|
background: #3daf5c;
|
||||||
|
}
|
||||||
|
.case-stepper .done:not(:last-child)::after {
|
||||||
|
background: #78ca8d;
|
||||||
|
}
|
||||||
.admin-main {
|
.admin-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -292,6 +1024,31 @@ input[aria-invalid='true'] {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
}
|
}
|
||||||
|
.workspace-view {
|
||||||
|
width: min(1440px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
.view-header {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.view-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.view-header p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 22px;
|
margin-bottom: 22px;
|
||||||
@@ -592,6 +1349,38 @@ tbody tr:hover {
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.dashboard-card {
|
||||||
|
display: grid;
|
||||||
|
padding: 18px;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.dashboard-card span {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.dashboard-card strong {
|
||||||
|
font-size: 32px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #ebecef;
|
||||||
|
}
|
||||||
|
.stat-row:last-of-type {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
.summary-card {
|
.summary-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 126px;
|
min-height: 126px;
|
||||||
@@ -742,6 +1531,15 @@ button.summary-card:hover {
|
|||||||
.summary-grid {
|
.summary-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.dashboard-charts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.quick-actions {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
@@ -757,7 +1555,27 @@ button.summary-card:hover {
|
|||||||
.brand-block {
|
.brand-block {
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
}
|
}
|
||||||
.admin-sidebar nav,
|
.admin-sidebar nav {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.admin-sidebar nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 44px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-item--active {
|
||||||
|
box-shadow: inset 0 -3px var(--color-primary);
|
||||||
|
}
|
||||||
.phase-note {
|
.phase-note {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -767,6 +1585,9 @@ button.summary-card:hover {
|
|||||||
.content-area {
|
.content-area {
|
||||||
padding: 20px 16px;
|
padding: 20px 16px;
|
||||||
}
|
}
|
||||||
|
.workspace-view {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
.page-header {
|
.page-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -789,6 +1610,12 @@ button.summary-card:hover {
|
|||||||
.summary-grid {
|
.summary-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.quick-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { SERVICE_CATEGORY_LABELS, type StatisticsOverview } from '@lehuo/contracts';
|
||||||
|
import { fetchStatistics } from '../api/statistics';
|
||||||
|
|
||||||
|
const emit = defineEmits<{ navigate: [view: string] }>();
|
||||||
|
const data = ref<StatisticsOverview | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const message = ref('');
|
||||||
|
const maxMonthly = computed(() =>
|
||||||
|
Math.max(1, ...(data.value?.monthly.map((item) => item.applications) ?? [1])),
|
||||||
|
);
|
||||||
|
const totalCategory = computed(() =>
|
||||||
|
Math.max(1, data.value?.byCategory.reduce((sum, item) => sum + item.count, 0) ?? 1),
|
||||||
|
);
|
||||||
|
const palette = ['#378fe8', '#ff7041', '#ffb84a', '#57bf58', '#7a67d8'];
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
data.value = await fetchStatistics();
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error instanceof Error ? error.message : '数据加载失败';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="content-area dashboard-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="page-kicker">管理后台</p>
|
||||||
|
<h1>工作概览</h1>
|
||||||
|
<p>业务数据由系统实时汇总,帮助团队掌握服务进展。</p>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button compact-button" @click="load">刷新数据</button>
|
||||||
|
</header>
|
||||||
|
<p v-if="message" class="global-message" role="alert">{{ message }}</p>
|
||||||
|
<section v-if="loading" class="summary-grid">
|
||||||
|
<div v-for="n in 4" :key="n" class="summary-card table-skeleton"></div>
|
||||||
|
</section>
|
||||||
|
<template v-else-if="data">
|
||||||
|
<section class="summary-grid dashboard-summary">
|
||||||
|
<article class="summary-card">
|
||||||
|
<span>服务对象总数</span
|
||||||
|
><strong>{{ data.uniqueBeneficiaryCount.toLocaleString() }}</strong
|
||||||
|
><small>统计区间内去重人数</small>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<span>服务工单完成</span><strong>{{ data.completedServiceCount.toLocaleString() }}</strong
|
||||||
|
><small>已完成服务事实</small>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<span>志愿者人数</span><strong>{{ data.volunteerCount.toLocaleString() }}</strong
|
||||||
|
><small>已登记志愿者</small>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<span>服务人次</span><strong>{{ data.servicePersonTimes.toLocaleString() }}</strong
|
||||||
|
><small>累计完成记录</small>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-charts">
|
||||||
|
<article class="panel chart-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>服务工作趋势</h2>
|
||||||
|
<p>申请量与完成服务量</p>
|
||||||
|
</div>
|
||||||
|
<span>{{ data.period.startDate }} — {{ data.period.endDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar-chart" role="img" aria-label="按月服务工作趋势">
|
||||||
|
<div v-for="item in data.monthly" :key="item.month" class="bar-column">
|
||||||
|
<div class="bar-values">
|
||||||
|
<i
|
||||||
|
class="bar bar--application"
|
||||||
|
:style="{ height: `${Math.max(5, (item.applications / maxMonthly) * 100)}%` }"
|
||||||
|
:title="`申请 ${item.applications}`"
|
||||||
|
></i
|
||||||
|
><i
|
||||||
|
class="bar bar--completed"
|
||||||
|
:style="{
|
||||||
|
height: `${Math.max(5, (item.completedServices / maxMonthly) * 100)}%`,
|
||||||
|
}"
|
||||||
|
:title="`完成 ${item.completedServices}`"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<small>{{ item.month.slice(5) }}月</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-legend">
|
||||||
|
<span><i class="legend-dot legend-dot--blue"></i>申请</span
|
||||||
|
><span><i class="legend-dot legend-dot--orange"></i>完成服务</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="panel chart-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>服务类型分布</h2>
|
||||||
|
<p>按申请服务类别统计</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="distribution-list">
|
||||||
|
<div v-for="(item, index) in data.byCategory" :key="item.category">
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
><i
|
||||||
|
class="legend-dot"
|
||||||
|
:style="{ background: palette[index % palette.length] }"
|
||||||
|
></i
|
||||||
|
>{{ SERVICE_CATEGORY_LABELS[item.category] }}</span
|
||||||
|
><strong>{{ item.count }}</strong>
|
||||||
|
</div>
|
||||||
|
<progress :value="item.count" :max="totalCategory"></progress>
|
||||||
|
</div>
|
||||||
|
<p v-if="!data.byCategory.length" class="muted-text">当前区间暂无分类数据</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel quick-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>快捷入口</h2>
|
||||||
|
<p>进入高频业务工作台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button @click="emit('navigate', 'applications')">
|
||||||
|
<b>申</b><span>新增服务跟进</span></button
|
||||||
|
><button @click="emit('navigate', 'cases')">
|
||||||
|
<b class="blue">单</b><span>处理服务工单</span></button
|
||||||
|
><button @click="emit('navigate', 'volunteers')">
|
||||||
|
<b class="green">愿</b><span>志愿者审核</span></button
|
||||||
|
><button @click="emit('navigate', 'content')">
|
||||||
|
<b class="gold">讯</b><span>发布公益内容</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import {
|
||||||
|
FINANCIAL_DISCLOSURE_STATUSES,
|
||||||
|
FINANCIAL_DISCLOSURE_STATUS_LABELS,
|
||||||
|
type FinancialDisclosureDetail,
|
||||||
|
type FinancialDisclosureListItem,
|
||||||
|
type FinancialDisclosureStatus,
|
||||||
|
} from '@lehuo/contracts';
|
||||||
|
import { ApiClientError } from '../api/api-client';
|
||||||
|
import {
|
||||||
|
createFinancialDisclosure,
|
||||||
|
fetchFinancialDisclosure,
|
||||||
|
fetchFinancialDisclosures,
|
||||||
|
transitionFinancialDisclosure,
|
||||||
|
updateFinancialDisclosure,
|
||||||
|
} from '../api/financial-disclosures';
|
||||||
|
import { defaultFinancialSummary } from '../financial-document';
|
||||||
|
|
||||||
|
const items = ref<FinancialDisclosureListItem[]>([]),
|
||||||
|
loading = ref(true),
|
||||||
|
busy = ref(false),
|
||||||
|
message = ref(''),
|
||||||
|
editing = ref(false),
|
||||||
|
previewing = ref(false),
|
||||||
|
editor = ref<HTMLElement>();
|
||||||
|
const filter = ref<FinancialDisclosureStatus | ''>(''),
|
||||||
|
page = ref(1),
|
||||||
|
total = ref(0),
|
||||||
|
pageSize = 20;
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||||
|
const form = reactive({
|
||||||
|
publicId: '',
|
||||||
|
revision: 0,
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
richText: '',
|
||||||
|
isPinned: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
const errorText = (error: unknown) =>
|
||||||
|
error instanceof ApiClientError
|
||||||
|
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: '操作失败,请稍后重试';
|
||||||
|
const sanitizeRichText = (html: string) =>
|
||||||
|
DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'p',
|
||||||
|
'br',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'strong',
|
||||||
|
'b',
|
||||||
|
'em',
|
||||||
|
'i',
|
||||||
|
'u',
|
||||||
|
's',
|
||||||
|
'blockquote',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td',
|
||||||
|
'a',
|
||||||
|
'img',
|
||||||
|
'hr',
|
||||||
|
'span',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'colspan', 'rowspan', 'style'],
|
||||||
|
});
|
||||||
|
async function load(target = 1) {
|
||||||
|
loading.value = true;
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
const result = await fetchFinancialDisclosures({
|
||||||
|
page: target,
|
||||||
|
pageSize,
|
||||||
|
...(filter.value ? { status: filter.value } : {}),
|
||||||
|
});
|
||||||
|
items.value = result.items;
|
||||||
|
page.value = result.page;
|
||||||
|
total.value = result.total;
|
||||||
|
} catch (error) {
|
||||||
|
message.value = errorText(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function newDocument() {
|
||||||
|
Object.assign(form, {
|
||||||
|
publicId: '',
|
||||||
|
revision: 0,
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
richText: '<h2>财务公开</h2><p>请在此编辑公开内容。</p>',
|
||||||
|
isPinned: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
editing.value = true;
|
||||||
|
previewing.value = false;
|
||||||
|
await syncEditor();
|
||||||
|
}
|
||||||
|
async function edit(item: FinancialDisclosureListItem) {
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const result = await fetchFinancialDisclosure(item.publicId);
|
||||||
|
fill(result.disclosure);
|
||||||
|
editing.value = true;
|
||||||
|
previewing.value = false;
|
||||||
|
await syncEditor();
|
||||||
|
} catch (error) {
|
||||||
|
message.value = errorText(error);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fill(item: FinancialDisclosureDetail) {
|
||||||
|
Object.assign(form, item);
|
||||||
|
}
|
||||||
|
async function syncEditor() {
|
||||||
|
await nextTick();
|
||||||
|
if (editor.value) editor.value.innerHTML = form.richText;
|
||||||
|
}
|
||||||
|
function command(name: string, value?: string) {
|
||||||
|
editor.value?.focus();
|
||||||
|
document.execCommand(name, false, value);
|
||||||
|
form.richText = editor.value?.innerHTML ?? form.richText;
|
||||||
|
}
|
||||||
|
function inputEditor() {
|
||||||
|
form.richText = editor.value?.innerHTML ?? '';
|
||||||
|
}
|
||||||
|
async function importDocx(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement,
|
||||||
|
file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.toLowerCase().endsWith('.docx')) {
|
||||||
|
message.value = '请选择 .docx 格式的 Word 文档。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
message.value = 'DOCX 文件不能超过 10MB。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const mammoth = await import('mammoth/mammoth.browser');
|
||||||
|
const result = await mammoth.convertToHtml({ arrayBuffer: await file.arrayBuffer() });
|
||||||
|
form.richText = sanitizeRichText(result.value);
|
||||||
|
if (!form.title) form.title = file.name.replace(/\.docx$/i, '').slice(0, 200);
|
||||||
|
if (!form.summary) form.summary = defaultFinancialSummary(result.value);
|
||||||
|
await syncEditor();
|
||||||
|
message.value = result.messages.length
|
||||||
|
? '文档已导入,部分复杂样式已简化,请预览确认。'
|
||||||
|
: '文档已导入,可继续编辑和排版。';
|
||||||
|
} catch {
|
||||||
|
message.value = 'Word 文档无法解析,请确认文件未损坏。';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
form.richText = sanitizeRichText(editor.value?.innerHTML ?? form.richText);
|
||||||
|
if (!form.title.trim() || !defaultFinancialSummary(form.richText)) {
|
||||||
|
message.value = '请填写标题和正文。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
...(form.summary.trim() ? { summary: form.summary.trim() } : {}),
|
||||||
|
richText: form.richText,
|
||||||
|
isPinned: form.isPinned,
|
||||||
|
sortOrder: Number(form.sortOrder) || 0,
|
||||||
|
};
|
||||||
|
const result = form.publicId
|
||||||
|
? await updateFinancialDisclosure(form.publicId, {
|
||||||
|
expectedRevision: form.revision,
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
: await createFinancialDisclosure({ clientRequestId: crypto.randomUUID(), ...payload });
|
||||||
|
fill(result.disclosure);
|
||||||
|
message.value = '财务公开草稿已保存。';
|
||||||
|
await load(page.value);
|
||||||
|
} catch (error) {
|
||||||
|
message.value = errorText(error);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function transition(item: FinancialDisclosureListItem) {
|
||||||
|
const targetStatus = item.status === 'DRAFT' ? 'PUBLISHED' : 'ARCHIVED';
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
targetStatus === 'PUBLISHED'
|
||||||
|
? `确认发布“${item.title}”?发布后小程序将立即可见。`
|
||||||
|
: `确认归档“${item.title}”?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await transitionFinancialDisclosure(item.publicId, {
|
||||||
|
clientRequestId: crypto.randomUUID(),
|
||||||
|
targetStatus,
|
||||||
|
});
|
||||||
|
message.value = targetStatus === 'PUBLISHED' ? '文档已发布到小程序。' : '文档已归档。';
|
||||||
|
editing.value = false;
|
||||||
|
await load(page.value);
|
||||||
|
} catch (error) {
|
||||||
|
message.value = errorText(error);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => load());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="content-area">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>财务公开</h1>
|
||||||
|
<p>导入 Word 文档,调整排版后直接发布到小程序。</p>
|
||||||
|
</div>
|
||||||
|
<button class="primary-button compact-button" type="button" @click="newDocument">
|
||||||
|
新建文档
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||||
|
<section v-if="editing" class="financial-workspace">
|
||||||
|
<div class="financial-editor-panel">
|
||||||
|
<div class="financial-editor-head">
|
||||||
|
<button class="secondary-button compact-button" type="button" @click="editing = false">
|
||||||
|
返回列表</button
|
||||||
|
><label class="import-button"
|
||||||
|
>导入 DOCX<input
|
||||||
|
type="file"
|
||||||
|
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
@change="importDocx" /></label
|
||||||
|
><button
|
||||||
|
class="secondary-button compact-button"
|
||||||
|
type="button"
|
||||||
|
@click="previewing = !previewing"
|
||||||
|
>
|
||||||
|
{{ previewing ? '返回编辑' : '小程序预览' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="!previewing"
|
||||||
|
><div class="financial-fields">
|
||||||
|
<label>标题<input v-model.trim="form.title" maxlength="200" /></label
|
||||||
|
><label
|
||||||
|
>摘要(选填)<textarea v-model.trim="form.summary" maxlength="500"></textarea></label
|
||||||
|
><label class="inline-check"
|
||||||
|
><input v-model="form.isPinned" type="checkbox" />置顶显示</label
|
||||||
|
><label
|
||||||
|
>排序<input v-model.number="form.sortOrder" type="number" min="-9999" max="9999"
|
||||||
|
/></label>
|
||||||
|
</div>
|
||||||
|
<div class="rich-toolbar" aria-label="排版工具栏">
|
||||||
|
<button type="button" @click="command('formatBlock', 'h2')">标题</button
|
||||||
|
><button type="button" @click="command('formatBlock', 'p')">正文</button
|
||||||
|
><button type="button" @click="command('bold')"><strong>加粗</strong></button
|
||||||
|
><button type="button" @click="command('underline')"><u>下划线</u></button
|
||||||
|
><button type="button" @click="command('insertUnorderedList')">项目符号</button
|
||||||
|
><button type="button" @click="command('insertOrderedList')">编号</button
|
||||||
|
><button type="button" @click="command('justifyLeft')">左对齐</button
|
||||||
|
><button type="button" @click="command('justifyCenter')">居中</button
|
||||||
|
><button type="button" @click="command('undo')">撤销</button
|
||||||
|
><button type="button" @click="command('redo')">重做</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="editor"
|
||||||
|
class="rich-editor"
|
||||||
|
contenteditable="true"
|
||||||
|
role="textbox"
|
||||||
|
aria-label="财务公开正文"
|
||||||
|
aria-multiline="true"
|
||||||
|
@input="inputEditor"
|
||||||
|
></div
|
||||||
|
></template>
|
||||||
|
<article v-else class="mobile-preview">
|
||||||
|
<p class="preview-label">小程序阅读效果预览</p>
|
||||||
|
<h1>{{ form.title }}</h1>
|
||||||
|
<p v-if="form.summary" class="preview-summary">{{ form.summary }}</p>
|
||||||
|
<div class="public-rich-text" v-html="form.richText"></div>
|
||||||
|
</article>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
class="primary-button compact-button"
|
||||||
|
:disabled="busy"
|
||||||
|
type="button"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{ busy ? '保存中…' : '保存草稿' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<template v-else
|
||||||
|
><form class="filter-panel" @submit.prevent="load(1)">
|
||||||
|
<div class="filter-field">
|
||||||
|
<label>状态</label
|
||||||
|
><select v-model="filter">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option v-for="status in FINANCIAL_DISCLOSURE_STATUSES" :key="status" :value="status">
|
||||||
|
{{ FINANCIAL_DISCLOSURE_STATUS_LABELS[status] }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="primary-button compact-button">查询</button>
|
||||||
|
</form>
|
||||||
|
<section v-if="loading" class="panel">正在加载财务公开文档…</section>
|
||||||
|
<section v-else-if="!items.length" class="empty-panel">
|
||||||
|
<h2>暂无财务公开文档</h2>
|
||||||
|
<p>可以新建空白文档,也可以导入本地 DOCX。</p>
|
||||||
|
</section>
|
||||||
|
<section v-else class="table-panel">
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>发布/更新</th>
|
||||||
|
<th>排序</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in items" :key="item.publicId">
|
||||||
|
<td>
|
||||||
|
<strong>{{ item.title }}</strong
|
||||||
|
><span v-if="item.isPinned" class="status-chip">置顶</span><br />{{
|
||||||
|
item.summary || '暂无摘要'
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>{{ FINANCIAL_DISCLOSURE_STATUS_LABELS[item.status] }}</td>
|
||||||
|
<td>{{ new Date(item.publishedAt || item.updatedAt).toLocaleString('zh-CN') }}</td>
|
||||||
|
<td>{{ item.sortOrder }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
v-if="item.status === 'DRAFT'"
|
||||||
|
class="link-button"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="edit(item)"
|
||||||
|
>
|
||||||
|
编辑</button
|
||||||
|
><button
|
||||||
|
v-if="item.status !== 'ARCHIVED'"
|
||||||
|
class="link-button"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="transition(item)"
|
||||||
|
>
|
||||||
|
{{ item.status === 'DRAFT' ? '发布' : '归档' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pagination">
|
||||||
|
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
|
||||||
|
><span>第 {{ page }} / {{ totalPages }} 页</span
|
||||||
|
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</section></template
|
||||||
|
>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -318,6 +318,25 @@ onMounted(() => load());
|
|||||||
仅可将以下信息用于已分配的公益服务;查看、指派和记录操作均会留痕。
|
仅可将以下信息用于已分配的公益服务;查看、指派和记录操作均会留痕。
|
||||||
</div>
|
</div>
|
||||||
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||||
|
<section class="case-stepper" aria-label="工单处理进度">
|
||||||
|
<div class="done"><i>1</i><span>已建单</span></div>
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
done: ['ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED'].includes(selected.status),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<i>2</i><span>已指派</span>
|
||||||
|
</div>
|
||||||
|
<div :class="{ done: ['IN_SERVICE', 'SERVICE_COMPLETED'].includes(selected.status) }">
|
||||||
|
<i>3</i><span>服务中</span>
|
||||||
|
</div>
|
||||||
|
<div :class="{ done: selected.status === 'SERVICE_COMPLETED' }">
|
||||||
|
<i>4</i><span>服务完成</span>
|
||||||
|
</div>
|
||||||
|
<div :class="{ done: selected.status === 'SERVICE_COMPLETED' }">
|
||||||
|
<i>5</i><span>进入回访</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="detail-grid">
|
<section class="detail-grid">
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<h2>服务计划</h2>
|
<h2>服务计划</h2>
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ RUN DATABASE_URL=mysql://build:build@127.0.0.1:3306/build pnpm --filter @lehuo/a
|
|||||||
FROM node:24.13.0-alpine AS runtime
|
FROM node:24.13.0-alpine AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable
|
|
||||||
COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
|
COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
|
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
|
||||||
COPY --from=build /app/apps/api/node_modules ./apps/api/node_modules
|
COPY --from=build /app/apps/api/node_modules ./apps/api/node_modules
|
||||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||||
|
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
|
||||||
|
COPY --from=build /app/apps/api/prisma.config.ts ./apps/api/prisma.config.ts
|
||||||
COPY --from=build /app/packages/contracts/package.json ./packages/contracts/package.json
|
COPY --from=build /app/packages/contracts/package.json ./packages/contracts/package.json
|
||||||
COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist
|
COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@lehuo/api",
|
"name": "@lehuo/api",
|
||||||
"version": "0.1.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bootstrap:admin": "tsx src/scripts/bootstrap-admin.ts",
|
"bootstrap:admin": "tsx --env-file-if-exists=../../.env src/scripts/bootstrap-admin.ts",
|
||||||
"build": "pnpm --filter @lehuo/contracts build && prisma generate && tsc -p tsconfig.build.json",
|
"build": "pnpm --filter @lehuo/contracts build && prisma generate && tsc -p tsconfig.build.json",
|
||||||
"db:deploy": "prisma migrate deploy",
|
"db:deploy": "prisma migrate deploy",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"dev": "pnpm --filter @lehuo/contracts build && tsx watch src/main.ts",
|
"dev": "pnpm --filter @lehuo/contracts build && tsx watch --env-file-if-exists=../../.env src/main.ts",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"test:integration": "pnpm --filter @lehuo/contracts build && tsx test/run-integration.ts",
|
"test:integration": "pnpm --filter @lehuo/contracts build && tsx --env-file-if-exists=../../.env test/run-integration.ts",
|
||||||
"test:unit": "pnpm --filter @lehuo/contracts build && vitest run --config vitest.config.ts",
|
"test:unit": "pnpm --filter @lehuo/contracts build && vitest run --config vitest.config.ts",
|
||||||
"typecheck": "prisma generate && tsc --noEmit"
|
"typecheck": "prisma generate && tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -31,13 +31,15 @@
|
|||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"mariadb": "^3.5.3",
|
"mariadb": "^3.5.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2",
|
||||||
|
"sanitize-html": "^2.17.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/testing": "^11.1.6",
|
"@nestjs/testing": "^11.1.6",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import 'dotenv/config';
|
import { config } from 'dotenv';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import { defineConfig, env } from 'prisma/config';
|
import { defineConfig, env } from 'prisma/config';
|
||||||
|
|
||||||
|
// Prisma CLI 的工作目录是 apps/api,仓库根 .env 不在 dotenv 的默认查找路径内。
|
||||||
|
// 显式加载根目录 .env,保证 generate / migrate 与 API 运行时读取同一份配置。
|
||||||
|
const rootEnvFile = resolve(process.cwd(), '../../.env');
|
||||||
|
if (existsSync(rootEnvFile)) {
|
||||||
|
config({ path: rootEnvFile });
|
||||||
|
} else {
|
||||||
|
config();
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: 'prisma/schema.prisma',
|
schema: 'prisma/schema.prisma',
|
||||||
migrations: {
|
migrations: {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
CREATE TABLE `financial_disclosures` (
|
||||||
|
`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(200) NOT NULL,
|
||||||
|
`summary` VARCHAR(500) NULL,
|
||||||
|
`rich_text` MEDIUMTEXT NOT NULL,
|
||||||
|
`status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NOT NULL DEFAULT 'DRAFT',
|
||||||
|
`is_pinned` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`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 `financial_disclosures_public_id_key` (`public_id`),
|
||||||
|
UNIQUE INDEX `financial_disclosures_creator_request_key` (`created_by`, `client_request_id`),
|
||||||
|
INDEX `financial_disclosures_public_list_idx` (`status`, `is_pinned`, `sort_order`, `published_at`),
|
||||||
|
INDEX `financial_disclosures_updated_at_idx` (`updated_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `financial_disclosure_status_history` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`financial_disclosure_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`request_id` CHAR(36) NOT NULL,
|
||||||
|
`request_fingerprint` CHAR(64) NOT NULL,
|
||||||
|
`from_status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NULL,
|
||||||
|
`to_status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NOT NULL,
|
||||||
|
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
UNIQUE INDEX `financial_disclosure_status_history_request_id_key` (`request_id`),
|
||||||
|
INDEX `financial_disclosure_history_document_idx` (`financial_disclosure_id`, `created_at`),
|
||||||
|
INDEX `financial_disclosure_history_operator_idx` (`operator_user_id`, `created_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `financial_disclosures` ADD CONSTRAINT `financial_disclosures_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `financial_disclosures` ADD CONSTRAINT `financial_disclosures_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `financial_disclosure_status_history` ADD CONSTRAINT `financial_disclosure_history_document_fkey` FOREIGN KEY (`financial_disclosure_id`) REFERENCES `financial_disclosures` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE `financial_disclosure_status_history` ADD CONSTRAINT `financial_disclosure_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||||
|
('financial_disclosures:read', '查看财务公开', '查看财务公开草稿与发布状态'),
|
||||||
|
('financial_disclosures:manage', '管理财务公开', '创建和编辑财务公开文档'),
|
||||||
|
('financial_disclosures:publish', '发布财务公开', '发布和归档财务公开文档');
|
||||||
|
|
||||||
|
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 ('financial_disclosures:read','financial_disclosures:manage','financial_disclosures:publish');
|
||||||
@@ -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');
|
||||||
@@ -153,6 +153,70 @@ enum FileBusinessType {
|
|||||||
PROJECT_COVER
|
PROJECT_COVER
|
||||||
ACTIVITY_COVER
|
ACTIVITY_COVER
|
||||||
CONTENT_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 {
|
enum ContentPostType {
|
||||||
@@ -170,6 +234,12 @@ enum ContentPostStatus {
|
|||||||
ARCHIVED
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FinancialDisclosureStatus {
|
||||||
|
DRAFT
|
||||||
|
PUBLISHED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
enum NotificationType {
|
enum NotificationType {
|
||||||
APPLICATION_SUBMITTED
|
APPLICATION_SUBMITTED
|
||||||
APPLICATION_ACCEPTED
|
APPLICATION_ACCEPTED
|
||||||
@@ -316,6 +386,26 @@ model User {
|
|||||||
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
|
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
notificationPreferences NotificationPreference[]
|
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")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -571,6 +661,7 @@ model BeneficiaryProfile {
|
|||||||
serviceRecords ServiceRecord[]
|
serviceRecords ServiceRecord[]
|
||||||
consents Consent[]
|
consents Consent[]
|
||||||
followUpTasks FollowUpTask[]
|
followUpTasks FollowUpTask[]
|
||||||
|
oldItemInventoryItems OldItemInventoryItem[]
|
||||||
|
|
||||||
@@index([displayNameHash], map: "beneficiary_profiles_display_name_hash_idx")
|
@@index([displayNameHash], map: "beneficiary_profiles_display_name_hash_idx")
|
||||||
@@index([mobileHash], map: "beneficiary_profiles_mobile_hash_idx")
|
@@index([mobileHash], map: "beneficiary_profiles_mobile_hash_idx")
|
||||||
@@ -783,6 +874,7 @@ model File {
|
|||||||
projectCover Project? @relation("ProjectCover")
|
projectCover Project? @relation("ProjectCover")
|
||||||
activityCover Activity? @relation("ActivityCover")
|
activityCover Activity? @relation("ActivityCover")
|
||||||
contentCover ContentPost? @relation("ContentCover")
|
contentCover ContentPost? @relation("ContentCover")
|
||||||
|
oldItemDonations OldItemDonationFile[]
|
||||||
|
|
||||||
@@unique([uploaderUserId, clientRequestId], map: "files_uploader_client_request_key")
|
@@unique([uploaderUserId, clientRequestId], map: "files_uploader_client_request_key")
|
||||||
@@index([caseId, status, createdAt], map: "files_case_status_created_at_idx")
|
@@index([caseId, status, createdAt], map: "files_case_status_created_at_idx")
|
||||||
@@ -790,6 +882,319 @@ model File {
|
|||||||
@@map("files")
|
@@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 {
|
model ServiceRecordFile {
|
||||||
serviceRecordId BigInt @map("service_record_id") @db.UnsignedBigInt
|
serviceRecordId BigInt @map("service_record_id") @db.UnsignedBigInt
|
||||||
fileId BigInt @map("file_id") @db.UnsignedBigInt
|
fileId BigInt @map("file_id") @db.UnsignedBigInt
|
||||||
@@ -1148,6 +1553,50 @@ model ContentPostStatusHistory {
|
|||||||
@@map("content_post_status_history")
|
@@map("content_post_status_history")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
statusHistory FinancialDisclosureStatusHistory[]
|
||||||
|
|
||||||
|
@@unique([createdById, clientRequestId], map: "financial_disclosures_creator_request_key")
|
||||||
|
@@index([status, isPinned, sortOrder, publishedAt], map: "financial_disclosures_public_list_idx")
|
||||||
|
@@index([updatedAt], map: "financial_disclosures_updated_at_idx")
|
||||||
|
@@map("financial_disclosures")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
@@index([financialDisclosureId, createdAt], map: "financial_disclosure_history_document_idx")
|
||||||
|
@@index([operatorUserId, createdAt], map: "financial_disclosure_history_operator_idx")
|
||||||
|
@@map("financial_disclosure_status_history")
|
||||||
|
}
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
publicId String @unique @map("public_id") @db.Char(36)
|
publicId String @unique @map("public_id") @db.Char(36)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { StatisticsModule } from './statistics/statistics.module';
|
|||||||
import { RequestMetricsService } from './common/request-metrics.service';
|
import { RequestMetricsService } from './common/request-metrics.service';
|
||||||
import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
|
import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
|
||||||
import { SystemMetricsController } from './common/system-metrics.controller';
|
import { SystemMetricsController } from './common/system-metrics.controller';
|
||||||
|
import { FinancialDisclosuresModule } from './financial-disclosures/financial-disclosures.module';
|
||||||
|
import { OldItemsModule } from './old-items/old-items.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -42,6 +44,8 @@ import { SystemMetricsController } from './common/system-metrics.controller';
|
|||||||
ParticipationModule,
|
ParticipationModule,
|
||||||
ContentNotificationsModule,
|
ContentNotificationsModule,
|
||||||
StatisticsModule,
|
StatisticsModule,
|
||||||
|
FinancialDisclosuresModule,
|
||||||
|
OldItemsModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController, SystemMetricsController],
|
controllers: [HealthController, SystemMetricsController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import { SensitiveDataService } from '../security/sensitive-data.service';
|
|||||||
const SENSITIVE_METADATA_KEY =
|
const SENSITIVE_METADATA_KEY =
|
||||||
/(password|token|secret|openid|unionid|mobile|phone|address|description|content)/i;
|
/(password|token|secret|openid|unionid|mobile|phone|address|description|content)/i;
|
||||||
|
|
||||||
|
// rawToken 严禁进入审计记录;此函数剥离敏感字段后再写入。
|
||||||
|
function sanitizeActor(actor?: AuthContext): Omit<AuthContext, 'rawToken'> | undefined {
|
||||||
|
if (!actor) return undefined;
|
||||||
|
// 直接过滤掉 rawToken,避免解构产生未使用变量警告。
|
||||||
|
const filtered = Object.entries(actor).filter(([key]) => key !== 'rawToken');
|
||||||
|
return Object.fromEntries(filtered) as Omit<AuthContext, 'rawToken'>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditEvent {
|
export interface AuditEvent {
|
||||||
action: string;
|
action: string;
|
||||||
result: AuditResult;
|
result: AuditResult;
|
||||||
@@ -32,6 +40,16 @@ export class AuditService {
|
|||||||
event: AuditEvent,
|
event: AuditEvent,
|
||||||
client: Prisma.TransactionClient | PrismaService = this.prisma,
|
client: Prisma.TransactionClient | PrismaService = this.prisma,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// 防御性检查:确保 AuditEvent.actor 中的 rawToken 不会通过任何路径写入审计记录。
|
||||||
|
// 当前实现只写入 actorUserId/actorRole 标量字段,此处显式校验以防未来变更引入风险。
|
||||||
|
if (event.actor) {
|
||||||
|
const safe = sanitizeActor(event.actor);
|
||||||
|
if (!safe) throw new Error('Audit actor must not contain rawToken');
|
||||||
|
// 确认 sanitized actor 不包含 rawToken(防御性断言)
|
||||||
|
const keys = Object.keys(safe);
|
||||||
|
if (keys.includes('rawToken'))
|
||||||
|
throw new Error('Audit actor contains rawToken after sanitization');
|
||||||
|
}
|
||||||
const metadata = this.safeMetadata(event.metadata);
|
const metadata = this.safeMetadata(event.metadata);
|
||||||
const userAgent =
|
const userAgent =
|
||||||
request
|
request
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export class AdminAuthController {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: this.config.adminCookieSecure,
|
secure: this.config.adminCookieSecure,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
|
partitioned: this.config.adminCookieSecure,
|
||||||
path: '/api/v1',
|
path: '/api/v1',
|
||||||
maxAge: this.config.adminSessionTtlSeconds * 1_000,
|
maxAge: this.config.adminSessionTtlSeconds * 1_000,
|
||||||
});
|
});
|
||||||
@@ -70,6 +71,7 @@ export class AdminAuthController {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: this.config.adminCookieSecure,
|
secure: this.config.adminCookieSecure,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
|
partitioned: this.config.adminCookieSecure,
|
||||||
path: '/api/v1',
|
path: '/api/v1',
|
||||||
});
|
});
|
||||||
return { loggedOut: true };
|
return { loggedOut: true };
|
||||||
|
|||||||
@@ -15,7 +15,19 @@ export class AdminOriginGuard implements CanActivate {
|
|||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const request = context.switchToHttp().getRequest<RequestContext>();
|
const request = context.switchToHttp().getRequest<RequestContext>();
|
||||||
if (request.header('origin') !== this.config.adminWebOrigin) throw new ForbiddenException();
|
const allowedOrigin = this.config.adminWebOrigin;
|
||||||
|
const origin = request.header('origin');
|
||||||
|
const referer = request.header('referer');
|
||||||
|
let refererOrigin: string | null = null;
|
||||||
|
if (referer) {
|
||||||
|
try {
|
||||||
|
refererOrigin = new URL(referer).origin;
|
||||||
|
} catch {
|
||||||
|
refererOrigin = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const matched = origin === allowedOrigin || (!origin && refererOrigin === allowedOrigin);
|
||||||
|
if (!matched) throw new ForbiddenException('请求来源不受允许');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
|
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
|
||||||
import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
type OnModuleDestroy,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
import { AppConfigService } from '../config/app-config.service';
|
import { AppConfigService } from '../config/app-config.service';
|
||||||
import { PrismaClient } from '../generated/prisma/client';
|
import { PrismaClient } from '../generated/prisma/client';
|
||||||
@@ -16,8 +22,35 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
|||||||
super({ adapter });
|
super({ adapter });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
|
await this.logAppliedMigrationStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录数据库已完成的 migration 状态;待部署检查仍由发布流程中的
|
||||||
|
* `prisma migrate deploy` 负责,避免把运行时日志误当作 schema 校验。
|
||||||
|
*/
|
||||||
|
private async logAppliedMigrationStatus(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rows = await this.$queryRawUnsafe<
|
||||||
|
Array<{
|
||||||
|
applied_count: bigint | number | string;
|
||||||
|
latest_migration: string | null;
|
||||||
|
}>
|
||||||
|
>(
|
||||||
|
'SELECT COUNT(*) AS applied_count, MAX(migration_name) AS latest_migration FROM _prisma_migrations WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL',
|
||||||
|
);
|
||||||
|
const status = rows[0];
|
||||||
|
const count = Number(status?.applied_count ?? 0);
|
||||||
|
this.logger.log(
|
||||||
|
`数据库已完成 ${count} 个 migration${status?.latest_migration ? `,最新为 ${status.latest_migration}` : ''}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.logger.warn('_prisma_migrations 表尚未初始化,跳过 migration 校验');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Length,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { FINANCIAL_DISCLOSURE_STATUSES } from '@lehuo/contracts';
|
||||||
|
import { PaginationDto } from '../../case-management/dto/case-management.dto';
|
||||||
|
|
||||||
|
export class FinancialDisclosureQueryDto extends PaginationDto {
|
||||||
|
@IsOptional() @IsIn(FINANCIAL_DISCLOSURE_STATUSES) status?: string;
|
||||||
|
}
|
||||||
|
export class CreateFinancialDisclosureDto {
|
||||||
|
@IsUUID('4') clientRequestId!: string;
|
||||||
|
@IsString() @Length(2, 200) title!: string;
|
||||||
|
@IsOptional() @IsString() @Length(0, 500) summary?: string;
|
||||||
|
@IsString() @Length(1, 1_000_000) richText!: string;
|
||||||
|
@IsOptional() @IsBoolean() isPinned?: boolean;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||||
|
}
|
||||||
|
export class UpdateFinancialDisclosureDto {
|
||||||
|
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||||
|
@IsString() @Length(2, 200) title!: string;
|
||||||
|
@IsOptional() @IsString() @Length(0, 500) summary?: string;
|
||||||
|
@IsString() @Length(1, 1_000_000) richText!: string;
|
||||||
|
@IsOptional() @IsBoolean() isPinned?: boolean;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||||
|
}
|
||||||
|
export class TransitionFinancialDisclosureDto {
|
||||||
|
@IsUUID('4') clientRequestId!: string;
|
||||||
|
@IsIn(['PUBLISHED', 'ARCHIVED']) targetStatus!: 'PUBLISHED' | 'ARCHIVED';
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PERMISSION_CODES } from '@lehuo/contracts';
|
||||||
|
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||||
|
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||||
|
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 {
|
||||||
|
CreateFinancialDisclosureDto,
|
||||||
|
FinancialDisclosureQueryDto,
|
||||||
|
TransitionFinancialDisclosureDto,
|
||||||
|
UpdateFinancialDisclosureDto,
|
||||||
|
} from './dto/financial-disclosure.dto';
|
||||||
|
import { FinancialDisclosuresService } from './financial-disclosures.service';
|
||||||
|
|
||||||
|
const strict = (expectedType: new () => object) =>
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
expectedType,
|
||||||
|
});
|
||||||
|
|
||||||
|
@Controller('api/v1/financial-disclosures')
|
||||||
|
@Public()
|
||||||
|
export class PublicFinancialDisclosuresController {
|
||||||
|
constructor(
|
||||||
|
@Inject(FinancialDisclosuresService) private readonly service: FinancialDisclosuresService,
|
||||||
|
) {}
|
||||||
|
@Get() list(@Query(strict(FinancialDisclosureQueryDto)) q: FinancialDisclosureQueryDto) {
|
||||||
|
return this.service.listPublic(q);
|
||||||
|
}
|
||||||
|
@Get(':publicId') get(@Param('publicId') id: string) {
|
||||||
|
return this.service.getPublic(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api/v1/admin/financial-disclosures')
|
||||||
|
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||||
|
export class AdminFinancialDisclosuresController {
|
||||||
|
constructor(
|
||||||
|
@Inject(FinancialDisclosuresService) private readonly service: FinancialDisclosuresService,
|
||||||
|
) {}
|
||||||
|
@Get() @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_READ) list(
|
||||||
|
@Query(strict(FinancialDisclosureQueryDto)) q: FinancialDisclosureQueryDto,
|
||||||
|
) {
|
||||||
|
return this.service.listAdmin(q);
|
||||||
|
}
|
||||||
|
@Get(':publicId') @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_READ) get(
|
||||||
|
@Param('publicId') id: string,
|
||||||
|
) {
|
||||||
|
return this.service.getAdmin(id);
|
||||||
|
}
|
||||||
|
@Post() @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_MANAGE) create(
|
||||||
|
@Body(strict(CreateFinancialDisclosureDto)) input: CreateFinancialDisclosureDto,
|
||||||
|
@CurrentAuth() actor: AuthContext,
|
||||||
|
@Req() req: RequestContext,
|
||||||
|
) {
|
||||||
|
return this.service.create(input, actor, req);
|
||||||
|
}
|
||||||
|
@Patch(':publicId') @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_MANAGE) update(
|
||||||
|
@Param('publicId') id: string,
|
||||||
|
@Body(strict(UpdateFinancialDisclosureDto)) input: UpdateFinancialDisclosureDto,
|
||||||
|
@CurrentAuth() actor: AuthContext,
|
||||||
|
@Req() req: RequestContext,
|
||||||
|
) {
|
||||||
|
return this.service.update(id, input, actor, req);
|
||||||
|
}
|
||||||
|
@Post(':publicId/transition')
|
||||||
|
@RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_PUBLISH)
|
||||||
|
transition(
|
||||||
|
@Param('publicId') id: string,
|
||||||
|
@Body(strict(TransitionFinancialDisclosureDto)) input: TransitionFinancialDisclosureDto,
|
||||||
|
@CurrentAuth() actor: AuthContext,
|
||||||
|
@Req() req: RequestContext,
|
||||||
|
) {
|
||||||
|
return this.service.transition(id, input, actor, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import {
|
||||||
|
AdminFinancialDisclosuresController,
|
||||||
|
PublicFinancialDisclosuresController,
|
||||||
|
} from './financial-disclosures.controller';
|
||||||
|
import { FinancialDisclosuresService } from './financial-disclosures.service';
|
||||||
|
@Module({
|
||||||
|
imports: [AuditModule],
|
||||||
|
controllers: [PublicFinancialDisclosuresController, AdminFinancialDisclosuresController],
|
||||||
|
providers: [FinancialDisclosuresService],
|
||||||
|
})
|
||||||
|
export class FinancialDisclosuresModule {}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
import {
|
||||||
|
API_ERROR_CODES,
|
||||||
|
type FinancialDisclosureDetail,
|
||||||
|
type FinancialDisclosureDetailResponse,
|
||||||
|
type PaginatedFinancialDisclosuresResponse,
|
||||||
|
} 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 { FinancialDisclosure, Prisma } from '../generated/prisma/client';
|
||||||
|
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||||
|
import type {
|
||||||
|
CreateFinancialDisclosureDto,
|
||||||
|
FinancialDisclosureQueryDto,
|
||||||
|
TransitionFinancialDisclosureDto,
|
||||||
|
UpdateFinancialDisclosureDto,
|
||||||
|
} from './dto/financial-disclosure.dto';
|
||||||
|
|
||||||
|
const clean = (html: string) =>
|
||||||
|
sanitizeHtml(html, {
|
||||||
|
allowedTags: [
|
||||||
|
'p',
|
||||||
|
'br',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'strong',
|
||||||
|
'b',
|
||||||
|
'em',
|
||||||
|
'i',
|
||||||
|
'u',
|
||||||
|
's',
|
||||||
|
'blockquote',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td',
|
||||||
|
'a',
|
||||||
|
'img',
|
||||||
|
'hr',
|
||||||
|
'span',
|
||||||
|
],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ['href', 'title'],
|
||||||
|
img: ['src', 'alt', 'title'],
|
||||||
|
th: ['colspan', 'rowspan'],
|
||||||
|
td: ['colspan', 'rowspan'],
|
||||||
|
span: ['style'],
|
||||||
|
p: ['style'],
|
||||||
|
h1: ['style'],
|
||||||
|
h2: ['style'],
|
||||||
|
h3: ['style'],
|
||||||
|
},
|
||||||
|
allowedStyles: {
|
||||||
|
'*': {
|
||||||
|
'text-align': [/^(left|right|center|justify)$/],
|
||||||
|
color: [/^#[0-9a-f]{3,6}$/i],
|
||||||
|
'background-color': [/^#[0-9a-f]{3,6}$/i],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowedSchemes: ['https'],
|
||||||
|
allowedSchemesByTag: { img: ['https'] },
|
||||||
|
enforceHtmlBoundary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FinancialDisclosuresService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
|
||||||
|
@Inject(AuditService) private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
listAdmin(q: FinancialDisclosureQueryDto) {
|
||||||
|
return this.list(q.status ? { status: q.status as never } : {}, q.page, q.pageSize);
|
||||||
|
}
|
||||||
|
listPublic(q: FinancialDisclosureQueryDto) {
|
||||||
|
return this.list({ status: 'PUBLISHED' }, q.page, q.pageSize);
|
||||||
|
}
|
||||||
|
async getAdmin(id: string): Promise<FinancialDisclosureDetailResponse> {
|
||||||
|
return { disclosure: this.detail(await this.find(id)) };
|
||||||
|
}
|
||||||
|
async getPublic(id: string): Promise<FinancialDisclosureDetailResponse> {
|
||||||
|
const item = await this.find(id);
|
||||||
|
if (item.status !== 'PUBLISHED') throw this.notFound();
|
||||||
|
return { disclosure: this.detail(item) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
input: CreateFinancialDisclosureDto,
|
||||||
|
actor: AuthContext,
|
||||||
|
req: RequestContext,
|
||||||
|
): Promise<FinancialDisclosureDetailResponse & { idempotentReplay: boolean }> {
|
||||||
|
const richText = this.sanitize(input.richText),
|
||||||
|
fp = this.fingerprint('financial-disclosure-create', { ...input, richText });
|
||||||
|
const replay = await this.prisma.financialDisclosure.findUnique({
|
||||||
|
where: {
|
||||||
|
createdById_clientRequestId: {
|
||||||
|
createdById: actor.userId,
|
||||||
|
clientRequestId: input.clientRequestId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (replay) {
|
||||||
|
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||||
|
return { disclosure: this.detail(replay), idempotentReplay: true };
|
||||||
|
}
|
||||||
|
const item = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.financialDisclosure.create({
|
||||||
|
data: {
|
||||||
|
publicId: randomUUID(),
|
||||||
|
clientRequestId: input.clientRequestId,
|
||||||
|
requestFingerprint: fp,
|
||||||
|
title: input.title.trim(),
|
||||||
|
summary: input.summary?.trim() || null,
|
||||||
|
richText,
|
||||||
|
isPinned: input.isPinned ?? false,
|
||||||
|
sortOrder: input.sortOrder ?? 0,
|
||||||
|
createdById: actor.userId,
|
||||||
|
updatedById: actor.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.financialDisclosureStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
financialDisclosureId: created.id,
|
||||||
|
requestId: input.clientRequestId,
|
||||||
|
requestFingerprint: fp,
|
||||||
|
toStatus: 'DRAFT',
|
||||||
|
operatorUserId: actor.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.audit.record(
|
||||||
|
req,
|
||||||
|
{
|
||||||
|
action: 'FINANCIAL_DISCLOSURE_CREATED',
|
||||||
|
result: 'SUCCESS',
|
||||||
|
actor,
|
||||||
|
resourceType: 'FINANCIAL_DISCLOSURE',
|
||||||
|
resourcePublicId: created.publicId,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return { disclosure: this.detail(item), idempotentReplay: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: UpdateFinancialDisclosureDto,
|
||||||
|
actor: AuthContext,
|
||||||
|
req: RequestContext,
|
||||||
|
): Promise<FinancialDisclosureDetailResponse> {
|
||||||
|
const current = await this.find(id);
|
||||||
|
if (current.status !== 'DRAFT') throw this.invalid();
|
||||||
|
if (current.revision !== input.expectedRevision) throw this.conflict();
|
||||||
|
const richText = this.sanitize(input.richText);
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const result = await tx.financialDisclosure.updateMany({
|
||||||
|
where: { id: current.id, revision: input.expectedRevision, status: 'DRAFT' },
|
||||||
|
data: {
|
||||||
|
title: input.title.trim(),
|
||||||
|
summary: input.summary?.trim() || null,
|
||||||
|
richText,
|
||||||
|
isPinned: input.isPinned ?? false,
|
||||||
|
sortOrder: input.sortOrder ?? 0,
|
||||||
|
updatedById: actor.userId,
|
||||||
|
revision: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw this.conflict();
|
||||||
|
await this.audit.record(
|
||||||
|
req,
|
||||||
|
{
|
||||||
|
action: 'FINANCIAL_DISCLOSURE_UPDATED',
|
||||||
|
result: 'SUCCESS',
|
||||||
|
actor,
|
||||||
|
resourceType: 'FINANCIAL_DISCLOSURE',
|
||||||
|
resourcePublicId: id,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return tx.financialDisclosure.findUniqueOrThrow({ where: { id: current.id } });
|
||||||
|
});
|
||||||
|
return { disclosure: this.detail(updated) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async transition(
|
||||||
|
id: string,
|
||||||
|
input: TransitionFinancialDisclosureDto,
|
||||||
|
actor: AuthContext,
|
||||||
|
req: RequestContext,
|
||||||
|
) {
|
||||||
|
const current = await this.find(id),
|
||||||
|
fp = this.fingerprint('financial-disclosure-transition', {
|
||||||
|
id,
|
||||||
|
targetStatus: input.targetStatus,
|
||||||
|
});
|
||||||
|
const replay = await this.prisma.financialDisclosureStatusHistory.findUnique({
|
||||||
|
where: { requestId: input.clientRequestId },
|
||||||
|
include: { financialDisclosure: true },
|
||||||
|
});
|
||||||
|
if (replay) {
|
||||||
|
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||||
|
return { disclosure: this.detail(replay.financialDisclosure), idempotentReplay: true };
|
||||||
|
}
|
||||||
|
const allowed =
|
||||||
|
(current.status === 'DRAFT' && input.targetStatus === 'PUBLISHED') ||
|
||||||
|
(current.status === 'PUBLISHED' && input.targetStatus === 'ARCHIVED');
|
||||||
|
if (!allowed) throw this.invalid();
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const result = await tx.financialDisclosure.updateMany({
|
||||||
|
where: { id: current.id, status: current.status },
|
||||||
|
data: {
|
||||||
|
status: input.targetStatus,
|
||||||
|
updatedById: actor.userId,
|
||||||
|
revision: { increment: 1 },
|
||||||
|
...(input.targetStatus === 'PUBLISHED' ? { publishedAt: new Date() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw this.conflict();
|
||||||
|
await tx.financialDisclosureStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
financialDisclosureId: current.id,
|
||||||
|
requestId: input.clientRequestId,
|
||||||
|
requestFingerprint: fp,
|
||||||
|
fromStatus: current.status,
|
||||||
|
toStatus: input.targetStatus,
|
||||||
|
operatorUserId: actor.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.audit.record(
|
||||||
|
req,
|
||||||
|
{
|
||||||
|
action: 'FINANCIAL_DISCLOSURE_STATUS_CHANGED',
|
||||||
|
result: 'SUCCESS',
|
||||||
|
actor,
|
||||||
|
resourceType: 'FINANCIAL_DISCLOSURE',
|
||||||
|
resourcePublicId: id,
|
||||||
|
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return tx.financialDisclosure.findUniqueOrThrow({ where: { id: current.id } });
|
||||||
|
});
|
||||||
|
return { disclosure: this.detail(updated), idempotentReplay: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async list(
|
||||||
|
where: Prisma.FinancialDisclosureWhereInput,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<PaginatedFinancialDisclosuresResponse> {
|
||||||
|
const [total, items] = await this.prisma.$transaction([
|
||||||
|
this.prisma.financialDisclosure.count({ where }),
|
||||||
|
this.prisma.financialDisclosure.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [
|
||||||
|
{ isPinned: 'desc' },
|
||||||
|
{ sortOrder: 'desc' },
|
||||||
|
{ publishedAt: 'desc' },
|
||||||
|
{ updatedAt: 'desc' },
|
||||||
|
],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items: items.map((i) => this.item(i)), page, pageSize, total };
|
||||||
|
}
|
||||||
|
private async find(publicId: string) {
|
||||||
|
const item = await this.prisma.financialDisclosure.findUnique({ where: { publicId } });
|
||||||
|
if (!item) throw this.notFound();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
private item(i: FinancialDisclosure) {
|
||||||
|
return {
|
||||||
|
publicId: i.publicId,
|
||||||
|
title: i.title,
|
||||||
|
summary: i.summary,
|
||||||
|
status: i.status,
|
||||||
|
isPinned: i.isPinned,
|
||||||
|
sortOrder: i.sortOrder,
|
||||||
|
revision: i.revision,
|
||||||
|
publishedAt: i.publishedAt?.toISOString() ?? null,
|
||||||
|
updatedAt: i.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private detail(i: FinancialDisclosure): FinancialDisclosureDetail {
|
||||||
|
return { ...this.item(i), richText: i.richText };
|
||||||
|
}
|
||||||
|
private sanitize(value: string) {
|
||||||
|
const result = clean(value).trim();
|
||||||
|
if (!result || !sanitizeHtml(result, { allowedTags: [], allowedAttributes: {} }).trim())
|
||||||
|
throw new ApplicationError(
|
||||||
|
API_ERROR_CODES.VALIDATION_ERROR,
|
||||||
|
'财务公开正文不能为空',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
private fingerprint(domain: string, value: object) {
|
||||||
|
return this.sensitive.lookupHash(domain, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
private notFound() {
|
||||||
|
return new ApplicationError(
|
||||||
|
API_ERROR_CODES.RESOURCE_NOT_FOUND,
|
||||||
|
'未找到该财务公开文档',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private invalid() {
|
||||||
|
return new ApplicationError(
|
||||||
|
API_ERROR_CODES.INVALID_STATE_TRANSITION,
|
||||||
|
'当前文档状态不能执行该操作',
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,635 @@
|
|||||||
|
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,
|
||||||
|
keyword: q.keyword ?? 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 {}
|
||||||
@@ -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;
|
@IsUUID('4') clientRequestId!: string;
|
||||||
@IsIn(['PROJECT_COVER', 'ACTIVITY_COVER']) businessType!: 'PROJECT_COVER' | 'ACTIVITY_COVER';
|
@IsIn(['PROJECT_COVER', 'ACTIVITY_COVER']) businessType!: 'PROJECT_COVER' | 'ACTIVITY_COVER';
|
||||||
@IsString() @Length(1, 255) filename!: string;
|
@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;
|
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
|
||||||
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
|
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
API_ERROR_CODES,
|
API_ERROR_CODES,
|
||||||
type ConfirmUploadResponse,
|
type ConfirmUploadResponse,
|
||||||
type CreateUploadIntentResponse,
|
type CreateUploadIntentResponse,
|
||||||
|
type CreateMediaUploadIntentRequest,
|
||||||
type ServiceRecordAttachment,
|
type ServiceRecordAttachment,
|
||||||
} from '@lehuo/contracts';
|
} 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 { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
|
||||||
import type { File, FileBusinessType } from '../generated/prisma/client';
|
import type { File, FileBusinessType } from '../generated/prisma/client';
|
||||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||||
import type { CreateMediaUploadIntentDto } from './dto/participation.dto';
|
|
||||||
|
|
||||||
const EXTENSIONS: Record<string, string> = {
|
const EXTENSIONS: Record<string, string> = {
|
||||||
'image/jpeg': 'jpg',
|
'image/jpeg': 'jpg',
|
||||||
@@ -31,7 +31,7 @@ export class MediaService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createIntent(
|
async createIntent(
|
||||||
input: CreateMediaUploadIntentDto,
|
input: CreateMediaUploadIntentRequest,
|
||||||
actor: AuthContext,
|
actor: AuthContext,
|
||||||
request: RequestContext,
|
request: RequestContext,
|
||||||
): Promise<CreateUploadIntentResponse> {
|
): Promise<CreateUploadIntentResponse> {
|
||||||
@@ -56,7 +56,9 @@ export class MediaService {
|
|||||||
return this.uploadResponse(previous, true);
|
return this.uploadResponse(previous, true);
|
||||||
}
|
}
|
||||||
const now = new Date();
|
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(
|
const file = await this.files.createPending(
|
||||||
{
|
{
|
||||||
publicId: randomUUID(),
|
publicId: randomUUID(),
|
||||||
@@ -86,7 +88,11 @@ export class MediaService {
|
|||||||
const file = await this.files.findByPublicId(publicId);
|
const file = await this.files.findByPublicId(publicId);
|
||||||
if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId)
|
if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId)
|
||||||
throw this.notFound();
|
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();
|
throw this.notFound();
|
||||||
}
|
}
|
||||||
if (file.status === 'AVAILABLE') return { file: this.attachment(file) };
|
if (file.status === 'AVAILABLE') return { file: this.attachment(file) };
|
||||||
@@ -141,7 +147,8 @@ export class MediaService {
|
|||||||
file: File,
|
file: File,
|
||||||
idempotentReplay: boolean,
|
idempotentReplay: boolean,
|
||||||
): Promise<CreateUploadIntentResponse> {
|
): Promise<CreateUploadIntentResponse> {
|
||||||
const expiresIn = 600;
|
// 缩短上传 URL 有效期至 120 秒,降低截获后滥用的风险窗口。
|
||||||
|
const expiresIn = 120;
|
||||||
let uploadUrl: string;
|
let uploadUrl: string;
|
||||||
try {
|
try {
|
||||||
uploadUrl = await this.storage.createUploadUrl(
|
uploadUrl = await this.storage.createUploadUrl(
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ import { VolunteersService } from './volunteers.service';
|
|||||||
AdminParticipationController,
|
AdminParticipationController,
|
||||||
],
|
],
|
||||||
providers: [VolunteersService, ProjectsService, ActivitiesService, MediaService],
|
providers: [VolunteersService, ProjectsService, ActivitiesService, MediaService],
|
||||||
|
exports: [MediaService],
|
||||||
})
|
})
|
||||||
export class ParticipationModule {}
|
export class ParticipationModule {}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ describe('Phase 1 authentication and authorization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||||
|
await prisma.financialDisclosure.deleteMany();
|
||||||
await prisma.auditLog.deleteMany();
|
await prisma.auditLog.deleteMany();
|
||||||
await prisma.notificationDelivery.deleteMany();
|
await prisma.notificationDelivery.deleteMany();
|
||||||
await prisma.notification.deleteMany();
|
await prisma.notification.deleteMany();
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ describe('Phase 3 beneficiary and case management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||||
|
await prisma.financialDisclosure.deleteMany();
|
||||||
await prisma.notificationDelivery.deleteMany();
|
await prisma.notificationDelivery.deleteMany();
|
||||||
await prisma.notification.deleteMany();
|
await prisma.notification.deleteMany();
|
||||||
await prisma.notificationPreference.deleteMany();
|
await prisma.notificationPreference.deleteMany();
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ describe('Phase 6 content and notifications', () => {
|
|||||||
passwords = app.get(PasswordService);
|
passwords = app.get(PasswordService);
|
||||||
});
|
});
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||||
|
await prisma.financialDisclosure.deleteMany();
|
||||||
await prisma.auditLog.deleteMany();
|
await prisma.auditLog.deleteMany();
|
||||||
await prisma.notificationDelivery.deleteMany();
|
await prisma.notificationDelivery.deleteMany();
|
||||||
await prisma.notification.deleteMany();
|
await prisma.notification.deleteMany();
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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 {
|
||||||
|
ApiErrorResponse,
|
||||||
|
ApiSuccessResponse,
|
||||||
|
FinancialDisclosureDetailResponse,
|
||||||
|
PaginatedFinancialDisclosuresResponse,
|
||||||
|
} 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 { PrismaService } from '../src/database/prisma.service';
|
||||||
|
|
||||||
|
const ORIGIN = 'http://localhost:5173';
|
||||||
|
const body = <T>(response: { body: unknown }) => response.body as T;
|
||||||
|
function sessionCookie(response: { headers: Record<string, unknown> }) {
|
||||||
|
const values = response.headers['set-cookie'];
|
||||||
|
const cookies: string[] = Array.isArray(values)
|
||||||
|
? values.filter((value): value is string => typeof value === 'string')
|
||||||
|
: [];
|
||||||
|
const found = cookies.find((value) => value.startsWith(`${ADMIN_SESSION_COOKIE}=`));
|
||||||
|
if (!found) throw new Error('no cookie');
|
||||||
|
return found.split(';')[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('financial disclosure publishing', () => {
|
||||||
|
let app: INestApplication, prisma: PrismaService, passwords: PasswordService;
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module = await Test.createTestingModule({ imports: [AppModule] }).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.auditLog.deleteMany();
|
||||||
|
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||||
|
await prisma.financialDisclosure.deleteMany();
|
||||||
|
await prisma.authSession.deleteMany();
|
||||||
|
await prisma.userRole.deleteMany();
|
||||||
|
await prisma.adminCredential.deleteMany();
|
||||||
|
await prisma.staffProfile.deleteMany();
|
||||||
|
});
|
||||||
|
afterAll(() => app.close());
|
||||||
|
async function admin() {
|
||||||
|
const username = `finance.${crypto.randomUUID()}`;
|
||||||
|
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } });
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
publicId: crypto.randomUUID(),
|
||||||
|
userType: 'STAFF',
|
||||||
|
staffProfile: { create: { displayName: '财务公开管理员' } },
|
||||||
|
adminCredential: {
|
||||||
|
create: { username, passwordHash: await passwords.hash('correct horse battery staple') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.post('/api/v1/admin/auth/login')
|
||||||
|
.set('origin', ORIGIN)
|
||||||
|
.send({ username, password: 'correct horse battery staple' })
|
||||||
|
.expect(201);
|
||||||
|
return sessionCookie(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps drafts private, sanitizes rich text, publishes and audits the document', async () => {
|
||||||
|
const cookie = await admin();
|
||||||
|
const created = await request(app.getHttpServer() as Server)
|
||||||
|
.post('/api/v1/admin/financial-disclosures')
|
||||||
|
.set('origin', ORIGIN)
|
||||||
|
.set('cookie', cookie)
|
||||||
|
.send({
|
||||||
|
clientRequestId: crypto.randomUUID(),
|
||||||
|
title: '2026年度财务公开',
|
||||||
|
summary: '年度财务公开说明',
|
||||||
|
richText: '<h2>年度说明</h2><p onclick="alert(1)">公开正文</p><script>alert(1)</script>',
|
||||||
|
isPinned: true,
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const disclosure =
|
||||||
|
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
|
||||||
|
expect(disclosure.richText).not.toMatch(/script|onclick/);
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/api/v1/financial-disclosures/${disclosure.publicId}`)
|
||||||
|
.expect(404);
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/api/v1/admin/financial-disclosures/${disclosure.publicId}/transition`)
|
||||||
|
.set('origin', ORIGIN)
|
||||||
|
.set('cookie', cookie)
|
||||||
|
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
|
||||||
|
.expect(201);
|
||||||
|
const listed = await request(app.getHttpServer() as Server)
|
||||||
|
.get('/api/v1/financial-disclosures?page=1&pageSize=20')
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
body<ApiSuccessResponse<PaginatedFinancialDisclosuresResponse>>(listed).data.items,
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(await prisma.financialDisclosureStatusHistory.count()).toBe(2);
|
||||||
|
expect(await prisma.auditLog.count({ where: { resourceType: 'FINANCIAL_DISCLOSURE' } })).toBe(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects edits after publishing and hides archived documents', async () => {
|
||||||
|
const cookie = await admin(),
|
||||||
|
requestId = crypto.randomUUID();
|
||||||
|
const created = await request(app.getHttpServer() as Server)
|
||||||
|
.post('/api/v1/admin/financial-disclosures')
|
||||||
|
.set('origin', ORIGIN)
|
||||||
|
.set('cookie', cookie)
|
||||||
|
.send({
|
||||||
|
clientRequestId: requestId,
|
||||||
|
title: '专项财务公开',
|
||||||
|
richText: '<p>专项公开正文内容</p>',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const item =
|
||||||
|
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
|
||||||
|
.set('origin', ORIGIN)
|
||||||
|
.set('cookie', cookie)
|
||||||
|
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
|
||||||
|
.expect(201);
|
||||||
|
const rejected = await request(app.getHttpServer() as Server)
|
||||||
|
.patch(`/api/v1/admin/financial-disclosures/${item.publicId}`)
|
||||||
|
.set('origin', ORIGIN)
|
||||||
|
.set('cookie', cookie)
|
||||||
|
.send({
|
||||||
|
expectedRevision: item.revision + 1,
|
||||||
|
title: item.title,
|
||||||
|
richText: '<p>覆盖已发布内容</p>',
|
||||||
|
})
|
||||||
|
.expect(409);
|
||||||
|
expect(body<ApiErrorResponse>(rejected).error.code).toBe('INVALID_STATE_TRANSITION');
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
|
||||||
|
.set('origin', ORIGIN)
|
||||||
|
.set('cookie', cookie)
|
||||||
|
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'ARCHIVED' })
|
||||||
|
.expect(201);
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/api/v1/financial-disclosures/${item.publicId}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,6 +61,8 @@ describe('Phase 4 follow-up closure', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||||
|
await prisma.financialDisclosure.deleteMany();
|
||||||
await prisma.notificationDelivery.deleteMany();
|
await prisma.notificationDelivery.deleteMany();
|
||||||
await prisma.notification.deleteMany();
|
await prisma.notification.deleteMany();
|
||||||
await prisma.notificationPreference.deleteMany();
|
await prisma.notificationPreference.deleteMany();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,6 +70,8 @@ describe('Phase 5 projects, activities and volunteers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||||
|
await prisma.financialDisclosure.deleteMany();
|
||||||
throttlerStorage.storage.clear();
|
throttlerStorage.storage.clear();
|
||||||
await prisma.auditLog.deleteMany();
|
await prisma.auditLog.deleteMany();
|
||||||
await prisma.notificationDelivery.deleteMany();
|
await prisma.notificationDelivery.deleteMany();
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ describe('Phase 2 service applications', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||||
|
await prisma.financialDisclosure.deleteMany();
|
||||||
await prisma.notificationDelivery.deleteMany();
|
await prisma.notificationDelivery.deleteMany();
|
||||||
await prisma.notification.deleteMany();
|
await prisma.notification.deleteMany();
|
||||||
await prisma.notificationPreference.deleteMany();
|
await prisma.notificationPreference.deleteMany();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 微信小程序
|
# 微信小程序
|
||||||
|
|
||||||
微信原生小程序 + TypeScript。Phase 1 已接入微信登录和短期会话;Phase 2–4 提供服务申请、协作任务和闭环;Phase 5 提供公开公益项目/活动、在线报名、志愿者认证、个人报名进度和服务时长。敏感申请草稿、志愿者资料和工单内容不写入本地持久化。
|
微信原生小程序 + TypeScript。公众端使用“首页 / 服务 / 公益 / 我的”四栏导航:首页突出本人/代申请与六类服务,服务页承载求助闭环,公益页展示公开项目和活动,我的页面按身份提供申请、消息、志愿服务和协作任务。Phase 1 已接入微信登录和短期会话;Phase 2–4 提供服务申请、协作任务和闭环;Phase 5 提供公开公益项目/活动、在线报名、志愿者认证、个人报名进度和服务时长。敏感申请草稿、志愿者资料和工单内容不写入本地持久化。
|
||||||
|
|
||||||
|
视觉系统集中定义在 `miniprogram/app.wxss`,包含暖橙主色、语义色、适老字号、圆角、阴影、状态卡和按钮基线;自定义底部导航位于 `miniprogram/custom-tab-bar/`。新增页面必须复用这些令牌并保持 loading、empty、error 与权限感知状态,不得加入未批准的捐赠交易或虚构业务数据。
|
||||||
|
|
||||||
使用微信开发者工具导入本目录;真实 AppID 只保存在本地私有配置中,不提交仓库。本地默认 API 为 `http://localhost:3000`;提审或发布前必须把 `miniprogram/app.ts` 中的地址替换为已加入微信 request 合法域名的 HTTPS 地址,不得发布本地 HTTP 配置。
|
使用微信开发者工具导入本目录;真实 AppID 只保存在本地私有配置中,不提交仓库。本地默认 API 为 `http://localhost:3000`;提审或发布前必须把 `miniprogram/app.ts` 中的地址替换为已加入微信 request 合法域名的 HTTPS 地址,不得发布本地 HTTP 配置。
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
"pages/index/index",
|
"pages/index/index",
|
||||||
|
"pages/service-hub/service-hub",
|
||||||
|
"pages/public-welfare/public-welfare",
|
||||||
|
"pages/profile/profile",
|
||||||
"pages/application-form/application-form",
|
"pages/application-form/application-form",
|
||||||
"pages/application-success/application-success",
|
"pages/application-success/application-success",
|
||||||
"pages/my-applications/my-applications",
|
"pages/my-applications/my-applications",
|
||||||
@@ -14,14 +17,36 @@
|
|||||||
"pages/volunteer-center/volunteer-center",
|
"pages/volunteer-center/volunteer-center",
|
||||||
"pages/content/content",
|
"pages/content/content",
|
||||||
"pages/content-detail/content-detail",
|
"pages/content-detail/content-detail",
|
||||||
"pages/notifications/notifications"
|
"pages/notifications/notifications",
|
||||||
|
"pages/financial-disclosures/financial-disclosures",
|
||||||
|
"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": {
|
"window": {
|
||||||
"backgroundColor": "#F5F5F5",
|
"backgroundColor": "#F7F5F2",
|
||||||
"backgroundTextStyle": "light",
|
"backgroundTextStyle": "light",
|
||||||
"navigationBarBackgroundColor": "#FFFFFF",
|
"navigationBarBackgroundColor": "#FFFFFF",
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"navigationBarTitleText": "乐活公益"
|
"navigationBarTitleText": "乐活公益"
|
||||||
},
|
},
|
||||||
|
"tabBar": {
|
||||||
|
"custom": true,
|
||||||
|
"color": "#77716B",
|
||||||
|
"selectedColor": "#F05A28",
|
||||||
|
"backgroundColor": "#FFFFFF",
|
||||||
|
"list": [
|
||||||
|
{ "pagePath": "pages/index/index", "text": "首页" },
|
||||||
|
{ "pagePath": "pages/service-hub/service-hub", "text": "服务" },
|
||||||
|
{ "pagePath": "pages/public-welfare/public-welfare", "text": "公益" },
|
||||||
|
{ "pagePath": "pages/profile/profile", "text": "我的" }
|
||||||
|
]
|
||||||
|
},
|
||||||
"sitemapLocation": "sitemap.json"
|
"sitemapLocation": "sitemap.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
App<IAppOption>({
|
App<IAppOption>({
|
||||||
globalData: {
|
globalData: {
|
||||||
// 本地开发地址;提审/发布前必须替换为已配置微信合法域名的 HTTPS API 地址。
|
// 本地开发使用回环地址;体验版/正式版会由请求层拒绝非 HTTPS 地址。
|
||||||
|
// 提审前必须替换为已配置微信合法域名的 HTTPS API 地址。
|
||||||
apiBaseUrl: 'http://localhost:3000',
|
apiBaseUrl: 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,39 @@
|
|||||||
page {
|
page {
|
||||||
--color-primary: #ff6b35;
|
/* Primitive → semantic → component tokens. */
|
||||||
--color-primary-pressed: #e95a28;
|
--primitive-orange-500: #ff6b35;
|
||||||
--color-primary-soft: #fff1eb;
|
--primitive-orange-600: #e85220;
|
||||||
--color-success: #2f8f2f;
|
--primitive-orange-50: #fff5ef;
|
||||||
--color-info: #1677c8;
|
--primitive-green-500: #34ad50;
|
||||||
|
--primitive-blue-500: #2f91ed;
|
||||||
|
--primitive-gray-950: #1f2429;
|
||||||
|
--primitive-gray-600: #687078;
|
||||||
|
--primitive-gray-200: #e8ebee;
|
||||||
|
--primitive-gray-50: #f6f7f8;
|
||||||
|
--primitive-white: #ffffff;
|
||||||
|
--color-primary: #f05a28;
|
||||||
|
--color-primary-pressed: #d94718;
|
||||||
|
--color-primary-soft: #fff0e8;
|
||||||
|
--color-primary-pale: #fff8f4;
|
||||||
|
--color-secondary: #f6a23c;
|
||||||
|
--color-success: #2f9b58;
|
||||||
|
--color-success-soft: #eaf7ef;
|
||||||
|
--color-info: #287fd1;
|
||||||
|
--color-info-soft: #ebf4fd;
|
||||||
|
--color-warning: #b76e00;
|
||||||
|
--color-warning-soft: #fff6dd;
|
||||||
--color-danger: #b42318;
|
--color-danger: #b42318;
|
||||||
--color-text: #1f2329;
|
--color-text: var(--primitive-gray-950);
|
||||||
--color-text-secondary: #646a73;
|
--color-text-secondary: var(--primitive-gray-600);
|
||||||
--color-border: #e5e6eb;
|
--color-text-tertiary: #918a83;
|
||||||
--color-background: #f5f5f5;
|
--color-border: var(--primitive-gray-200);
|
||||||
--color-surface: #ffffff;
|
--color-divider: #f0f1f2;
|
||||||
|
--color-background: var(--primitive-gray-50);
|
||||||
|
--color-surface: var(--primitive-white);
|
||||||
|
--radius-sm: 16rpx;
|
||||||
|
--radius-md: 24rpx;
|
||||||
|
--radius-lg: 32rpx;
|
||||||
|
--shadow-card: 0 10rpx 32rpx rgba(91, 62, 45, 0.07);
|
||||||
|
--shadow-hero: 0 16rpx 38rpx rgba(231, 79, 29, 0.22);
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
@@ -17,27 +41,63 @@ page {
|
|||||||
-apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei',
|
-apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
button::after {
|
button::after {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 32rpx 32rpx calc(48rpx + env(safe-area-inset-bottom));
|
padding: 28rpx 28rpx calc(152rpx + env(safe-area-inset-bottom));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
margin: 8rpx 4rpx 28rpx;
|
||||||
|
}
|
||||||
|
.page-title,
|
||||||
|
.page-subtitle,
|
||||||
|
.section-title,
|
||||||
|
.section-note {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
.page-subtitle {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
margin: 40rpx 4rpx 20rpx;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.section-note {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--color-primary-pressed);
|
color: var(--color-primary-pressed);
|
||||||
font-size: 28rpx;
|
font-size: 26rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button,
|
.primary-button,
|
||||||
@@ -48,105 +108,149 @@ button::after {
|
|||||||
padding: 20rpx 32rpx;
|
padding: 20rpx 32rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 24rpx;
|
border-radius: 999rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 34rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
color: #ffffff;
|
color: #fff;
|
||||||
background: var(--color-primary);
|
background: linear-gradient(135deg, #ff713d, var(--color-primary));
|
||||||
|
box-shadow: 0 12rpx 24rpx rgba(240, 90, 40, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:active {
|
.primary-button:active {
|
||||||
background: var(--color-primary-pressed);
|
background: var(--color-primary-pressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-button {
|
.secondary-button {
|
||||||
color: var(--color-primary-pressed);
|
color: var(--color-primary-pressed);
|
||||||
background: var(--color-primary-soft);
|
background: #fff;
|
||||||
border: 2rpx solid var(--color-primary);
|
border: 2rpx solid #f5a083;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button[disabled],
|
.primary-button[disabled],
|
||||||
.secondary-button[disabled] {
|
.secondary-button[disabled] {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-card,
|
.state-card,
|
||||||
.form-card,
|
.form-card,
|
||||||
.detail-card,
|
.detail-card,
|
||||||
.detail-summary,
|
.detail-summary,
|
||||||
|
.success-card,
|
||||||
|
.action-card,
|
||||||
|
.case-card,
|
||||||
|
.application-card {
|
||||||
|
border: 2rpx solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.form-card,
|
||||||
|
.detail-card,
|
||||||
|
.detail-summary,
|
||||||
.success-card {
|
.success-card {
|
||||||
padding: 32rpx;
|
padding: 32rpx;
|
||||||
border: 2rpx solid var(--color-border);
|
|
||||||
border-radius: 32rpx;
|
|
||||||
background: var(--color-surface);
|
|
||||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-card {
|
.state-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 280rpx;
|
min-height: 280rpx;
|
||||||
|
padding: 36rpx 30rpx;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.state-card::before {
|
||||||
|
content: '♡';
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
color: #f6a07d;
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
.state-title {
|
.state-title {
|
||||||
font-size: 38rpx;
|
font-size: 38rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-text {
|
.state-text {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 16rpx;
|
margin-top: 14rpx;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-card,
|
.action-card {
|
||||||
.skeleton-line {
|
display: flex;
|
||||||
overflow: hidden;
|
width: 100%;
|
||||||
border-radius: 20rpx;
|
min-height: 144rpx;
|
||||||
background: linear-gradient(90deg, #f0f1f3 25%, #fafafa 50%, #f0f1f3 75%);
|
margin: 0 0 20rpx;
|
||||||
background-size: 200% 100%;
|
padding: 28rpx 30rpx;
|
||||||
animation: skeleton 1.4s ease-in-out infinite;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.action-card:active,
|
||||||
|
.case-card:active,
|
||||||
|
.application-card:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
opacity: 0.84;
|
||||||
|
}
|
||||||
|
.action-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.action-description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.card-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 25rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-card {
|
.notice-card {
|
||||||
height: 160rpx;
|
margin: 20rpx 0;
|
||||||
margin-bottom: 24rpx;
|
padding: 26rpx 28rpx;
|
||||||
|
border: 2rpx solid #f4dfc8;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #fffaf4;
|
||||||
}
|
}
|
||||||
|
.notice-title,
|
||||||
.skeleton-line {
|
.notice-text {
|
||||||
width: 60%;
|
display: block;
|
||||||
height: 28rpx;
|
|
||||||
margin: 12rpx auto;
|
|
||||||
}
|
}
|
||||||
|
.notice-title {
|
||||||
.skeleton-line--wide {
|
font-weight: 700;
|
||||||
width: 84%;
|
}
|
||||||
height: 44rpx;
|
.notice-text {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #6f583f;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-height: 48rpx;
|
min-height: 48rpx;
|
||||||
padding: 8rpx 16rpx;
|
padding: 7rpx 16rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 12rpx;
|
border-radius: 999rpx;
|
||||||
color: #145ea8;
|
color: #17653a;
|
||||||
background: #eaf4ff;
|
background: var(--color-success-soft);
|
||||||
font-size: 26rpx;
|
font-size: 25rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
.inline-error,
|
||||||
.inline-error {
|
.form-message {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 20rpx 8rpx 0;
|
margin: 20rpx 8rpx 0;
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
@@ -154,6 +258,61 @@ button::after {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeleton-card,
|
||||||
|
.skeleton-line {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(90deg, #eeeae6 25%, #faf8f6 50%, #eeeae6 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.skeleton-card {
|
||||||
|
height: 170rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.skeleton-line {
|
||||||
|
width: 60%;
|
||||||
|
height: 28rpx;
|
||||||
|
margin: 12rpx auto;
|
||||||
|
}
|
||||||
|
.skeleton-line--wide {
|
||||||
|
width: 84%;
|
||||||
|
height: 44rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
padding: 38rpx 32rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: #762b12;
|
||||||
|
background: linear-gradient(135deg, #fff4ea 0%, #ffd4be 100%);
|
||||||
|
box-shadow: 0 14rpx 36rpx rgba(208, 93, 41, 0.13);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 46rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: #70432e;
|
||||||
|
font-size: 29rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
.picker-field,
|
||||||
|
.filter-picker {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: #fff;
|
||||||
|
border-color: #dcd5ce !important;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes skeleton {
|
@keyframes skeleton {
|
||||||
0% {
|
0% {
|
||||||
background-position: 200% 0;
|
background-position: 200% 0;
|
||||||
@@ -162,3 +321,9 @@ button::after {
|
|||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton-card,
|
||||||
|
.skeleton-line {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ApplicationApplyMode, ServiceCategory, ServiceRecordType } from '@lehuo/contracts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序运行时使用的稳定业务字典。
|
||||||
|
* 类型继续由共享 contracts 约束,但真机运行不得依赖未打包的 workspace 模块。
|
||||||
|
*/
|
||||||
|
export const APPLICATION_APPLY_MODES: readonly ApplicationApplyMode[] = [
|
||||||
|
'SELF',
|
||||||
|
'FAMILY',
|
||||||
|
'COMMUNITY',
|
||||||
|
'VOLUNTEER',
|
||||||
|
'OTHER',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const APPLICATION_APPLY_MODE_LABELS: Record<ApplicationApplyMode, string> = {
|
||||||
|
SELF: '为自己申请',
|
||||||
|
FAMILY: '帮家人申请',
|
||||||
|
COMMUNITY: '社区或机构代办',
|
||||||
|
VOLUNTEER: '志愿者代办',
|
||||||
|
OTHER: '其他代办',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SERVICE_CATEGORIES: readonly ServiceCategory[] = [
|
||||||
|
'OLD_AGE',
|
||||||
|
'WOMEN',
|
||||||
|
'CHILD',
|
||||||
|
'DISABILITY',
|
||||||
|
'LIFE_DIFFICULTY',
|
||||||
|
'OTHER',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SERVICE_CATEGORY_LABELS: Record<ServiceCategory, string> = {
|
||||||
|
OLD_AGE: '关爱老人',
|
||||||
|
WOMEN: '困境妇女',
|
||||||
|
CHILD: '儿童关爱',
|
||||||
|
DISABILITY: '残障帮扶',
|
||||||
|
LIFE_DIFFICULTY: '生活困难',
|
||||||
|
OTHER: '其他服务',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SERVICE_RECORD_TYPES: readonly ServiceRecordType[] = [
|
||||||
|
'HOME_VISIT',
|
||||||
|
'PHONE_CONTACT',
|
||||||
|
'ESCORT',
|
||||||
|
'MATERIAL_SUPPORT',
|
||||||
|
'ACTIVITY',
|
||||||
|
'OTHER',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SERVICE_RECORD_TYPE_LABELS: Record<ServiceRecordType, string> = {
|
||||||
|
HOME_VISIT: '上门探访',
|
||||||
|
PHONE_CONTACT: '电话关怀',
|
||||||
|
ESCORT: '陪同协助',
|
||||||
|
MATERIAL_SUPPORT: '物资支持',
|
||||||
|
ACTIVITY: '公益活动',
|
||||||
|
OTHER: '其他服务',
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 后续能力只在此登记扩展边界,不注册页面、不展示入口,也不产生模拟数据。
|
||||||
|
* 接入时必须完成独立产品评审、接口契约、权限、审计与迁移设计。
|
||||||
|
*/
|
||||||
|
export const FUTURE_CAPABILITIES = {
|
||||||
|
donation: { enabled: false, route: '' },
|
||||||
|
recurringDonation: { enabled: false, route: '' },
|
||||||
|
donationRecords: { enabled: false, route: '' },
|
||||||
|
materialOrders: { enabled: false, route: '' },
|
||||||
|
volunteerLearning: { enabled: false, route: '' },
|
||||||
|
volunteerCredentials: { enabled: false, route: '' },
|
||||||
|
volunteerTeams: { enabled: false, route: '' },
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "component": true }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { selectedTabIndex } from './tab-state';
|
||||||
|
|
||||||
|
Component({
|
||||||
|
data: {
|
||||||
|
selected: 0,
|
||||||
|
tabs: [
|
||||||
|
{ pagePath: '/pages/index/index', text: '首页', icon: 'home' },
|
||||||
|
{ pagePath: '/pages/service-hub/service-hub', text: '服务', icon: 'service' },
|
||||||
|
{ pagePath: '/pages/public-welfare/public-welfare', text: '发现', icon: 'discover' },
|
||||||
|
{ pagePath: '/pages/profile/profile', text: '我的', icon: 'profile' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
lifetimes: {
|
||||||
|
attached() {
|
||||||
|
this.syncSelected();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pageLifetimes: {
|
||||||
|
show() {
|
||||||
|
this.syncSelected();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
syncSelected() {
|
||||||
|
const route = getCurrentPages().at(-1)?.route ?? 'pages/index/index';
|
||||||
|
const selected = selectedTabIndex(route, this.data.tabs);
|
||||||
|
if (selected !== null && selected !== this.data.selected) this.setData({ selected });
|
||||||
|
},
|
||||||
|
switchTab(event: WechatMiniprogram.BaseEvent) {
|
||||||
|
const index = Number(event.currentTarget.dataset['index']);
|
||||||
|
const tab = this.data.tabs[index];
|
||||||
|
if (tab) {
|
||||||
|
this.setData({ selected: index });
|
||||||
|
void wx.switchTab({ url: tab.pagePath });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<view class="tab-bar">
|
||||||
|
<view class="tab-bar__inner">
|
||||||
|
<button wx:for="{{tabs}}" wx:key="pagePath" class="tab {{selected === index ? 'tab--active' : ''}}" data-index="{{index}}" bindtap="switchTab" aria-label="{{item.text}}">
|
||||||
|
<view class="tab__icon tab__icon--{{item.icon}}" aria-hidden="true"><view class="tab__icon-core"></view></view>
|
||||||
|
<text class="tab__text">{{item.text}}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
.tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 90;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 0 20rpx env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
.tab-bar__inner {
|
||||||
|
display: grid;
|
||||||
|
height: 106rpx;
|
||||||
|
padding: 8rpx 10rpx 4rpx;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
border: 2rpx solid #eceff1;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 28rpx 28rpx 0 0;
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
box-shadow: 0 -8rpx 30rpx rgba(31, 36, 41, 0.08);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5rpx;
|
||||||
|
color: #7b8288;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.tab--active {
|
||||||
|
color: #f05a28;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.tab__icon {
|
||||||
|
position: relative;
|
||||||
|
width: 38rpx;
|
||||||
|
height: 38rpx;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
.tab__icon::before,
|
||||||
|
.tab__icon::after,
|
||||||
|
.tab__icon-core {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
.tab__icon--home::before {
|
||||||
|
left: 6rpx;
|
||||||
|
bottom: 4rpx;
|
||||||
|
width: 27rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
border: 4rpx solid currentColor;
|
||||||
|
border-top: 0;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
}
|
||||||
|
.tab__icon--home::after {
|
||||||
|
left: 6rpx;
|
||||||
|
top: 4rpx;
|
||||||
|
width: 26rpx;
|
||||||
|
height: 26rpx;
|
||||||
|
border-top: 4rpx solid currentColor;
|
||||||
|
border-left: 4rpx solid currentColor;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.tab__icon--service::before {
|
||||||
|
left: 2rpx;
|
||||||
|
top: 10rpx;
|
||||||
|
width: 34rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border: 4rpx solid currentColor;
|
||||||
|
border-radius: 18rpx 18rpx 9rpx 9rpx;
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
.tab__icon--service::after {
|
||||||
|
left: 9rpx;
|
||||||
|
top: 0;
|
||||||
|
width: 20rpx;
|
||||||
|
height: 17rpx;
|
||||||
|
border: 4rpx solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 15rpx 15rpx 0 0;
|
||||||
|
}
|
||||||
|
.tab__icon--discover::before {
|
||||||
|
inset: 3rpx;
|
||||||
|
border: 4rpx solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.tab__icon--discover::after {
|
||||||
|
left: 15rpx;
|
||||||
|
top: 9rpx;
|
||||||
|
width: 9rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
border-radius: 8rpx 8rpx 2rpx 2rpx;
|
||||||
|
background: currentColor;
|
||||||
|
transform: rotate(35deg);
|
||||||
|
}
|
||||||
|
.tab__icon--profile::before {
|
||||||
|
left: 12rpx;
|
||||||
|
top: 2rpx;
|
||||||
|
width: 15rpx;
|
||||||
|
height: 15rpx;
|
||||||
|
border: 4rpx solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.tab__icon--profile::after {
|
||||||
|
left: 5rpx;
|
||||||
|
bottom: 1rpx;
|
||||||
|
width: 29rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
border: 4rpx solid currentColor;
|
||||||
|
border-radius: 18rpx 18rpx 4rpx 4rpx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { selectedTabIndex } from './tab-state';
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ pagePath: '/pages/index/index' },
|
||||||
|
{ pagePath: '/pages/service-hub/service-hub' },
|
||||||
|
{ pagePath: '/pages/public-welfare/public-welfare' },
|
||||||
|
{ pagePath: '/pages/profile/profile' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('selectedTabIndex', () => {
|
||||||
|
it('matches the current page route after a tab page becomes visible', () => {
|
||||||
|
expect(selectedTabIndex('pages/profile/profile', tabs)).toBe(3);
|
||||||
|
expect(selectedTabIndex('/pages/service-hub/service-hub', tabs)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not select a tab for a non-tab route', () => {
|
||||||
|
expect(selectedTabIndex('pages/application-form/application-form', tabs)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface TabRoute {
|
||||||
|
pagePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedTabIndex(route: string, tabs: readonly TabRoute[]): number | null {
|
||||||
|
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
||||||
|
const index = tabs.findIndex((tab) => tab.pagePath === normalizedRoute);
|
||||||
|
return index >= 0 ? index : null;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
<view class="page"><view class="section-heading"><text class="section-title">公益活动</text><text class="section-note">已认证志愿者可在线报名</text></view><view wx:if="{{loading}}" class="state-card">正在加载活动…</view><view wx:elif="{{message}}" class="state-card">{{message}}</view><view wx:elif="{{!items.length}}" class="state-card">暂无可查看活动</view><button wx:for="{{items}}" wx:key="publicId" class="action-card" data-id="{{item.publicId}}" bindtap="open"><text class="action-title">{{item.title}}</text><text class="action-description">{{item.summary}}</text><text class="card-meta">{{item.statusLabel}} · 已报名 {{item.activeRegistrationCount}} / {{item.capacity === null ? '不限' : item.capacity}}</text></button></view>
|
<view class="page"><view class="page-heading"><text class="page-title">公益活动</text><text class="page-subtitle">已认证志愿者可在线报名,报名结果请在志愿者中心查看。</text></view><block wx:if="{{loading}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block><view wx:elif="{{message}}" class="state-card"><text class="state-title">活动暂时无法加载</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view><view wx:elif="{{!items.length}}" class="state-card"><text class="state-title">暂无可查看活动</text><text class="state-text">有新的志愿活动时会展示在这里。</text></view><button wx:for="{{items}}" wx:key="publicId" class="activity-card" data-id="{{item.publicId}}" bindtap="open"><view class="activity-card__top"><text class="activity-card__tag">{{item.statusLabel}}</text><text class="activity-card__count">{{item.activeRegistrationCount}} / {{item.capacity === null ? '不限' : item.capacity}} 人</text></view><text class="activity-card__title">{{item.title}}</text><text class="activity-card__copy">{{item.summary}}</text><view class="activity-card__footer"><text>查看活动详情</text><text>›</text></view></button></view>
|
||||||
|
|||||||
@@ -1,7 +1,56 @@
|
|||||||
@import '../../app.wxss';
|
.activity-card {
|
||||||
.card-meta {
|
display: flex;
|
||||||
display: block;
|
width: 100%;
|
||||||
margin-top: 12rpx;
|
min-height: 210rpx;
|
||||||
color: #646a73;
|
margin: 0 0 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 2rpx solid var(--color-border);
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.activity-card__top,
|
||||||
|
.activity-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.activity-card__tag {
|
||||||
|
padding: 6rpx 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #17653a;
|
||||||
|
background: var(--color-success-soft);
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.activity-card__count {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
.activity-card__title {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.activity-card__copy {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 27rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
.activity-card__footer {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 16rpx;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-top: 2rpx solid #f0ece8;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { fetchPublicActivity, registerActivity } from '../../services/participat
|
|||||||
Page({
|
Page({
|
||||||
data: { loading: true, busy: false, message: '', activity: null as ActivityDetail | null },
|
data: { loading: true, busy: false, message: '', activity: null as ActivityDetail | null },
|
||||||
onLoad(options: Record<string, string>) {
|
onLoad(options: Record<string, string>) {
|
||||||
if (options['publicId']) void this.load(options['publicId']);
|
const publicId = options['publicId'];
|
||||||
|
if (publicId) void this.load(publicId);
|
||||||
|
else this.setData({ loading: false, message: '缺少活动标识,请返回活动列表重新选择。' });
|
||||||
},
|
},
|
||||||
async load(publicId: string) {
|
async load(publicId: string) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<view class="page"><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{!activity}}" class="state-card">{{message}}</view><block wx:else><view class="brand-card"><text class="eyebrow">{{activity.statusLabel}}</text><text class="title">{{activity.title}}</text><text class="message">{{activity.summary}}</text></view><view class="notice-card"><text class="notice-title">时间与地点</text><text class="notice-text">{{activity.startAt}} 至 {{activity.endAt}}</text><text class="notice-text">{{activity.locationText}}</text><text class="notice-text">报名截止:{{activity.registrationDeadline}}</text></view><view class="notice-card"><text class="notice-title">活动介绍</text><text class="notice-text">{{activity.content}}</text></view><text class="form-message" wx:if="{{message}}">{{message}}</text><button class="primary-button" disabled="{{!activity.canRegister || busy}}" bindtap="register">{{busy ? '正在提交…' : activity.canRegister ? '立即报名' : '当前不可报名'}}</button><button class="secondary-button" bindtap="volunteerCenter">志愿者认证与我的报名</button></block></view>
|
<view class="page"><block wx:if="{{loading}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block><view wx:elif="{{!activity}}" class="state-card"><text class="state-title">活动暂时无法查看</text><text class="state-text">{{message}}</text></view><block wx:else><view class="activity-hero"><text class="activity-hero__status">{{activity.statusLabel}}</text><text class="activity-hero__title">{{activity.title}}</text><text class="activity-hero__copy">{{activity.summary}}</text></view><view class="info-card"><view><text class="info-icon orange">时</text><text class="info-label">活动时间</text><text class="info-value">{{activity.startAt}} 至 {{activity.endAt}}</text></view><view><text class="info-icon green">地</text><text class="info-label">活动地点</text><text class="info-value">{{activity.locationText}}</text></view><view><text class="info-icon blue">止</text><text class="info-label">报名截止</text><text class="info-value">{{activity.registrationDeadline}}</text></view></view><view class="detail-card"><text class="detail-section-title">活动介绍</text><text class="detail-copy" selectable>{{activity.content}}</text></view><view wx:if="{{message}}" class="notice-card"><text class="notice-text">{{message}}</text></view><button class="primary-button" disabled="{{!activity.canRegister || busy}}" bindtap="register">{{busy ? '正在提交…' : activity.canRegister ? '立即报名' : '当前不可报名'}}</button><button class="secondary-button" bindtap="volunteerCenter">志愿者认证与我的报名</button></block></view>
|
||||||
|
|||||||
@@ -1 +1,83 @@
|
|||||||
@import '../../app.wxss';
|
.activity-hero {
|
||||||
|
padding: 38rpx 32rpx;
|
||||||
|
border-radius: 34rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(145deg, #54b775, #29944e);
|
||||||
|
box-shadow: 0 14rpx 32rpx rgba(36, 137, 69, 0.18);
|
||||||
|
}
|
||||||
|
.activity-hero__status,
|
||||||
|
.activity-hero__title,
|
||||||
|
.activity-hero__copy {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.activity-hero__status {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.activity-hero__title {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.activity-hero__copy {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.info-card {
|
||||||
|
margin: 20rpx 0;
|
||||||
|
padding: 4rpx 28rpx;
|
||||||
|
border: 2rpx solid var(--color-border);
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.info-card > view {
|
||||||
|
display: grid;
|
||||||
|
min-height: 106rpx;
|
||||||
|
padding: 18rpx 0;
|
||||||
|
grid-template-columns: 54rpx 128rpx 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
border-bottom: 2rpx solid #f0ece8;
|
||||||
|
}
|
||||||
|
.info-card > view:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.info-icon {
|
||||||
|
display: flex;
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 15rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: #f16b39;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
.info-icon.green {
|
||||||
|
background: #38a45c;
|
||||||
|
}
|
||||||
|
.info-icon.blue {
|
||||||
|
background: #3b89cf;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.detail-section-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.detail-copy {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: #544f4a;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Page({
|
|||||||
|
|
||||||
async loadApplication() {
|
async loadApplication() {
|
||||||
if (!this.data.publicId) {
|
if (!this.data.publicId) {
|
||||||
this.setData({ status: 'error', errorMessage: '申请信息不完整,请返回重试' });
|
this.setData({ status: 'error', errorMessage: '帮扶信息不完整,请返回重试' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setData({ status: 'loading', errorMessage: '' });
|
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>
|
<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>
|
<block wx:else>
|
||||||
<view class="detail-summary">
|
<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="status-pill">{{application.statusLabel}}</text>
|
||||||
<text class="application-meta">服务编号:{{application.applicationNo}}</text>
|
<text class="application-meta">服务编号:{{application.applicationNo}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="detail-card">
|
<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.applyModeLabel}}</text></view>
|
||||||
<view class="summary-row"><text>联系电话</text><text>{{application.contactMobileMasked}}</text></view>
|
<view class="summary-row"><text>联系电话</text><text>{{application.contactMobileMasked}}</text></view>
|
||||||
<view class="summary-row"><text>所在地区</text><text>{{application.regionCode}}</text></view>
|
<view class="summary-row"><text>所在地区</text><text>{{application.regionCode}}</text></view>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { APPLICATION_FORM_STEPS, createApplicationFormEntry } from './application-form-flow';
|
||||||
|
|
||||||
|
describe('application form flow', () => {
|
||||||
|
it('starts at the relationship step when a service category is preselected', () => {
|
||||||
|
expect(createApplicationFormEntry(true)).toEqual({
|
||||||
|
choosingCategory: false,
|
||||||
|
step: 1,
|
||||||
|
totalSteps: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an unnumbered category chooser when no category is preselected', () => {
|
||||||
|
expect(createApplicationFormEntry(false)).toEqual({
|
||||||
|
choosingCategory: true,
|
||||||
|
step: 1,
|
||||||
|
totalSteps: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renumbers the remaining application steps from one to four', () => {
|
||||||
|
expect(APPLICATION_FORM_STEPS).toEqual(['申请关系', '联系方式', '描述需求', '确认提交']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const APPLICATION_FORM_STEPS = ['申请关系', '联系方式', '描述需求', '确认提交'] as const;
|
||||||
|
|
||||||
|
export function createApplicationFormEntry(hasPreselectedCategory: boolean) {
|
||||||
|
return {
|
||||||
|
choosingCategory: !hasPreselectedCategory,
|
||||||
|
step: 1,
|
||||||
|
totalSteps: APPLICATION_FORM_STEPS.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"navigationBarTitleText": "服务申请"
|
"navigationBarTitleText": "帮扶服务"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
APPLICATION_APPLY_MODES,
|
APPLICATION_APPLY_MODES,
|
||||||
SERVICE_CATEGORIES,
|
SERVICE_CATEGORIES,
|
||||||
SERVICE_CATEGORY_LABELS,
|
SERVICE_CATEGORY_LABELS,
|
||||||
} from '@lehuo/contracts';
|
} from '../../constants/business-options';
|
||||||
|
import { createApplicationFormEntry } from './application-form-flow';
|
||||||
|
|
||||||
import { MiniProgramApiError } from '../../services/api-client';
|
import { MiniProgramApiError } from '../../services/api-client';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,31 @@ import {
|
|||||||
} from '../../services/service-applications';
|
} from '../../services/service-applications';
|
||||||
import { createClientRequestId } from '../../utils/request-id';
|
import { createClientRequestId } from '../../utils/request-id';
|
||||||
|
|
||||||
const TOTAL_STEPS = 5;
|
const DEFAULT_FORM_ENTRY = createApplicationFormEntry(false);
|
||||||
|
|
||||||
|
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 {
|
function isServiceCategory(value: string): value is ServiceCategory {
|
||||||
return (SERVICE_CATEGORIES as readonly string[]).includes(value);
|
return (SERVICE_CATEGORIES as readonly string[]).includes(value);
|
||||||
@@ -30,12 +55,11 @@ function isApplyMode(value: string): value is ApplicationApplyMode {
|
|||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
step: 1,
|
...DEFAULT_FORM_ENTRY,
|
||||||
totalSteps: TOTAL_STEPS,
|
|
||||||
loading: true,
|
loading: true,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
categories: [] as ServiceCategoryOption[],
|
categories: LOCAL_SERVICE_CATEGORIES,
|
||||||
modeOptions: APPLICATION_APPLY_MODES.map((code) => ({
|
modeOptions: APPLICATION_APPLY_MODES.map((code) => ({
|
||||||
code,
|
code,
|
||||||
label: APPLICATION_APPLY_MODE_LABELS[code],
|
label: APPLICATION_APPLY_MODE_LABELS[code],
|
||||||
@@ -61,6 +85,14 @@ Page({
|
|||||||
const mode = options.mode as ApplicationApplyMode;
|
const mode = options.mode as ApplicationApplyMode;
|
||||||
this.setData({ applyMode: mode, applyModeLabel: APPLICATION_APPLY_MODE_LABELS[mode] });
|
this.setData({ applyMode: mode, applyModeLabel: APPLICATION_APPLY_MODE_LABELS[mode] });
|
||||||
}
|
}
|
||||||
|
if (options.category && isServiceCategory(options.category)) {
|
||||||
|
this.setData({
|
||||||
|
...createApplicationFormEntry(true),
|
||||||
|
serviceCategory: options.category,
|
||||||
|
serviceCategoryLabel: SERVICE_CATEGORY_LABELS[options.category],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
void this.loadCategories();
|
void this.loadCategories();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -68,9 +100,19 @@ Page({
|
|||||||
this.setData({ loading: true, errorMessage: '' });
|
this.setData({ loading: true, errorMessage: '' });
|
||||||
try {
|
try {
|
||||||
const result = await fetchServiceCategories();
|
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) {
|
} catch (error) {
|
||||||
this.setData({ loading: false, errorMessage: this.errorText(error) });
|
this.setData({
|
||||||
|
loading: false,
|
||||||
|
categories: LOCAL_SERVICE_CATEGORIES,
|
||||||
|
errorMessage: `${this.errorText(error)};您仍可先填写申请,提交前请确认服务连接。`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -83,6 +125,8 @@ Page({
|
|||||||
this.setData({
|
this.setData({
|
||||||
serviceCategory: code,
|
serviceCategory: code,
|
||||||
serviceCategoryLabel: SERVICE_CATEGORY_LABELS[code],
|
serviceCategoryLabel: SERVICE_CATEGORY_LABELS[code],
|
||||||
|
choosingCategory: false,
|
||||||
|
step: 1,
|
||||||
clientRequestId: createClientRequestId(),
|
clientRequestId: createClientRequestId(),
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
});
|
});
|
||||||
@@ -136,7 +180,7 @@ Page({
|
|||||||
this.setData({ errorMessage: message });
|
this.setData({ errorMessage: message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = Math.min(TOTAL_STEPS, this.data.step + 1);
|
const next = Math.min(this.data.totalSteps, this.data.step + 1);
|
||||||
this.setData({
|
this.setData({
|
||||||
step: next,
|
step: next,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
@@ -194,14 +238,13 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
validateStep(step: number): string {
|
validateStep(step: number): string {
|
||||||
if (step === 1 && !this.data.serviceCategory) return '请选择需要的服务类型';
|
if (step === 1 && !this.data.applyMode) return '请选择您与服务对象的关系';
|
||||||
if (step === 2 && !this.data.applyMode) return '请选择您与服务对象的关系';
|
if (step === 2) {
|
||||||
if (step === 3) {
|
|
||||||
if (!this.data.beneficiaryName.trim()) return '请填写服务对象姓名或称呼';
|
if (!this.data.beneficiaryName.trim()) return '请填写服务对象姓名或称呼';
|
||||||
if (!/^1[3-9]\d{9}$/.test(this.data.contactMobile.trim())) return '请填写正确的手机号码';
|
if (!/^1[3-9]\d{9}$/.test(this.data.contactMobile.trim())) return '请填写正确的手机号码';
|
||||||
if (this.data.regionCode.trim().length < 2) return '请填写所在市、区或街道';
|
if (this.data.regionCode.trim().length < 2) return '请填写所在市、区或街道';
|
||||||
}
|
}
|
||||||
if (step === 4 && this.data.description.trim().length < 10) {
|
if (step === 3 && this.data.description.trim().length < 10) {
|
||||||
return '请至少用 10 个字描述目前的困难或希望得到的帮助';
|
return '请至少用 10 个字描述目前的困难或希望得到的帮助';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
<view class="page application-page">
|
<view class="page application-page">
|
||||||
<view class="progress-card" aria-label="申请步骤">
|
<view wx:if="{{!choosingCategory}}" class="progress-card" aria-label="申请步骤">
|
||||||
<view class="progress-meta">
|
<view class="progress-meta">
|
||||||
<text>第 {{step}} 步,共 {{totalSteps}} 步</text>
|
<text>第 {{step}} 步,共 {{totalSteps}} 步</text>
|
||||||
<text>{{step === 1 ? '选择服务' : step === 2 ? '申请关系' : step === 3 ? '联系方式' : step === 4 ? '描述需求' : '确认提交'}}</text>
|
<text>{{step === 1 ? '申请关系' : step === 2 ? '联系方式' : step === 3 ? '描述需求' : '确认提交'}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="progress-track"><view class="progress-value step-{{step}}"></view></view>
|
<view class="progress-track"><view class="progress-value step-{{step}}"></view></view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:if="{{loading}}" class="form-card" aria-live="polite">
|
<view wx:if="{{choosingCategory && loading}}" class="form-card" aria-live="polite">
|
||||||
<view class="skeleton-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view>
|
<view class="skeleton-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view>
|
||||||
<text class="state-text">正在读取服务类型…</text>
|
<text class="state-text">正在读取服务类型…</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:elif="{{loading === false && categories.length === 0}}" class="state-card">
|
<view wx:elif="{{choosingCategory && loading === false && categories.length === 0}}" class="state-card">
|
||||||
<text class="state-title">暂时无法读取服务类型</text>
|
<text class="state-title">暂时无法读取服务类型</text>
|
||||||
<text class="state-text">{{errorMessage}}</text>
|
<text class="state-text">{{errorMessage}}</text>
|
||||||
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
|
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<block wx:else>
|
<block wx:else>
|
||||||
<view wx:if="{{step === 1}}" class="form-card">
|
<view wx:if="{{choosingCategory}}" class="form-card">
|
||||||
<text class="form-title">需要什么帮助?</text>
|
<view class="application-banner"><text>请选择需要的服务类型</text><text>我们会认真了解并及时回应</text></view>
|
||||||
<text class="form-hint">请选择最接近的一项,工作人员后续会进一步了解。</text>
|
|
||||||
<button
|
<button
|
||||||
wx:for="{{categories}}"
|
wx:for="{{categories}}"
|
||||||
wx:key="code"
|
wx:key="code"
|
||||||
@@ -30,15 +29,16 @@
|
|||||||
bindtap="selectCategory"
|
bindtap="selectCategory"
|
||||||
aria-label="{{item.label}}:{{item.description}}"
|
aria-label="{{item.label}}:{{item.description}}"
|
||||||
>
|
>
|
||||||
|
<image class="selection-photo" src="{{item.image}}" mode="aspectFill" />
|
||||||
<view class="selection-content">
|
<view class="selection-content">
|
||||||
<text class="selection-title">{{item.label}}</text>
|
<text class="selection-title">{{item.label}}</text>
|
||||||
<text class="selection-description">{{item.description}}</text>
|
<text class="selection-description">{{item.description}}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="selection-status">{{serviceCategory === item.code ? '已选择' : '选择'}}</text>
|
<text class="selection-status">{{serviceCategory === item.code ? '✓' : '›'}}</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:elif="{{step === 2}}" class="form-card">
|
<view wx:elif="{{step === 1}}" class="form-card">
|
||||||
<text class="form-title">帮谁申请?</text>
|
<text class="form-title">帮谁申请?</text>
|
||||||
<text class="form-hint">请选择您与服务对象的关系。</text>
|
<text class="form-hint">请选择您与服务对象的关系。</text>
|
||||||
<button
|
<button
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:elif="{{step === 3}}" class="form-card">
|
<view wx:elif="{{step === 2}}" class="form-card">
|
||||||
<text class="form-title">留下联系方式</text>
|
<text class="form-title">留下联系方式</text>
|
||||||
<text class="form-hint">只填写联系服务所必需的信息,不需要精确门牌地址。</text>
|
<text class="form-hint">只填写联系服务所必需的信息,不需要精确门牌地址。</text>
|
||||||
<text class="field-label">服务对象姓名或称呼 *</text>
|
<text class="field-label">服务对象姓名或称呼 *</text>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<input class="field-input" value="{{preferredContactTime}}" maxlength="100" placeholder="例如:工作日上午" bindinput="updatePreferredContactTime" />
|
<input class="field-input" value="{{preferredContactTime}}" maxlength="100" placeholder="例如:工作日上午" bindinput="updatePreferredContactTime" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:elif="{{step === 4}}" class="form-card">
|
<view wx:elif="{{step === 3}}" class="form-card">
|
||||||
<text class="form-title">简单描述需要的帮助</text>
|
<text class="form-title">简单描述需要的帮助</text>
|
||||||
<text class="form-hint">请勿填写身份证号码、银行卡号或精确门牌地址。</text>
|
<text class="form-hint">请勿填写身份证号码、银行卡号或精确门牌地址。</text>
|
||||||
<textarea class="field-textarea" value="{{description}}" maxlength="1000" placeholder="请至少用 10 个字描述目前遇到的困难或希望得到的帮助" bindinput="updateDescription"></textarea>
|
<textarea class="field-textarea" value="{{description}}" maxlength="1000" placeholder="请至少用 10 个字描述目前遇到的困难或希望得到的帮助" bindinput="updateDescription"></textarea>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:else class="form-card">
|
<view wx:else class="form-card">
|
||||||
<text class="form-title">确认申请信息</text>
|
<text class="form-title">确认帮扶信息</text>
|
||||||
<text class="form-hint">提交前请确认,联系方式仅供授权工作人员提供服务使用。</text>
|
<text class="form-hint">提交前请确认,联系方式仅供授权工作人员提供服务使用。</text>
|
||||||
<view class="summary-list">
|
<view class="summary-list">
|
||||||
<view class="summary-row"><text>服务类型</text><text>{{serviceCategoryLabel}}</text></view>
|
<view class="summary-row"><text>服务类型</text><text>{{serviceCategoryLabel}}</text></view>
|
||||||
@@ -96,16 +96,16 @@
|
|||||||
<checkbox-group bindchange="updatePrivacyConsent">
|
<checkbox-group bindchange="updatePrivacyConsent">
|
||||||
<label class="consent-row">
|
<label class="consent-row">
|
||||||
<checkbox value="accepted" checked="{{privacyConsent}}" color="#FF6B35" />
|
<checkbox value="accepted" checked="{{privacyConsent}}" color="#FF6B35" />
|
||||||
<text>我已阅读并同意本次服务申请的隐私授权,确认所填信息用于公益服务联系、评估和必要留痕。</text>
|
<text>我已阅读并同意本次帮扶服务的隐私授权,确认所填信息用于公益服务联系、评估和必要留痕。</text>
|
||||||
</label>
|
</label>
|
||||||
</checkbox-group>
|
</checkbox-group>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<text wx:if="{{errorMessage}}" class="inline-error" aria-live="assertive">{{errorMessage}}</text>
|
<text wx:if="{{errorMessage}}" class="inline-error" aria-live="assertive">{{errorMessage}}</text>
|
||||||
<view class="form-actions">
|
<view wx:if="{{!choosingCategory}}" class="form-actions">
|
||||||
<button wx:if="{{step > 1}}" class="secondary-button action-button" disabled="{{submitting}}" bindtap="previousStep">上一步</button>
|
<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: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>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -28,18 +28,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-1 {
|
.step-1 {
|
||||||
width: 20%;
|
width: 25%;
|
||||||
}
|
}
|
||||||
.step-2 {
|
.step-2 {
|
||||||
width: 40%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
.step-3 {
|
.step-3 {
|
||||||
width: 60%;
|
width: 75%;
|
||||||
}
|
}
|
||||||
.step-4 {
|
.step-4 {
|
||||||
width: 80%;
|
|
||||||
}
|
|
||||||
.step-5 {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,3 +232,36 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.application-banner {
|
||||||
|
display: flex;
|
||||||
|
min-height: 126rpx;
|
||||||
|
margin: -32rpx -32rpx 24rpx;
|
||||||
|
padding: 28rpx 30rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 30rpx 30rpx 16rpx 16rpx;
|
||||||
|
color: #e84e1e;
|
||||||
|
background: linear-gradient(120deg, #fff0e7, #fff8f3);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.application-banner text:last-child {
|
||||||
|
margin-top: 7rpx;
|
||||||
|
color: #7b6257;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.selection-photo {
|
||||||
|
width: 148rpx;
|
||||||
|
height: 104rpx;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.selection-photo--fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #7c6d65;
|
||||||
|
background: #f4f1ee;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Page({
|
|||||||
|
|
||||||
async loadApplication() {
|
async loadApplication() {
|
||||||
if (!this.data.publicId) {
|
if (!this.data.publicId) {
|
||||||
this.setData({ loading: false, errorMessage: '申请信息不完整,请前往“我的申请”查看' });
|
this.setData({ loading: false, errorMessage: '帮扶信息不完整,请前往“我的帮扶服务”查看' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setData({ loading: true, errorMessage: '' });
|
this.setData({ loading: true, errorMessage: '' });
|
||||||
@@ -40,7 +40,7 @@ Page({
|
|||||||
? `${error.message}(编号:${error.requestId})`
|
? `${error.message}(编号:${error.requestId})`
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: '暂时无法读取申请信息';
|
: '暂时无法读取帮扶信息';
|
||||||
this.setData({ loading: false, errorMessage: message });
|
this.setData({ loading: false, errorMessage: message });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<view class="page success-page">
|
<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:if="{{loading}}" class="state-card"><view class="skeleton-card"></view><text class="state-text">正在确认提交结果…</text></view>
|
||||||
<view wx:elif="{{errorMessage}}" class="state-card">
|
<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>
|
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
|
||||||
</view>
|
</view>
|
||||||
<view wx:else class="success-card">
|
<view wx:else class="success-card">
|
||||||
<view class="success-mark" aria-hidden="true">✓</view>
|
<view class="success-mark" aria-hidden="true">✓</view>
|
||||||
<text class="success-title">申请已提交</text>
|
<text class="success-title">帮扶需求已提交</text>
|
||||||
<text class="success-message">乐活公益工作人员将根据实际情况与您联系,请留意来电。</text>
|
<text class="success-message">乐活公益工作人员将根据实际情况与您联系,请留意来电。</text>
|
||||||
<view class="result-list">
|
<view class="result-list">
|
||||||
<view><text>服务编号</text><text class="result-value">{{application.applicationNo}}</text></view>
|
<view><text>服务编号</text><text class="result-value">{{application.applicationNo}}</text></view>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import type { ServiceCaseDetail } from '@lehuo/contracts';
|
||||||
SERVICE_RECORD_TYPES,
|
|
||||||
SERVICE_RECORD_TYPE_LABELS,
|
import { SERVICE_RECORD_TYPES, SERVICE_RECORD_TYPE_LABELS } from '../../constants/business-options';
|
||||||
type ServiceCaseDetail,
|
|
||||||
} from '@lehuo/contracts';
|
|
||||||
|
|
||||||
import { MiniProgramApiError } from '../../services/api-client';
|
import { MiniProgramApiError } from '../../services/api-client';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
<block wx:if="{{state === 'loading'}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block>
|
<block wx:if="{{state === 'loading'}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block>
|
||||||
<view wx:elif="{{state === 'error'}}" class="state-card"><text class="state-title">任务加载失败</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view>
|
<view wx:elif="{{state === 'error'}}" class="state-card"><text class="state-title">任务加载失败</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view>
|
||||||
<view wx:elif="{{state === 'empty'}}" class="state-card"><text class="state-title">暂无协作任务</text><text class="state-text">当工作人员将服务工单指派给您后,会显示在这里。</text></view>
|
<view wx:elif="{{state === 'empty'}}" class="state-card"><text class="state-title">暂无协作任务</text><text class="state-text">当工作人员将服务工单指派给您后,会显示在这里。</text></view>
|
||||||
<button wx:else wx:for="{{items}}" wx:key="publicId" class="case-card" data-id="{{item.publicId}}" bindtap="openCase"><view class="case-top"><text class="case-no">{{item.caseNo}}</text><text class="status-pill">{{item.statusLabel}}</text></view><text class="case-name">{{item.beneficiaryDisplayName}}</text><text class="case-meta">{{item.priorityLabel}}优先级 · 负责人 {{item.ownerDisplayName}}</text><text class="case-link">查看服务任务 ›</text></button>
|
<block wx:else>
|
||||||
|
<button wx:for="{{items}}" wx:key="publicId" class="case-card" data-id="{{item.publicId}}" bindtap="openCase"><view class="case-top"><text class="case-no">{{item.caseNo}}</text><text class="status-pill">{{item.statusLabel}}</text></view><text class="case-name">{{item.beneficiaryDisplayName}}</text><text class="case-meta">{{item.priorityLabel}}优先级 · 负责人 {{item.ownerDisplayName}}</text><text class="case-link">查看服务任务 ›</text></button>
|
||||||
|
</block>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Page({
|
|||||||
onLoad(o) {
|
onLoad(o) {
|
||||||
const id = o['publicId'];
|
const id = o['publicId'];
|
||||||
if (!id) {
|
if (!id) {
|
||||||
this.setData({ loading: false, message: '内容参数无效' });
|
this.setData({ loading: false, message: '缺少内容标识,请返回资讯列表重新选择。' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void this.load(id);
|
void this.load(id);
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<view class="page"><view wx:if="{{loading}}" class="state-card">加载中…</view><view wx:elif="{{message}}" class="state-card">{{message}}</view><article wx:else class="detail-card"><text class="detail-title">{{post.title}}</text><text class="detail-summary">{{post.summary}}</text><text class="detail-content" selectable>{{post.content}}</text><text class="detail-date">发布时间:{{post.publishedAt}}</text></article></view>
|
<view class="page"><view wx:if="{{loading}}" class="state-card">加载中…</view><view wx:elif="{{message}}" class="state-card">{{message}}</view><view wx:else class="detail-card"><text class="detail-title">{{post.title}}</text><text class="detail-summary">{{post.summary}}</text><text class="detail-content" selectable>{{post.content}}</text><text class="detail-date">发布时间:{{post.publishedAt}}</text></view></view>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationBarTitleText": "财务公开详情" }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FinancialDisclosureDetail } from '@lehuo/contracts';
|
||||||
|
import { fetchFinancialDisclosure } from '../../services/financial-disclosures';
|
||||||
|
Page({
|
||||||
|
data: { loading: true, message: '', disclosure: null as FinancialDisclosureDetail | null },
|
||||||
|
onLoad(options) {
|
||||||
|
const id = String(options['publicId'] || '');
|
||||||
|
if (!id) {
|
||||||
|
this.setData({ loading: false, message: '缺少财务公开文档标识,请返回重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.load(id);
|
||||||
|
},
|
||||||
|
async load(id: string) {
|
||||||
|
this.setData({ loading: true, message: '' });
|
||||||
|
try {
|
||||||
|
const result = await fetchFinancialDisclosure(id);
|
||||||
|
this.setData({ disclosure: result.disclosure });
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ message: error instanceof Error ? error.message : '财务公开内容加载失败' });
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<view class="page disclosure-detail-page"><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{message}}" class="state-card"><text class="state-title">文档暂时无法查看</text><text class="state-text">{{message}}</text></view><view wx:elif="{{disclosure}}" class="disclosure-paper"><text class="disclosure-title">{{disclosure.title}}</text><text wx:if="{{disclosure.summary}}" class="disclosure-summary">{{disclosure.summary}}</text><view class="disclosure-meta"><text>发布时间</text><text>{{disclosure.publishedAt}}</text></view><scroll-view class="document-scroll" scroll-x="true"><rich-text class="disclosure-rich-text" nodes="{{disclosure.richText}}" selectable="true"></rich-text></scroll-view><text class="disclosure-note">本页面内容由乐活公益管理后台正式发布。</text></view></view>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@import '../../app.wxss';
|
||||||
|
.disclosure-paper {
|
||||||
|
padding: 38rpx 32rpx;
|
||||||
|
border-radius: 26rpx;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 10rpx 30rpx rgba(38, 42, 48, 0.07);
|
||||||
|
}
|
||||||
|
.disclosure-title,
|
||||||
|
.disclosure-summary,
|
||||||
|
.disclosure-note {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.disclosure-title {
|
||||||
|
color: #25292f;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.disclosure-summary {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 22rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fff5ef;
|
||||||
|
color: #684d40;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.disclosure-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 28rpx 0;
|
||||||
|
padding-bottom: 22rpx;
|
||||||
|
border-bottom: 1rpx solid #eceef1;
|
||||||
|
color: #8c929a;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
.document-scroll {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.disclosure-rich-text {
|
||||||
|
display: block;
|
||||||
|
min-width: 100%;
|
||||||
|
color: #333840;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1.85;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.disclosure-note {
|
||||||
|
margin-top: 36rpx;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
border-top: 1rpx solid #eceef1;
|
||||||
|
color: #9a9fa7;
|
||||||
|
font-size: 23rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "navigationBarTitleText": "财务公开" }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { FinancialDisclosureListItem } from '@lehuo/contracts';
|
||||||
|
import { fetchFinancialDisclosures } from '../../services/financial-disclosures';
|
||||||
|
Page({
|
||||||
|
data: { loading: true, message: '', items: [] as FinancialDisclosureListItem[] },
|
||||||
|
onLoad() {
|
||||||
|
void this.load();
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.setData({ loading: true, message: '' });
|
||||||
|
try {
|
||||||
|
const result = await fetchFinancialDisclosures();
|
||||||
|
this.setData({ items: result.items });
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ message: error instanceof Error ? error.message : '财务公开内容加载失败' });
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
open(event: WechatMiniprogram.BaseEvent) {
|
||||||
|
const id = String(event.currentTarget.dataset['id'] || '');
|
||||||
|
if (id)
|
||||||
|
void wx.navigateTo({
|
||||||
|
url: `/pages/financial-disclosure-detail/financial-disclosure-detail?publicId=${encodeURIComponent(id)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||