feat: complete V1 phases 0-7

This commit is contained in:
2026-08-12 14:35:12 +08:00
commit c3dd9be674
311 changed files with 38750 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# 微信小程序
微信原生小程序 + TypeScript。Phase 1 已接入微信登录和短期会话;Phase 2–4 提供服务申请、协作任务和闭环;Phase 5 提供公开公益项目/活动、在线报名、志愿者认证、个人报名进度和服务时长。敏感申请草稿、志愿者资料和工单内容不写入本地持久化。
使用微信开发者工具导入本目录;真实 AppID 只保存在本地私有配置中,不提交仓库。本地默认 API 为 `http://localhost:3000`;提审或发布前必须把 `miniprogram/app.ts` 中的地址替换为已加入微信 request 合法域名的 HTTPS 地址,不得发布本地 HTTP 配置。
+27
View File
@@ -0,0 +1,27 @@
{
"pages": [
"pages/index/index",
"pages/application-form/application-form",
"pages/application-success/application-success",
"pages/my-applications/my-applications",
"pages/application-detail/application-detail",
"pages/assigned-cases/assigned-cases",
"pages/assigned-case-detail/assigned-case-detail",
"pages/projects/projects",
"pages/project-detail/project-detail",
"pages/activities/activities",
"pages/activity-detail/activity-detail",
"pages/volunteer-center/volunteer-center",
"pages/content/content",
"pages/content-detail/content-detail",
"pages/notifications/notifications"
],
"window": {
"backgroundColor": "#F5F5F5",
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"navigationBarTitleText": "乐活公益"
},
"sitemapLocation": "sitemap.json"
}
+6
View File
@@ -0,0 +1,6 @@
App<IAppOption>({
globalData: {
// 本地开发地址;提审/发布前必须替换为已配置微信合法域名的 HTTPS API 地址。
apiBaseUrl: 'http://localhost:3000',
},
});
+164
View File
@@ -0,0 +1,164 @@
page {
--color-primary: #ff6b35;
--color-primary-pressed: #e95a28;
--color-primary-soft: #fff1eb;
--color-success: #2f8f2f;
--color-info: #1677c8;
--color-danger: #b42318;
--color-text: #1f2329;
--color-text-secondary: #646a73;
--color-border: #e5e6eb;
--color-background: #f5f5f5;
--color-surface: #ffffff;
min-height: 100%;
color: var(--color-text);
background: var(--color-background);
font-family:
-apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei',
sans-serif;
font-size: 32rpx;
}
button {
font: inherit;
}
button::after {
border: 0;
}
.page {
min-height: 100vh;
padding: 32rpx 32rpx calc(48rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.eyebrow {
display: block;
color: var(--color-primary-pressed);
font-size: 28rpx;
font-weight: 600;
}
.primary-button,
.secondary-button {
display: flex;
min-height: 96rpx;
margin: 24rpx 0 0;
padding: 20rpx 32rpx;
align-items: center;
justify-content: center;
border-radius: 24rpx;
box-sizing: border-box;
font-size: 34rpx;
font-weight: 600;
line-height: 1.4;
}
.primary-button {
color: #ffffff;
background: var(--color-primary);
}
.primary-button:active {
background: var(--color-primary-pressed);
}
.secondary-button {
color: var(--color-primary-pressed);
background: var(--color-primary-soft);
border: 2rpx solid var(--color-primary);
}
.primary-button[disabled],
.secondary-button[disabled] {
opacity: 0.5;
}
.state-card,
.form-card,
.detail-card,
.detail-summary,
.success-card {
padding: 32rpx;
border: 2rpx solid var(--color-border);
border-radius: 32rpx;
background: var(--color-surface);
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.05);
}
.state-card {
display: flex;
min-height: 280rpx;
flex-direction: column;
align-items: stretch;
justify-content: center;
text-align: center;
}
.state-title {
font-size: 38rpx;
font-weight: 600;
}
.state-text {
display: block;
margin-top: 16rpx;
color: var(--color-text-secondary);
line-height: 1.7;
}
.skeleton-card,
.skeleton-line {
overflow: hidden;
border-radius: 20rpx;
background: linear-gradient(90deg, #f0f1f3 25%, #fafafa 50%, #f0f1f3 75%);
background-size: 200% 100%;
animation: skeleton 1.4s ease-in-out infinite;
}
.skeleton-card {
height: 160rpx;
margin-bottom: 24rpx;
}
.skeleton-line {
width: 60%;
height: 28rpx;
margin: 12rpx auto;
}
.skeleton-line--wide {
width: 84%;
height: 44rpx;
}
.status-pill {
display: inline-flex;
min-height: 48rpx;
padding: 8rpx 16rpx;
align-items: center;
border-radius: 12rpx;
color: #145ea8;
background: #eaf4ff;
font-size: 26rpx;
font-weight: 600;
line-height: 1.2;
}
.inline-error {
display: block;
margin: 20rpx 8rpx 0;
color: var(--color-danger);
font-size: 28rpx;
line-height: 1.6;
}
@keyframes skeleton {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { MINI_PROGRAM_TITLE } from './app-info';
describe('mini program information', () => {
it('uses the approved brand name', () => {
expect(MINI_PROGRAM_TITLE).toBe('乐活公益');
});
});
@@ -0,0 +1,2 @@
export const MINI_PROGRAM_TITLE = '乐活公益';
export const AUTH_READY_MESSAGE = '身份验证已完成,服务功能将在后续阶段开放。';
@@ -0,0 +1 @@
{ "navigationBarTitleText": "公益活动" }
@@ -0,0 +1,24 @@
import type { ActivityListItem } from '@lehuo/contracts';
import { fetchPublicActivities } from '../../services/participation';
Page({
data: { loading: true, message: '', projectPublicId: '', items: [] as ActivityListItem[] },
onLoad(options: Record<string, string>) {
const projectPublicId = options['projectPublicId'] ?? '';
this.setData({ projectPublicId });
void this.load();
},
async load() {
try {
const result = await fetchPublicActivities(this.data.projectPublicId || undefined);
this.setData({ items: result.items });
} catch {
this.setData({ message: '活动加载失败,请稍后重试' });
} finally {
this.setData({ loading: false });
}
},
open(event: WechatMiniprogram.BaseEvent) {
const publicId = event.currentTarget.dataset['id'] as string;
void wx.navigateTo({ url: `/pages/activity-detail/activity-detail?publicId=${publicId}` });
},
});
@@ -0,0 +1 @@
<view class="page"><view class="section-heading"><text class="section-title">公益活动</text><text class="section-note">已认证志愿者可在线报名</text></view><view wx:if="{{loading}}" class="state-card">正在加载活动…</view><view wx:elif="{{message}}" class="state-card">{{message}}</view><view wx:elif="{{!items.length}}" class="state-card">暂无可查看活动</view><button wx:for="{{items}}" wx:key="publicId" class="action-card" data-id="{{item.publicId}}" bindtap="open"><text class="action-title">{{item.title}}</text><text class="action-description">{{item.summary}}</text><text class="card-meta">{{item.statusLabel}} · 已报名 {{item.activeRegistrationCount}} / {{item.capacity === null ? '不限' : item.capacity}}</text></button></view>
@@ -0,0 +1,7 @@
@import '../../app.wxss';
.card-meta {
display: block;
margin-top: 12rpx;
color: #646a73;
font-size: 24rpx;
}
@@ -0,0 +1 @@
{ "navigationBarTitleText": "活动详情" }
@@ -0,0 +1,38 @@
import type { ActivityDetail } from '@lehuo/contracts';
import { MiniProgramApiError } from '../../services/api-client';
import { fetchPublicActivity, registerActivity } from '../../services/participation';
Page({
data: { loading: true, busy: false, message: '', activity: null as ActivityDetail | null },
onLoad(options: Record<string, string>) {
if (options['publicId']) void this.load(options['publicId']);
},
async load(publicId: string) {
try {
const result = await fetchPublicActivity(publicId);
this.setData({ activity: result.activity });
} catch {
this.setData({ message: '活动暂时无法查看,请稍后重试' });
} finally {
this.setData({ loading: false });
}
},
async register() {
const activity = this.data.activity;
if (!activity || !activity.canRegister) return;
this.setData({ busy: true, message: '' });
try {
await registerActivity(activity.publicId);
this.setData({ message: '报名已提交,请在志愿者中心查看审核进度。' });
await this.load(activity.publicId);
} catch (error) {
this.setData({
message: error instanceof MiniProgramApiError ? error.message : '报名失败,请稍后重试',
});
} finally {
this.setData({ busy: false });
}
},
volunteerCenter() {
void wx.navigateTo({ url: '/pages/volunteer-center/volunteer-center' });
},
});
@@ -0,0 +1 @@
<view class="page"><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{!activity}}" class="state-card">{{message}}</view><block wx:else><view class="brand-card"><text class="eyebrow">{{activity.statusLabel}}</text><text class="title">{{activity.title}}</text><text class="message">{{activity.summary}}</text></view><view class="notice-card"><text class="notice-title">时间与地点</text><text class="notice-text">{{activity.startAt}} 至 {{activity.endAt}}</text><text class="notice-text">{{activity.locationText}}</text><text class="notice-text">报名截止:{{activity.registrationDeadline}}</text></view><view class="notice-card"><text class="notice-title">活动介绍</text><text class="notice-text">{{activity.content}}</text></view><text class="form-message" wx:if="{{message}}">{{message}}</text><button class="primary-button" disabled="{{!activity.canRegister || busy}}" bindtap="register">{{busy ? '正在提交…' : activity.canRegister ? '立即报名' : '当前不可报名'}}</button><button class="secondary-button" bindtap="volunteerCenter">志愿者认证与我的报名</button></block></view>
@@ -0,0 +1 @@
@import '../../app.wxss';
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "服务进度"
}
@@ -0,0 +1,56 @@
import type { ServiceApplicationDetail } from '@lehuo/contracts';
import { MiniProgramApiError } from '../../services/api-client';
import { fetchServiceApplication } from '../../services/service-applications';
import { formatChinaDateTime } from '../../utils/date';
type DisplayApplication = ServiceApplicationDetail & {
history: Array<ServiceApplicationDetail['history'][number] & { createdAtText: string }>;
};
Page({
data: {
status: 'loading',
publicId: '',
errorMessage: '',
application: null as DisplayApplication | null,
},
onLoad(options: Record<string, string | undefined>) {
this.setData({ publicId: options.publicId ?? '' });
void this.loadApplication();
},
async loadApplication() {
if (!this.data.publicId) {
this.setData({ status: 'error', errorMessage: '申请信息不完整,请返回重试' });
return;
}
this.setData({ status: 'loading', errorMessage: '' });
try {
const result = await fetchServiceApplication(this.data.publicId);
this.setData({
status: 'success',
application: {
...result.application,
history: result.application.history.map((item) => ({
...item,
createdAtText: formatChinaDateTime(item.createdAt),
})),
},
});
} catch (error) {
const message =
error instanceof MiniProgramApiError && error.requestId
? `${error.message}(编号:${error.requestId})`
: error instanceof Error
? error.message
: '暂时无法读取服务进度';
this.setData({ status: 'error', errorMessage: message });
}
},
retryLoad() {
void this.loadApplication();
},
});
@@ -0,0 +1,26 @@
<view class="page detail-page">
<view wx:if="{{status === 'loading'}}" class="state-card"><view class="skeleton-card"></view><text class="state-text">正在读取服务进度…</text></view>
<view wx:elif="{{status === 'error'}}" class="state-card"><text class="state-title">暂时无法读取服务进度</text><text class="state-text">{{errorMessage}}</text><button class="secondary-button" bindtap="retryLoad">重新加载</button></view>
<block wx:else>
<view class="detail-summary">
<view><text class="eyebrow">{{application.serviceCategoryLabel}}</text><text class="detail-title">{{application.beneficiaryName}}的服务申请</text></view>
<text class="status-pill">{{application.statusLabel}}</text>
<text class="application-meta">服务编号:{{application.applicationNo}}</text>
</view>
<view class="detail-card">
<text class="form-title">申请信息</text>
<view class="summary-row"><text>申请关系</text><text>{{application.applyModeLabel}}</text></view>
<view class="summary-row"><text>联系电话</text><text>{{application.contactMobileMasked}}</text></view>
<view class="summary-row"><text>所在地区</text><text>{{application.regionCode}}</text></view>
<view class="description-summary"><text class="summary-label">需求说明</text><text>{{application.description}}</text></view>
</view>
<view class="detail-card">
<text class="form-title">服务进度</text>
<view wx:for="{{application.history}}" wx:key="createdAt" class="timeline-item">
<view class="timeline-dot"></view>
<view class="timeline-content"><text class="timeline-title">{{item.statusLabel}}</text><text class="timeline-note">{{item.reasonLabel}}</text><text class="timeline-time">{{item.createdAtText}}</text></view>
</view>
<view class="timeline-next"><view class="timeline-dot timeline-dot--muted"></view><text>工作人员会根据实际情况与您联系</text></view>
</view>
</block>
</view>
@@ -0,0 +1,108 @@
.detail-summary {
display: flex;
margin-bottom: 24rpx;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.detail-title {
display: block;
margin-top: 8rpx;
font-size: 40rpx;
font-weight: 600;
}
.application-meta {
width: 100%;
color: var(--color-text-secondary);
font-size: 27rpx;
font-variant-numeric: tabular-nums;
}
.detail-card {
margin-bottom: 24rpx;
}
.form-title {
display: block;
margin-bottom: 20rpx;
font-size: 36rpx;
font-weight: 600;
}
.summary-row {
display: flex;
min-height: 80rpx;
padding: 14rpx 0;
align-items: center;
justify-content: space-between;
gap: 24rpx;
border-top: 2rpx solid #ebedf0;
}
.summary-row text:first-child {
color: var(--color-text-secondary);
}
.description-summary {
display: flex;
padding-top: 24rpx;
flex-direction: column;
gap: 12rpx;
border-top: 2rpx solid #ebedf0;
line-height: 1.7;
}
.summary-label {
color: var(--color-text-secondary);
font-weight: 600;
}
.timeline-item,
.timeline-next {
position: relative;
display: flex;
min-height: 128rpx;
gap: 24rpx;
}
.timeline-item::before {
position: absolute;
top: 30rpx;
bottom: -8rpx;
left: 11rpx;
width: 4rpx;
content: '';
background: #ffd3c1;
}
.timeline-dot {
position: relative;
z-index: 1;
width: 26rpx;
height: 26rpx;
margin-top: 10rpx;
flex: 0 0 26rpx;
border: 6rpx solid #ffe6dc;
border-radius: 50%;
background: var(--color-primary);
box-sizing: border-box;
}
.timeline-dot--muted {
background: #aeb3bb;
border-color: #eceef1;
}
.timeline-content {
display: flex;
flex-direction: column;
}
.timeline-title {
font-weight: 600;
}
.timeline-note {
margin-top: 8rpx;
color: var(--color-text-secondary);
font-size: 28rpx;
}
.timeline-time {
margin-top: 8rpx;
color: #7b818b;
font-size: 25rpx;
font-variant-numeric: tabular-nums;
}
.timeline-next {
min-height: 72rpx;
color: var(--color-text-secondary);
font-size: 28rpx;
}
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "服务申请"
}
@@ -0,0 +1,216 @@
import type {
ApplicationApplyMode,
CreateServiceApplicationRequest,
ServiceCategory,
ServiceCategoryOption,
} from '@lehuo/contracts';
import {
APPLICATION_APPLY_MODE_LABELS,
APPLICATION_APPLY_MODES,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
} from '@lehuo/contracts';
import { MiniProgramApiError } from '../../services/api-client';
import {
createServiceApplication,
fetchServiceCategories,
} from '../../services/service-applications';
import { createClientRequestId } from '../../utils/request-id';
const TOTAL_STEPS = 5;
function isServiceCategory(value: string): value is ServiceCategory {
return (SERVICE_CATEGORIES as readonly string[]).includes(value);
}
function isApplyMode(value: string): value is ApplicationApplyMode {
return (APPLICATION_APPLY_MODES as readonly string[]).includes(value);
}
Page({
data: {
step: 1,
totalSteps: TOTAL_STEPS,
loading: true,
submitting: false,
errorMessage: '',
categories: [] as ServiceCategoryOption[],
modeOptions: APPLICATION_APPLY_MODES.map((code) => ({
code,
label: APPLICATION_APPLY_MODE_LABELS[code],
})),
clientRequestId: createClientRequestId(),
serviceCategory: '',
applyMode: '',
beneficiaryName: '',
contactMobile: '',
regionCode: '',
description: '',
preferredContactTime: '',
needsHomeVisit: false,
mobilityDifficulty: false,
privacyConsent: false,
serviceCategoryLabel: '',
applyModeLabel: '',
contactMobileMasked: '',
},
onLoad(options: Record<string, string | undefined>) {
if (options.mode && APPLICATION_APPLY_MODES.includes(options.mode as ApplicationApplyMode)) {
const mode = options.mode as ApplicationApplyMode;
this.setData({ applyMode: mode, applyModeLabel: APPLICATION_APPLY_MODE_LABELS[mode] });
}
void this.loadCategories();
},
async loadCategories() {
this.setData({ loading: true, errorMessage: '' });
try {
const result = await fetchServiceCategories();
this.setData({ categories: result.items, loading: false });
} catch (error) {
this.setData({ loading: false, errorMessage: this.errorText(error) });
}
},
retryLoad() {
void this.loadCategories();
},
selectCategory(event: WechatMiniprogram.TouchEvent) {
const code = event.currentTarget.dataset.code as ServiceCategory;
this.setData({
serviceCategory: code,
serviceCategoryLabel: SERVICE_CATEGORY_LABELS[code],
clientRequestId: createClientRequestId(),
errorMessage: '',
});
},
selectMode(event: WechatMiniprogram.TouchEvent) {
const code = event.currentTarget.dataset.code as ApplicationApplyMode;
this.setData({
applyMode: code,
applyModeLabel: APPLICATION_APPLY_MODE_LABELS[code],
clientRequestId: createClientRequestId(),
errorMessage: '',
});
},
updateBeneficiaryName(event: WechatMiniprogram.Input) {
this.updateField({ beneficiaryName: event.detail.value });
},
updateContactMobile(event: WechatMiniprogram.Input) {
this.updateField({ contactMobile: event.detail.value });
},
updateRegionCode(event: WechatMiniprogram.Input) {
this.updateField({ regionCode: event.detail.value });
},
updateDescription(event: WechatMiniprogram.Input) {
this.updateField({ description: event.detail.value });
},
updatePreferredContactTime(event: WechatMiniprogram.Input) {
this.updateField({ preferredContactTime: event.detail.value });
},
updateNeedsHomeVisit(event: WechatMiniprogram.SwitchChange) {
this.updateField({ needsHomeVisit: event.detail.value });
},
updateMobilityDifficulty(event: WechatMiniprogram.SwitchChange) {
this.updateField({ mobilityDifficulty: event.detail.value });
},
updatePrivacyConsent(event: WechatMiniprogram.CheckboxGroupChange) {
this.setData({ privacyConsent: event.detail.value.includes('accepted'), errorMessage: '' });
},
nextStep() {
const message = this.validateStep(this.data.step);
if (message) {
this.setData({ errorMessage: message });
return;
}
const next = Math.min(TOTAL_STEPS, this.data.step + 1);
this.setData({
step: next,
errorMessage: '',
contactMobileMasked: this.data.contactMobile.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'),
});
},
previousStep() {
this.setData({ step: Math.max(1, this.data.step - 1), errorMessage: '' });
},
async submitApplication() {
if (!this.data.privacyConsent) {
this.setData({ errorMessage: '请阅读并同意隐私授权后再提交' });
return;
}
if (
!isServiceCategory(this.data.serviceCategory) ||
!isApplyMode(this.data.applyMode) ||
this.data.submitting
)
return;
this.setData({ submitting: true, errorMessage: '' });
const input: CreateServiceApplicationRequest = {
clientRequestId: this.data.clientRequestId,
serviceCategory: this.data.serviceCategory,
applyMode: this.data.applyMode,
beneficiaryName: this.data.beneficiaryName.trim(),
contactMobile: this.data.contactMobile.trim(),
regionCode: this.data.regionCode.trim(),
description: this.data.description.trim(),
...(this.data.preferredContactTime.trim()
? { preferredContactTime: this.data.preferredContactTime.trim() }
: {}),
needsHomeVisit: this.data.needsHomeVisit,
mobilityDifficulty: this.data.mobilityDifficulty,
privacyConsent: true,
};
try {
const result = await createServiceApplication(input);
void wx.redirectTo({
url: `/pages/application-success/application-success?publicId=${encodeURIComponent(result.application.publicId)}`,
});
} catch (error) {
this.setData({ submitting: false, errorMessage: this.errorText(error) });
}
},
updateField(values: Record<string, string | boolean>) {
this.setData({
...values,
clientRequestId: createClientRequestId(),
errorMessage: '',
});
},
validateStep(step: number): string {
if (step === 1 && !this.data.serviceCategory) return '请选择需要的服务类型';
if (step === 2 && !this.data.applyMode) return '请选择您与服务对象的关系';
if (step === 3) {
if (!this.data.beneficiaryName.trim()) return '请填写服务对象姓名或称呼';
if (!/^1[3-9]\d{9}$/.test(this.data.contactMobile.trim())) return '请填写正确的手机号码';
if (this.data.regionCode.trim().length < 2) return '请填写所在市、区或街道';
}
if (step === 4 && this.data.description.trim().length < 10) {
return '请至少用 10 个字描述目前的困难或希望得到的帮助';
}
return '';
},
errorText(error: unknown): string {
if (error instanceof MiniProgramApiError) {
return `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`;
}
return error instanceof Error ? error.message : '提交失败,请稍后重试,已填写内容不会清空';
},
});
@@ -0,0 +1,111 @@
<view class="page application-page">
<view class="progress-card" aria-label="申请步骤">
<view class="progress-meta">
<text>第 {{step}} 步,共 {{totalSteps}} 步</text>
<text>{{step === 1 ? '选择服务' : step === 2 ? '申请关系' : step === 3 ? '联系方式' : step === 4 ? '描述需求' : '确认提交'}}</text>
</view>
<view class="progress-track"><view class="progress-value step-{{step}}"></view></view>
</view>
<view wx:if="{{loading}}" class="form-card" aria-live="polite">
<view class="skeleton-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view>
<text class="state-text">正在读取服务类型…</text>
</view>
<view wx:elif="{{loading === false && categories.length === 0}}" class="state-card">
<text class="state-title">暂时无法读取服务类型</text>
<text class="state-text">{{errorMessage}}</text>
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
</view>
<block wx:else>
<view wx:if="{{step === 1}}" class="form-card">
<text class="form-title">需要什么帮助?</text>
<text class="form-hint">请选择最接近的一项,工作人员后续会进一步了解。</text>
<button
wx:for="{{categories}}"
wx:key="code"
class="selection-card {{serviceCategory === item.code ? 'selection-card--selected' : ''}}"
data-code="{{item.code}}"
bindtap="selectCategory"
aria-label="{{item.label}}:{{item.description}}"
>
<view class="selection-content">
<text class="selection-title">{{item.label}}</text>
<text class="selection-description">{{item.description}}</text>
</view>
<text class="selection-status">{{serviceCategory === item.code ? '已选择' : '选择'}}</text>
</button>
</view>
<view wx:elif="{{step === 2}}" class="form-card">
<text class="form-title">帮谁申请?</text>
<text class="form-hint">请选择您与服务对象的关系。</text>
<button
wx:for="{{modeOptions}}"
wx:key="code"
class="selection-card selection-card--simple {{applyMode === item.code ? 'selection-card--selected' : ''}}"
data-code="{{item.code}}"
bindtap="selectMode"
>
<text class="selection-title">{{item.label}}</text>
<text class="selection-status">{{applyMode === item.code ? '已选择' : '选择'}}</text>
</button>
</view>
<view wx:elif="{{step === 3}}" class="form-card">
<text class="form-title">留下联系方式</text>
<text class="form-hint">只填写联系服务所必需的信息,不需要精确门牌地址。</text>
<text class="field-label">服务对象姓名或称呼 *</text>
<input class="field-input" value="{{beneficiaryName}}" maxlength="50" placeholder="例如:王阿姨" bindinput="updateBeneficiaryName" />
<text class="field-label">联系电话 *</text>
<input class="field-input" value="{{contactMobile}}" type="number" maxlength="11" placeholder="请输入 11 位手机号码" bindinput="updateContactMobile" />
<text class="field-label">所在地区 *</text>
<input class="field-input" value="{{regionCode}}" maxlength="100" placeholder="例如:丹阳市开发区" bindinput="updateRegionCode" />
<text class="field-label">期望联系时间(可选)</text>
<input class="field-input" value="{{preferredContactTime}}" maxlength="100" placeholder="例如:工作日上午" bindinput="updatePreferredContactTime" />
</view>
<view wx:elif="{{step === 4}}" class="form-card">
<text class="form-title">简单描述需要的帮助</text>
<text class="form-hint">请勿填写身份证号码、银行卡号或精确门牌地址。</text>
<textarea class="field-textarea" value="{{description}}" maxlength="1000" placeholder="请至少用 10 个字描述目前遇到的困难或希望得到的帮助" bindinput="updateDescription"></textarea>
<text class="character-count">{{description.length}} / 1000</text>
<view class="switch-row">
<view><text class="switch-title">是否希望上门服务</text><text class="switch-hint">工作人员会根据情况确认</text></view>
<switch color="#FF6B35" checked="{{needsHomeVisit}}" bindchange="updateNeedsHomeVisit" />
</view>
<view class="switch-row">
<view><text class="switch-title">行动是否不方便</text><text class="switch-hint">帮助工作人员做好联系准备</text></view>
<switch color="#FF6B35" checked="{{mobilityDifficulty}}" bindchange="updateMobilityDifficulty" />
</view>
<view class="emergency-note">如遇危及生命安全的紧急情况,请立即联系 110 或 120。</view>
</view>
<view wx:else class="form-card">
<text class="form-title">确认申请信息</text>
<text class="form-hint">提交前请确认,联系方式仅供授权工作人员提供服务使用。</text>
<view class="summary-list">
<view class="summary-row"><text>服务类型</text><text>{{serviceCategoryLabel}}</text></view>
<view class="summary-row"><text>申请关系</text><text>{{applyModeLabel}}</text></view>
<view class="summary-row"><text>服务对象</text><text>{{beneficiaryName}}</text></view>
<view class="summary-row"><text>联系电话</text><text>{{contactMobileMasked}}</text></view>
<view class="summary-row"><text>所在地区</text><text>{{regionCode}}</text></view>
</view>
<view class="description-summary"><text class="summary-label">需求说明</text><text>{{description}}</text></view>
<checkbox-group bindchange="updatePrivacyConsent">
<label class="consent-row">
<checkbox value="accepted" checked="{{privacyConsent}}" color="#FF6B35" />
<text>我已阅读并同意本次服务申请的隐私授权,确认所填信息用于公益服务联系、评估和必要留痕。</text>
</label>
</checkbox-group>
</view>
<text wx:if="{{errorMessage}}" class="inline-error" aria-live="assertive">{{errorMessage}}</text>
<view class="form-actions">
<button wx:if="{{step > 1}}" class="secondary-button action-button" disabled="{{submitting}}" bindtap="previousStep">上一步</button>
<button wx:if="{{step < totalSteps}}" class="primary-button action-button" bindtap="nextStep">下一步</button>
<button wx:else class="primary-button action-button" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submitApplication">{{submitting ? '正在提交…' : '提交申请'}}</button>
</view>
</block>
</view>
@@ -0,0 +1,237 @@
.progress-card {
margin-bottom: 24rpx;
padding: 24rpx 28rpx;
border-radius: 24rpx;
background: #ffffff;
}
.progress-meta {
display: flex;
margin-bottom: 16rpx;
justify-content: space-between;
color: var(--color-text-secondary);
font-size: 26rpx;
}
.progress-track {
height: 12rpx;
overflow: hidden;
border-radius: 999rpx;
background: #eceef1;
}
.progress-value {
height: 100%;
border-radius: inherit;
background: var(--color-primary);
transition: width 180ms ease;
}
.step-1 {
width: 20%;
}
.step-2 {
width: 40%;
}
.step-3 {
width: 60%;
}
.step-4 {
width: 80%;
}
.step-5 {
width: 100%;
}
.form-title,
.form-hint {
display: block;
}
.form-title {
font-size: 40rpx;
font-weight: 600;
}
.form-hint {
margin: 12rpx 0 28rpx;
color: var(--color-text-secondary);
font-size: 28rpx;
line-height: 1.7;
}
.selection-card {
display: flex;
width: 100%;
min-height: 128rpx;
margin: 0 0 20rpx;
padding: 24rpx;
align-items: center;
gap: 20rpx;
border: 2rpx solid var(--color-border);
border-radius: 24rpx;
color: var(--color-text);
background: #ffffff;
text-align: left;
}
.selection-card--selected {
border-color: var(--color-primary);
background: var(--color-primary-soft);
}
.selection-card--simple {
min-height: 96rpx;
}
.selection-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.selection-title {
font-size: 34rpx;
font-weight: 600;
}
.selection-description {
margin-top: 6rpx;
color: var(--color-text-secondary);
font-size: 27rpx;
line-height: 1.5;
}
.selection-status {
color: var(--color-primary-pressed);
font-size: 26rpx;
}
.field-label {
display: block;
margin: 28rpx 0 12rpx;
font-weight: 600;
}
.field-input,
.field-textarea {
width: 100%;
border: 2rpx solid #cfd2d8;
border-radius: 20rpx;
background: #ffffff;
box-sizing: border-box;
font-size: 32rpx;
}
.field-input {
min-height: 96rpx;
padding: 20rpx 24rpx;
}
.field-textarea {
height: 280rpx;
padding: 24rpx;
line-height: 1.7;
}
.character-count {
display: block;
margin-top: 8rpx;
color: var(--color-text-secondary);
font-size: 26rpx;
text-align: right;
}
.switch-row {
display: flex;
min-height: 112rpx;
margin-top: 20rpx;
padding: 20rpx 0;
align-items: center;
justify-content: space-between;
border-top: 2rpx solid #ebedf0;
}
.switch-title,
.switch-hint {
display: block;
}
.switch-title {
font-weight: 600;
}
.switch-hint {
margin-top: 6rpx;
color: var(--color-text-secondary);
font-size: 26rpx;
}
.emergency-note {
margin-top: 24rpx;
padding: 20rpx;
border-radius: 16rpx;
color: #704c00;
background: #fff6d8;
font-size: 28rpx;
line-height: 1.6;
}
.summary-list {
border-top: 2rpx solid #ebedf0;
}
.summary-row {
display: flex;
min-height: 88rpx;
padding: 16rpx 0;
align-items: center;
justify-content: space-between;
gap: 24rpx;
border-bottom: 2rpx solid #ebedf0;
}
.summary-row text:first-child {
color: var(--color-text-secondary);
}
.summary-row text:last-child {
text-align: right;
}
.description-summary {
display: flex;
margin-top: 24rpx;
flex-direction: column;
gap: 12rpx;
line-height: 1.7;
}
.summary-label {
color: var(--color-text-secondary);
font-weight: 600;
}
.consent-row {
display: flex;
min-height: 96rpx;
margin-top: 28rpx;
align-items: flex-start;
gap: 16rpx;
font-size: 28rpx;
line-height: 1.65;
}
.form-actions {
display: flex;
gap: 20rpx;
}
.action-button {
flex: 1;
}
@media (prefers-reduced-motion: reduce) {
.progress-value {
transition: none;
}
}
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "提交成功"
}
@@ -0,0 +1,61 @@
import type { ServiceApplicationDetail } from '@lehuo/contracts';
import { MiniProgramApiError } from '../../services/api-client';
import { fetchServiceApplication } from '../../services/service-applications';
import { formatChinaDateTime } from '../../utils/date';
type DisplayApplication = ServiceApplicationDetail & { submittedAtText: string };
Page({
data: {
loading: true,
errorMessage: '',
publicId: '',
application: null as DisplayApplication | null,
},
onLoad(options: Record<string, string | undefined>) {
this.setData({ publicId: options.publicId ?? '' });
void this.loadApplication();
},
async loadApplication() {
if (!this.data.publicId) {
this.setData({ loading: false, errorMessage: '申请信息不完整,请前往“我的申请”查看' });
return;
}
this.setData({ loading: true, errorMessage: '' });
try {
const result = await fetchServiceApplication(this.data.publicId);
this.setData({
application: {
...result.application,
submittedAtText: formatChinaDateTime(result.application.submittedAt),
},
loading: false,
});
} catch (error) {
const message =
error instanceof MiniProgramApiError && error.requestId
? `${error.message}(编号:${error.requestId})`
: error instanceof Error
? error.message
: '暂时无法读取申请信息';
this.setData({ loading: false, errorMessage: message });
}
},
retryLoad() {
void this.loadApplication();
},
viewProgress() {
void wx.redirectTo({
url: `/pages/application-detail/application-detail?publicId=${encodeURIComponent(this.data.publicId)}`,
});
},
backHome() {
void wx.reLaunch({ url: '/pages/index/index' });
},
});
@@ -0,0 +1,19 @@
<view class="page success-page">
<view wx:if="{{loading}}" class="state-card"><view class="skeleton-card"></view><text class="state-text">正在确认提交结果…</text></view>
<view wx:elif="{{errorMessage}}" class="state-card">
<text class="state-title">暂时无法读取申请结果</text><text class="state-text">{{errorMessage}}</text>
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
</view>
<view wx:else class="success-card">
<view class="success-mark" aria-hidden="true">✓</view>
<text class="success-title">申请已提交</text>
<text class="success-message">乐活公益工作人员将根据实际情况与您联系,请留意来电。</text>
<view class="result-list">
<view><text>服务编号</text><text class="result-value">{{application.applicationNo}}</text></view>
<view><text>提交时间</text><text class="result-value">{{application.submittedAtText}}</text></view>
<view><text>当前状态</text><text class="status-pill">{{application.statusLabel}}</text></view>
</view>
<button class="primary-button" bindtap="viewProgress">查看服务进度</button>
<button class="secondary-button" bindtap="backHome">返回首页</button>
</view>
</view>
@@ -0,0 +1,47 @@
.success-card {
display: flex;
flex-direction: column;
align-items: stretch;
text-align: center;
}
.success-mark {
display: flex;
width: 112rpx;
height: 112rpx;
margin: 12rpx auto 24rpx;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #ffffff;
background: var(--color-success);
font-size: 64rpx;
font-weight: 600;
}
.success-title {
font-size: 44rpx;
font-weight: 600;
}
.success-message {
margin-top: 16rpx;
color: var(--color-text-secondary);
line-height: 1.7;
}
.result-list {
margin: 32rpx 0 8rpx;
text-align: left;
}
.result-list > view {
display: flex;
min-height: 88rpx;
padding: 16rpx 0;
align-items: center;
justify-content: space-between;
gap: 20rpx;
border-top: 2rpx solid #ebedf0;
}
.result-value {
text-align: right;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "协作任务详情"
}
@@ -0,0 +1,128 @@
import {
SERVICE_RECORD_TYPES,
SERVICE_RECORD_TYPE_LABELS,
type ServiceCaseDetail,
} from '@lehuo/contracts';
import { MiniProgramApiError } from '../../services/api-client';
import {
createAssignedCaseRecord,
fetchAssignedCase,
fetchAssignedFileUrl,
} from '../../services/assigned-cases';
import { formatChinaDateTime } from '../../utils/date';
import { createClientRequestId } from '../../utils/request-id';
const recordTypes = SERVICE_RECORD_TYPES.map((value) => ({
value,
label: SERVICE_RECORD_TYPE_LABELS[value],
}));
Page({
data: {
publicId: '',
state: 'loading',
message: '',
serviceCase: null as ServiceCaseDetail | null,
records: [] as Array<Record<string, unknown>>,
recordTypes,
recordTypeIndex: 0,
content: '',
result: '',
followUpSuggestion: '',
submitting: false,
},
onLoad(options: Record<string, string | undefined>) {
this.setData({ publicId: options['id'] ?? '' });
void this.load();
},
async load() {
if (!this.data.publicId) {
this.setData({ state: 'error', message: '任务标识无效,请返回重试' });
return;
}
this.setData({ state: 'loading', message: '' });
try {
const result = await fetchAssignedCase(this.data.publicId);
this.setData({
state: 'success',
serviceCase: result.serviceCase,
records: result.serviceCase.records.map((record) => ({
...record,
serviceTimeLabel: formatChinaDateTime(record.serviceStartAt),
})),
});
} catch (error) {
this.setData({ state: 'error', message: this.errorMessage(error) });
}
},
errorMessage(error: unknown) {
return error instanceof MiniProgramApiError && error.requestId
? `${error.message}(编号:${error.requestId})`
: error instanceof Error
? error.message
: '操作失败,请稍后重试';
},
changeRecordType(event: WechatMiniprogram.PickerChange) {
this.setData({ recordTypeIndex: Number(event.detail.value) });
},
inputContent(event: WechatMiniprogram.Input) {
this.setData({ content: event.detail.value });
},
inputResult(event: WechatMiniprogram.Input) {
this.setData({ result: event.detail.value });
},
inputFollowUp(event: WechatMiniprogram.Input) {
this.setData({ followUpSuggestion: event.detail.value });
},
async submitRecord() {
const serviceCase = this.data.serviceCase;
if (!serviceCase || serviceCase.status !== 'IN_SERVICE') return;
if (this.data.content.trim().length < 2 || this.data.result.trim().length < 2) {
this.setData({ message: '请填写完整的服务内容和服务结果。' });
return;
}
this.setData({ submitting: true, message: '' });
try {
const selectedType = recordTypes[this.data.recordTypeIndex]?.value ?? 'OTHER';
await createAssignedCaseRecord(serviceCase.publicId, {
clientRequestId: createClientRequestId(),
serviceStartAt: new Date().toISOString(),
serviceType: selectedType,
content: this.data.content.trim(),
result: this.data.result.trim(),
...(this.data.followUpSuggestion.trim()
? { followUpSuggestion: this.data.followUpSuggestion.trim() }
: {}),
beneficiaryConfirmStatus: 'NOT_REQUESTED',
attachmentPublicIds: [],
});
this.setData({
content: '',
result: '',
followUpSuggestion: '',
message: '服务记录已安全保存。',
});
await this.load();
} catch (error) {
this.setData({ message: this.errorMessage(error) });
} finally {
this.setData({ submitting: false });
}
},
async previewFile(event: WechatMiniprogram.BaseEvent) {
const publicId = event.currentTarget.dataset['id'] as string;
try {
const result = await fetchAssignedFileUrl(publicId);
await wx.previewImage({ urls: [result.downloadUrl], current: result.downloadUrl });
} catch (error) {
this.setData({ message: this.errorMessage(error) });
}
},
});
@@ -0,0 +1,12 @@
<view class="page">
<block wx:if="{{state === 'loading'}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block>
<view wx:elif="{{state === 'error'}}" class="state-card"><text class="state-title">任务加载失败</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view>
<block wx:else>
<view class="detail-summary"><view><text class="eyebrow">{{serviceCase.caseNo}}</text><text class="detail-title">{{serviceCase.beneficiaryDisplayName}}</text></view><text class="status-pill">{{serviceCase.statusLabel}}</text><text class="application-meta">负责人:{{serviceCase.ownerDisplayName}}</text></view>
<view class="privacy-note">只展示完成本次服务所需的最少信息。请勿转发、截图传播或用于其他目的;本次查看已记录审计。</view>
<view class="detail-card"><text class="form-title">服务安排</text><view class="summary-row"><text>所在地区</text><text>{{serviceCase.regionCode}}</text></view><view class="summary-row"><text>联系电话</text><text>{{serviceCase.beneficiaryMobileMasked || '未留存'}}</text></view><view class="description-summary"><text class="summary-label">服务计划</text><text>{{serviceCase.summary}}</text></view></view>
<view wx:if="{{serviceCase.status === 'ASSIGNED'}}" class="notice-card"><text class="notice-title">等待负责人开始服务</text><text class="notice-text">工单开始后,您可以填写本次服务记录。</text></view>
<view wx:else class="form-card"><text class="form-title">记录本次服务</text><label class="field-label">服务方式</label><picker range="{{recordTypes}}" range-key="label" value="{{recordTypeIndex}}" bindchange="changeRecordType"><view class="picker-field">{{recordTypes[recordTypeIndex].label}} <text>⌄</text></view></picker><label class="field-label">服务内容</label><textarea value="{{content}}" maxlength="5000" placeholder="说明本次实际提供的服务" bindinput="inputContent"></textarea><label class="field-label">服务结果</label><textarea value="{{result}}" maxlength="5000" placeholder="说明完成情况与服务对象反馈" bindinput="inputResult"></textarea><label class="field-label">后续建议(选填)</label><textarea value="{{followUpSuggestion}}" maxlength="2000" placeholder="如需后续跟进,请在此说明" bindinput="inputFollowUp"></textarea><text wx:if="{{message}}" class="inline-error">{{message}}</text><button class="primary-button" disabled="{{submitting}}" bindtap="submitRecord">{{submitting ? '保存中…' : '保存服务记录'}}</button></view>
<view class="detail-card records"><text class="form-title">历史服务记录</text><text wx:if="{{!records.length}}" class="empty-note">暂无服务记录</text><view wx:for="{{records}}" wx:key="publicId" class="record"><view class="record-top"><text class="record-type">{{item.serviceTypeLabel}}</text><text class="record-time">{{item.serviceTimeLabel}}</text></view><text class="record-text">{{item.content}}</text><text class="record-result">结果:{{item.result}}</text><button wx:for="{{item.attachments}}" wx:for-item="file" wx:key="publicId" class="file-link" data-id="{{file.publicId}}" bindtap="previewFile">查看服务照片:{{file.filename}}</button></view></view>
</block>
</view>
@@ -0,0 +1,147 @@
.privacy-note {
margin-bottom: 24rpx;
padding: 24rpx;
border-left: 8rpx solid #faad14;
border-radius: 20rpx;
color: #654f17;
background: #fff9e8;
font-size: 27rpx;
line-height: 1.7;
}
.field-label {
display: block;
margin: 24rpx 0 12rpx;
font-weight: 600;
}
.picker-field,
textarea {
width: 100%;
min-height: 88rpx;
padding: 20rpx 24rpx;
border: 2rpx solid var(--color-border);
border-radius: 20rpx;
background: #fff;
box-sizing: border-box;
}
.picker-field {
display: flex;
align-items: center;
justify-content: space-between;
}
textarea {
min-height: 200rpx;
line-height: 1.6;
}
.records {
margin-top: 24rpx;
}
.empty-note {
color: var(--color-text-secondary);
}
.record {
padding: 24rpx 0;
border-top: 2rpx solid var(--color-border);
}
.record-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.record-type {
font-weight: 600;
}
.record-time {
color: var(--color-text-secondary);
font-size: 25rpx;
}
.record-text,
.record-result {
display: block;
margin-top: 14rpx;
line-height: 1.65;
}
.record-result {
color: var(--color-text-secondary);
}
.file-link {
min-height: 80rpx;
margin: 14rpx 0 0;
padding: 12rpx 0;
color: #145ea8;
background: transparent;
font-size: 28rpx;
text-align: left;
}
.notice-card {
margin-bottom: 24rpx;
padding: 28rpx;
border-left: 8rpx solid #faad14;
border-radius: 20rpx;
background: #fff9e8;
}
.notice-title,
.notice-text {
display: block;
}
.notice-title {
font-weight: 600;
}
.notice-text {
margin-top: 8rpx;
color: #654f17;
font-size: 28rpx;
line-height: 1.7;
}
.detail-summary {
display: flex;
margin-bottom: 24rpx;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.detail-title {
display: block;
margin-top: 8rpx;
font-size: 40rpx;
font-weight: 600;
}
.application-meta {
width: 100%;
color: var(--color-text-secondary);
font-size: 27rpx;
}
.detail-card {
margin-bottom: 24rpx;
}
.form-title {
display: block;
margin-bottom: 20rpx;
font-size: 36rpx;
font-weight: 600;
}
.summary-row {
display: flex;
min-height: 80rpx;
padding: 14rpx 0;
align-items: center;
justify-content: space-between;
gap: 24rpx;
border-top: 2rpx solid #ebedf0;
}
.summary-row text:first-child {
color: var(--color-text-secondary);
}
.description-summary {
display: flex;
padding-top: 24rpx;
flex-direction: column;
gap: 12rpx;
border-top: 2rpx solid #ebedf0;
line-height: 1.7;
}
.summary-label {
color: var(--color-text-secondary);
font-weight: 600;
}
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "我的协作任务"
}
@@ -0,0 +1,53 @@
import type { ServiceCaseListItem, ServiceCaseStatus } from '@lehuo/contracts';
import { MiniProgramApiError } from '../../services/api-client';
import { fetchAssignedCases } from '../../services/assigned-cases';
import { formatChinaDateTime } from '../../utils/date';
const statuses: Array<{ value: '' | ServiceCaseStatus; label: string }> = [
{ value: '', label: '全部任务' },
{ value: 'ASSIGNED', label: '待开始' },
{ value: 'IN_SERVICE', label: '服务中' },
];
Page({
data: {
state: 'loading',
message: '',
items: [] as ServiceCaseListItem[],
statuses,
statusIndex: 0,
},
onShow() {
void this.load();
},
async load() {
this.setData({ state: 'loading', message: '' });
try {
const selected = statuses[this.data.statusIndex];
const result = await fetchAssignedCases(selected?.value || undefined);
this.setData({ state: result.items.length ? 'success' : 'empty', items: result.items });
} catch (error) {
const message =
error instanceof MiniProgramApiError && error.requestId
? `${error.message}(编号:${error.requestId})`
: error instanceof Error
? error.message
: '任务加载失败';
this.setData({ state: 'error', message });
}
},
changeStatus(event: WechatMiniprogram.PickerChange) {
this.setData({ statusIndex: Number(event.detail.value) });
void this.load();
},
openCase(event: WechatMiniprogram.BaseEvent) {
const publicId = event.currentTarget.dataset['id'] as string;
void wx.navigateTo({
url: `/pages/assigned-case-detail/assigned-case-detail?id=${encodeURIComponent(publicId)}`,
});
},
formatDate(value: string) {
return formatChinaDateTime(value);
},
});
@@ -0,0 +1,8 @@
<view class="page">
<view class="page-heading"><text class="page-title">我的协作任务</text><text class="page-subtitle">仅展示已指派给您的必要服务信息。</text></view>
<picker range="{{statuses}}" range-key="label" value="{{statusIndex}}" bindchange="changeStatus"><view class="filter-picker">筛选:{{statuses[statusIndex].label}} <text>⌄</text></view></picker>
<block wx:if="{{state === 'loading'}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block>
<view wx:elif="{{state === 'error'}}" class="state-card"><text class="state-title">任务加载失败</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view>
<view wx:elif="{{state === 'empty'}}" class="state-card"><text class="state-title">暂无协作任务</text><text class="state-text">当工作人员将服务工单指派给您后,会显示在这里。</text></view>
<button wx:else wx:for="{{items}}" wx:key="publicId" class="case-card" data-id="{{item.publicId}}" bindtap="openCase"><view class="case-top"><text class="case-no">{{item.caseNo}}</text><text class="status-pill">{{item.statusLabel}}</text></view><text class="case-name">{{item.beneficiaryDisplayName}}</text><text class="case-meta">{{item.priorityLabel}}优先级 · 负责人 {{item.ownerDisplayName}}</text><text class="case-link">查看服务任务 ›</text></button>
</view>
@@ -0,0 +1,75 @@
.page-heading {
margin: 8rpx 0 24rpx;
}
.page-title,
.page-subtitle,
.case-name,
.case-meta,
.case-link {
display: block;
}
.page-title {
font-size: 44rpx;
font-weight: 600;
}
.page-subtitle {
margin-top: 10rpx;
color: var(--color-text-secondary);
font-size: 28rpx;
line-height: 1.6;
}
.filter-picker {
display: flex;
min-height: 88rpx;
margin-bottom: 24rpx;
padding: 16rpx 24rpx;
align-items: center;
justify-content: space-between;
border: 2rpx solid var(--color-border);
border-radius: 20rpx;
background: #fff;
box-sizing: border-box;
}
.case-card {
display: flex;
width: 100%;
margin: 0 0 24rpx;
padding: 28rpx;
flex-direction: column;
align-items: stretch;
border: 2rpx solid var(--color-border);
border-radius: 28rpx;
color: var(--color-text);
background: #fff;
text-align: left;
}
.case-card:active {
opacity: 0.72;
}
.case-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.case-no {
color: var(--color-primary-pressed);
font-size: 27rpx;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.case-name {
margin-top: 22rpx;
font-size: 36rpx;
font-weight: 600;
}
.case-meta {
margin-top: 10rpx;
color: var(--color-text-secondary);
font-size: 27rpx;
}
.case-link {
margin-top: 20rpx;
color: #145ea8;
font-weight: 600;
}
@@ -0,0 +1 @@
{ "navigationBarTitleText": "内容详情" }
@@ -0,0 +1,22 @@
import type { ContentPostDetail } from '@lehuo/contracts';
import { fetchPublicContentDetail } from '../../services/content-notifications';
Page({
data: { loading: true, message: '', post: null as ContentPostDetail | null },
onLoad(o) {
const id = o['publicId'];
if (!id) {
this.setData({ loading: false, message: '内容参数无效' });
return;
}
void this.load(id);
},
async load(id: string) {
try {
this.setData({ post: (await fetchPublicContentDetail(id)).post });
} catch (e) {
this.setData({ message: e instanceof Error ? e.message : '内容加载失败' });
} finally {
this.setData({ loading: false });
}
},
});
@@ -0,0 +1 @@
<view class="page"><view wx:if="{{loading}}" class="state-card">加载中…</view><view wx:elif="{{message}}" class="state-card">{{message}}</view><article wx:else class="detail-card"><text class="detail-title">{{post.title}}</text><text class="detail-summary">{{post.summary}}</text><text class="detail-content" selectable>{{post.content}}</text><text class="detail-date">发布时间:{{post.publishedAt}}</text></article></view>
@@ -0,0 +1,31 @@
@import '../../app.wxss';
.detail-card {
padding: 36rpx;
background: #fff;
border-radius: 24rpx;
}
.detail-title,
.detail-summary,
.detail-content,
.detail-date {
display: block;
}
.detail-title {
font-size: 40rpx;
font-weight: 700;
color: #25282d;
}
.detail-summary {
margin-top: 24rpx;
color: #646a73;
}
.detail-content {
margin-top: 36rpx;
white-space: pre-wrap;
line-height: 1.8;
}
.detail-date {
margin-top: 36rpx;
color: #8b9098;
font-size: 24rpx;
}
@@ -0,0 +1 @@
{ "navigationBarTitleText": "公益资讯" }
@@ -0,0 +1,30 @@
import type { ContentPostListItem } from '@lehuo/contracts';
import { fetchPublicContent } from '../../services/content-notifications';
Page({
data: {
loading: true,
message: '',
type: '',
items: [] as ContentPostListItem[],
},
onLoad(options) {
this.setData({ type: options['type'] ?? '' });
void this.load();
},
async load() {
this.setData({ loading: true, message: '' });
try {
const r = await fetchPublicContent(this.data.type || undefined);
this.setData({ items: r.items });
} catch (e) {
this.setData({ message: e instanceof Error ? e.message : '内容加载失败' });
} finally {
this.setData({ loading: false });
}
},
open(e: WechatMiniprogram.BaseEvent) {
void wx.navigateTo({
url: `/pages/content-detail/content-detail?publicId=${String(e.currentTarget.dataset['id'])}`,
});
},
});
@@ -0,0 +1 @@
<view class="page"><view class="section-heading"><text class="section-title">公益资讯</text><text class="section-note">所有内容均经审核后发布</text></view><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{message}}" class="state-card"><text>{{message}}</text><button class="secondary-button" bindtap="load">重试</button></view><view wx:elif="{{!items.length}}" class="state-card">暂无已发布内容</view><button wx:for="{{items}}" wx:key="publicId" class="action-card" data-id="{{item.publicId}}" bindtap="open"><text class="action-title">{{item.title}}</text><text class="action-description">{{item.summary}}</text><text class="card-meta">{{item.publishedAt}}</text></button></view>
@@ -0,0 +1,10 @@
@import '../../app.wxss';
.card-meta {
display: block;
margin-top: 12rpx;
color: #646a73;
font-size: 24rpx;
}
.state-card {
margin-top: 24rpx;
}
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "乐活公益"
}
@@ -0,0 +1,78 @@
import { AUTH_READY_MESSAGE, MINI_PROGRAM_TITLE } from '../../constants/app-info';
import { MiniProgramApiError } from '../../services/api-client';
import { loginWithWechat, restoreCurrentSession } from '../../services/auth';
Page({
data: {
authStatus: 'loading',
message: '正在安全登录,请稍候…',
title: MINI_PROGRAM_TITLE,
canCollaborate: false,
},
onLoad() {
void this.authenticate();
},
async authenticate() {
this.setData({ authStatus: 'loading', message: '正在安全登录,请稍候…' });
try {
const session = await restoreCurrentSession();
const user = session?.user ?? (await loginWithWechat()).user;
this.setData({
authStatus: 'success',
message: AUTH_READY_MESSAGE,
canCollaborate: user.permissions.includes('assigned_cases:read'),
});
} catch (error) {
const message =
error instanceof MiniProgramApiError && error.requestId
? `${error.message}(编号:${error.requestId})`
: error instanceof Error
? error.message
: '登录失败,请稍后重试';
this.setData({ authStatus: 'error', message });
}
},
retryLogin() {
void this.authenticate();
},
applyForSelf() {
void wx.navigateTo({ url: '/pages/application-form/application-form?mode=SELF' });
},
applyForFamily() {
void wx.navigateTo({ url: '/pages/application-form/application-form?mode=FAMILY' });
},
viewMyApplications() {
void wx.navigateTo({ url: '/pages/my-applications/my-applications' });
},
viewAssignedCases() {
void wx.navigateTo({ url: '/pages/assigned-cases/assigned-cases' });
},
viewProjects() {
void wx.navigateTo({ url: '/pages/projects/projects' });
},
viewActivities() {
void wx.navigateTo({ url: '/pages/activities/activities' });
},
viewVolunteerCenter() {
void wx.navigateTo({ url: '/pages/volunteer-center/volunteer-center' });
},
viewContent() {
void wx.navigateTo({ url: '/pages/content/content' });
},
viewOrganization() {
void wx.navigateTo({ url: '/pages/content/content?type=ORGANIZATION_INTRO' });
},
viewNotifications() {
void wx.navigateTo({ url: '/pages/notifications/notifications' });
},
});
@@ -0,0 +1,63 @@
<view class="page">
<view class="brand-card">
<text class="eyebrow">乐活公益 · 暖心服务平台</text>
<text class="title">用爱连接,让爱传递</text>
<text class="message">为老人、妇女儿童、残障人士和困难家庭提供公益服务支持。</text>
</view>
<view wx:if="{{authStatus === 'loading'}}" class="state-card" aria-live="polite">
<view class="skeleton-line skeleton-line--wide"></view>
<view class="skeleton-line"></view>
<text class="state-text">正在安全登录,请稍候…</text>
</view>
<view wx:elif="{{authStatus === 'error'}}" class="state-card" aria-live="polite">
<text class="state-title">暂时无法登录</text>
<text class="state-text">{{message}}</text>
<button class="secondary-button" bindtap="retryLogin">重新登录</button>
</view>
<block wx:else>
<view class="section-heading">
<text class="section-title">需要哪种帮助?</text>
<text class="section-note">选择后只需几步即可提交</text>
</view>
<button class="action-card action-card--primary" bindtap="applyForSelf">
<text class="action-title">我需要帮助</text>
<text class="action-description">为自己提交服务需求</text>
</button>
<button class="action-card" bindtap="applyForFamily">
<text class="action-title">帮家人申请</text>
<text class="action-description">替家人或他人说明需要的帮助</text>
</button>
<button class="progress-link" bindtap="viewMyApplications">
<text>查看我的服务申请与进度</text>
<text aria-hidden="true">›</text>
</button>
<view class="section-heading">
<text class="section-title">参与公益</text>
<text class="section-note">了解项目,报名志愿活动</text>
</view>
<button class="progress-link" bindtap="viewProjects">
<text>浏览公益项目</text><text aria-hidden="true">›</text>
</button>
<button class="progress-link" bindtap="viewActivities">
<text>查看公益活动</text><text aria-hidden="true">›</text>
</button>
<button class="progress-link progress-link--volunteer" bindtap="viewVolunteerCenter">
<text>志愿者认证与我的报名</text><text aria-hidden="true">›</text>
</button>
<view class="section-heading"><text class="section-title">了解我们</text><text class="section-note">经审核发布的公益内容</text></view>
<button class="progress-link" bindtap="viewContent"><text>公益动态与公告</text><text aria-hidden="true">›</text></button>
<button class="progress-link" bindtap="viewOrganization"><text>机构介绍</text><text aria-hidden="true">›</text></button>
<button class="progress-link" bindtap="viewNotifications"><text>我的消息与通知设置</text><text aria-hidden="true">›</text></button>
<button wx:if="{{canCollaborate}}" class="progress-link progress-link--volunteer" bindtap="viewAssignedCases">
<text>我的公益协作任务</text>
<text aria-hidden="true">›</text>
</button>
<view class="notice-card">
<text class="notice-title">温馨提示</text>
<text class="notice-text">提交后工作人员会根据实际服务能力与您联系。遇到危及生命安全的紧急情况,请及时联系 110 或 120。</text>
</view>
</block>
</view>
@@ -0,0 +1,115 @@
.brand-card {
margin-bottom: 32rpx;
padding: 40rpx 32rpx;
border-radius: 32rpx;
color: #7a2e12;
background: linear-gradient(135deg, #fff7f0, #ffe2d2);
}
.title {
display: block;
margin-top: 12rpx;
font-size: 48rpx;
font-weight: 600;
line-height: 1.3;
}
.message {
display: block;
margin-top: 16rpx;
color: #653b2c;
line-height: 1.7;
}
.section-heading {
display: flex;
margin: 40rpx 0 20rpx;
flex-direction: column;
gap: 8rpx;
}
.section-title {
font-size: 40rpx;
font-weight: 600;
}
.section-note,
.action-description {
color: var(--color-text-secondary);
font-size: 28rpx;
}
.action-card {
display: flex;
min-height: 152rpx;
margin: 0 0 24rpx;
padding: 28rpx 32rpx;
flex-direction: column;
align-items: flex-start;
justify-content: center;
border: 2rpx solid var(--color-border);
border-radius: 28rpx;
color: var(--color-text);
background: #ffffff;
text-align: left;
}
.action-card--primary {
border-color: #ffb08e;
background: var(--color-primary-soft);
}
.action-card:active,
.progress-link:active {
opacity: 0.72;
}
.action-title {
font-size: 36rpx;
font-weight: 600;
}
.action-description {
margin-top: 8rpx;
}
.progress-link {
display: flex;
min-height: 104rpx;
margin: 8rpx 0 32rpx;
padding: 20rpx 28rpx;
align-items: center;
justify-content: space-between;
border-radius: 24rpx;
color: #145ea8;
background: #eaf4ff;
font-weight: 600;
}
.progress-link--volunteer {
margin-top: -16rpx;
color: #237a2b;
background: #edf8ee;
}
.notice-card {
padding: 28rpx;
border-left: 8rpx solid #faad14;
border-radius: 20rpx;
background: #fff9e8;
}
.notice-title,
.notice-text {
display: block;
}
.notice-title {
font-weight: 600;
}
.notice-text {
margin-top: 8rpx;
color: #654f17;
font-size: 28rpx;
line-height: 1.7;
}
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "我的申请"
}
@@ -0,0 +1,56 @@
import type { ServiceApplicationListItem } from '@lehuo/contracts';
import { MiniProgramApiError } from '../../services/api-client';
import { fetchMyServiceApplications } from '../../services/service-applications';
import { formatChinaDateTime } from '../../utils/date';
type DisplayListItem = ServiceApplicationListItem & { submittedAtText: string };
Page({
data: {
status: 'loading',
errorMessage: '',
items: [] as DisplayListItem[],
},
onShow() {
void this.loadApplications();
},
async loadApplications() {
this.setData({ status: 'loading', errorMessage: '' });
try {
const result = await fetchMyServiceApplications();
this.setData({
status: result.items.length ? 'success' : 'empty',
items: result.items.map((item) => ({
...item,
submittedAtText: formatChinaDateTime(item.submittedAt),
})),
});
} catch (error) {
const message =
error instanceof MiniProgramApiError && error.requestId
? `${error.message}(编号:${error.requestId})`
: error instanceof Error
? error.message
: '暂时无法读取申请';
this.setData({ status: 'error', errorMessage: message });
}
},
openApplication(event: WechatMiniprogram.TouchEvent) {
const publicId = event.currentTarget.dataset.publicId as string;
void wx.navigateTo({
url: `/pages/application-detail/application-detail?publicId=${encodeURIComponent(publicId)}`,
});
},
startApplication() {
void wx.navigateTo({ url: '/pages/application-form/application-form' });
},
retryLoad() {
void this.loadApplications();
},
});
@@ -0,0 +1,21 @@
<view class="page list-page">
<view class="page-heading"><text class="page-title">我的服务申请</text><text class="page-subtitle">查看已提交的需求和工作人员处理进度</text></view>
<view wx:if="{{status === 'loading'}}" class="application-list"><view wx:for="{{[1, 2, 3]}}" wx:key="*this" class="skeleton-card"></view></view>
<view wx:elif="{{status === 'empty'}}" class="state-card">
<text class="state-title">您还没有提交过服务申请</text><text class="state-text">如果您或家人需要帮助,可以从这里开始。</text>
<button class="primary-button" bindtap="startApplication">开始申请</button>
</view>
<view wx:elif="{{status === 'error'}}" class="state-card">
<text class="state-title">暂时无法读取申请</text><text class="state-text">{{errorMessage}}</text>
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
</view>
<view wx:else class="application-list">
<button wx:for="{{items}}" wx:key="publicId" class="application-card" data-public-id="{{item.publicId}}" bindtap="openApplication">
<view class="application-card__top"><text class="application-category">{{item.serviceCategoryLabel}}</text><text class="status-pill">{{item.statusLabel}}</text></view>
<text class="application-name">{{item.beneficiaryName}}</text>
<text class="application-meta">服务编号:{{item.applicationNo}}</text>
<text class="application-meta">提交时间:{{item.submittedAtText}}</text>
<text class="application-link">查看进度 ›</text>
</button>
</view>
</view>
@@ -0,0 +1,59 @@
.page-heading {
margin: 8rpx 0 28rpx;
}
.page-title,
.page-subtitle {
display: block;
}
.page-title {
font-size: 44rpx;
font-weight: 600;
}
.page-subtitle {
margin-top: 10rpx;
color: var(--color-text-secondary);
font-size: 28rpx;
line-height: 1.6;
}
.application-card {
display: flex;
width: 100%;
margin: 0 0 24rpx;
padding: 28rpx;
flex-direction: column;
align-items: stretch;
border: 2rpx solid var(--color-border);
border-radius: 28rpx;
color: var(--color-text);
background: #ffffff;
text-align: left;
}
.application-card:active {
opacity: 0.72;
}
.application-card__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.application-category {
color: var(--color-primary-pressed);
font-weight: 600;
}
.application-name {
margin-top: 24rpx;
font-size: 36rpx;
font-weight: 600;
}
.application-meta {
margin-top: 10rpx;
color: var(--color-text-secondary);
font-size: 27rpx;
font-variant-numeric: tabular-nums;
}
.application-link {
margin-top: 20rpx;
color: #145ea8;
font-weight: 600;
}
@@ -0,0 +1 @@
{ "navigationBarTitleText": "我的消息" }
@@ -0,0 +1,79 @@
import type { NotificationItem, NotificationPreferenceItem } from '@lehuo/contracts';
import {
fetchNotificationPreferences,
fetchNotifications,
readNotification,
updateNotificationPreferences,
} from '../../services/content-notifications';
Page({
data: {
loading: true,
message: '',
items: [] as NotificationItem[],
preferences: [] as NotificationPreferenceItem[],
adapterConfigured: false,
},
onShow() {
void this.load();
},
async load() {
this.setData({ loading: true, message: '' });
try {
const [n, p] = await Promise.all([fetchNotifications(), fetchNotificationPreferences()]);
this.setData({
items: n.items,
preferences: p.items,
adapterConfigured: p.wechatAdapterConfigured,
});
} catch (e) {
this.setData({ message: e instanceof Error ? e.message : '消息加载失败' });
} finally {
this.setData({ loading: false });
}
},
async read(e: WechatMiniprogram.BaseEvent) {
const id = String(e.currentTarget.dataset['id']);
await readNotification(id);
await this.load();
},
async toggleWechat(e: WechatMiniprogram.SwitchChange) {
const type = String(e.currentTarget.dataset['type']);
const enabled = e.detail.value;
const next = this.data.preferences.map((p) =>
p.type === type ? { ...p, wechatEnabled: enabled } : p,
);
try {
if (enabled) {
const accepted = await new Promise<boolean>((resolve) =>
wx.showModal({
title: '订阅通知授权',
content: '开启后仍需在微信授权弹窗中接受对应模板。当前操作会记录您的明确选择。',
confirmText: '确认开启',
success: (r) => resolve(r.confirm),
fail: () => resolve(false),
}),
);
if (!accepted) {
await this.load();
return;
}
}
const result = await updateNotificationPreferences({
items: next.map((p) => ({
type: p.type,
inAppEnabled: p.inAppEnabled,
wechatEnabled: p.wechatEnabled,
})),
});
this.setData({
preferences: result.items,
message: enabled
? '已记录订阅选择;需微信模板授权与平台配置后才能发送。'
: '已关闭微信订阅通知。',
});
} catch (err) {
this.setData({ message: err instanceof Error ? err.message : '设置失败' });
await this.load();
}
},
});
@@ -0,0 +1 @@
<view class="page"><view class="section-heading"><text class="section-title">我的消息</text><text class="section-note">服务进度与活动报名结果</text></view><view wx:if="{{loading}}" class="state-card">正在加载消息…</view><view wx:elif="{{message}}" class="notice-card">{{message}}</view><view wx:elif="{{!items.length}}" class="state-card">暂无消息</view><button wx:for="{{items}}" wx:key="publicId" class="message-card {{item.readAt?'':'message-card--unread'}}" data-id="{{item.publicId}}" bindtap="read"><text class="message-title">{{item.title}}</text><text class="message-body">{{item.body}}</text><text class="message-time">{{item.createdAt}}</text></button><view class="section-heading"><text class="section-title">通知设置</text><text class="section-note">站内消息默认开启</text></view><view wx:if="{{!adapterConfigured}}" class="notice-card">微信模板与 AppID/AppSecret 尚未配置;现在可以保留偏好,配置完成后生效。</view><view wx:for="{{preferences}}" wx:key="type" class="preference-row"><text>{{item.type}}</text><switch checked="{{item.wechatEnabled}}" data-type="{{item.type}}" bindchange="toggleWechat" color="#ff6b35" /></view></view>
@@ -0,0 +1,38 @@
@import '../../app.wxss';
.message-card,
.preference-row {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 20rpx;
padding: 28rpx;
background: #fff;
border: 2rpx solid #eceef0;
border-radius: 20rpx;
text-align: left;
}
.message-card--unread {
border-left: 8rpx solid #ff6b35;
}
.message-title,
.message-body,
.message-time {
display: block;
}
.message-title {
font-weight: 700;
}
.message-body {
margin-top: 12rpx;
color: #555b63;
}
.message-time {
margin-top: 14rpx;
color: #8b9098;
font-size: 22rpx;
}
.preference-row {
display: flex;
align-items: center;
justify-content: space-between;
}
@@ -0,0 +1 @@
{ "navigationBarTitleText": "项目详情" }
@@ -0,0 +1,25 @@
import type { ProjectDetail } from '@lehuo/contracts';
import { fetchPublicProject } from '../../services/participation';
Page({
data: { loading: true, message: '', project: null as ProjectDetail | null },
onLoad(options: Record<string, string>) {
if (options['publicId']) void this.load(options['publicId']);
},
async load(publicId: string) {
try {
const result = await fetchPublicProject(publicId);
this.setData({ project: result.project });
} catch {
this.setData({ message: '项目暂时无法查看,请稍后重试' });
} finally {
this.setData({ loading: false });
}
},
activities() {
const project = this.data.project;
if (project)
void wx.navigateTo({
url: `/pages/activities/activities?projectPublicId=${project.publicId}`,
});
},
});
@@ -0,0 +1 @@
<view class="page"><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{message}}" class="state-card">{{message}}</view><block wx:elif="{{project}}"><view class="brand-card"><text class="eyebrow">{{project.statusLabel}}</text><text class="title">{{project.name}}</text><text class="message">{{project.summary}}</text></view><view class="notice-card"><text class="notice-title">项目周期</text><text class="notice-text">{{project.startDate}} 至 {{project.endDate}} · 负责人 {{project.managerDisplayName}}</text></view><view class="notice-card"><text class="notice-title">项目介绍</text><text class="notice-text">{{project.content}}</text></view><button class="primary-button" bindtap="activities">查看项目活动</button></block></view>
@@ -0,0 +1 @@
@import '../../app.wxss';
@@ -0,0 +1 @@
{ "navigationBarTitleText": "公益项目" }
@@ -0,0 +1,27 @@
import type { ProjectListItem } from '@lehuo/contracts';
import { MiniProgramApiError } from '../../services/api-client';
import { fetchPublicProjects } from '../../services/participation';
Page({
data: { loading: true, message: '', items: [] as ProjectListItem[] },
onLoad() {
void this.load();
},
async load() {
this.setData({ loading: true, message: '' });
try {
const result = await fetchPublicProjects();
this.setData({ items: result.items });
} catch (error) {
this.setData({
message: error instanceof MiniProgramApiError ? error.message : '项目加载失败,请稍后重试',
});
} finally {
this.setData({ loading: false });
}
},
open(event: WechatMiniprogram.BaseEvent) {
const publicId = event.currentTarget.dataset['id'] as string;
void wx.navigateTo({ url: `/pages/project-detail/project-detail?publicId=${publicId}` });
},
});
@@ -0,0 +1 @@
<view class="page"><view class="section-heading"><text class="section-title">公益项目</text><text class="section-note">了解正在开展的公益服务</text></view><view wx:if="{{loading}}" class="state-card">正在加载项目…</view><view wx:elif="{{message}}" class="state-card"><text>{{message}}</text><button class="secondary-button" bindtap="load">重试</button></view><view wx:elif="{{!items.length}}" class="state-card">暂无公开项目</view><button wx:for="{{items}}" wx:key="publicId" class="action-card" data-id="{{item.publicId}}" bindtap="open"><text class="action-title">{{item.name}}</text><text class="action-description">{{item.summary}}</text><text class="card-meta">{{item.statusLabel}} · {{item.startDate}} 至 {{item.endDate}}</text></button></view>
@@ -0,0 +1,10 @@
@import '../../app.wxss';
.card-meta {
display: block;
margin-top: 12rpx;
color: #646a73;
font-size: 24rpx;
}
.state-card {
margin-top: 24rpx;
}
@@ -0,0 +1 @@
{ "navigationBarTitleText": "志愿者中心" }
@@ -0,0 +1,137 @@
import type {
ActivityRegistrationItem,
VolunteerProfile,
VolunteerSkillCode,
ServiceCategory,
} from '@lehuo/contracts';
import { MiniProgramApiError } from '../../services/api-client';
import {
fetchMyRegistrations,
fetchVolunteerProfile,
saveVolunteerProfile,
} from '../../services/participation';
import { createClientRequestId } from '../../utils/request-id';
Page({
data: {
loading: true,
busy: false,
message: '',
profile: null as VolunteerProfile | null,
registrations: [] as ActivityRegistrationItem[],
skillSelected: {},
interestSelected: {},
form: {
realName: '',
mobile: '',
regionCode: '',
availabilityNote: '',
skillCodes: [] as VolunteerSkillCode[],
interestCategories: [] as ServiceCategory[],
privacyConsent: false,
},
skills: [
{ code: 'ELDER_CARE', label: '老年关怀' },
{ code: 'CHILD_CARE', label: '儿童陪伴' },
{ code: 'DISABILITY_SUPPORT', label: '残障支持' },
{ code: 'EVENT_SUPPORT', label: '活动协助' },
{ code: 'FIRST_AID', label: '急救技能' },
{ code: 'PHOTOGRAPHY', label: '摄影记录' },
],
interests: [
{ code: 'OLD_AGE', label: '老年服务' },
{ code: 'WOMEN_CHILDREN', label: '妇女儿童' },
{ code: 'DISABILITY', label: '残障支持' },
{ code: 'DIFFICULT_FAMILY', label: '困难家庭' },
{ code: 'OTHER', label: '其他公益' },
],
},
onLoad() {
void this.load();
},
async load() {
this.setData({ loading: true, message: '' });
try {
const [profileResult, registrations] = await Promise.all([
fetchVolunteerProfile(),
fetchMyRegistrations(),
]);
const profile = profileResult.volunteer;
this.setData({
profile,
registrations: registrations.items,
skillSelected: Object.fromEntries((profile?.skillCodes ?? []).map((code) => [code, true])),
interestSelected: Object.fromEntries(
(profile?.interestCategories ?? []).map((code) => [code, true]),
),
...(profile
? {
form: {
realName: profile.realName,
mobile: profile.mobile,
regionCode: profile.regionCode,
availabilityNote: profile.availabilityNote,
skillCodes: profile.skillCodes,
interestCategories: profile.interestCategories,
privacyConsent: true,
},
}
: {}),
});
} catch (error) {
this.setData({
message: error instanceof MiniProgramApiError ? error.message : '志愿者中心加载失败',
});
} finally {
this.setData({ loading: false });
}
},
field(event: WechatMiniprogram.Input) {
const key = event.currentTarget.dataset['key'] as string;
this.setData({ [`form.${key}`]: event.detail.value });
},
skills(event: WechatMiniprogram.CheckboxGroupChange) {
this.setData({
'form.skillCodes': event.detail.value,
skillSelected: Object.fromEntries(event.detail.value.map((code) => [code, true])),
});
},
interests(event: WechatMiniprogram.CheckboxGroupChange) {
this.setData({
'form.interestCategories': event.detail.value,
interestSelected: Object.fromEntries(event.detail.value.map((code) => [code, true])),
});
},
consent(event: WechatMiniprogram.CheckboxGroupChange) {
this.setData({ 'form.privacyConsent': event.detail.value.includes('yes') });
},
async submit() {
const form = this.data.form;
if (!form.privacyConsent) {
this.setData({ message: '请先阅读并确认志愿者资料使用说明。' });
return;
}
this.setData({ busy: true, message: '' });
try {
await saveVolunteerProfile({
clientRequestId: createClientRequestId(),
...(this.data.profile ? { expectedRevision: this.data.profile.revision } : {}),
realName: form.realName,
mobile: form.mobile,
regionCode: form.regionCode,
availabilityNote: form.availabilityNote,
skillCodes: form.skillCodes,
interestCategories: form.interestCategories,
privacyConsent: true,
});
this.setData({ message: '志愿者资料已提交,请等待工作人员审核。' });
await this.load();
} catch (error) {
this.setData({
message: error instanceof MiniProgramApiError ? error.message : '提交失败,请稍后重试',
});
} finally {
this.setData({ busy: false });
}
},
});
@@ -0,0 +1 @@
<view class="page"><view class="section-heading"><text class="section-title">志愿者中心</text><text class="section-note">认证资料、报名进度与服务时长</text></view><view wx:if="{{loading}}" class="state-card">正在加载…</view><block wx:else><view wx:if="{{profile}}" class="notice-card"><text class="notice-title">认证状态:{{profile.statusLabel}}</text><text class="notice-text">累计 {{profile.completedActivityCount}} 次活动,{{profile.totalServiceMinutes}} 分钟服务</text></view><view class="form-card"><label>姓名<input value="{{form.realName}}" data-key="realName" bindinput="field" maxlength="50" /></label><label>手机号<input value="{{form.mobile}}" data-key="mobile" bindinput="field" type="number" maxlength="11" /></label><label>所在地区<input value="{{form.regionCode}}" data-key="regionCode" bindinput="field" maxlength="100" /></label><label>可参与时间<textarea value="{{form.availabilityNote}}" data-key="availabilityNote" bindinput="field" maxlength="500"></textarea></label><text class="field-label">服务技能</text><checkbox-group bindchange="skills"><label wx:for="{{skills}}" wx:key="code" class="check-row"><checkbox value="{{item.code}}" checked="{{skillSelected[item.code]}}" />{{item.label}}</label></checkbox-group><text class="field-label">服务意向</text><checkbox-group bindchange="interests"><label wx:for="{{interests}}" wx:key="code" class="check-row"><checkbox value="{{item.code}}" checked="{{interestSelected[item.code]}}" />{{item.label}}</label></checkbox-group><checkbox-group bindchange="consent"><label class="check-row"><checkbox value="yes" checked="{{form.privacyConsent}}" />我同意按志愿服务目的使用上述最少必要资料</label></checkbox-group><text wx:if="{{message}}" class="form-message">{{message}}</text><button class="primary-button" disabled="{{busy}}" bindtap="submit">{{busy ? '提交中…' : profile ? '更新资料' : '申请志愿者认证'}}</button></view><view class="section-heading"><text class="section-title">我的活动报名</text></view><view wx:if="{{!registrations.length}}" class="state-card">暂无报名记录</view><view wx:for="{{registrations}}" wx:key="publicId" class="notice-card"><text class="notice-title">{{item.activityTitle}}</text><text class="notice-text">{{item.statusLabel}} · {{item.checkInStatusLabel}}</text><text class="notice-text">服务时长:{{item.serviceMinutes === null ? '待核定' : item.serviceMinutes + ' 分钟'}}</text></view></block></view>
@@ -0,0 +1,29 @@
@import '../../app.wxss';
.form-card {
padding: 28rpx;
border-radius: 24rpx;
background: #fff;
}
.form-card label,
.field-label {
display: block;
margin-top: 24rpx;
font-weight: 600;
}
.form-card input,
.form-card textarea {
width: 100%;
min-height: 88rpx;
margin-top: 12rpx;
padding: 18rpx;
border: 2rpx solid #d9dce1;
border-radius: 16rpx;
box-sizing: border-box;
}
.check-row {
display: flex !important;
min-height: 72rpx;
align-items: center;
gap: 12rpx;
font-weight: 400 !important;
}
@@ -0,0 +1,65 @@
import type { ApiErrorResponse, ApiSuccessResponse } from '@lehuo/contracts';
import { readSession, removeSession } from './session';
const API_BASE_URL = getApp<IAppOption>().globalData.apiBaseUrl;
type ApiRequestOptions = Omit<
WechatMiniprogram.RequestOption,
'url' | 'success' | 'fail' | 'complete'
>;
export class MiniProgramApiError extends Error {
constructor(
message: string,
readonly code: string,
readonly requestId: string | null,
) {
super(message);
}
}
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
if (!API_BASE_URL) {
return Promise.reject(
new MiniProgramApiError(
'小程序尚未配置服务地址,请联系工作人员',
'CONFIGURATION_ERROR',
null,
),
);
}
return new Promise((resolve, reject) => {
const session = readSession();
wx.request<ApiSuccessResponse<T> | ApiErrorResponse>({
...options,
url: `${API_BASE_URL}${path}`,
header: {
'content-type': 'application/json',
...(session ? { authorization: `Bearer ${session.accessToken}` } : {}),
...options.header,
},
success(response) {
const payload = response.data;
if (response.statusCode >= 200 && response.statusCode < 300 && payload.success) {
resolve(payload.data);
return;
}
const error = payload as ApiErrorResponse;
if (response.statusCode === 401) removeSession();
reject(
new MiniProgramApiError(
response.statusCode === 401
? '登录状态已失效,请返回首页重新登录'
: error.error?.message || '服务连接失败,请稍后重试',
error.error?.code || 'UNKNOWN_ERROR',
error.requestId || null,
),
);
},
fail() {
reject(new MiniProgramApiError('网络连接失败,请检查网络后重试', 'NETWORK_ERROR', null));
},
});
});
}
@@ -0,0 +1,36 @@
import type {
CreateServiceRecordRequest,
PaginatedServiceCasesResponse,
ServiceCaseDetailResponse,
ServiceCaseStatus,
ServiceRecordResponse,
DownloadUrlResponse,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
export function fetchAssignedCases(
status?: ServiceCaseStatus,
): Promise<PaginatedServiceCasesResponse> {
return apiRequest(
`/api/v1/service-cases?page=1&pageSize=50${status ? `&status=${encodeURIComponent(status)}` : ''}`,
);
}
export function fetchAssignedCase(publicId: string): Promise<ServiceCaseDetailResponse> {
return apiRequest(`/api/v1/service-cases/${encodeURIComponent(publicId)}`);
}
export function createAssignedCaseRecord(
publicId: string,
input: CreateServiceRecordRequest,
): Promise<ServiceRecordResponse> {
return apiRequest(`/api/v1/service-cases/${encodeURIComponent(publicId)}/records`, {
method: 'POST',
data: input,
});
}
export function fetchAssignedFileUrl(publicId: string): Promise<DownloadUrlResponse> {
return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/download-url`);
}
@@ -0,0 +1,50 @@
import type { CurrentUserResponse, WechatLoginResponse } from '@lehuo/contracts';
import { apiRequest } from './api-client';
import { readSession, removeSession, saveSession, type StoredSession } from './session';
function requestWechatCode(): Promise<string> {
return new Promise((resolve, reject) => {
wx.login({
success(result) {
if (result.code) resolve(result.code);
else reject(new Error('微信未返回登录凭证'));
},
fail() {
reject(new Error('无法连接微信登录,请稍后重试'));
},
});
});
}
export function restoreSession(): StoredSession | null {
return readSession();
}
export async function restoreCurrentSession(): Promise<StoredSession | null> {
const session = readSession();
if (!session) return null;
if (session.user) return session;
const result = await apiRequest<CurrentUserResponse>('/api/v1/me');
const refreshed = { ...session, user: result.user };
saveSession(refreshed);
return refreshed;
}
export async function loginWithWechat(): Promise<WechatLoginResponse> {
const code = await requestWechatCode();
const result = await apiRequest<WechatLoginResponse>('/api/v1/auth/wechat/login', {
method: 'POST',
data: { code },
});
saveSession({
accessToken: result.accessToken,
expiresAt: result.expiresAt,
user: result.user,
});
return result;
}
export function clearSession(): void {
removeSession();
}
@@ -0,0 +1,25 @@
import type {
ContentPostDetailResponse,
NotificationPreferencesResponse,
PaginatedContentPostsResponse,
PaginatedNotificationsResponse,
UpdateNotificationPreferencesRequest,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
export const fetchPublicContent = (type?: string) =>
apiRequest<PaginatedContentPostsResponse>(
`/api/v1/content?page=1&pageSize=50${type ? `&type=${encodeURIComponent(type)}` : ''}`,
);
export const fetchPublicContentDetail = (id: string) =>
apiRequest<ContentPostDetailResponse>(`/api/v1/content/${id}`);
export const fetchNotifications = () =>
apiRequest<PaginatedNotificationsResponse>('/api/v1/me/notifications?page=1&pageSize=50');
export const readNotification = (id: string) =>
apiRequest<{ read: true }>(`/api/v1/me/notifications/${id}/read`, { method: 'POST' });
export const fetchNotificationPreferences = () =>
apiRequest<NotificationPreferencesResponse>('/api/v1/me/notifications/preferences');
export const updateNotificationPreferences = (input: UpdateNotificationPreferencesRequest) =>
apiRequest<NotificationPreferencesResponse>('/api/v1/me/notifications/preferences', {
method: 'PUT',
data: input,
});
@@ -0,0 +1,36 @@
import type {
ActivityDetailResponse,
ActivityRegistrationResponse,
ApplyVolunteerRequest,
PaginatedActivitiesResponse,
PaginatedActivityRegistrationsResponse,
PaginatedProjectsResponse,
ProjectDetailResponse,
VolunteerProfileResponse,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
import { createClientRequestId } from '../utils/request-id';
export const fetchPublicProjects = () =>
apiRequest<PaginatedProjectsResponse>('/api/v1/public/projects?page=1&pageSize=50');
export const fetchPublicProject = (publicId: string) =>
apiRequest<ProjectDetailResponse>(`/api/v1/public/projects/${publicId}`);
export const fetchPublicActivities = (projectPublicId?: string) =>
apiRequest<PaginatedActivitiesResponse>(
`/api/v1/public/activities?page=1&pageSize=50${projectPublicId ? `&projectPublicId=${encodeURIComponent(projectPublicId)}` : ''}`,
);
export const fetchPublicActivity = (publicId: string) =>
apiRequest<ActivityDetailResponse>(`/api/v1/public/activities/${publicId}`);
export const registerActivity = (publicId: string) =>
apiRequest<ActivityRegistrationResponse>(`/api/v1/activities/${publicId}/registrations`, {
method: 'POST',
data: { clientRequestId: createClientRequestId() },
});
export const fetchVolunteerProfile = () =>
apiRequest<VolunteerProfileResponse>('/api/v1/me/volunteer');
export const saveVolunteerProfile = (input: ApplyVolunteerRequest) =>
apiRequest<VolunteerProfileResponse>('/api/v1/me/volunteer', { method: 'PUT', data: input });
export const fetchMyRegistrations = () =>
apiRequest<PaginatedActivityRegistrationsResponse>(
'/api/v1/me/activity-registrations?page=1&pageSize=50',
);
@@ -0,0 +1,29 @@
import type {
CreateServiceApplicationRequest,
CreateServiceApplicationResponse,
PaginatedServiceApplicationsResponse,
ServiceApplicationDetailResponse,
ServiceCategoriesResponse,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
export function fetchServiceCategories(): Promise<ServiceCategoriesResponse> {
return apiRequest('/api/v1/service-categories');
}
export function createServiceApplication(
input: CreateServiceApplicationRequest,
): Promise<CreateServiceApplicationResponse> {
return apiRequest('/api/v1/service-applications', { method: 'POST', data: input });
}
export function fetchMyServiceApplications(): Promise<PaginatedServiceApplicationsResponse> {
return apiRequest('/api/v1/service-applications?page=1&pageSize=50');
}
export function fetchServiceApplication(
publicId: string,
): Promise<ServiceApplicationDetailResponse> {
return apiRequest(`/api/v1/service-applications/${encodeURIComponent(publicId)}`);
}
@@ -0,0 +1,25 @@
const STORAGE_KEY = 'lehuo_wechat_session';
export interface StoredSession {
accessToken: string;
expiresAt: string;
user?: AuthUser;
}
export function readSession(): StoredSession | null {
const session = wx.getStorageSync<StoredSession>(STORAGE_KEY);
if (!session?.accessToken || Date.parse(session.expiresAt) <= Date.now()) {
wx.removeStorageSync(STORAGE_KEY);
return null;
}
return session;
}
export function saveSession(session: StoredSession): void {
wx.setStorageSync(STORAGE_KEY, session);
}
export function removeSession(): void {
wx.removeStorageSync(STORAGE_KEY);
}
import type { AuthUser } from '@lehuo/contracts';
@@ -0,0 +1,4 @@
{
"desc": "站点索引配置",
"rules": [{ "action": "allow", "page": "*" }]
}
+5
View File
@@ -0,0 +1,5 @@
interface IAppOption {
globalData: {
apiBaseUrl: string;
};
}
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { formatChinaDateTime } from './date';
describe('mini program date formatting', () => {
it('formats valid dates and recovers from invalid values', () => {
expect(formatChinaDateTime('2026-08-11T02:30:00.000Z')).toMatch(/^2026-08-11 \d{2}:\d{2}$/);
expect(formatChinaDateTime('invalid')).toBe('时间待确认');
});
});
@@ -0,0 +1,6 @@
export function formatChinaDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '时间待确认';
const pad = (number: number) => String(number).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { createClientRequestId } from './request-id';
describe('client request identifiers', () => {
it('creates distinct UUID v4 shaped idempotency identifiers', () => {
const first = createClientRequestId();
const second = createClientRequestId();
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(second).not.toBe(first);
});
});
@@ -0,0 +1,7 @@
export function createClientRequestId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
const random = Math.floor(Math.random() * 16);
const value = character === 'x' ? random : (random & 0x3) | 0x8;
return value.toString(16);
});
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@lehuo/miniprogram",
"version": "0.1.0",
"private": true,
"dependencies": {
"@lehuo/contracts": "workspace:*"
},
"scripts": {
"build": "tsc --noEmit",
"test:unit": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/wechat-miniprogram": "^3.4.8",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"appid": "touristappid",
"compileType": "miniprogram",
"libVersion": "trial",
"miniprogramRoot": "miniprogram/",
"projectname": "lehuo-public-service",
"setting": {
"es6": true,
"minified": true,
"postcss": true,
"useCompilerPlugins": ["typescript"]
}
}
@@ -0,0 +1,6 @@
{
"projectname": "lehuo-public-service-local",
"setting": {
"compileHotReLoad": true
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["wechat-miniprogram", "vitest/globals"]
},
"include": ["miniprogram/**/*.ts"]
}