feat: complete V1 phases 0-7

This commit is contained in:
2026-08-12 14:35:12 +08:00
commit c3dd9be674
311 changed files with 38750 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000
+5
View File
@@ -0,0 +1,5 @@
# 管理后台
Vue 3 + TypeScript + Vite。Phase 1 至 Phase 5 提供后台账号登录、服务申请与接单、服务对象、工单与记录、回访工作台,以及志愿者审核、公益项目、活动报名、签到和服务时长工作台。凭据由 HttpOnly Cookie 管理,不写入 localStorage;列表手机号默认脱敏,敏感详情查看记录审计。
复制 `.env.example` 为 `.env.local` 并配置 `VITE_API_BASE_URL`,然后运行 `pnpm --filter @lehuo/admin-web dev`。该地址必须与 API 的 `ADMIN_WEB_ORIGIN` CORS 配置配套。
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="乐活公益暖心服务平台管理后台" />
<title>乐活公益管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@lehuo/admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm --filter @lehuo/contracts build && vite build",
"dev": "pnpm --filter @lehuo/contracts build && vite",
"test:unit": "pnpm --filter @lehuo/contracts build && vitest run",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@lehuo/contracts": "workspace:*",
"pinia": "^3.0.3",
"vue": "^3.5.20",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"typescript": "^5.9.2",
"vite": "^7.1.3",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"
}
}
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ApiClientError } from './api/api-client';
import { ADMIN_APP_TITLE } from './app-info';
import { validateLoginForm } from './login-form';
import { useAuthStore } from './stores/auth';
import ServiceApplicationsView from './views/ServiceApplicationsView.vue';
import BeneficiariesView from './views/BeneficiariesView.vue';
import ServiceCasesView from './views/ServiceCasesView.vue';
import FollowUpsView from './views/FollowUpsView.vue';
import VolunteersView from './views/VolunteersView.vue';
import ProjectsView from './views/ProjectsView.vue';
import ActivitiesView from './views/ActivitiesView.vue';
import ContentView from './views/ContentView.vue';
import NotificationDeliveriesView from './views/NotificationDeliveriesView.vue';
import StatisticsView from './views/StatisticsView.vue';
const auth = useAuthStore();
const form = reactive({ username: '', password: '' });
const errors = reactive<{ username?: string; password?: string }>({});
const submitting = ref(false);
const showPassword = ref(false);
const message = ref('');
const activeView = ref<
| 'applications'
| 'beneficiaries'
| 'cases'
| 'follow-ups'
| 'volunteers'
| 'projects'
| 'activities'
| 'content'
| 'notifications'
| 'statistics'
>('applications');
onMounted(() => auth.restore());
async function submit(): Promise<void> {
message.value = '';
errors.username = undefined;
errors.password = undefined;
const validation = validateLoginForm(form.username, form.password);
Object.assign(errors, validation);
if (Object.keys(validation).length) return;
submitting.value = true;
try {
await auth.login(form.username.trim().toLowerCase(), form.password);
form.password = '';
} catch (error) {
message.value =
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '登录失败,请稍后重试';
} finally {
submitting.value = false;
}
}
async function logout(): Promise<void> {
submitting.value = true;
message.value = '';
try {
await auth.logout();
} catch (error) {
message.value = error instanceof Error ? error.message : '退出失败,请稍后重试';
} finally {
submitting.value = false;
}
}
</script>
<template>
<main v-if="auth.checking" class="app-shell app-shell--centered" aria-live="polite">
<section class="welcome-card">
<p class="eyebrow">乐活公益 · 暖心服务平台</p>
<h1>{{ ADMIN_APP_TITLE }}</h1>
<p>正在确认登录状态,请稍候…</p>
</section>
</main>
<main v-else-if="!auth.authenticated" class="app-shell app-shell--centered">
<section class="login-card" aria-labelledby="app-title">
<p class="eyebrow">乐活公益 · 暖心服务平台</p>
<h1 id="app-title">{{ ADMIN_APP_TITLE }}</h1>
<p class="intro">仅限经授权的工作人员使用。登录操作将记录安全审计。</p>
<form novalidate @submit.prevent="submit">
<label for="username">登录账号</label>
<input
id="username"
v-model="form.username"
name="username"
autocomplete="username"
:aria-invalid="Boolean(errors.username)"
:aria-describedby="errors.username ? 'username-error' : undefined"
/>
<p v-if="errors.username" id="username-error" class="field-error" role="alert">
{{ errors.username }}
</p>
<label for="password">登录密码</label>
<div class="password-field">
<input
id="password"
v-model="form.password"
name="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
:aria-invalid="Boolean(errors.password)"
:aria-describedby="errors.password ? 'password-error' : undefined"
/>
<button
type="button"
class="password-toggle"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
{{ showPassword ? '隐藏' : '显示' }}
</button>
</div>
<p v-if="errors.password" id="password-error" class="field-error" role="alert">
{{ errors.password }}
</p>
<p v-if="message" class="form-message" role="alert">{{ message }}</p>
<button class="primary-button" type="submit" :disabled="submitting">
{{ submitting ? '正在安全登录…' : '登录管理后台' }}
</button>
</form>
</section>
</main>
<div v-else class="admin-layout">
<aside class="admin-sidebar" aria-label="管理后台导航">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true"></span>
<div><strong>乐活公益</strong><small>管理平台</small></div>
</div>
<nav>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'applications' }]"
type="button"
:aria-current="activeView === 'applications' ? 'page' : undefined"
@click="activeView = 'applications'"
>
服务申请
</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>
</nav>
<p class="phase-note">Phase 6:内容审核、站内消息与微信订阅通知</p>
</aside>
<div class="admin-main">
<header class="admin-topbar">
<div>
<strong>{{ auth.user?.displayName ?? '工作人员' }}</strong>
<span>已安全登录</span>
</div>
<button class="text-button" type="button" :disabled="submitting" @click="logout">
{{ submitting ? '正在退出…' : '安全退出' }}
</button>
</header>
<p v-if="message" class="global-message" role="alert">{{ message }}</p>
<ServiceApplicationsView v-if="activeView === 'applications'" />
<BeneficiariesView v-else-if="activeView === 'beneficiaries'" />
<ServiceCasesView v-else-if="activeView === 'cases'" />
<FollowUpsView v-else-if="activeView === 'follow-ups'" />
<VolunteersView v-else-if="activeView === 'volunteers'" />
<ProjectsView v-else-if="activeView === 'projects'" />
<ActivitiesView v-else-if="activeView === 'activities'" />
<ContentView v-else-if="activeView === 'content'" />
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
<StatisticsView v-else />
</div>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
import type { ApiErrorResponse, ApiSuccessResponse } from '@lehuo/contracts';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined;
export class ApiClientError extends Error {
constructor(
message: string,
readonly code: string,
readonly requestId: string | null,
) {
super(message);
}
}
export async function apiRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
if (!API_BASE_URL) {
throw new ApiClientError('管理后台尚未配置服务地址', 'CONFIGURATION_ERROR', null);
}
let response: Response;
try {
response = await fetch(new URL(path, API_BASE_URL), {
...init,
credentials: 'include',
headers: {
'content-type': 'application/json',
...init.headers,
},
});
} catch {
throw new ApiClientError('网络连接失败,请检查网络后重试', 'NETWORK_ERROR', null);
}
const payload = (await response.json()) as ApiSuccessResponse<T> | ApiErrorResponse;
if (!response.ok || !payload.success) {
const error = payload as ApiErrorResponse;
throw new ApiClientError(
error.error?.message || '请求失败,请稍后重试',
error.error?.code || 'UNKNOWN_ERROR',
error.requestId || response.headers.get('x-request-id'),
);
}
return payload.data;
}
+172
View File
@@ -0,0 +1,172 @@
import type {
AcceptServiceApplicationRequest,
AcceptServiceApplicationResponse,
AssigneeOption,
BeneficiaryDetailResponse,
BeneficiaryTagOption,
ConfirmUploadResponse,
CompleteServiceCaseRequest,
CompleteServiceCaseResponse,
CreateServiceRecordRequest,
CreateUploadIntentRequest,
CreateUploadIntentResponse,
DownloadUrlResponse,
PaginatedBeneficiariesResponse,
PaginatedServiceCasesResponse,
ReplaceCaseAssigneesRequest,
ServiceCaseDetailResponse,
ServiceCategory,
ServiceCaseStatus,
ServiceRecordResponse,
TransitionServiceApplicationRequest,
UpdateBeneficiaryRequest,
UpdateServiceRecordRequest,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
function queryString(values: Record<string, string | number | boolean | undefined>): string {
const params = new URLSearchParams();
Object.entries(values).forEach(([key, value]) => {
if (value !== undefined && value !== '') params.set(key, String(value));
});
return params.toString();
}
export function transitionServiceApplication(
publicId: string,
input: TransitionServiceApplicationRequest,
): Promise<{ status: string }> {
return apiRequest(
`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}/transition`,
{
method: 'POST',
body: JSON.stringify(input),
},
);
}
export function acceptServiceApplication(
publicId: string,
input: AcceptServiceApplicationRequest,
): Promise<AcceptServiceApplicationResponse> {
return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}/accept`, {
method: 'POST',
body: JSON.stringify(input),
});
}
export function fetchBeneficiaryTags(): Promise<BeneficiaryTagOption[]> {
return apiRequest('/api/v1/admin/beneficiaries/tags');
}
export function fetchBeneficiaries(query: {
page: number;
pageSize: number;
primaryCategory?: ServiceCategory;
status?: string;
keyCareFlag?: boolean;
}): Promise<PaginatedBeneficiariesResponse> {
return apiRequest(`/api/v1/admin/beneficiaries?${queryString(query)}`);
}
export function fetchBeneficiary(publicId: string): Promise<BeneficiaryDetailResponse> {
return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`);
}
export function updateBeneficiary(
publicId: string,
input: UpdateBeneficiaryRequest,
): Promise<BeneficiaryDetailResponse> {
return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`, {
method: 'PUT',
body: JSON.stringify(input),
});
}
export function fetchAssigneeOptions(): Promise<AssigneeOption[]> {
return apiRequest('/api/v1/admin/service-cases/assignee-options');
}
export function fetchServiceCases(query: {
page: number;
pageSize: number;
status?: ServiceCaseStatus;
category?: ServiceCategory;
caseNo?: string;
}): Promise<PaginatedServiceCasesResponse> {
return apiRequest(`/api/v1/admin/service-cases?${queryString(query)}`);
}
export function fetchServiceCase(publicId: string): Promise<ServiceCaseDetailResponse> {
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}`);
}
export function startServiceCase(publicId: string): Promise<ServiceCaseDetailResponse> {
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/start`, {
method: 'POST',
body: '{}',
});
}
export function completeServiceCase(
publicId: string,
input: CompleteServiceCaseRequest,
): Promise<CompleteServiceCaseResponse> {
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/complete`, {
method: 'POST',
body: JSON.stringify(input),
});
}
export function replaceCaseAssignees(
publicId: string,
input: ReplaceCaseAssigneesRequest,
): Promise<ServiceCaseDetailResponse> {
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/assignees`, {
method: 'PUT',
body: JSON.stringify(input),
});
}
export function createServiceRecord(
publicId: string,
input: CreateServiceRecordRequest,
): Promise<ServiceRecordResponse> {
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/records`, {
method: 'POST',
body: JSON.stringify(input),
});
}
export function updateServiceRecord(
casePublicId: string,
recordPublicId: string,
input: UpdateServiceRecordRequest,
): Promise<ServiceRecordResponse> {
return apiRequest(
`/api/v1/admin/service-cases/${encodeURIComponent(casePublicId)}/records/${encodeURIComponent(recordPublicId)}`,
{ method: 'PATCH', body: JSON.stringify(input) },
);
}
export function createUploadIntent(
casePublicId: string,
input: CreateUploadIntentRequest,
): Promise<CreateUploadIntentResponse> {
return apiRequest(
`/api/v1/service-cases/${encodeURIComponent(casePublicId)}/files/upload-intents`,
{ method: 'POST', body: JSON.stringify(input) },
);
}
export function confirmUpload(publicId: string): Promise<ConfirmUploadResponse> {
return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/confirm`, {
method: 'POST',
body: '{}',
});
}
export function fetchDownloadUrl(publicId: string): Promise<DownloadUrlResponse> {
return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/download-url`);
}
@@ -0,0 +1,37 @@
import type {
ContentPostDetailResponse,
CreateContentPostRequest,
PaginatedContentPostsResponse,
PaginatedNotificationDeliveriesResponse,
TransitionContentPostRequest,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
const query = (v: Record<string, string | number | undefined>) => {
const p = new URLSearchParams();
Object.entries(v).forEach(([k, x]) => x !== undefined && p.set(k, String(x)));
return p.toString();
};
export const fetchContent = (v: {
page: number;
pageSize: number;
status?: string;
type?: string;
}) => apiRequest<PaginatedContentPostsResponse>(`/api/v1/admin/content?${query(v)}`);
export const createContent = (input: CreateContentPostRequest) =>
apiRequest<ContentPostDetailResponse>('/api/v1/admin/content', {
method: 'POST',
body: JSON.stringify(input),
});
export const transitionContent = (id: string, input: TransitionContentPostRequest) =>
apiRequest<ContentPostDetailResponse>(`/api/v1/admin/content/${id}/transition`, {
method: 'POST',
body: JSON.stringify(input),
});
export const fetchDeliveries = (v: { page: number; pageSize: number; status?: string }) =>
apiRequest<PaginatedNotificationDeliveriesResponse>(
`/api/v1/admin/notification-deliveries?${query(v)}`,
);
export const retryDelivery = (id: string) =>
apiRequest<{ queued: true }>(`/api/v1/admin/notification-deliveries/${id}/retry`, {
method: 'POST',
});
+55
View File
@@ -0,0 +1,55 @@
import type {
CompleteFollowUpRequest,
CreateFollowUpRequest,
FollowUpDetailResponse,
FollowUpMutationResponse,
FollowUpStatus,
FollowUpType,
FollowUpWorkbenchSummary,
PaginatedFollowUpsResponse,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
function queryString(values: Record<string, string | number | boolean | undefined>): string {
const params = new URLSearchParams();
Object.entries(values).forEach(([key, value]) => {
if (value !== undefined && value !== '') params.set(key, String(value));
});
return params.toString();
}
export function fetchFollowUpSummary(): Promise<FollowUpWorkbenchSummary> {
return apiRequest('/api/v1/admin/follow-ups/summary');
}
export function fetchFollowUps(query: {
page: number;
pageSize: number;
status?: FollowUpStatus;
followUpType?: FollowUpType;
overdue?: boolean;
}): Promise<PaginatedFollowUpsResponse> {
return apiRequest(`/api/v1/admin/follow-ups?${queryString(query)}`);
}
export function fetchFollowUp(publicId: string): Promise<FollowUpDetailResponse> {
return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}`);
}
export function createFollowUp(input: CreateFollowUpRequest): Promise<FollowUpMutationResponse> {
return apiRequest('/api/v1/admin/follow-ups', {
method: 'POST',
body: JSON.stringify(input),
});
}
export function completeFollowUp(
publicId: string,
input: CompleteFollowUpRequest,
): Promise<FollowUpMutationResponse> {
return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}/complete`, {
method: 'POST',
body: JSON.stringify(input),
});
}
+89
View File
@@ -0,0 +1,89 @@
import type {
ActivityDetailResponse,
ActivityRegistrationResponse,
AssigneeOption,
CheckInActivityRegistrationRequest,
CreateActivityRequest,
CreateProjectRequest,
PaginatedActivitiesResponse,
PaginatedActivityRegistrationsResponse,
PaginatedProjectsResponse,
PaginatedVolunteersResponse,
ProjectDetailResponse,
RecordServiceMinutesRequest,
ReviewActivityRegistrationRequest,
ReviewVolunteerRequest,
TransitionActivityRequest,
TransitionProjectRequest,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
const query = (values: Record<string, string | number | undefined>) => {
const params = new URLSearchParams();
Object.entries(values).forEach(
([key, value]) => value !== undefined && params.set(key, String(value)),
);
return params.toString();
};
export const fetchVolunteers = (values: { page: number; pageSize: number; status?: string }) =>
apiRequest<PaginatedVolunteersResponse>(`/api/v1/admin/volunteers?${query(values)}`);
export const reviewVolunteer = (publicId: string, input: ReviewVolunteerRequest) =>
apiRequest(`/api/v1/admin/volunteers/${publicId}/review`, {
method: 'POST',
body: JSON.stringify(input),
});
export const fetchProjects = (values: { page: number; pageSize: number; status?: string }) =>
apiRequest<PaginatedProjectsResponse>(`/api/v1/admin/projects?${query(values)}`);
export const fetchProjectManagerOptions = () =>
apiRequest<AssigneeOption[]>('/api/v1/admin/project-manager-options');
export const createProject = (input: CreateProjectRequest) =>
apiRequest<ProjectDetailResponse>('/api/v1/admin/projects', {
method: 'POST',
body: JSON.stringify(input),
});
export const transitionProject = (publicId: string, input: TransitionProjectRequest) =>
apiRequest<ProjectDetailResponse>(`/api/v1/admin/projects/${publicId}/transition`, {
method: 'POST',
body: JSON.stringify(input),
});
export const fetchActivities = (values: { page: number; pageSize: number; status?: string }) =>
apiRequest<PaginatedActivitiesResponse>(`/api/v1/admin/activities?${query(values)}`);
export const createActivity = (input: CreateActivityRequest) =>
apiRequest<ActivityDetailResponse>('/api/v1/admin/activities', {
method: 'POST',
body: JSON.stringify(input),
});
export const transitionActivity = (publicId: string, input: TransitionActivityRequest) =>
apiRequest<ActivityDetailResponse>(`/api/v1/admin/activities/${publicId}/transition`, {
method: 'POST',
body: JSON.stringify(input),
});
export const fetchRegistrations = (values: {
page: number;
pageSize: number;
status?: string;
activityPublicId?: string;
}) =>
apiRequest<PaginatedActivityRegistrationsResponse>(
`/api/v1/admin/activity-registrations?${query(values)}`,
);
export const reviewRegistration = (publicId: string, input: ReviewActivityRegistrationRequest) =>
apiRequest<ActivityRegistrationResponse>(
`/api/v1/admin/activity-registrations/${publicId}/review`,
{ method: 'POST', body: JSON.stringify(input) },
);
export const checkInRegistration = (publicId: string, input: CheckInActivityRegistrationRequest) =>
apiRequest<ActivityRegistrationResponse>(
`/api/v1/admin/activity-registrations/${publicId}/check-in`,
{ method: 'POST', body: JSON.stringify(input) },
);
export const recordServiceMinutes = (publicId: string, input: RecordServiceMinutesRequest) =>
apiRequest<ActivityRegistrationResponse>(
`/api/v1/admin/activity-registrations/${publicId}/service-minutes`,
{ method: 'POST', body: JSON.stringify(input) },
);
@@ -0,0 +1,25 @@
import type {
AdminServiceApplicationQuery,
PaginatedServiceApplicationsResponse,
ServiceApplicationDetailResponse,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
export function fetchAdminServiceApplications(
query: AdminServiceApplicationQuery,
): Promise<PaginatedServiceApplicationsResponse> {
const params = new URLSearchParams();
if (query.page) params.set('page', String(query.page));
if (query.pageSize) params.set('pageSize', String(query.pageSize));
if (query.applicationNo) params.set('applicationNo', query.applicationNo);
if (query.serviceCategory) params.set('serviceCategory', query.serviceCategory);
if (query.status) params.set('status', query.status);
return apiRequest(`/api/v1/admin/service-applications?${params.toString()}`);
}
export function fetchAdminServiceApplication(
publicId: string,
): Promise<ServiceApplicationDetailResponse> {
return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}`);
}
+8
View File
@@ -0,0 +1,8 @@
import type { StatisticsOverview } from '@lehuo/contracts';
import { apiRequest } from './api-client';
export const fetchStatistics = (startDate?: string, endDate?: string) => {
const p = new URLSearchParams();
if (startDate) p.set('startDate', startDate);
if (endDate) p.set('endDate', endDate);
return apiRequest<StatisticsOverview>(`/api/v1/admin/statistics/overview?${p.toString()}`);
};
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { ADMIN_APP_TITLE } from './app-info';
describe('admin app information', () => {
it('uses the approved public-service brand', () => {
expect(ADMIN_APP_TITLE).toContain('乐活公益');
});
});
+2
View File
@@ -0,0 +1,2 @@
export const ADMIN_APP_TITLE = '乐活公益管理后台';
export const PHASE_ZERO_MESSAGE = '工程基线已就绪,业务功能将在后续阶段实现。';
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { formatChinaDateTime, statusTone } from './application-format';
describe('application formatting', () => {
it('formats API timestamps for China display', () => {
expect(formatChinaDateTime('2026-08-11T02:30:00.000Z')).toContain('2026-08-11');
expect(formatChinaDateTime('invalid')).toBe('时间待确认');
});
it('uses text-compatible semantic status tones', () => {
expect(statusTone('SUBMITTED')).toBe('info');
expect(statusTone('CLOSED')).toBe('success');
expect(statusTone('REJECTED')).toBe('danger');
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { ServiceApplicationStatus } from '@lehuo/contracts';
export function formatChinaDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '时间待确认';
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
.format(date)
.replaceAll('/', '-');
}
export function statusTone(status: ServiceApplicationStatus): string {
if (['REJECTED', 'OUT_OF_SCOPE', 'CANCELLED'].includes(status)) return 'danger';
if (['CLOSED', 'SERVICE_COMPLETED'].includes(status)) return 'success';
if (['SUSPENDED', 'FOLLOW_UP_PENDING'].includes(status)) return 'warning';
return 'info';
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { validateLoginForm } from './login-form';
describe('validateLoginForm', () => {
it('accepts a valid staff login form', () => {
expect(validateLoginForm('staff.admin', 'correct horse battery staple')).toEqual({});
});
it('returns field-level recovery guidance', () => {
expect(validateLoginForm('?', 'short')).toEqual({
username: '请输入有效的登录账号',
password: '密码长度应为 12 至 128 个字符',
});
});
});
+15
View File
@@ -0,0 +1,15 @@
export interface LoginFormErrors {
username?: string;
password?: string;
}
export function validateLoginForm(username: string, password: string): LoginFormErrors {
const errors: LoginFormErrors = {};
if (!/^[a-z0-9._-]{3,64}$/.test(username.trim().toLowerCase())) {
errors.username = '请输入有效的登录账号';
}
if (password.length < 12 || password.length > 128) {
errors.password = '密码长度应为 12 至 128 个字符';
}
return errors;
}
+7
View File
@@ -0,0 +1,7 @@
import { createPinia } from 'pinia';
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
createApp(App).use(createPinia()).mount('#app');
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type { AdminLoginResponse, AuthUser, CurrentUserResponse } from '@lehuo/contracts';
import { apiRequest } from '../api/api-client';
export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUser | null>(null);
const checking = ref(true);
const authenticated = computed(() => user.value !== null);
async function restore(): Promise<void> {
checking.value = true;
try {
const result = await apiRequest<CurrentUserResponse>('/api/v1/admin/auth/me');
user.value = result.user;
} catch {
user.value = null;
} finally {
checking.value = false;
}
}
async function login(username: string, password: string): Promise<void> {
const result = await apiRequest<AdminLoginResponse>('/api/v1/admin/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
user.value = result.user;
}
async function logout(): Promise<void> {
await apiRequest('/api/v1/admin/auth/logout', { method: 'POST', body: '{}' });
user.value = null;
}
return { user, checking, authenticated, restore, login, logout };
});
+803
View File
@@ -0,0 +1,803 @@
:root {
--color-primary: #ff6b35;
--color-primary-hover: #e95a28;
--color-primary-soft: #fff1eb;
--color-text: #1f2329;
--color-text-secondary: #646a73;
--color-danger: #b42318;
--color-success: #237a2b;
--color-warning: #8a5a00;
--color-info: #145ea8;
--color-surface: #fff;
--color-background: #f5f5f5;
--color-border: #d9dce1;
--color-sidebar: #263342;
--focus-ring: 0 0 0 3px rgb(255 107 53 / 28%);
color: var(--color-text);
background: var(--color-background);
font-family:
-apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei',
sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
button:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.app-shell {
min-height: 100vh;
padding: 24px 16px;
}
.app-shell--centered {
display: grid;
place-items: center;
}
.welcome-card,
.login-card {
width: min(560px, 100%);
padding: 32px;
border: 1px solid var(--color-border);
border-radius: 16px;
background: var(--color-surface);
box-shadow: 0 4px 16px rgb(0 0 0 / 6%);
}
.welcome-card h1,
.login-card h1 {
margin: 8px 0 16px;
font-size: clamp(24px, 4vw, 32px);
line-height: 1.25;
}
.welcome-card p,
.login-card p {
line-height: 1.7;
}
.eyebrow {
margin: 0;
color: var(--color-primary-hover);
font-weight: 600;
}
.intro {
margin-bottom: 24px;
color: var(--color-text-secondary);
}
.login-card form {
display: grid;
gap: 8px;
}
.login-card label {
margin-top: 8px;
font-weight: 600;
}
.login-card input {
width: 100%;
min-height: 48px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 12px;
color: var(--color-text);
background: var(--color-surface);
}
.login-card input:hover {
border-color: #aeb3bb;
}
input[aria-invalid='true'] {
border-color: var(--color-danger);
}
.password-field {
position: relative;
}
.password-field input {
padding-right: 72px;
}
.password-toggle {
position: absolute;
top: 2px;
right: 2px;
min-width: 64px;
min-height: 44px;
border: 0;
border-radius: 10px;
color: var(--color-primary-hover);
background: transparent;
}
.field-error,
.form-message,
.global-message {
margin: 0;
color: var(--color-danger);
font-size: 14px;
}
.primary-button,
.secondary-button {
min-height: 48px;
margin-top: 16px;
padding: 10px 20px;
border-radius: 12px;
font-weight: 600;
transition:
background-color 180ms ease,
border-color 180ms ease;
}
.primary-button {
border: 1px solid var(--color-primary);
color: #fff;
background: var(--color-primary);
}
.primary-button:hover:not(:disabled) {
border-color: var(--color-primary-hover);
background: var(--color-primary-hover);
}
.secondary-button {
border: 1px solid var(--color-primary);
color: var(--color-primary-hover);
background: var(--color-primary-soft);
}
.compact-button {
min-height: 44px;
margin: 0;
padding: 8px 18px;
}
.admin-layout {
display: grid;
min-height: 100vh;
grid-template-columns: 240px minmax(0, 1fr);
background: var(--color-background);
}
.admin-sidebar {
position: sticky;
top: 0;
display: flex;
height: 100vh;
padding: 24px 16px;
flex-direction: column;
color: #fff;
background: var(--color-sidebar);
}
.brand-block {
display: flex;
min-height: 64px;
padding: 8px;
align-items: center;
gap: 12px;
}
.brand-block strong,
.brand-block small {
display: block;
}
.brand-block strong {
font-size: 18px;
}
.brand-block small {
margin-top: 4px;
color: #bfc8d2;
}
.brand-mark {
width: 36px;
height: 36px;
border: 8px solid var(--color-primary);
border-top-color: #ffb13b;
border-radius: 50% 50% 50% 16px;
transform: rotate(-18deg);
}
.admin-sidebar nav {
margin-top: 28px;
overflow-y: auto;
}
.nav-item {
width: 100%;
min-height: 48px;
padding: 10px 14px;
border: 0;
border-radius: 10px;
color: #e8edf2;
background: transparent;
text-align: left;
}
.nav-item--active {
color: #fff;
background: rgb(255 107 53 / 22%);
box-shadow: inset 3px 0 var(--color-primary);
}
.admin-sidebar nav {
display: grid;
gap: 6px;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.link-button--danger {
color: var(--color-danger);
}
.phase-note {
margin-top: auto;
padding: 14px;
border-radius: 10px;
color: #cbd3dc;
background: rgb(255 255 255 / 7%);
font-size: 13px;
line-height: 1.6;
}
.admin-main {
min-width: 0;
}
.admin-topbar {
display: flex;
min-height: 64px;
padding: 0 28px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--color-border);
background: #fff;
}
.admin-topbar strong,
.admin-topbar span {
display: block;
}
.admin-topbar span {
margin-top: 2px;
color: var(--color-text-secondary);
font-size: 13px;
}
.text-button,
.back-button,
.link-button {
min-height: 44px;
padding: 8px 12px;
border: 0;
border-radius: 8px;
color: var(--color-info);
background: transparent;
font-weight: 600;
}
.text-button:hover,
.back-button:hover,
.link-button:hover {
background: #eaf4ff;
}
.admin-main > .global-message {
margin: 12px 28px 0;
}
.content-area {
width: min(1440px, 100%);
margin: 0 auto;
padding: 28px;
}
.page-header {
display: flex;
margin-bottom: 22px;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.page-header h1 {
margin: 0;
font-size: 28px;
line-height: 1.3;
}
.page-header p {
margin: 8px 0 0;
color: var(--color-text-secondary);
line-height: 1.6;
}
.back-button {
margin: -8px 0 8px -12px;
}
.result-count {
padding: 8px 12px;
border-radius: 8px;
color: var(--color-text-secondary);
background: #fff;
font-variant-numeric: tabular-nums;
}
.filter-panel {
display: grid;
margin-bottom: 20px;
padding: 20px;
grid-template-columns: minmax(180px, 1.2fr) minmax(150px, 1fr) minmax(150px, 1fr) auto;
align-items: end;
gap: 16px;
border: 1px solid var(--color-border);
border-radius: 14px;
background: #fff;
}
.filter-field {
display: grid;
gap: 8px;
}
.filter-field label {
font-size: 14px;
font-weight: 600;
}
.filter-field input,
.filter-field select {
width: 100%;
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 9px;
color: var(--color-text);
background: #fff;
}
.filter-actions {
display: flex;
gap: 10px;
}
.panel,
.table-panel,
.empty-panel {
border: 1px solid var(--color-border);
border-radius: 14px;
background: #fff;
box-shadow: 0 3px 12px rgb(0 0 0 / 4%);
}
.panel {
padding: 24px;
}
.panel h2 {
margin: 0 0 20px;
font-size: 19px;
}
.panel h3 {
margin: 24px 0 10px;
font-size: 16px;
}
.empty-panel {
display: grid;
min-height: 280px;
padding: 32px;
place-content: center;
justify-items: center;
text-align: center;
}
.empty-panel h2 {
margin: 0;
font-size: 21px;
}
.empty-panel p {
max-width: 520px;
margin: 12px 0;
color: var(--color-text-secondary);
line-height: 1.7;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
}
th,
td {
padding: 15px 16px;
border-bottom: 1px solid #ebecef;
text-align: left;
vertical-align: middle;
}
th {
color: #4e545d;
background: #fafafa;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
td {
font-size: 14px;
}
tbody tr:hover {
background: #fffaf7;
}
.tabular {
font-variant-numeric: tabular-nums;
}
.pagination {
display: flex;
min-height: 64px;
padding: 10px 16px;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
.pagination button {
min-height: 40px;
padding: 6px 14px;
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-text);
background: #fff;
}
.pagination span {
color: var(--color-text-secondary);
font-size: 14px;
}
.status-tag {
display: inline-flex;
min-height: 28px;
padding: 4px 9px;
align-items: center;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.status-tag--info {
color: var(--color-info);
background: #eaf4ff;
}
.status-tag--success {
color: var(--color-success);
background: #edf8ee;
}
.status-tag--warning {
color: var(--color-warning);
background: #fff6d8;
}
.status-tag--danger {
color: var(--color-danger);
background: #fff0ee;
}
.audit-notice {
margin-bottom: 20px;
padding: 14px 16px;
border-left: 4px solid #d18a00;
border-radius: 8px;
color: #604500;
background: #fff7de;
line-height: 1.6;
}
.detail-grid {
display: grid;
margin-bottom: 20px;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.detail-list {
margin: 0;
}
.detail-list > div {
display: grid;
min-height: 48px;
padding: 11px 0;
grid-template-columns: 120px 1fr;
border-top: 1px solid #ebecef;
}
.detail-list dt {
color: var(--color-text-secondary);
}
.detail-list dd {
margin: 0;
overflow-wrap: anywhere;
}
.sensitive-value {
font-weight: 600;
}
.description-text {
margin: 0;
padding: 16px;
border-radius: 9px;
background: #fafafa;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.timeline-panel {
margin-bottom: 28px;
}
.workflow-panel {
margin-bottom: 20px;
}
.workflow-panel > p,
.consent-note {
color: var(--color-text-secondary);
line-height: 1.6;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 20px;
}
.form-grid label {
display: grid;
align-content: start;
gap: 8px;
font-size: 14px;
font-weight: 600;
}
.form-grid input:not([type='checkbox']),
.form-grid select,
.form-grid textarea {
width: 100%;
min-height: 44px;
padding: 9px 12px;
border: 1px solid var(--color-border);
border-radius: 9px;
color: var(--color-text);
background: #fff;
}
.form-grid select[multiple] {
min-height: 132px;
}
.form-grid textarea {
min-height: 112px;
resize: vertical;
line-height: 1.6;
}
.form-span {
grid-column: 1 / -1;
}
.form-actions {
display: flex;
gap: 12px;
}
.checkbox-group {
display: flex;
padding: 14px;
flex-wrap: wrap;
gap: 12px 20px;
border: 1px solid var(--color-border);
border-radius: 10px;
}
.checkbox-group legend {
padding: 0 6px;
font-weight: 600;
}
.contact-summary {
padding: 16px;
border-radius: 10px;
background: #fafafa;
}
.contact-summary p {
margin: 8px 0 0;
}
.record-list {
margin-top: 20px;
}
.summary-grid {
display: grid;
margin-bottom: 20px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.summary-card {
display: grid;
min-height: 126px;
padding: 18px;
align-content: space-between;
border: 1px solid var(--color-border);
border-radius: 14px;
color: var(--color-text);
background: #fff;
text-align: left;
box-shadow: 0 3px 12px rgb(0 0 0 / 4%);
}
button.summary-card:hover {
border-color: var(--color-danger);
background: #fff8f7;
}
.summary-card strong {
font-size: 32px;
font-variant-numeric: tabular-nums;
}
.summary-card small,
.block-note {
color: var(--color-text-secondary);
}
.summary-card--danger strong,
.danger-text {
color: var(--color-danger);
}
.block-note {
display: block;
margin-top: 4px;
font-size: 12px;
}
.consent-check {
display: flex !important;
min-height: 44px;
align-items: center;
gap: 10px;
line-height: 1.5;
}
.record-card {
padding: 18px 0;
border-top: 1px solid #ebecef;
}
.record-card header {
display: flex;
justify-content: space-between;
gap: 16px;
}
.record-card time {
color: var(--color-text-secondary);
font-size: 13px;
}
.record-card p {
line-height: 1.65;
white-space: pre-wrap;
}
.admin-timeline {
margin: 0;
padding: 0;
list-style: none;
}
.admin-timeline li {
position: relative;
display: grid;
min-height: 104px;
grid-template-columns: 24px 1fr;
gap: 14px;
}
.admin-timeline li:not(:last-child)::before {
position: absolute;
top: 18px;
bottom: -4px;
left: 7px;
width: 2px;
content: '';
background: #ffd3c1;
}
.timeline-marker {
position: relative;
z-index: 1;
width: 16px;
height: 16px;
margin-top: 3px;
border: 4px solid #ffe4d8;
border-radius: 50%;
background: var(--color-primary);
}
.admin-timeline strong {
font-size: 16px;
}
.admin-timeline p {
margin: 6px 0;
color: var(--color-text-secondary);
}
.admin-timeline time {
color: #767d87;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.table-skeleton {
height: 50px;
margin-bottom: 10px;
border-radius: 8px;
background: linear-gradient(90deg, #f0f1f3 25%, #fafafa 50%, #f0f1f3 75%);
background-size: 200% 100%;
animation: skeleton 1.4s ease-in-out infinite;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@keyframes skeleton {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@media (max-width: 1100px) {
.admin-layout {
grid-template-columns: 190px minmax(0, 1fr);
}
.filter-panel {
grid-template-columns: 1fr 1fr;
}
.filter-actions {
align-self: end;
}
.detail-grid {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
.form-span {
grid-column: auto;
}
.summary-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.admin-layout {
display: block;
}
.admin-sidebar {
position: static;
width: 100%;
height: auto;
padding: 12px 16px;
}
.brand-block {
min-height: 52px;
}
.admin-sidebar nav,
.phase-note {
display: none;
}
.admin-topbar {
padding: 0 16px;
}
.content-area {
padding: 20px 16px;
}
.page-header {
flex-direction: column;
}
.filter-panel {
grid-template-columns: 1fr;
}
.filter-actions {
width: 100%;
}
.filter-actions button {
flex: 1;
}
.detail-list > div {
grid-template-columns: 100px 1fr;
}
.welcome-card,
.login-card {
padding: 24px 20px;
}
.summary-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+370
View File
@@ -0,0 +1,370 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
ACTIVITY_STATUSES,
ACTIVITY_STATUS_LABELS,
type ActivityListItem,
type ActivityRegistrationItem,
type ActivityStatus,
type ProjectListItem,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
checkInRegistration,
createActivity,
fetchActivities,
fetchProjects,
fetchRegistrations,
recordServiceMinutes,
reviewRegistration,
transitionActivity,
} from '../api/participation';
const activities = ref<ActivityListItem[]>([]),
projects = ref<ProjectListItem[]>([]),
registrations = ref<ActivityRegistrationItem[]>([]);
const loading = ref(true),
busy = ref(false),
showCreate = ref(false),
message = ref('');
const page = ref(1),
total = ref(0),
pageSize = 20,
totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{ status: ActivityStatus | '' }>({ status: '' });
const form = reactive({
projectPublicId: '',
title: '',
summary: '',
content: '',
startAt: '',
endAt: '',
registrationDeadline: '',
locationText: '',
capacity: undefined as number | undefined,
});
const errorText = (error: unknown) =>
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load(target = 1) {
loading.value = true;
message.value = '';
try {
const [list, regs] = await Promise.all([
fetchActivities({
page: target,
pageSize,
...(filters.status ? { status: filters.status } : {}),
}),
fetchRegistrations({ page: 1, pageSize: 100 }),
]);
activities.value = list.items;
total.value = list.total;
page.value = list.page;
registrations.value = regs.items;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function save() {
if (!form.startAt || !form.endAt || !form.registrationDeadline) {
message.value = '请完整填写活动与报名时间。';
return;
}
busy.value = true;
try {
await createActivity({
clientRequestId: crypto.randomUUID(),
...(form.projectPublicId ? { projectPublicId: form.projectPublicId } : {}),
title: form.title,
summary: form.summary,
content: form.content,
startAt: new Date(form.startAt).toISOString(),
endAt: new Date(form.endAt).toISOString(),
registrationDeadline: new Date(form.registrationDeadline).toISOString(),
locationText: form.locationText,
...(form.capacity ? { capacity: form.capacity } : {}),
});
showCreate.value = false;
Object.assign(form, {
projectPublicId: '',
title: '',
summary: '',
content: '',
startAt: '',
endAt: '',
registrationDeadline: '',
locationText: '',
capacity: undefined,
});
message.value = '活动草稿已创建。';
await load(1);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
function nextStatus(status: ActivityStatus): Exclude<ActivityStatus, 'DRAFT'> | null {
return (
(
{
DRAFT: 'PUBLISHED',
PUBLISHED: 'REGISTRATION_CLOSED',
REGISTRATION_CLOSED: 'IN_PROGRESS',
IN_PROGRESS: 'COMPLETED',
} as Partial<Record<ActivityStatus, Exclude<ActivityStatus, 'DRAFT'>>>
)[status] ?? null
);
}
async function advance(item: ActivityListItem) {
const targetStatus = nextStatus(item.status);
if (
!targetStatus ||
!window.confirm(`确认将活动推进至“${ACTIVITY_STATUS_LABELS[targetStatus]}”?`)
)
return;
busy.value = true;
try {
await transitionActivity(item.publicId, { clientRequestId: crypto.randomUUID(), targetStatus });
message.value = '活动状态已更新并记录审计。';
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function review(item: ActivityRegistrationItem, targetStatus: 'APPROVED' | 'REJECTED') {
busy.value = true;
try {
await reviewRegistration(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
note: targetStatus === 'APPROVED' ? '报名审核通过' : '报名不符合本次活动要求',
});
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function checkIn(item: ActivityRegistrationItem, targetStatus: 'CHECKED_IN' | 'ABSENT') {
busy.value = true;
try {
await checkInRegistration(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
});
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function minutes(item: ActivityRegistrationItem) {
const value = Number(window.prompt('请输入核定服务分钟数', String(item.serviceMinutes ?? 60)));
if (!Number.isInteger(value) || value <= 0) return;
busy.value = true;
try {
await recordServiceMinutes(item.publicId, {
clientRequestId: crypto.randomUUID(),
serviceMinutes: value,
});
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
onMounted(async () => {
await Promise.all([
load(),
fetchProjects({ page: 1, pageSize: 100 }).then((result) => {
projects.value = result.items.filter((item) => item.status !== 'COMPLETED');
}),
]);
});
</script>
<template>
<main class="content-area" tabindex="-1">
<header class="page-header">
<div>
<h1>公益活动与报名</h1>
<p>发布活动、控制报名容量,并记录审核、签到和服务时长事实。</p>
</div>
<button class="primary-button compact-button" @click="showCreate = !showCreate">
{{ showCreate ? '取消新建' : '新建活动' }}
</button>
</header>
<form v-if="showCreate" class="panel form-grid" @submit.prevent="save">
<label
>所属项目<select v-model="form.projectPublicId">
<option value="">独立活动</option>
<option v-for="project in projects" :key="project.publicId" :value="project.publicId">
{{ project.name }}
</option>
</select></label
><label
>活动标题<input v-model.trim="form.title" required minlength="2" maxlength="200" /></label
><label
>报名截止<input v-model="form.registrationDeadline" type="datetime-local" required /></label
><label>人数上限<input v-model.number="form.capacity" type="number" min="1" /></label
><label>开始时间<input v-model="form.startAt" type="datetime-local" required /></label
><label>结束时间<input v-model="form.endAt" type="datetime-local" required /></label
><label class="form-span"
>活动地点<input
v-model.trim="form.locationText"
required
minlength="2"
maxlength="300" /></label
><label class="form-span"
>活动摘要<textarea
v-model.trim="form.summary"
required
minlength="10"
maxlength="500"
></textarea></label
><label class="form-span"
>活动内容<textarea
v-model.trim="form.content"
required
minlength="20"
maxlength="20000"
></textarea>
</label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '保存中…' : '保存草稿' }}
</button>
</div>
</form>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label for="activity-status">活动状态</label
><select id="activity-status" v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in ACTIVITY_STATUSES" :key="status" :value="status">
{{ ACTIVITY_STATUS_LABELS[status] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="loading" class="panel">正在加载活动与报名…</section>
<template v-else
><section 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 activities" :key="item.publicId">
<td>
<strong>{{ item.title }}</strong
><br />{{ item.projectName ?? '独立活动' }}
</td>
<td>
{{ new Date(item.startAt).toLocaleString('zh-CN') }}<br />{{ item.locationText }}
</td>
<td>{{ item.activeRegistrationCount }} / {{ item.capacity ?? '不限' }}</td>
<td>{{ item.statusLabel }}</td>
<td>
<button
v-if="nextStatus(item.status)"
class="link-button"
:disabled="busy"
@click="advance(item)"
>
推进至{{ ACTIVITY_STATUS_LABELS[nextStatus(item.status)!] }}
</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>
<section class="panel">
<h2>报名与现场记录</h2>
<p v-if="!registrations.length">暂无报名记录。</p>
<div v-else class="table-scroll">
<table>
<thead>
<tr>
<th>活动</th>
<th>志愿者</th>
<th>报名/签到</th>
<th>服务时长</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in registrations" :key="item.publicId">
<td>{{ item.activityTitle }}</td>
<td>{{ item.volunteerName }}<br />{{ item.mobileMasked }}</td>
<td>{{ item.statusLabel }} / {{ item.checkInStatusLabel }}</td>
<td>
{{ item.serviceMinutes === null ? '待核定' : `${item.serviceMinutes} 分钟` }}
</td>
<td>
<div class="inline-actions">
<button
v-if="item.status === 'PENDING'"
class="link-button"
@click="review(item, 'APPROVED')"
>
通过</button
><button
v-if="item.status === 'PENDING'"
class="link-button link-button--danger"
@click="review(item, 'REJECTED')"
>
拒绝</button
><button
v-if="item.status === 'APPROVED' && item.checkInStatus === 'NOT_CHECKED_IN'"
class="link-button"
@click="checkIn(item, 'CHECKED_IN')"
>
签到</button
><button
v-if="item.status === 'APPROVED' && item.checkInStatus === 'NOT_CHECKED_IN'"
class="link-button"
@click="checkIn(item, 'ABSENT')"
>
缺席</button
><button
v-if="item.checkInStatus === 'CHECKED_IN'"
class="link-button"
@click="minutes(item)"
>
核定时长
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section></template
>
</main>
</template>
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
BENEFICIARY_STATUSES,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
type BeneficiaryDetail,
type BeneficiaryListItem,
type BeneficiaryStatus,
type ServiceCategory,
type UpdateBeneficiaryRequest,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { fetchBeneficiaries, fetchBeneficiary, updateBeneficiary } from '../api/case-management';
import { formatChinaDateTime } from '../application-format';
const items = ref<BeneficiaryListItem[]>([]);
const selected = ref<BeneficiaryDetail | null>(null);
const loading = ref(true);
const saving = ref(false);
const message = ref('');
const page = ref(1);
const total = ref(0);
const pageSize = 20;
const filters = reactive<{
primaryCategory: ServiceCategory | '';
status: BeneficiaryStatus | '';
keyCare: '' | 'true' | 'false';
}>({
primaryCategory: '',
status: '',
keyCare: '',
});
const form = reactive<UpdateBeneficiaryRequest>({
displayName: '',
regionCode: '',
keyCareFlag: false,
isMinor: false,
contacts: [],
tagCodes: [],
status: 'ACTIVE',
});
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
function showError(error: unknown): void {
message.value =
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
}
async function load(targetPage = 1): Promise<void> {
loading.value = true;
message.value = '';
selected.value = null;
try {
const result = await fetchBeneficiaries({
page: targetPage,
pageSize,
...(filters.primaryCategory ? { primaryCategory: filters.primaryCategory } : {}),
...(filters.status ? { status: filters.status } : {}),
...(filters.keyCare ? { keyCareFlag: filters.keyCare === 'true' } : {}),
});
items.value = result.items;
total.value = result.total;
page.value = result.page;
} catch (error) {
showError(error);
} finally {
loading.value = false;
}
}
async function open(publicId: string): Promise<void> {
loading.value = true;
message.value = '';
try {
const result = await fetchBeneficiary(publicId);
selected.value = result.beneficiary;
Object.assign(form, {
displayName: result.beneficiary.displayName,
gender: result.beneficiary.gender ?? undefined,
birthYear: result.beneficiary.birthYear ?? undefined,
mobile: result.beneficiary.mobile ?? undefined,
regionCode: result.beneficiary.regionCode,
addressDetail: result.beneficiary.addressDetail ?? undefined,
keyCareFlag: result.beneficiary.keyCareFlag,
isMinor: result.beneficiary.isMinor,
notes: result.beneficiary.notes ?? undefined,
contacts: result.beneficiary.contacts.map(
({ name, relation, mobile, isGuardian, isEmergencyContact }) => ({
name,
relation,
mobile,
isGuardian,
isEmergencyContact,
}),
),
tagCodes: result.beneficiary.tags.map((tag) => tag.code),
status: result.beneficiary.status,
...(result.beneficiary.isMinor ? { guardianConsentConfirmed: true as const } : {}),
});
} catch (error) {
showError(error);
} finally {
loading.value = false;
}
}
async function save(): Promise<void> {
if (!selected.value) return;
saving.value = true;
message.value = '';
try {
selected.value = (await updateBeneficiary(selected.value.publicId, form)).beneficiary;
message.value = '档案已安全保存并记录审计。';
} catch (error) {
showError(error);
} finally {
saving.value = false;
}
}
</script>
<template>
<main class="content-area" tabindex="-1">
<template v-if="selected">
<header class="page-header">
<div>
<button class="back-button" type="button" @click="selected = null">
← 返回服务对象列表
</button>
<h1>服务对象档案</h1>
<p>查看和维护提供服务所需的最少信息。</p>
</div>
<span class="status-tag status-tag--info">{{ selected.serviceCaseCount }} 个工单</span>
</header>
<div class="audit-notice">档案含敏感信息,本次查看和修改均会记录安全审计。</div>
<form class="panel form-grid" @submit.prevent="save">
<label>姓名<input v-model.trim="form.displayName" required maxlength="50" /></label>
<label
>手机号<input v-model.trim="form.mobile" inputmode="numeric" pattern="1[3-9][0-9]{9}"
/></label>
<label>所在地区<input v-model.trim="form.regionCode" required maxlength="100" /></label>
<label
>出生年份<input v-model.number="form.birthYear" type="number" min="1900" max="2200"
/></label>
<label
>档案状态<select v-model="form.status">
<option v-for="status in BENEFICIARY_STATUSES" :key="status" :value="status">
{{ status === 'ACTIVE' ? '正常' : status === 'INACTIVE' ? '暂停' : '归档' }}
</option>
</select></label
>
<label>详细地址<input v-model.trim="form.addressDetail" maxlength="300" /></label>
<label class="form-span"
><input v-model="form.keyCareFlag" type="checkbox" /> 重点关怀对象</label
>
<label class="form-span"
>备注<textarea v-model.trim="form.notes" maxlength="1000"></textarea>
</label>
<div class="form-span contact-summary">
<strong>联系人</strong>
<p v-if="!form.contacts.length">暂无联系人</p>
<p v-for="(contact, index) in form.contacts" :key="index">
{{ contact.name }}({{ contact.relation }}) · {{ contact.mobile
}}<span v-if="contact.isGuardian"> · 监护人</span>
</p>
</div>
<p v-if="message" class="global-message form-span" role="status">{{ message }}</p>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="saving">
{{ saving ? '保存中…' : '保存档案' }}
</button>
</div>
</form>
</template>
<template v-else>
<header class="page-header">
<div>
<h1>服务对象</h1>
<p>按服务类别、状态和重点关怀标记查找档案。</p>
</div>
<span class="result-count">共 {{ total }} 人</span>
</header>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label>服务类型</label
><select v-model="filters.primaryCategory">
<option value="">全部类型</option>
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
{{ SERVICE_CATEGORY_LABELS[category] }}
</option>
</select>
</div>
<div class="filter-field">
<label>档案状态</label
><select v-model="filters.status">
<option value="">全部状态</option>
<option value="ACTIVE">正常</option>
<option value="INACTIVE">暂停</option>
<option value="ARCHIVED">归档</option>
</select>
</div>
<div class="filter-field">
<label>重点关怀</label
><select v-model="filters.keyCare">
<option value="">全部</option>
<option value="true">是</option>
<option value="false">否</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<section v-if="loading" class="panel" aria-live="polite">
<div v-for="n in 6" :key="n" class="table-skeleton"></div>
</section>
<section v-else-if="message" class="empty-panel" role="alert">
<h2>服务对象加载失败</h2>
<p>{{ message }}</p>
<button class="secondary-button compact-button" @click="load(page)">重试</button>
</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无符合条件的服务对象</h2>
<p>档案将在申请通过评估后建立。</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>
<th>建立时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>
{{ item.displayName }}
<span v-if="item.keyCareFlag" class="status-tag status-tag--warning">重点</span>
</td>
<td>{{ item.mobileMasked ?? '未留存' }}</td>
<td>{{ item.regionCode }}</td>
<td>{{ SERVICE_CATEGORY_LABELS[item.primaryCategory] }}</td>
<td>{{ item.tags.map((tag) => tag.name).join('、') || '—' }}</td>
<td>{{ formatChinaDateTime(item.createdAt) }}</td>
<td><button class="link-button" @click="open(item.publicId)">查看</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>
+242
View File
@@ -0,0 +1,242 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
CONTENT_POST_STATUSES,
CONTENT_POST_STATUS_LABELS,
CONTENT_POST_TYPES,
CONTENT_POST_TYPE_LABELS,
type ContentPostListItem,
type ContentPostStatus,
type ContentPostType,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { createContent, fetchContent, transitionContent } from '../api/content-notifications';
const items = ref<ContentPostListItem[]>([]),
loading = ref(true),
busy = ref(false),
showCreate = ref(false),
message = ref(''),
page = ref(1),
total = ref(0),
pageSize = 20,
totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{ status: ContentPostStatus | ''; type: ContentPostType | '' }>({
status: '',
type: '',
});
const form = reactive({
type: 'PUBLIC_WELFARE_NEWS' as ContentPostType,
title: '',
summary: '',
content: '',
});
const errorText = (e: unknown) =>
e instanceof ApiClientError
? `${e.message}${e.requestId ? `(编号:${e.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load(target = 1) {
loading.value = true;
try {
const r = await fetchContent({
page: target,
pageSize,
...(filters.status ? { status: filters.status } : {}),
...(filters.type ? { type: filters.type } : {}),
});
items.value = r.items;
page.value = r.page;
total.value = r.total;
} catch (e) {
message.value = errorText(e);
} finally {
loading.value = false;
}
}
async function save() {
busy.value = true;
try {
await createContent({ clientRequestId: crypto.randomUUID(), ...form });
Object.assign(form, { title: '', summary: '', content: '' });
showCreate.value = false;
message.value = '内容草稿已创建,发布前必须提交审核。';
await load(1);
} catch (e) {
message.value = errorText(e);
} finally {
busy.value = false;
}
}
async function transition(item: ContentPostListItem) {
const targetStatus =
item.status === 'DRAFT'
? 'PENDING_REVIEW'
: item.status === 'PENDING_REVIEW'
? 'PUBLISHED'
: 'ARCHIVED';
const note = window.prompt(
targetStatus === 'PENDING_REVIEW'
? '请填写送审说明'
: targetStatus === 'PUBLISHED'
? '请填写审核结论'
: '请填写归档原因',
);
if (!note) return;
busy.value = true;
try {
await transitionContent(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
note,
...(targetStatus === 'PUBLISHED' && item.type === 'SERVICE_STORY'
? { privacyReviewed: true as const }
: {}),
});
message.value = '状态已更新并写入不可变审核历史。';
await load(page.value);
} catch (e) {
message.value = errorText(e);
} finally {
busy.value = false;
}
}
async function reject(item: ContentPostListItem) {
const note = window.prompt('请填写退回修改原因');
if (!note) return;
busy.value = true;
try {
await transitionContent(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus: 'DRAFT',
note,
});
await load(page.value);
} catch (e) {
message.value = errorText(e);
} finally {
busy.value = false;
}
}
onMounted(() => load());
</script>
<template>
<main class="content-area">
<header class="page-header">
<div>
<h1>内容发布与审核</h1>
<p>公益动态、机构介绍、服务故事、公告和常见问题统一审核后公开。</p>
</div>
<button class="primary-button compact-button" @click="showCreate = !showCreate">
{{ showCreate ? '取消新建' : '新建内容' }}
</button>
</header>
<form v-if="showCreate" class="panel form-grid" @submit.prevent="save">
<label
>内容类型<select v-model="form.type">
<option v-for="t in CONTENT_POST_TYPES" :key="t" :value="t">
{{ CONTENT_POST_TYPE_LABELS[t] }}
</option>
</select></label
><label class="form-span"
>标题<input v-model.trim="form.title" required minlength="2" maxlength="200" /></label
><label class="form-span"
>摘要<textarea
v-model.trim="form.summary"
required
minlength="10"
maxlength="500"
></textarea></label
><label class="form-span"
>正文(纯文本或 Markdown)<textarea
v-model.trim="form.content"
required
minlength="20"
maxlength="50000"
></textarea>
</label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">保存草稿</button>
</div>
</form>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label>类型</label
><select v-model="filters.type">
<option value="">全部</option>
<option v-for="t in CONTENT_POST_TYPES" :key="t" :value="t">
{{ CONTENT_POST_TYPE_LABELS[t] }}
</option>
</select>
</div>
<div class="filter-field">
<label>状态</label
><select v-model="filters.status">
<option value="">全部</option>
<option v-for="s in CONTENT_POST_STATUSES" :key="s" :value="s">
{{ CONTENT_POST_STATUS_LABELS[s] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="loading" class="panel">正在加载内容…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无内容</h2>
<p>可先创建公益动态或机构介绍草稿。</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
><br />{{ item.summary }}
</td>
<td>{{ CONTENT_POST_TYPE_LABELS[item.type] }}</td>
<td>{{ CONTENT_POST_STATUS_LABELS[item.status] }}</td>
<td>{{ new Date(item.updatedAt).toLocaleString('zh-CN') }}</td>
<td>
<button
v-if="item.status !== 'ARCHIVED'"
class="link-button"
:disabled="busy"
@click="transition(item)"
>
{{
item.status === 'DRAFT'
? '提交审核'
: item.status === 'PENDING_REVIEW'
? '审核发布'
: '归档'
}}</button
><button
v-if="item.status === 'PENDING_REVIEW'"
class="link-button"
:disabled="busy"
@click="reject(item)"
>
退回
</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>
</main>
</template>
+469
View File
@@ -0,0 +1,469 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
FOLLOW_UP_RESULT_CODES,
FOLLOW_UP_RESULT_LABELS,
FOLLOW_UP_STATUSES,
FOLLOW_UP_STATUS_LABELS,
FOLLOW_UP_TYPES,
FOLLOW_UP_TYPE_LABELS,
SERVICE_CATEGORY_LABELS,
type AssigneeOption,
type BeneficiaryListItem,
type FollowUpDetail,
type FollowUpListItem,
type FollowUpResultCode,
type FollowUpStatus,
type FollowUpType,
type FollowUpWorkbenchSummary,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { fetchAssigneeOptions, fetchBeneficiaries } from '../api/case-management';
import {
completeFollowUp,
createFollowUp,
fetchFollowUp,
fetchFollowUps,
fetchFollowUpSummary,
} from '../api/follow-ups';
import { formatChinaDateTime } from '../application-format';
const items = ref<FollowUpListItem[]>([]);
const selected = ref<FollowUpDetail | null>(null);
const summary = ref<FollowUpWorkbenchSummary>({
dueToday: 0,
overdue: 0,
dueThisWeek: 0,
keyCarePending: 0,
});
const beneficiaries = ref<BeneficiaryListItem[]>([]);
const assignees = ref<AssigneeOption[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const showCreate = ref(false);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{
status: FollowUpStatus | '';
followUpType: FollowUpType | '';
overdue: boolean;
}>({ status: 'PENDING', followUpType: '', overdue: false });
const createForm = reactive({
beneficiaryPublicId: '',
ownerStaffPublicId: '',
followUpType: 'MANUAL' as 'KEY_CARE' | 'MANUAL',
dueAt: '',
reason: '',
});
const completeForm = reactive({
resultCode: 'SATISFIED' as FollowUpResultCode,
resultNote: '',
continuationConsentConfirmed: false,
continuationDescription: '',
});
function errorText(error: unknown): string {
return error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
}
async function load(targetPage = 1): Promise<void> {
loading.value = true;
message.value = '';
try {
const [list, counts] = await Promise.all([
fetchFollowUps({
page: targetPage,
pageSize,
...(filters.status ? { status: filters.status } : {}),
...(filters.followUpType ? { followUpType: filters.followUpType } : {}),
...(filters.overdue ? { overdue: true } : {}),
}),
fetchFollowUpSummary(),
]);
items.value = list.items;
total.value = list.total;
page.value = list.page;
summary.value = counts;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function loadOptions(): Promise<void> {
try {
const [beneficiaryResult, assigneeResult] = await Promise.all([
fetchBeneficiaries({ page: 1, pageSize: 100, status: 'ACTIVE' }),
fetchAssigneeOptions(),
]);
beneficiaries.value = beneficiaryResult.items;
assignees.value = assigneeResult.filter((item) => item.type === 'STAFF');
} catch (error) {
message.value = errorText(error);
}
}
async function open(publicId: string): Promise<void> {
loading.value = true;
message.value = '';
try {
selected.value = (await fetchFollowUp(publicId)).followUp;
Object.assign(completeForm, {
resultCode: 'SATISFIED',
resultNote: '',
continuationConsentConfirmed: false,
continuationDescription: '',
});
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function saveCreate(): Promise<void> {
if (
!createForm.beneficiaryPublicId ||
!createForm.ownerStaffPublicId ||
!createForm.dueAt ||
createForm.reason.trim().length < 2
) {
message.value = '请完整选择服务对象、负责人并填写回访安排。';
return;
}
busy.value = true;
message.value = '';
try {
await createFollowUp({
clientRequestId: crypto.randomUUID(),
beneficiaryPublicId: createForm.beneficiaryPublicId,
ownerStaffPublicId: createForm.ownerStaffPublicId,
followUpType: createForm.followUpType,
dueAt: new Date(createForm.dueAt).toISOString(),
reason: createForm.reason.trim(),
});
showCreate.value = false;
Object.assign(createForm, {
beneficiaryPublicId: '',
ownerStaffPublicId: '',
followUpType: 'MANUAL',
dueAt: '',
reason: '',
});
await load(1);
message.value = '回访任务已创建并记录审计。';
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function saveCompletion(): Promise<void> {
if (!selected.value || completeForm.resultNote.trim().length < 2) {
message.value = '请填写回访结果说明。';
return;
}
if (
completeForm.resultCode === 'NEED_MORE_HELP' &&
(!completeForm.continuationConsentConfirmed ||
completeForm.continuationDescription.trim().length < 10)
) {
message.value = '继续帮扶需要确认授权,并填写不少于 10 个字的新需求。';
return;
}
busy.value = true;
message.value = '';
try {
const result = await completeFollowUp(selected.value.publicId, {
clientRequestId: crypto.randomUUID(),
resultCode: completeForm.resultCode,
resultNote: completeForm.resultNote.trim(),
...(completeForm.resultCode === 'NEED_MORE_HELP'
? {
continuationConsentConfirmed: true as const,
continuationDescription: completeForm.continuationDescription.trim(),
}
: {}),
});
selected.value = result.followUp;
await load(page.value);
message.value = result.followUp.continuedApplicationNo
? `回访已完成,继续帮扶申请 ${result.followUp.continuedApplicationNo} 已创建。`
: '回访已完成,原服务申请已闭环。';
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
function showOverdue(): void {
filters.status = 'PENDING';
filters.overdue = true;
void load(1);
}
function isOverdue(item: FollowUpListItem): boolean {
return item.status === 'PENDING' && new Date(item.dueAt).getTime() < Date.now();
}
onMounted(async () => {
await Promise.all([load(), loadOptions()]);
});
</script>
<template>
<main class="content-area" tabindex="-1">
<template v-if="selected">
<header class="page-header">
<div>
<button class="back-button" @click="selected = null">← 返回回访工作台</button>
<h1>{{ selected.followUpNo }}</h1>
<p>
{{ selected.beneficiaryDisplayName }} · {{ selected.followUpTypeLabel }} ·
{{ selected.ownerDisplayName }}
</p>
</div>
<span
:class="[
'status-tag',
selected.status === 'COMPLETED' ? 'status-tag--success' : 'status-tag--warning',
]"
>{{ selected.statusLabel }}</span
>
</header>
<div class="audit-notice">
回访原因与结论属于敏感服务信息,仅限履职使用;查看与提交均会留痕。
</div>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section class="detail-grid">
<article class="panel">
<h2>回访安排</h2>
<dl class="detail-list">
<div>
<dt>服务对象</dt>
<dd>{{ selected.beneficiaryDisplayName }}</dd>
</div>
<div>
<dt>服务类型</dt>
<dd>{{ SERVICE_CATEGORY_LABELS[selected.category] }}</dd>
</div>
<div>
<dt>计划时间</dt>
<dd>{{ formatChinaDateTime(selected.dueAt) }}</dd>
</div>
<div>
<dt>关联工单</dt>
<dd>{{ selected.serviceCaseNo ?? '独立回访' }}</dd>
</div>
</dl>
<h3>回访原因</h3>
<p class="description-text">{{ selected.reason }}</p>
</article>
<article class="panel">
<h2>回访结果</h2>
<template v-if="selected.status === 'COMPLETED'">
<p>
<strong>{{ selected.resultLabel }}</strong>
</p>
<p class="description-text">{{ selected.resultNote }}</p>
<p v-if="selected.continuedApplicationNo" class="consent-note">
已创建继续帮扶申请:{{ selected.continuedApplicationNo }}
</p>
</template>
<form v-else class="form-grid" @submit.prevent="saveCompletion">
<label class="form-span"
>回访结论<select v-model="completeForm.resultCode">
<option v-for="code in FOLLOW_UP_RESULT_CODES" :key="code" :value="code">
{{ FOLLOW_UP_RESULT_LABELS[code] }}
</option>
</select></label
><label class="form-span"
>结果说明<textarea
v-model="completeForm.resultNote"
maxlength="5000"
required
></textarea></label
><template v-if="completeForm.resultCode === 'NEED_MORE_HELP'">
<label class="form-span"
>新的帮扶需求<textarea
v-model="completeForm.continuationDescription"
minlength="10"
maxlength="5000"
required
></textarea></label
><label class="consent-check form-span"
><input v-model="completeForm.continuationConsentConfirmed" type="checkbox" />
已取得服务对象或监护人对创建继续帮扶申请的明确授权</label
>
</template>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '正在提交…' : '完成回访' }}
</button>
</div>
</form>
</article>
</section>
</template>
<template v-else>
<header class="page-header">
<div>
<h1>回访工作台</h1>
<p>优先处理逾期、今日到期和重点关爱对象的回访任务。</p>
</div>
<button class="primary-button compact-button" @click="showCreate = !showCreate">
{{ showCreate ? '收起新建表单' : '新建回访' }}
</button>
</header>
<section class="summary-grid" aria-label="回访任务概览">
<button class="summary-card summary-card--danger" type="button" @click="showOverdue">
<span>已逾期</span><strong>{{ summary.overdue }}</strong
><small>优先处理</small>
</button>
<article class="summary-card">
<span>今日到期</span><strong>{{ summary.dueToday }}</strong
><small>按时联系</small>
</article>
<article class="summary-card">
<span>未来 7 日</span><strong>{{ summary.dueThisWeek }}</strong
><small>提前安排</small>
</article>
<article class="summary-card">
<span>重点关爱待办</span><strong>{{ summary.keyCarePending }}</strong
><small>持续关注</small>
</article>
</section>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="showCreate" class="panel workflow-panel">
<h2>新建人工回访</h2>
<form class="form-grid" @submit.prevent="saveCreate">
<label
>服务对象<select v-model="createForm.beneficiaryPublicId" required>
<option value="">请选择</option>
<option v-for="item in beneficiaries" :key="item.publicId" :value="item.publicId">
{{ item.displayName }}{{ item.keyCareFlag ? ' · 重点关爱' : '' }}
</option>
</select></label
><label
>负责人<select v-model="createForm.ownerStaffPublicId" required>
<option value="">请选择</option>
<option v-for="item in assignees" :key="item.userPublicId" :value="item.userPublicId">
{{ item.displayName }}
</option>
</select></label
><label
>回访类型<select v-model="createForm.followUpType">
<option value="MANUAL">人工回访</option>
<option value="KEY_CARE">重点关爱回访</option>
</select></label
><label>计划时间<input v-model="createForm.dueAt" type="datetime-local" required /></label
><label class="form-span"
>回访原因<textarea v-model="createForm.reason" maxlength="2000" required></textarea>
</label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '正在创建…' : '创建回访任务' }}
</button>
</div>
</form>
</section>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label>状态</label
><select v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in FOLLOW_UP_STATUSES" :key="status" :value="status">
{{ FOLLOW_UP_STATUS_LABELS[status] }}
</option>
</select>
</div>
<div class="filter-field">
<label>类型</label
><select v-model="filters.followUpType">
<option value="">全部类型</option>
<option v-for="type in FOLLOW_UP_TYPES" :key="type" :value="type">
{{ FOLLOW_UP_TYPE_LABELS[type] }}
</option>
</select>
</div>
<div class="filter-field">
<label class="consent-check"
><input v-model="filters.overdue" type="checkbox" /> 仅看逾期待办</label
>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<section v-if="loading" class="panel">
<div v-for="n in 6" :key="n" class="table-skeleton"></div>
</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无符合条件的回访</h2>
<p>服务完成或工作人员新建回访后,任务会显示在这里。</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>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>{{ item.followUpNo }}</td>
<td>
{{ item.beneficiaryDisplayName }}
<span v-if="item.beneficiaryKeyCareFlag" class="status-tag status-tag--warning"
>重点关爱</span
>
</td>
<td>{{ item.followUpTypeLabel }}</td>
<td :class="{ 'danger-text': isOverdue(item) }">
{{ formatChinaDateTime(item.dueAt)
}}<small v-if="isOverdue(item)" class="block-note">已逾期</small>
</td>
<td>{{ item.ownerDisplayName }}</td>
<td>
<span
:class="[
'status-tag',
item.status === 'COMPLETED'
? 'status-tag--success'
: isOverdue(item)
? 'status-tag--danger'
: 'status-tag--info',
]"
>{{ item.statusLabel }}</span
>
</td>
<td><button class="link-button" @click="open(item.publicId)">查看处理</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,74 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { NotificationDeliveryItem } from '@lehuo/contracts';
import { fetchDeliveries, retryDelivery } from '../api/content-notifications';
const items = ref<NotificationDeliveryItem[]>([]),
loading = ref(true),
message = ref('');
async function load() {
loading.value = true;
try {
items.value = (await fetchDeliveries({ page: 1, pageSize: 50 })).items;
} catch (e) {
message.value = e instanceof Error ? e.message : '加载失败';
} finally {
loading.value = false;
}
}
async function retry(id: string) {
await retryDelivery(id);
message.value = '已重新加入安全投递队列。';
await load();
}
onMounted(load);
</script>
<template>
<main class="content-area">
<header class="page-header">
<div>
<h1>通知投递</h1>
<p>站内消息独立持久化;微信失败不会回滚业务,可审计重试。</p>
</div>
</header>
<p v-if="message" class="global-message">{{ message }}</p>
<section v-if="loading" class="panel">加载中…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无微信投递</h2>
<p>用户授权订阅且模板配置完成后会生成投递任务。</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>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="i in items" :key="i.publicId">
<td>{{ i.type }}</td>
<td>{{ i.status }}</td>
<td>{{ i.attemptCount }}</td>
<td>{{ new Date(i.nextAttemptAt).toLocaleString('zh-CN') }}</td>
<td>{{ i.errorCode ?? '—' }}</td>
<td>
<button
v-if="i.status === 'FAILED' || i.status === 'DEAD'"
class="link-button"
@click="retry(i.publicId)"
>
重试
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</template>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
PROJECT_STATUSES,
PROJECT_STATUS_LABELS,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
type AssigneeOption,
type ProjectListItem,
type ProjectStatus,
type ServiceCategory,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
createProject,
fetchProjectManagerOptions,
fetchProjects,
transitionProject,
} from '../api/participation';
const items = ref<ProjectListItem[]>([]),
managers = ref<AssigneeOption[]>([]),
loading = ref(true),
busy = ref(false),
showCreate = ref(false),
message = ref('');
const page = ref(1),
total = ref(0),
pageSize = 20,
totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{ status: ProjectStatus | '' }>({ status: '' });
const form = reactive({
name: '',
category: 'OLD_AGE' as ServiceCategory,
summary: '',
content: '',
managerUserPublicId: '',
startDate: '',
endDate: '',
targetPeople: undefined as number | undefined,
isPublic: true,
sortOrder: 0,
});
const errorText = (error: unknown) =>
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load(target = 1) {
loading.value = true;
message.value = '';
try {
const result = await fetchProjects({
page: target,
pageSize,
...(filters.status ? { status: filters.status } : {}),
});
items.value = result.items;
total.value = result.total;
page.value = result.page;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function save() {
if (!form.managerUserPublicId || !form.startDate || !form.endDate) {
message.value = '请选择负责人并填写项目周期。';
return;
}
busy.value = true;
try {
await createProject({ clientRequestId: crypto.randomUUID(), ...form });
showCreate.value = false;
Object.assign(form, {
name: '',
summary: '',
content: '',
startDate: '',
endDate: '',
targetPeople: undefined,
});
message.value = '项目草稿已创建。';
await load(1);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function transition(item: ProjectListItem) {
const targetStatus = item.status === 'DRAFT' ? 'PUBLISHED' : 'COMPLETED';
if (!window.confirm(`确认${targetStatus === 'PUBLISHED' ? '发布' : '完成'}项目“${item.name}”?`))
return;
busy.value = true;
try {
await transitionProject(item.publicId, { clientRequestId: crypto.randomUUID(), targetStatus });
message.value = '项目状态已更新并记录审计。';
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
onMounted(async () => {
await Promise.all([
load(),
fetchProjectManagerOptions().then((value) => {
managers.value = value;
}),
]);
});
</script>
<template>
<main class="content-area" tabindex="-1">
<header class="page-header">
<div>
<h1>公益项目</h1>
<p>维护项目内容、负责人、周期与公开状态。</p>
</div>
<button class="primary-button compact-button" @click="showCreate = !showCreate">
{{ showCreate ? '取消新建' : '新建项目' }}
</button>
</header>
<form v-if="showCreate" class="panel form-grid" @submit.prevent="save">
<label
>项目名称<input v-model.trim="form.name" required minlength="2" maxlength="200" /></label
><label
>服务类型<select v-model="form.category">
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
{{ SERVICE_CATEGORY_LABELS[category] }}
</option>
</select></label
><label
>负责人<select v-model="form.managerUserPublicId" required>
<option value="">请选择</option>
<option
v-for="manager in managers"
:key="manager.userPublicId"
:value="manager.userPublicId"
>
{{ manager.displayName }}
</option>
</select></label
><label>目标人数<input v-model.number="form.targetPeople" type="number" min="1" /></label
><label>开始日期<input v-model="form.startDate" type="date" required /></label
><label>结束日期<input v-model="form.endDate" type="date" required /></label
><label class="form-span"
>项目摘要<textarea
v-model.trim="form.summary"
required
minlength="10"
maxlength="500"
></textarea></label
><label class="form-span"
>项目内容<textarea
v-model.trim="form.content"
required
minlength="20"
maxlength="20000"
></textarea></label
><label><input v-model="form.isPublic" type="checkbox" /> 发布后公开展示</label
><label
>排序<input v-model.number="form.sortOrder" type="number" min="-10000" max="10000"
/></label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '保存中…' : '保存草稿' }}
</button>
</div>
</form>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label for="project-status">项目状态</label
><select id="project-status" v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in PROJECT_STATUSES" :key="status" :value="status">
{{ PROJECT_STATUS_LABELS[status] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="loading" class="panel">正在加载项目…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无项目</h2>
<p>可先建立项目草稿。</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>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>
<strong>{{ item.name }}</strong
><br />{{ item.projectNo }} · {{ SERVICE_CATEGORY_LABELS[item.category] }}
</td>
<td>{{ item.managerDisplayName }}</td>
<td>{{ item.startDate }} 至 {{ item.endDate }}</td>
<td>{{ item.statusLabel }}<br />{{ item.isPublic ? '公开' : '内部' }}</td>
<td>{{ item.activityCount }} / {{ item.serviceCaseCount }}</td>
<td>
<button
v-if="item.status !== 'COMPLETED'"
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>
</main>
</template>
@@ -0,0 +1,572 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
SERVICE_APPLICATION_STATUSES,
SERVICE_APPLICATION_STATUS_LABELS,
SERVICE_CASE_PRIORITIES,
SERVICE_CASE_PRIORITY_LABELS,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
type ServiceApplicationDetail,
type ServiceApplicationListItem,
type ServiceApplicationStatus,
type ServiceCategory,
type AssigneeOption,
type BeneficiaryTagOption,
type ServiceCasePriority,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
acceptServiceApplication,
fetchAssigneeOptions,
fetchBeneficiaryTags,
transitionServiceApplication,
} from '../api/case-management';
import {
fetchAdminServiceApplication,
fetchAdminServiceApplications,
} from '../api/service-applications';
import { formatChinaDateTime, statusTone } from '../application-format';
type ViewState = 'loading' | 'success' | 'empty' | 'error' | 'no-permission';
const state = ref<ViewState>('loading');
const message = ref('');
const items = ref<ServiceApplicationListItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 20;
const selected = ref<ServiceApplicationDetail | null>(null);
const detailLoading = ref(false);
const workflowBusy = ref(false);
const assignees = ref<AssigneeOption[]>([]);
const tags = ref<BeneficiaryTagOption[]>([]);
const acceptance = reactive({
mode: 'CREATE' as 'CREATE' | 'LINK_EXISTING',
existingPublicId: '',
ownerStaffPublicId: '',
collaboratorUserPublicIds: [] as string[],
priority: 'NORMAL' as ServiceCasePriority,
summary: '',
serviceLocation: '',
displayName: '',
mobile: '',
regionCode: '',
birthYear: undefined as number | undefined,
keyCareFlag: false,
isMinor: false,
guardianName: '',
guardianRelation: '监护人',
guardianMobile: '',
tagCodes: [] as string[],
});
const filters = reactive<{
applicationNo: string;
serviceCategory: ServiceCategory | '';
status: ServiceApplicationStatus | '';
}>({ applicationNo: '', serviceCategory: '', status: '' });
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
function errorMessage(error: unknown): string {
if (error instanceof ApiClientError) {
return `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`;
}
return '服务申请加载失败,请稍后重试';
}
async function loadList(targetPage = page.value): Promise<void> {
state.value = 'loading';
message.value = '';
selected.value = null;
try {
const result = await fetchAdminServiceApplications({
page: targetPage,
pageSize,
...(filters.applicationNo.trim()
? { applicationNo: filters.applicationNo.trim().toUpperCase() }
: {}),
...(filters.serviceCategory ? { serviceCategory: filters.serviceCategory } : {}),
...(filters.status ? { status: filters.status } : {}),
});
page.value = result.page;
items.value = result.items;
total.value = result.total;
state.value = result.items.length ? 'success' : 'empty';
} catch (error) {
state.value =
error instanceof ApiClientError && error.code === 'FORBIDDEN' ? 'no-permission' : 'error';
message.value = errorMessage(error);
}
}
async function openDetail(publicId: string): Promise<void> {
detailLoading.value = true;
message.value = '';
try {
selected.value = (await fetchAdminServiceApplication(publicId)).application;
acceptance.displayName = selected.value.beneficiaryName;
acceptance.mobile = selected.value.contactMobile;
acceptance.regionCode = selected.value.regionCode;
acceptance.summary = selected.value.description;
if (selected.value.status === 'ASSESSING') {
[assignees.value, tags.value] = await Promise.all([
fetchAssigneeOptions(),
fetchBeneficiaryTags(),
]);
}
} catch (error) {
message.value = errorMessage(error);
} finally {
detailLoading.value = false;
}
}
async function transition(targetStatus: 'PENDING_CONTACT' | 'ASSESSING'): Promise<void> {
if (!selected.value) return;
workflowBusy.value = true;
message.value = '';
try {
await transitionServiceApplication(selected.value.publicId, {
targetStatus,
reasonCode: targetStatus === 'PENDING_CONTACT' ? 'CONTACT_STARTED' : 'CONTACT_COMPLETED',
});
await openDetail(selected.value.publicId);
message.value = '申请状态已更新并记录审计。';
} catch (error) {
message.value = errorMessage(error);
} finally {
workflowBusy.value = false;
}
}
async function accept(): Promise<void> {
if (!selected.value || !acceptance.ownerStaffPublicId || acceptance.summary.trim().length < 10) {
message.value = '请选择负责人,并填写至少 10 个字的服务计划。';
return;
}
if (acceptance.mode === 'LINK_EXISTING' && !acceptance.existingPublicId) {
message.value = '请输入已核验的服务对象档案标识。';
return;
}
if (
acceptance.mode === 'CREATE' &&
acceptance.isMinor &&
(!acceptance.guardianName || !acceptance.guardianMobile)
) {
message.value = '未成年人档案必须填写监护人及手机号。';
return;
}
workflowBusy.value = true;
message.value = '';
try {
const beneficiary =
acceptance.mode === 'LINK_EXISTING'
? ({
mode: 'LINK_EXISTING',
beneficiaryPublicId: acceptance.existingPublicId.trim(),
manualMatchConfirmed: true,
} as const)
: ({
mode: 'CREATE',
profile: {
displayName: acceptance.displayName.trim(),
...(acceptance.birthYear ? { birthYear: acceptance.birthYear } : {}),
...(acceptance.mobile ? { mobile: acceptance.mobile.trim() } : {}),
regionCode: acceptance.regionCode.trim(),
keyCareFlag: acceptance.keyCareFlag,
isMinor: acceptance.isMinor,
contacts: acceptance.isMinor
? [
{
name: acceptance.guardianName.trim(),
relation: acceptance.guardianRelation.trim(),
mobile: acceptance.guardianMobile.trim(),
isGuardian: true,
isEmergencyContact: true,
},
]
: [],
tagCodes: acceptance.tagCodes,
...(acceptance.isMinor ? { guardianConsentConfirmed: true as const } : {}),
},
} as const);
const result = await acceptServiceApplication(selected.value.publicId, {
clientRequestId: crypto.randomUUID(),
beneficiary,
ownerStaffPublicId: acceptance.ownerStaffPublicId,
collaboratorUserPublicIds: acceptance.collaboratorUserPublicIds,
priority: acceptance.priority,
...(acceptance.serviceLocation.trim()
? { serviceLocation: acceptance.serviceLocation.trim() }
: {}),
summary: acceptance.summary.trim(),
});
message.value = `接单成功,已生成工单 ${result.serviceCase.caseNo}。`;
selected.value = null;
await loadList(1);
} catch (error) {
message.value = errorMessage(error);
} finally {
workflowBusy.value = false;
}
}
function resetFilters(): void {
filters.applicationNo = '';
filters.serviceCategory = '';
filters.status = '';
void loadList(1);
}
onMounted(() => loadList(1));
</script>
<template>
<main id="main-content" class="content-area" tabindex="-1">
<template v-if="selected">
<header class="page-header">
<div>
<button class="back-button" type="button" @click="selected = null">← 返回申请列表</button>
<h1>服务申请详情</h1>
<p>查看本次申请的必要信息与状态历史。</p>
</div>
<span :class="['status-tag', `status-tag--${statusTone(selected.status)}`]">
{{ selected.statusLabel }}
</span>
</header>
<div class="audit-notice" role="note">
此页面包含服务联系所需的敏感信息,本次查看已记录安全审计。请勿复制或用于服务以外的目的。
</div>
<section
v-if="selected.status === 'SUBMITTED' || selected.status === 'PENDING_CONTACT'"
class="panel workflow-panel"
>
<h2>受理进度</h2>
<p>状态必须按顺序推进;系统会在同一事务中写入状态历史和审计记录。</p>
<button
v-if="selected.status === 'SUBMITTED'"
class="primary-button compact-button"
:disabled="workflowBusy"
@click="transition('PENDING_CONTACT')"
>
开始联系
</button>
<button
v-else
class="primary-button compact-button"
:disabled="workflowBusy"
@click="transition('ASSESSING')"
>
完成联系,进入评估
</button>
</section>
<section v-if="selected.status === 'ASSESSING'" class="panel workflow-panel">
<h2>评估通过并生成工单</h2>
<p>接单会原子化建立或关联服务对象档案、指派负责人并生成工单。</p>
<form class="form-grid" @submit.prevent="accept">
<label
>档案处理方式<select v-model="acceptance.mode">
<option value="CREATE">建立新档案</option>
<option value="LINK_EXISTING">关联已有档案</option>
</select></label
>
<label v-if="acceptance.mode === 'LINK_EXISTING'"
>已人工核验的档案标识<input v-model.trim="acceptance.existingPublicId" required
/></label>
<template v-else>
<label
>服务对象姓名<input v-model.trim="acceptance.displayName" required maxlength="50"
/></label>
<label
>手机号<input
v-model.trim="acceptance.mobile"
inputmode="numeric"
pattern="1[3-9][0-9]{9}"
/></label>
<label
>所在地区<input v-model.trim="acceptance.regionCode" required maxlength="100"
/></label>
<label
>出生年份<input
v-model.number="acceptance.birthYear"
type="number"
min="1900"
max="2200"
/></label>
<label><input v-model="acceptance.keyCareFlag" type="checkbox" /> 重点关怀对象</label>
<label><input v-model="acceptance.isMinor" type="checkbox" /> 未成年人</label>
<template v-if="acceptance.isMinor">
<label
>监护人姓名<input v-model.trim="acceptance.guardianName" required maxlength="50"
/></label>
<label
>与服务对象关系<input
v-model.trim="acceptance.guardianRelation"
required
maxlength="50"
/></label>
<label
>监护人手机号<input
v-model.trim="acceptance.guardianMobile"
required
pattern="1[3-9][0-9]{9}"
/></label>
<p class="form-span consent-note">
提交表示工作人员已取得监护人明确授权;系统将创建独立授权记录。
</p>
</template>
<fieldset class="form-span checkbox-group">
<legend>服务标签</legend>
<label v-for="tag in tags" :key="tag.code"
><input v-model="acceptance.tagCodes" type="checkbox" :value="tag.code" />
{{ tag.name }}</label
>
</fieldset>
</template>
<label
>负责人<select v-model="acceptance.ownerStaffPublicId" required>
<option value="">请选择</option>
<option
v-for="item in assignees.filter((option) => option.type === 'STAFF')"
:key="item.userPublicId"
:value="item.userPublicId"
>
{{ item.displayName }}{{ item.department ? ` · ${item.department}` : '' }}
</option>
</select></label
>
<label
>优先级<select v-model="acceptance.priority">
<option v-for="priority in SERVICE_CASE_PRIORITIES" :key="priority" :value="priority">
{{ SERVICE_CASE_PRIORITY_LABELS[priority] }}
</option>
</select></label
>
<label class="form-span"
>协作人员<select v-model="acceptance.collaboratorUserPublicIds" multiple size="5">
<option v-for="item in assignees" :key="item.userPublicId" :value="item.userPublicId">
{{ item.displayName }} · {{ item.type === 'STAFF' ? '工作人员' : '志愿者' }}
</option>
</select></label
>
<label class="form-span"
>服务地点<input v-model.trim="acceptance.serviceLocation" maxlength="300"
/></label>
<label class="form-span"
>服务计划<textarea
v-model.trim="acceptance.summary"
required
minlength="10"
maxlength="2000"
></textarea>
</label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="workflowBusy">
{{ workflowBusy ? '正在生成…' : '确认接单并生成工单' }}
</button>
</div>
</form>
</section>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section class="detail-grid">
<article class="panel">
<h2>申请概况</h2>
<dl class="detail-list">
<div>
<dt>服务编号</dt>
<dd class="tabular">{{ selected.applicationNo }}</dd>
</div>
<div>
<dt>服务类型</dt>
<dd>{{ selected.serviceCategoryLabel }}</dd>
</div>
<div>
<dt>申请关系</dt>
<dd>{{ selected.applyModeLabel }}</dd>
</div>
<div>
<dt>服务对象</dt>
<dd>{{ selected.beneficiaryName }}</dd>
</div>
<div>
<dt>所在地区</dt>
<dd>{{ selected.regionCode }}</dd>
</div>
<div>
<dt>联系电话</dt>
<dd class="sensitive-value">{{ selected.contactMobile }}</dd>
</div>
<div>
<dt>期望联系</dt>
<dd>{{ selected.preferredContactTime ?? '未填写' }}</dd>
</div>
<div>
<dt>提交时间</dt>
<dd>{{ formatChinaDateTime(selected.submittedAt) }}</dd>
</div>
</dl>
</article>
<article class="panel">
<h2>补充情况</h2>
<dl class="detail-list">
<div>
<dt>希望上门</dt>
<dd>{{ selected.needsHomeVisit ? '是' : '否或未说明' }}</dd>
</div>
<div>
<dt>行动不便</dt>
<dd>{{ selected.mobilityDifficulty ? '是' : '否或未说明' }}</dd>
</div>
</dl>
<h3>需求说明</h3>
<p class="description-text">{{ selected.description }}</p>
</article>
</section>
<section class="panel timeline-panel">
<h2>状态历史</h2>
<ol class="admin-timeline">
<li v-for="history in selected.history" :key="history.createdAt">
<span class="timeline-marker" aria-hidden="true"></span>
<div>
<strong>{{ history.statusLabel }}</strong>
<p>{{ history.reasonLabel }}</p>
<time :datetime="history.createdAt">{{
formatChinaDateTime(history.createdAt)
}}</time>
</div>
</li>
</ol>
</section>
</template>
<template v-else>
<header class="page-header">
<div>
<h1>服务申请</h1>
<p>查看服务需求,并按状态机完成联系、评估、接单和工单生成。</p>
</div>
<span class="result-count">共 {{ total }} 条</span>
</header>
<form class="filter-panel" aria-label="申请筛选" @submit.prevent="loadList(1)">
<div class="filter-field">
<label for="application-no">服务编号</label>
<input id="application-no" v-model="filters.applicationNo" placeholder="输入服务编号" />
</div>
<div class="filter-field">
<label for="service-category">服务类型</label>
<select id="service-category" v-model="filters.serviceCategory">
<option value="">全部类型</option>
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
{{ SERVICE_CATEGORY_LABELS[category] }}
</option>
</select>
</div>
<div class="filter-field">
<label for="application-status">当前状态</label>
<select id="application-status" v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in SERVICE_APPLICATION_STATUSES" :key="status" :value="status">
{{ SERVICE_APPLICATION_STATUS_LABELS[status] }}
</option>
</select>
</div>
<div class="filter-actions">
<button class="primary-button compact-button" type="submit">查询</button>
<button class="secondary-button compact-button" type="button" @click="resetFilters">
重置
</button>
</div>
</form>
<section v-if="state === 'loading'" class="panel" aria-live="polite">
<div v-for="index in 6" :key="index" class="table-skeleton"></div>
<p class="sr-only">正在加载服务申请</p>
</section>
<section v-else-if="state === 'empty'" class="empty-panel">
<h2>没有符合条件的服务申请</h2>
<p>可以调整筛选条件后重新查询。</p>
<button class="secondary-button compact-button" type="button" @click="resetFilters">
清除筛选
</button>
</section>
<section v-else-if="state === 'no-permission'" class="empty-panel" role="alert">
<h2>您暂无权限查看服务申请</h2>
<p>{{ message }}</p>
</section>
<section v-else-if="state === 'error'" class="empty-panel" role="alert">
<h2>服务申请加载失败</h2>
<p>{{ message }}</p>
<button class="secondary-button compact-button" type="button" @click="loadList()">
重新加载
</button>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<caption class="sr-only">
服务申请列表
</caption>
<thead>
<tr>
<th>服务编号</th>
<th>服务对象</th>
<th>服务类型</th>
<th>联系电话</th>
<th>状态</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td class="tabular">{{ item.applicationNo }}</td>
<td>{{ item.beneficiaryName }}</td>
<td>{{ item.serviceCategoryLabel }}</td>
<td class="tabular">{{ item.contactMobileMasked }}</td>
<td>
<span :class="['status-tag', `status-tag--${statusTone(item.status)}`]">{{
item.statusLabel
}}</span>
</td>
<td>{{ formatChinaDateTime(item.submittedAt) }}</td>
<td>
<button
class="link-button"
type="button"
:disabled="detailLoading"
@click="openDetail(item.publicId)"
>
{{ detailLoading ? '读取中…' : '查看' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" aria-label="分页">
<button type="button" :disabled="page <= 1" @click="loadList(page - 1)">上一页</button>
<span>第 {{ page }} / {{ totalPages }} 页</span>
<button type="button" :disabled="page >= totalPages" @click="loadList(page + 1)">
下一页
</button>
</div>
</section>
<p v-if="message && state === 'success'" class="global-message" role="alert">{{ message }}</p>
</template>
</main>
</template>
@@ -0,0 +1,590 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
BENEFICIARY_CONFIRM_STATUSES,
SERVICE_CASE_STATUSES,
SERVICE_CASE_STATUS_LABELS,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
SERVICE_RECORD_TYPES,
SERVICE_RECORD_TYPE_LABELS,
type AssigneeOption,
type ServiceCaseDetail,
type ServiceCaseListItem,
type ServiceCaseStatus,
type ServiceCategory,
type ServiceRecordItem,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
confirmUpload,
completeServiceCase,
createServiceRecord,
createUploadIntent,
fetchAssigneeOptions,
fetchDownloadUrl,
fetchServiceCase,
fetchServiceCases,
replaceCaseAssignees,
startServiceCase,
updateServiceRecord,
} from '../api/case-management';
import { formatChinaDateTime } from '../application-format';
const items = ref<ServiceCaseListItem[]>([]);
const selected = ref<ServiceCaseDetail | null>(null);
const options = ref<AssigneeOption[]>([]);
const collaborators = ref<string[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const total = ref(0);
const page = ref(1);
const pageSize = 20;
const filters = reactive<{
caseNo: string;
status: ServiceCaseStatus | '';
category: ServiceCategory | '';
}>({ caseNo: '', status: '', category: '' });
const recordForm = reactive({
publicId: '',
revision: 0,
serviceStartAt: '',
serviceEndAt: '',
serviceType: 'HOME_VISIT' as (typeof SERVICE_RECORD_TYPES)[number],
content: '',
result: '',
followUpSuggestion: '',
beneficiaryConfirmStatus: 'NOT_REQUESTED' as (typeof BENEFICIARY_CONFIRM_STATUSES)[number],
attachmentPublicIds: [] as string[],
});
const pendingFiles = ref<Array<{ publicId: string; filename: string }>>([]);
const completionForm = reactive({ resultSummary: '', followUpPlan: '', followUpDueAt: '' });
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
function errorText(error: unknown): string {
return error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
}
function localDate(value: string): string {
return value ? new Date(value).toISOString() : '';
}
function resetRecord(): void {
Object.assign(recordForm, {
publicId: '',
revision: 0,
serviceStartAt: '',
serviceEndAt: '',
serviceType: 'HOME_VISIT',
content: '',
result: '',
followUpSuggestion: '',
beneficiaryConfirmStatus: 'NOT_REQUESTED',
attachmentPublicIds: [],
});
pendingFiles.value = [];
}
async function load(targetPage = 1): Promise<void> {
loading.value = true;
selected.value = null;
message.value = '';
try {
const result = await fetchServiceCases({
page: targetPage,
pageSize,
...(filters.caseNo.trim() ? { caseNo: filters.caseNo.trim().toUpperCase() } : {}),
...(filters.status ? { status: filters.status } : {}),
...(filters.category ? { category: filters.category } : {}),
});
items.value = result.items;
total.value = result.total;
page.value = result.page;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function open(publicId: string): Promise<void> {
loading.value = true;
message.value = '';
try {
const [caseResult, assignees] = await Promise.all([
fetchServiceCase(publicId),
fetchAssigneeOptions().catch(() => []),
]);
selected.value = caseResult.serviceCase;
options.value = assignees;
collaborators.value = caseResult.serviceCase.assignees
.filter((item) => item.role === 'COLLABORATOR')
.map((item) => item.userPublicId);
resetRecord();
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function start(): Promise<void> {
if (!selected.value) return;
busy.value = true;
message.value = '';
try {
selected.value = (await startServiceCase(selected.value.publicId)).serviceCase;
message.value = '服务已开始,申请进度与工单状态已同步更新。';
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function completeCase(): Promise<void> {
if (
!selected.value ||
completionForm.resultSummary.trim().length < 2 ||
completionForm.followUpPlan.trim().length < 2 ||
!completionForm.followUpDueAt
) {
message.value = '请完整填写服务总结、回访计划和回访时间。';
return;
}
busy.value = true;
message.value = '';
try {
const publicId = selected.value.publicId;
const result = await completeServiceCase(publicId, {
clientRequestId: crypto.randomUUID(),
resultSummary: completionForm.resultSummary.trim(),
followUpPlan: completionForm.followUpPlan.trim(),
followUpDueAt: localDate(completionForm.followUpDueAt),
});
await open(publicId);
message.value = `服务已完成,回访任务 ${result.followUp.followUpNo} 已自动生成。`;
Object.assign(completionForm, { resultSummary: '', followUpPlan: '', followUpDueAt: '' });
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function saveAssignees(): Promise<void> {
if (!selected.value) return;
busy.value = true;
message.value = '';
try {
selected.value = (
await replaceCaseAssignees(selected.value.publicId, {
collaboratorUserPublicIds: collaborators.value,
})
).serviceCase;
message.value = '协作人员已更新并记录审计。';
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function upload(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!selected.value || !file) return;
busy.value = true;
message.value = '';
try {
const intent = await createUploadIntent(selected.value.publicId, {
clientRequestId: crypto.randomUUID(),
filename: file.name,
mimeType: file.type as 'image/jpeg' | 'image/png' | 'image/webp',
size: file.size,
});
const uploaded = await fetch(intent.uploadUrl, {
method: 'PUT',
headers: intent.uploadHeaders,
body: file,
});
if (!uploaded.ok) throw new Error('对象存储上传失败');
const confirmed = await confirmUpload(intent.file.publicId);
recordForm.attachmentPublicIds.push(confirmed.file.publicId);
pendingFiles.value.push({
publicId: confirmed.file.publicId,
filename: confirmed.file.filename,
});
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
input.value = '';
}
}
function editRecord(record: ServiceRecordItem): void {
Object.assign(recordForm, {
publicId: record.publicId,
revision: record.revision,
serviceStartAt: record.serviceStartAt.slice(0, 16),
serviceEndAt: record.serviceEndAt?.slice(0, 16) ?? '',
serviceType: record.serviceType,
content: record.content,
result: record.result,
followUpSuggestion: record.followUpSuggestion ?? '',
beneficiaryConfirmStatus: record.beneficiaryConfirmStatus,
attachmentPublicIds: record.attachments.map((item) => item.publicId),
});
pendingFiles.value = record.attachments.map(({ publicId, filename }) => ({ publicId, filename }));
}
async function saveRecord(): Promise<void> {
if (
!selected.value ||
!recordForm.serviceStartAt ||
recordForm.content.trim().length < 2 ||
recordForm.result.trim().length < 2
) {
message.value = '请完整填写服务时间、内容和结果。';
return;
}
busy.value = true;
message.value = '';
const editing = Boolean(recordForm.publicId);
const common = {
serviceStartAt: localDate(recordForm.serviceStartAt),
...(recordForm.serviceEndAt ? { serviceEndAt: localDate(recordForm.serviceEndAt) } : {}),
serviceType: recordForm.serviceType,
content: recordForm.content.trim(),
result: recordForm.result.trim(),
...(recordForm.followUpSuggestion.trim()
? { followUpSuggestion: recordForm.followUpSuggestion.trim() }
: {}),
beneficiaryConfirmStatus: recordForm.beneficiaryConfirmStatus,
attachmentPublicIds: recordForm.attachmentPublicIds,
};
try {
if (recordForm.publicId)
await updateServiceRecord(selected.value.publicId, recordForm.publicId, {
...common,
expectedRevision: recordForm.revision,
});
else
await createServiceRecord(selected.value.publicId, {
...common,
clientRequestId: crypto.randomUUID(),
});
await open(selected.value.publicId);
message.value = editing ? '服务记录已更新。' : '服务记录已保存。';
resetRecord();
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function download(publicId: string): Promise<void> {
try {
window.open((await fetchDownloadUrl(publicId)).downloadUrl, '_blank', 'noopener,noreferrer');
} catch (error) {
message.value = errorText(error);
}
}
onMounted(() => load());
</script>
<template>
<main class="content-area" tabindex="-1">
<template v-if="selected">
<header class="page-header">
<div>
<button class="back-button" @click="selected = null">← 返回工单列表</button>
<h1>{{ selected.caseNo }}</h1>
<p>
{{ selected.beneficiaryDisplayName }} ·
{{ SERVICE_CATEGORY_LABELS[selected.category] }} · 负责人
{{ selected.ownerDisplayName }}
</p>
</div>
<span class="status-tag status-tag--info">{{ selected.statusLabel }}</span>
</header>
<div class="audit-notice">
仅可将以下信息用于已分配的公益服务;查看、指派和记录操作均会留痕。
</div>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section class="detail-grid">
<article class="panel">
<h2>服务计划</h2>
<dl class="detail-list">
<div>
<dt>地区</dt>
<dd>{{ selected.regionCode }}</dd>
</div>
<div>
<dt>联系电话</dt>
<dd>{{ selected.beneficiaryMobile ?? '未留存' }}</dd>
</div>
<div>
<dt>服务地点</dt>
<dd>{{ selected.serviceLocation ?? '未填写' }}</dd>
</div>
<div>
<dt>计划说明</dt>
<dd>{{ selected.summary }}</dd>
</div>
<div v-if="selected.resultSummary">
<dt>服务总结</dt>
<dd>{{ selected.resultSummary }}</dd>
</div>
<div v-if="selected.followUpPlan">
<dt>回访计划</dt>
<dd>{{ selected.followUpPlan }}</dd>
</div>
</dl>
<button
v-if="selected.status === 'ASSIGNED'"
class="primary-button compact-button"
:disabled="busy"
@click="start"
>
开始服务
</button>
</article>
<article class="panel">
<h2>负责人和协作人员</h2>
<p v-for="item in selected.assignees" :key="item.userPublicId">
{{ item.role === 'OWNER' ? '负责人' : '协作' }} · {{ item.displayName }}({{
item.type === 'STAFF' ? '工作人员' : '志愿者'
}})
</p>
<label
>调整协作人员<select v-model="collaborators" multiple size="6">
<option v-for="item in options" :key="item.userPublicId" :value="item.userPublicId">
{{ item.displayName }} · {{ item.type === 'STAFF' ? '工作人员' : '志愿者' }}
</option>
</select></label
><button class="secondary-button compact-button" :disabled="busy" @click="saveAssignees">
保存指派
</button>
</article>
</section>
<section class="panel timeline-panel">
<h2>状态历史</h2>
<ol class="admin-timeline">
<li v-for="item in selected.history" :key="item.createdAt">
<span class="timeline-marker"></span>
<div>
<strong>{{ item.statusLabel }}</strong>
<p>{{ item.reasonLabel }}</p>
<time>{{ formatChinaDateTime(item.createdAt) }}</time>
</div>
</li>
</ol>
</section>
<section class="panel">
<h2>{{ recordForm.publicId ? '修改服务记录' : '新增服务记录' }}</h2>
<form class="form-grid" @submit.prevent="saveRecord">
<label
>开始时间<input
v-model="recordForm.serviceStartAt"
type="datetime-local"
required /></label
><label>结束时间<input v-model="recordForm.serviceEndAt" type="datetime-local" /></label
><label
>服务方式<select v-model="recordForm.serviceType">
<option v-for="type in SERVICE_RECORD_TYPES" :key="type" :value="type">
{{ SERVICE_RECORD_TYPE_LABELS[type] }}
</option>
</select></label
><label
>对象确认<select v-model="recordForm.beneficiaryConfirmStatus">
<option value="NOT_REQUESTED">未请求确认</option>
<option value="CONFIRMED">已确认</option>
<option value="PARTIALLY_CONFIRMED">部分确认</option>
<option value="DISPUTED">有异议</option>
</select></label
><label class="form-span"
>服务内容<textarea
v-model="recordForm.content"
maxlength="5000"
required
></textarea></label
><label class="form-span"
>服务结果<textarea
v-model="recordForm.result"
maxlength="5000"
required
></textarea></label
><label class="form-span"
>后续建议<textarea
v-model="recordForm.followUpSuggestion"
maxlength="2000"
></textarea></label
><label class="form-span"
>服务照片(JPEG / PNG / WebP,单张不超过 10 MiB)<input
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="busy"
@change="upload"
/></label>
<p v-if="pendingFiles.length" class="form-span">
已附加:{{ pendingFiles.map((item) => item.filename).join('、') }}
</p>
<div class="form-actions form-span">
<button
class="primary-button compact-button"
:disabled="busy || selected.status !== 'IN_SERVICE'"
>
{{ busy ? '处理中…' : recordForm.publicId ? '保存修改' : '保存记录' }}</button
><button
v-if="recordForm.publicId"
class="secondary-button compact-button"
type="button"
@click="resetRecord"
>
取消修改
</button>
</div>
</form>
</section>
<section class="panel record-list">
<h2>服务记录</h2>
<p v-if="!selected.records.length">工单开始服务后,可在上方添加记录。</p>
<article v-for="record in selected.records" :key="record.publicId" class="record-card">
<header>
<strong>{{ record.serviceTypeLabel }}</strong
><time>{{ formatChinaDateTime(record.serviceStartAt) }}</time>
</header>
<p>{{ record.content }}</p>
<p><strong>结果:</strong>{{ record.result }}</p>
<div>
<button class="link-button" @click="editRecord(record)">修改</button
><button
v-for="file in record.attachments"
:key="file.publicId"
class="link-button"
@click="download(file.publicId)"
>
查看 {{ file.filename }}
</button>
</div>
</article>
</section>
<section v-if="selected.status === 'IN_SERVICE'" class="panel record-list">
<h2>完成本阶段服务</h2>
<p class="consent-note">
完成后将同步更新申请状态并自动生成回访任务。案件至少需要一条服务记录。
</p>
<form class="form-grid" @submit.prevent="completeCase">
<label class="form-span"
>服务总结<textarea
v-model="completionForm.resultSummary"
maxlength="5000"
required
></textarea></label
><label class="form-span"
>回访计划<textarea
v-model="completionForm.followUpPlan"
maxlength="2000"
required
></textarea></label
><label
>计划回访时间<input
v-model="completionForm.followUpDueAt"
type="datetime-local"
required
/></label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '正在完成并创建回访…' : '完成服务并创建回访' }}
</button>
</div>
</form>
</section>
</template>
<template v-else>
<header class="page-header">
<div>
<h1>服务工单</h1>
<p>按工单状态和服务类型查看已授权范围内的任务。</p>
</div>
<span class="result-count">共 {{ total }} 个</span>
</header>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field"><label>工单编号</label><input v-model="filters.caseNo" /></div>
<div class="filter-field">
<label>状态</label
><select v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in SERVICE_CASE_STATUSES" :key="status" :value="status">
{{ SERVICE_CASE_STATUS_LABELS[status] }}
</option>
</select>
</div>
<div class="filter-field">
<label>服务类型</label
><select v-model="filters.category">
<option value="">全部类型</option>
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
{{ SERVICE_CATEGORY_LABELS[category] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<section v-if="loading" class="panel">
<div v-for="n in 6" :key="n" class="table-skeleton"></div>
</section>
<section v-else-if="message" class="empty-panel">
<h2>工单加载失败</h2>
<p>{{ message }}</p>
<button class="secondary-button compact-button" @click="load(page)">重试</button>
</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无符合条件的工单</h2>
<p>申请完成评估与接单后将在此生成工单。</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>
<th>负责人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>{{ item.caseNo }}</td>
<td>{{ item.beneficiaryDisplayName }}</td>
<td>{{ SERVICE_CATEGORY_LABELS[item.category] }}</td>
<td>{{ item.priorityLabel }}</td>
<td>
<span class="status-tag status-tag--info">{{ item.statusLabel }}</span>
</td>
<td>{{ item.ownerDisplayName }}</td>
<td><button class="link-button" @click="open(item.publicId)">进入工单</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,77 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { SERVICE_CATEGORY_LABELS, type StatisticsOverview } from '@lehuo/contracts';
import { fetchStatistics } from '../api/statistics';
const data = ref<StatisticsOverview | null>(null),
loading = ref(true),
message = ref('');
async function load() {
loading.value = true;
try {
data.value = await fetchStatistics();
} catch (e) {
message.value = e instanceof Error ? e.message : '统计加载失败';
} finally {
loading.value = false;
}
}
function download() {
const base = import.meta.env.VITE_API_BASE_URL as string | undefined;
if (base)
window.open(
new URL('/api/v1/admin/statistics/export.csv', base).toString(),
'_blank',
'noopener',
);
}
onMounted(load);
</script>
<template>
<main class="content-area">
<header class="page-header">
<div>
<h1>服务统计</h1>
<p>指标直接从业务事实表计算,不支持人工覆盖。</p>
</div>
<button class="primary-button compact-button" @click="download">导出脱敏汇总 CSV</button>
</header>
<p v-if="message" class="global-message">{{ message }}</p>
<section v-if="loading" class="panel">正在计算统计…</section>
<template v-else-if="data"
><section class="dashboard-grid">
<article class="dashboard-card">
<span>申请数量</span><strong>{{ data.applicationCount }}</strong>
</article>
<article class="dashboard-card">
<span>已受理</span><strong>{{ data.acceptedCount }}</strong>
</article>
<article class="dashboard-card">
<span>完成服务</span><strong>{{ data.completedServiceCount }}</strong>
</article>
<article class="dashboard-card">
<span>服务人次</span><strong>{{ data.servicePersonTimes }}</strong>
</article>
<article class="dashboard-card">
<span>服务对象</span><strong>{{ data.uniqueBeneficiaryCount }}</strong>
</article>
<article class="dashboard-card">
<span>志愿者</span><strong>{{ data.volunteerCount }}</strong>
</article>
<article class="dashboard-card">
<span>活动</span><strong>{{ data.activityCount }}</strong>
</article>
<article class="dashboard-card">
<span>志愿分钟</span><strong>{{ data.volunteerMinutes }}</strong>
</article>
</section>
<section class="panel">
<h2>按服务类别</h2>
<div v-for="item in data.byCategory" :key="item.category" class="stat-row">
<span>{{ SERVICE_CATEGORY_LABELS[item.category] }}</span
><strong>{{ item.count }}</strong>
</div>
<p>统计区间:{{ data.period.startDate }} 至 {{ data.period.endDate }}</p>
</section></template
>
</main>
</template>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
VOLUNTEER_STATUSES,
VOLUNTEER_STATUS_LABELS,
type AdminVolunteerListItem,
type VolunteerStatus,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { fetchVolunteers, reviewVolunteer } from '../api/participation';
import { formatChinaDateTime } from '../application-format';
const items = ref<AdminVolunteerListItem[]>([]),
loading = ref(true),
busy = ref(false),
message = ref('');
const page = ref(1),
total = ref(0),
pageSize = 20;
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{ status: VolunteerStatus | '' }>({ status: 'APPLIED' });
const errorText = (error: unknown) =>
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load(target = 1) {
loading.value = true;
message.value = '';
try {
const result = await fetchVolunteers({
page: target,
pageSize,
...(filters.status ? { status: filters.status } : {}),
});
items.value = result.items;
total.value = result.total;
page.value = result.page;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function review(
item: AdminVolunteerListItem,
targetStatus: 'APPROVED' | 'REJECTED' | 'SUSPENDED',
) {
const note =
targetStatus === 'APPROVED'
? '资料核验通过'
: targetStatus === 'REJECTED'
? '资料未通过审核'
: '暂停志愿者资格';
if (!window.confirm(`确认将“${item.realName}”设为${VOLUNTEER_STATUS_LABELS[targetStatus]}?`))
return;
busy.value = true;
try {
await reviewVolunteer(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
note,
});
message.value = '志愿者状态已更新并记录审计。';
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
onMounted(() => load());
</script>
<template>
<main class="content-area" tabindex="-1">
<header class="page-header">
<div>
<h1>志愿者审核</h1>
<p>核验最少必要资料,管理志愿者资格与累计服务事实。</p>
</div>
<span class="result-count">共 {{ total }} 人</span>
</header>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label for="volunteer-status">资格状态</label
><select id="volunteer-status" v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in VOLUNTEER_STATUSES" :key="status" :value="status">
{{ VOLUNTEER_STATUS_LABELS[status] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="loading" class="panel" aria-live="polite">正在加载志愿者资料…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无符合条件的志愿者</h2>
<p>新的申请会显示在这里。</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>
<th>申请时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>{{ item.realName }}</td>
<td>{{ item.mobileMasked }}</td>
<td>{{ item.regionCode }}<br />{{ item.skillLabels.join('、') || '未选择' }}</td>
<td>
<span class="status-tag">{{ item.statusLabel }}</span>
</td>
<td>{{ item.completedActivityCount }} 次 / {{ item.totalServiceMinutes }} 分钟</td>
<td>{{ formatChinaDateTime(item.createdAt) }}</td>
<td>
<div class="inline-actions">
<button
v-if="item.status === 'APPLIED'"
class="link-button"
:disabled="busy"
@click="review(item, 'APPROVED')"
>
通过</button
><button
v-if="item.status === 'APPLIED'"
class="link-button link-button--danger"
:disabled="busy"
@click="review(item, 'REJECTED')"
>
拒绝</button
><button
v-if="item.status === 'APPROVED'"
class="link-button link-button--danger"
:disabled="busy"
@click="review(item, 'SUSPENDED')"
>
暂停</button
><button
v-if="item.status === 'SUSPENDED'"
class="link-button"
:disabled="busy"
@click="review(item, 'APPROVED')"
>
恢复
</button>
</div>
</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>
</main>
</template>
+6
View File
@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent;
export default component;
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["vite/client", "vitest/globals"]
},
"include": ["src/**/*.ts", "vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue()],
});