feat: complete V1 phases 0-7
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
@@ -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 配置配套。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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()}`);
|
||||
};
|
||||
@@ -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('乐活公益');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 个字符',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
|
||||
const component: DefineComponent;
|
||||
export default component;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
Reference in New Issue
Block a user