feat: complete V1 phases 0-7
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# 微信小程序
|
||||
|
||||
微信原生小程序 + TypeScript。Phase 1 已接入微信登录和短期会话;Phase 2–4 提供服务申请、协作任务和闭环;Phase 5 提供公开公益项目/活动、在线报名、志愿者认证、个人报名进度和服务时长。敏感申请草稿、志愿者资料和工单内容不写入本地持久化。
|
||||
|
||||
使用微信开发者工具导入本目录;真实 AppID 只保存在本地私有配置中,不提交仓库。本地默认 API 为 `http://localhost:3000`;提审或发布前必须把 `miniprogram/app.ts` 中的地址替换为已加入微信 request 合法域名的 HTTPS 地址,不得发布本地 HTTP 配置。
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
App<IAppOption>({
|
||||
globalData: {
|
||||
// 本地开发地址;提审/发布前必须替换为已配置微信合法域名的 HTTPS API 地址。
|
||||
apiBaseUrl: 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user