fix(ops-backend/service-station): close Phase 19 KEEP-VERIFY — 25 defects (unreachable announcements + station write privilege escalation; banner 500 + soft-delete leak; content publish dead-end; program approval dead-end/draft leak/zero counts/missing withdraw; zero-coverage test schema) ; add V042 announcement chain + 34 guard tests; full regression 602 green

Backend
- Service Station: expose the 「临时公告」 chain end-to-end (V042 table/service existed
  but had ZERO endpoints -> both admin and miniapp could never reach it): 5 operator
  endpoints (list/create/update/publish/archive) + 1 user-facing read endpoint.
- Fix privilege escalation: create/update were guarded only by the class-level
  service_location.view, making V015's service_location.manage a never-used dead
  permission -> read-only roles could edit stations. Permissions moved to method level.
- Add ServiceLocationOperatorController to OperatorEndpointPermissionCoverageTest
  (it was NOT scanned, which is why the escalation stayed invisible).
- Add create/update validation (address NOT NULL + status CHECK only blew up on PG).
- Banner: validation failures threw bare RuntimeException -> 500; now VALIDATION_ERROR
  -> 400. Add deleted_at IS NULL to both home queries (soft-deleted banners stayed live).
- Content: operator list/detail split (admin «publish» was a dead end because it reused
  the published-only public API); category deactivate/reactivate; article body on edit.
- Service Program: operator member list now includes pending (approval reachable);
  active-only public visibility (draft leak); memberCount/pendingCount aggregation;
  withdraw/removeMember; reuse row on re-apply (UNIQUE(program_id,user_id) crash).
- MeController: add the missing GET /me/service-history route.

Schema / guards
- schema-h2.sql: add service_location_announcements (+ chk_announcement_status) and
  content_categories (+ uk_content_category_code, chk_category_status); move both into
  REQUIRED_IN_TEST_SCHEMA. MigrationValidationTest baseline 41 -> 42.
- V11PermissionMatrixContractTest now scans ALL migrations instead of hardcoding V015+V029.
- New: ServiceLocationAnnouncementPhase19IntegrationTest (24),
  ServiceLocationControllerPermissionContractTest (5),
  ServiceLocationAnnouncementConstraintParityTest (5, prod DDL <-> test schema <->
  backend whitelist <-> admin labels).

Frontend
- admin-web: ServiceStationView gains announcement management (statuses, create/edit,
  publish/archive with confirm, expiry); write buttons gated by service_location.manage.
- miniapp: U50 renders the 临时公告 block via new serviceStationService; page no longer
  talks to utils/api directly; announcement failure degrades to [] instead of erroring.
- ui-tests: U37 four-group + U50 announcement contracts; /home assertion retargeted to
  the service layer.

Verification
- Negative verification: 3 injected defects (drop soft-delete filter / drop test-schema
  CHECK / downgrade write endpoint to view) -> 4 named tests fail precisely, no
  collateral failures; restored, NEG-VAL residue = 0.
- Backend full regression: 602 tests, 0 failures, 0 errors, 3 skipped (baseline 420).
- admin-web: vue-tsc --noEmit EXIT=0, vite build EXIT=0. Miniapp: tsc --noEmit EXIT=0.
- ui-tests 14/14 PASS.
This commit is contained in:
WorkBuddy
2026-09-17 22:21:31 +08:00
parent 3f60611a74
commit 72b4e9b846
70 changed files with 5847 additions and 308 deletions
+49 -1
View File
@@ -1 +1,49 @@
Page({data:{},report(){wx.navigateTo({url:'/pages/report/report?type=content'})},related(){wx.navigateTo({url:'/pages/counseling-intro/counseling-intro'})}})
/**
* U54 内容详情
*
* Phase 19:此前正文与标题**写死在 wxml**(任何文章都显示同一篇演示内容)。
* 现接入 GET /content/articles/{id},按 id 展示真实正文。
*
* 边界:用户端内容只做列表与详情,不做 Feed / 评论 / 点赞。
*/
const { contentService } = require('../../services');
Page({
data: {
loading: true,
error: '',
article: null,
paragraphs: [],
},
onLoad(options) {
this.articleId = (options && options.id) || '';
this.load();
},
async load() {
if (!this.articleId) {
this.setData({ loading: false, error: '未指定内容' });
return;
}
this.setData({ loading: true, error: '' });
try {
const article = await contentService.getArticleDetail(this.articleId);
if (!article) {
this.setData({ loading: false, error: '内容不存在或已下架' });
return;
}
const paragraphs = String(article.content || '')
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean);
this.setData({ article, paragraphs, loading: false });
} catch (error) {
this.setData({ loading: false, error: '加载失败,请稍后重试' });
}
},
report() {
wx.navigateTo({ url: '/pages/report/report?type=content' });
},
related() {
wx.navigateTo({ url: '/pages/counseling-intro/counseling-intro' });
},
});
@@ -1 +1 @@
<view class="service-page"><view class="hero"><text class="eyebrow">沟通成长 · 阅读约 5 分钟</text><text class="hero-title">第一次见面,怎样自然地开始交流</text><text class="hero-desc">善缘丹阳婚恋课堂</text></view><view class="article-content"><text class="lead">把注意力放在认识一个真实的人,而不是完成一场“面试”。</text><text>初次见面时,可以从当下的环境、共同参与的活动或轻松的生活话题开始。相比连续发问,认真倾听并回应对方刚刚说过的内容,更容易建立平等、自然的交流。</text><text style="display:block;margin-top:28rpx">不必急着判断结果,也不需要过度展示自己。尊重彼此的节奏,在涉及联系方式、住址和财务信息时保留必要边界。</text><view class="notice">本文为公益科普内容,不能替代专业咨询或法律意见。</view></view><view class="content"><view class="block"><text class="block-title">需要进一步帮助?</text><view class="card menu-row" bindtap="related"><view class="feature-icon">咨</view><view class="feature-copy"><text class="feature-title">了解婚恋咨询</text><text class="feature-desc">由咨询人员提供关系与情绪支持</text></view><text class="arrow">›</text></view></view></view></view>
<view class="service-page"><view wx:if="{{loading}}" class="content"><view class="card empty-panel"><view class="empty-symbol">…</view><text class="empty-title">正在加载</text></view></view><view wx:elif="{{error}}" class="content"><view class="card empty-panel"><view class="empty-symbol">!</view><text class="empty-title">{{error}}</text></view></view><block wx:else><view class="hero"><text class="eyebrow">善缘丹阳婚恋课堂</text><text class="hero-title">{{article.title}}</text><text class="hero-desc">{{article.summary}}</text></view><view class="article-content"><text wx:if="{{article.summary}}" class="lead">{{article.summary}}</text><text wx:for="{{paragraphs}}" wx:key="*this" style="display:block;margin-top:28rpx">{{item}}</text><view class="notice">本文为公益科普内容,不能替代专业咨询或法律意见。</view></view></block><view class="content"><view class="block"><text class="block-title">需要进一步帮助?</text><view class="card menu-row" bindtap="related"><view class="feature-icon">咨</view><view class="feature-copy"><text class="feature-title">了解婚恋咨询</text><text class="feature-desc">由咨询人员提供关系与情绪支持</text></view><text class="arrow">›</text></view><view class="card menu-row" bindtap="report"><view class="feature-icon">报</view><view class="feature-copy"><text class="feature-title">举报不实内容</text><text class="feature-desc">发现虚假或违规信息可提交举报</text></view><text class="arrow">›</text></view></view></view></view>
+2
View File
@@ -153,6 +153,8 @@ Page({
onGoClassroom() { wx.navigateTo({ url: ROUTE.SERVICE.CLASSROOM }); },
onGoAntiFraud() { wx.navigateTo({ url: ROUTE.SERVICE.ANTI_FRAUD }); },
onGoStation() { wx.navigateTo({ url: ROUTE.SERVICE.STATION }); },
// Phase 19:U49 公益服务中心此前只有 app.json 注册、全仓无任何入口(用户不可达)。
onGoServiceCenter() { wx.navigateTo({ url: ROUTE.SERVICE.CENTER }); },
// ============ 其他 ============
+28 -9
View File
@@ -32,26 +32,45 @@
<image class="identity-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" />
</view>
<!-- 分组一:我的资料与缘分(U37 四组结构)-->
<ui-section-header title="我的资料与缘分" subtitle="资料、择偶条件与当前缘分" />
<view class="list-group">
<view class="list-row" bindtap="onEditProfile"><text class="list-label">我的资料</text><text class="list-value">{{reviewStatusText}}</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onEditPreference"><text class="list-label">择偶条件</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onGoFate"><text class="list-label">我的缘分</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
</view>
<!-- 分组二:公益服务(U49 公益服务中心入口 + 四类服务 + 服务记录)-->
<ui-section-header title="我的公益服务" subtitle="服务进度与预约记录" />
<view class="list-group">
<view class="list-row" bindtap="onGoServiceCenter"><text class="list-label">公益服务中心</text><text class="list-value">咨询 · 预约 · 课堂</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
</view>
<view class="service-grid">
<view class="service-entry" bindtap="onGoMatchmaker"><view class="service-icon rose"><image src="/assets/icons/home/matchmaker.png" mode="aspectFit" /></view><text>我的红娘</text></view>
<view class="service-entry" bindtap="onGoCounseling"><view class="service-icon blue"><image src="/assets/icons/home/counseling.png" mode="aspectFit" /></view><text>我的咨询</text></view>
<view class="service-entry" bindtap="onGoActivities"><view class="service-icon amber"><image src="/assets/icons/home/activity.png" mode="aspectFit" /></view><text>我的活动</text></view>
<view class="service-entry" bindtap="onGoAppointments"><view class="service-icon green"><image src="/assets/icons/home/service-station.png" mode="aspectFit" /></view><text>我的预约</text></view>
</view>
<view class="list-group">
<view class="list-row" bindtap="onEditProfile"><text class="list-label">我的资料</text><text class="list-value">{{reviewStatusText}}</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onEditPreference"><text class="list-label">择偶条件</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onGoFate"><text class="list-label">我的缘分</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onGoServiceHistory"><text class="list-label">公益服务记录</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onGoServiceHistory"><text class="list-label">公益服务记录</text><text class="list-value">仅本人可见</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
</view>
<!-- 分组三:安全与隐私 -->
<ui-section-header title="安全与隐私" subtitle="隐私、黑名单与账号" />
<view class="list-group secondary-list">
<view class="list-row" bindtap="onGoPrivacy"><text class="list-label">隐私设置</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onGoBlacklist"><text class="list-label">黑名单</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onGoSafety"><text class="list-label">举报与安全</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onGoSettings"><text class="list-label">账号设置</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
</view>
<view class="list-group secondary-list">
<view class="list-row" bindtap="onGoPrivacy"><text class="list-label">隐私设置</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onGoSafety"><text class="list-label">举报与安全</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<view class="list-row" bindtap="onGoAntiFraud"><text class="list-label">婚恋反诈</text><image class="list-arrow" src="/assets/icons/ui/chevron-right.png" mode="aspectFit" /></view>
<!-- 分组四:公益信息 -->
<ui-section-header title="公益信息" subtitle="课堂、反诈与服务站" />
<view class="service-grid">
<view class="service-entry" bindtap="onGoClassroom"><view class="service-icon amber"><image src="/assets/icons/home/classroom.png" mode="aspectFit" /></view><text>婚恋课堂</text></view>
<view class="service-entry" bindtap="onGoAntiFraud"><view class="service-icon rose"><image src="/assets/icons/home/anti-fraud.png" mode="aspectFit" /></view><text>婚恋反诈</text></view>
<view class="service-entry" bindtap="onGoStation"><view class="service-icon green"><image src="/assets/icons/home/service-station.png" mode="aspectFit" /></view><text>善缘服务站</text></view>
<view class="service-entry" bindtap="onGoAbout"><view class="service-icon blue"><image src="/assets/brand/logo-mark.png" mode="aspectFit" /></view><text>关于善缘丹阳</text></view>
</view>
<view class="logout-row" bindtap="onLogout">退出登录</view>
@@ -1 +1,64 @@
Page({data:{appointments:[{id:'1',title:'资料填写协助',date:'2026-09-12 14:30',station:'善缘丹阳公益服务站',status:'已确认'}]},cancel(){wx.showModal({title:'取消预约',content:'确定取消本次服务预约吗?',confirmText:'确认取消',confirmColor:'#D94A4A'})}})
/**
* U52 我的预约
*
* Phase 19:此前是硬编码假预约(取消只弹 Modal,不改数据)。
* 现接入 GET /service-appointments(服务端按登录主体过滤)+ POST /{id}/cancel。
*/
const { serviceAppointmentService } = require('../../services');
const STATUS_LABEL = {
submitted: '待确认',
pending_confirmation: '待确认',
confirmed: '已确认',
completed: '已完成',
cancelled: '已取消',
};
Page({
data: {
loading: true,
error: '',
appointments: [],
},
onShow() {
this.load();
},
async load() {
this.setData({ loading: true, error: '' });
try {
const list = await serviceAppointmentService.getMyAppointments();
const appointments = (list || []).map((item) => ({
id: item.id,
title: item.serviceLocationName || '善缘丹阳公益服务站',
status: STATUS_LABEL[item.status] || item.status || '',
date: [item.preferredDate, item.preferredTimeSlot].filter(Boolean).join(' ') || '时间待确认',
station: item.serviceLocationName || '',
cancellable: item.status !== 'completed' && item.status !== 'cancelled',
}));
this.setData({ appointments, loading: false });
} catch (error) {
this.setData({ loading: false, error: '加载失败,请稍后重试' });
}
},
cancel(event) {
const id = event.currentTarget.dataset.id;
wx.showModal({
title: '取消预约',
content: '确定取消本次服务预约吗?',
confirmText: '确认取消',
confirmColor: '#D94A4A',
success: async (res) => {
if (!res.confirm) {
return;
}
try {
await serviceAppointmentService.cancelAppointment(id, '用户取消');
wx.showToast({ title: '已取消', icon: 'success' });
this.load();
} catch (error) {
wx.showToast({ title: '取消失败,请稍后重试', icon: 'none' });
}
},
});
},
});
@@ -1 +1 @@
<view class="service-page"><view class="hero"><text class="eyebrow">我的公益服务</text><text class="hero-title">我的预约</text><text class="hero-desc">查看预约状态、到站时间与服务地点。</text></view><view class="content"><view wx:if="{{appointments.length}}" class="block"><view wx:for="{{appointments}}" wx:key="id" class="card"><view style="display:flex;justify-content:space-between"><text class="feature-title">{{item.title}}</text><text class="tag">{{item.status}}</text></view><text class="meta">{{item.date}}</text><text class="meta">{{item.station}}</text><view class="secondary-btn" style="margin-top:24rpx" bindtap="cancel">取消预约</view></view></view><view wx:else class="card empty-panel"><view class="empty-symbol">约</view><text class="empty-title">还没有服务预约</text><text class="meta">需要线下帮助时,可以预约善缘服务站。</text></view></view></view>
<view class="service-page"><view class="hero"><text class="eyebrow">我的公益服务</text><text class="hero-title">我的预约</text><text class="hero-desc">查看预约状态、到站时间与服务地点。</text></view><view class="content"><view wx:if="{{loading}}" class="card empty-panel"><view class="empty-symbol">…</view><text class="empty-title">正在加载</text></view><view wx:elif="{{error}}" class="card empty-panel"><view class="empty-symbol">!</view><text class="empty-title">{{error}}</text><text class="meta">请检查网络后重试。</text></view><view wx:elif="{{appointments.length}}" class="block"><view wx:for="{{appointments}}" wx:key="id" class="card"><view style="display:flex;justify-content:space-between"><text class="feature-title">{{item.title}}</text><text class="tag">{{item.status}}</text></view><text class="meta">{{item.date}}</text><text class="meta">{{item.station}}</text><view wx:if="{{item.cancellable}}" class="secondary-btn" style="margin-top:24rpx" data-id="{{item.id}}" bindtap="cancel">取消预约</view></view></view><view wx:else class="card empty-panel"><view class="empty-symbol">约</view><text class="empty-title">还没有服务预约</text><text class="meta">需要线下帮助时,可以预约善缘服务站。</text></view></view></view>
@@ -1 +1,92 @@
Page({data:{date:'',service:'请选择服务项目',services:['资料填写协助','公益红娘咨询','活动报名指导','婚恋咨询预约'],note:''},chooseService(e){this.setData({service:this.data.services[Number(e.detail.value)]})},chooseDate(e){this.setData({date:e.detail.value})},submit(){if(!this.data.date)return wx.showToast({title:'请选择预约日期',icon:'none'});wx.showModal({title:'预约已提交',content:'工作人员确认后会通过平台通知你。',showCancel:false,success(){wx.navigateTo({url:'/pages/my-appointments/my-appointments'})}})}})
/**
* U51 服务预约
*
* Phase 19:此前提交只弹本地 Modal(后端收不到任何记录),属"假成功"。
* 现接入 POST /service-appointments:来源由服务端强制为 user_self,
* 前端**不传** requestSource / createdByOperatorId / userId(防伪造运营代登记身份)。
*
* 站点上下文:从 U50 服务站页跳转时携带 `?serviceLocationId=`,本页解析后
* 原样回传,保证预约真正落在该站点;站点名仅作展示,解析失败不阻断提交。
*/
const { serviceAppointmentService } = require('../../services');
const SERVICE_OPTIONS = [
{ label: '到站资料协助', type: 'station_visit' },
{ label: '公益红娘咨询', type: 'matchmaker' },
{ label: '活动报名指导', type: 'station_visit' },
{ label: '婚恋咨询预约', type: 'counselor_consultation' },
];
Page({
data: {
services: SERVICE_OPTIONS.map((option) => option.label),
service: '请选择服务项目',
date: '',
note: '',
serviceLocationId: '',
serviceLocationName: '',
submitting: false,
},
selectedIndex: -1,
onLoad(options) {
const raw = options && options.serviceLocationId;
const serviceLocationId = raw ? decodeURIComponent(raw).trim() : '';
if (!serviceLocationId) {
return;
}
this.setData({ serviceLocationId });
this.resolveStationName(serviceLocationId);
},
async resolveStationName(serviceLocationId) {
const location = await serviceAppointmentService.getCurrentServiceLocation();
if (location && location.id === serviceLocationId) {
this.setData({ serviceLocationName: location.name });
} else {
// 站点信息不可得时仍保留归属 ID,仅以占位文案展示,避免误导为空
this.setData({ serviceLocationName: '已选服务站点' });
}
},
chooseService(event) {
const index = Number(event.detail.value);
this.selectedIndex = index;
this.setData({ service: SERVICE_OPTIONS[index].label });
},
chooseDate(event) {
this.setData({ date: event.detail.value });
},
onNoteInput(event) {
this.setData({ note: event.detail.value });
},
async submit() {
if (this.selectedIndex < 0) {
wx.showToast({ title: '请选择服务项目', icon: 'none' });
return;
}
if (!this.data.date) {
wx.showToast({ title: '请选择预约日期', icon: 'none' });
return;
}
if (this.data.submitting) {
return;
}
this.setData({ submitting: true });
try {
await serviceAppointmentService.createAppointment({
serviceLocationId: this.data.serviceLocationId || undefined,
serviceType: SERVICE_OPTIONS[this.selectedIndex].type,
preferredDate: this.data.date,
userNote: this.data.note,
});
wx.showModal({
title: '预约已提交',
content: '工作人员确认后会通过平台通知你。',
showCancel: false,
success: () => wx.navigateTo({ url: '/pages/my-appointments/my-appointments' }),
});
} catch (error) {
wx.showToast({ title: '提交失败,请稍后重试', icon: 'none' });
} finally {
this.setData({ submitting: false });
}
},
});
@@ -1 +1 @@
<view class="service-page"><view class="hero"><text class="eyebrow">U51 服务预约</text><text class="hero-title">预约到站服务</text><text class="hero-desc">选择合适的时间和服务项目,提交后等待工作人员确认。</text></view><view class="content"><view class="card"><view class="field"><text class="label">服务项目</text><picker range="{{services}}" bindchange="chooseService"><view class="select-box">{{service}}<text class="arrow" style="margin-left:auto">›</text></view></picker></view><view class="field"><text class="label">预约日期</text><picker mode="date" bindchange="chooseDate"><view class="select-box">{{date || '请选择日期'}}</view></picker></view><view class="field"><text class="label">希望沟通的事项(选填)</text><textarea class="textarea" placeholder="请简要说明,避免填写身份证号等敏感信息" maxlength="200"/></view><view class="notice">提交预约不代表已确认。工作人员确认后,预约状态会更新为“已确认”。</view></view></view><view class="fixed-action"><view class="primary-btn" bindtap="submit">提交预约</view></view></view>
<view class="service-page"><view class="hero"><text class="eyebrow">U51 服务预约</text><text class="hero-title">预约到站服务</text><text class="hero-desc">选择合适的时间和服务项目,提交后等待工作人员确认。</text></view><view class="content"><view class="card"><view class="field" wx:if="{{serviceLocationId}}"><text class="label">服务站点</text><view class="select-box is-readonly">{{serviceLocationName || '已选服务站点'}}</view></view><view class="field"><text class="label">服务项目</text><picker range="{{services}}" bindchange="chooseService"><view class="select-box">{{service}}<text class="arrow" style="margin-left:auto">›</text></view></picker></view><view class="field"><text class="label">预约日期</text><picker mode="date" bindchange="chooseDate"><view class="select-box">{{date || '请选择日期'}}</view></picker></view><view class="field"><text class="label">希望沟通的事项(选填)</text><textarea class="textarea" value="{{note}}" bindinput="onNoteInput" placeholder="请简要说明,避免填写身份证号等敏感信息" maxlength="200"/></view><view class="notice">提交预约不代表已确认。工作人员确认后,预约状态会更新为“已确认”。</view></view></view><view class="fixed-action"><view class="primary-btn {{submitting ? 'is-disabled' : ''}}" bindtap="submit">{{submitting ? '提交中…' : '提交预约'}}</view></view></view>
@@ -1 +1,50 @@
Page({data:{records:[{title:'善缘服务站 · 资料协助',date:'2026-08-28',status:'已完成'},{title:'青年公益交友活动',date:'2026-08-16',status:'已参加'},{title:'公益红娘服务申请',date:'2026-08-03',status:'服务中'}]}})
/**
* U64 公益服务记录
*
* Phase 19:此前是硬编码假记录(与用户真实服务无关)。
* 现接入 GET /me/service-history —— 服务端只返回**本人**记录摘要,
* 且已排除咨询正文与内部备注(INV-013 最小可见)。
*/
const { serviceHistoryService } = require('../../services');
const CATEGORY_ICON = { appointment: '约', counseling: '咨' };
const STATUS_LABEL = {
submitted: '待确认',
pending_confirmation: '待确认',
confirmed: '已确认',
completed: '已完成',
cancelled: '已取消',
pending: '待处理',
assigned: '已分配',
scheduled: '已排期',
in_progress: '进行中',
closed: '已关闭',
};
Page({
data: {
loading: true,
error: '',
records: [],
},
onShow() {
this.load();
},
async load() {
this.setData({ loading: true, error: '' });
try {
const list = await serviceHistoryService.getMyServiceHistory();
const records = (list || []).map((item) => ({
key: item.id,
icon: CATEGORY_ICON[item.category] || '记',
title: item.title,
date: item.occurredAt || '',
station: item.stationName || '',
status: STATUS_LABEL[item.status] || item.status || '',
}));
this.setData({ records, loading: false });
} catch (error) {
this.setData({ loading: false, error: '加载失败,请稍后重试' });
}
},
});
@@ -1 +1 @@
<view class="service-page"><view class="hero"><text class="eyebrow">U64 公益服务记录</text><text class="hero-title">每一次服务,都清楚可查</text><text class="hero-desc">这里只展示与你有关的公益服务事实,不展示咨询正文。</text></view><view class="content"><view class="block"><view class="card"><view wx:for="{{records}}" wx:key="title" class="record-row"><view class="feature-icon">记</view><view class="feature-copy"><text class="feature-title">{{item.title}}</text><text class="feature-desc">{{item.date}}</text></view><text class="tag">{{item.status}}</text></view></view></view><view class="notice">咨询与公益红娘记录彼此分离;工作人员只能查看职责范围内的信息。</view></view></view>
<view class="service-page"><view class="hero"><text class="eyebrow">U64 公益服务记录</text><text class="hero-title">每一次服务,都清楚可查</text><text class="hero-desc">这里只展示与你有关的公益服务事实,不展示咨询正文。</text></view><view class="content"><view wx:if="{{loading}}" class="card empty-panel"><view class="empty-symbol">…</view><text class="empty-title">正在加载</text></view><view wx:elif="{{error}}" class="card empty-panel"><view class="empty-symbol">!</view><text class="empty-title">{{error}}</text></view><view wx:elif="{{records.length}}" class="block"><view class="card"><view wx:for="{{records}}" wx:key="key" class="record-row"><view class="feature-icon">{{item.icon}}</view><view class="feature-copy"><text class="feature-title">{{item.title}}</text><text class="feature-desc">{{item.date}}</text></view><text class="tag">{{item.status}}</text></view></view></view><view wx:else class="card empty-panel"><view class="empty-symbol">记</view><text class="empty-title">还没有公益服务记录</text><text class="meta">预约到站服务或申请公益咨询后,会在这里留下时间线。</text></view></view><view class="notice">咨询与公益红娘记录彼此分离;工作人员只能查看职责范围内的信息。</view></view></view>
@@ -1 +1,64 @@
Page({data:{},apply(){wx.showModal({title:'申请说明',content:'请先完成基本资料,符合项目条件后工作人员会与你联系。',showCancel:false})}})
/**
* U61 专项公益服务介绍
*
* Phase 19:此前项目文案写死、申请只弹说明(无任何后端交互)。
* 现接入 GET /service-programs + POST /service-programs/{id}/apply。
*
* 边界:项目成员身份只用于服务办理,**不得**成为用户价值标签;
* 项目主办/协作关系也不会自动获得用户资料访问权限。
*/
const { serviceProgramService } = require('../../services');
const STATUS_LABEL = {
draft: '筹备中',
active: '进行中',
ended: '已结束',
cancelled: '已取消',
};
Page({
data: {
loading: true,
error: '',
programs: [],
},
onShow() {
this.load();
},
async load() {
this.setData({ loading: true, error: '' });
try {
const list = await serviceProgramService.getPrograms();
const programs = (list || []).map((item) => ({
id: item.id,
name: item.name,
description: item.description || '',
status: STATUS_LABEL[item.status] || item.status || '',
applicable: item.status === 'active' || item.status === 'draft',
}));
this.setData({ programs, loading: false });
} catch (error) {
this.setData({ loading: false, error: '加载失败,请稍后重试' });
}
},
apply(event) {
const id = event.currentTarget.dataset.id;
wx.showModal({
title: '申请说明',
content: '请先完成基本资料,符合项目条件后工作人员会与你联系。',
confirmText: '提交申请',
success: async (res) => {
if (!res.confirm) {
return;
}
try {
await serviceProgramService.applyToProgram(id);
wx.showToast({ title: '申请已提交', icon: 'success' });
this.load();
} catch (error) {
wx.showToast({ title: '申请失败,请稍后重试', icon: 'none' });
}
},
});
},
});
@@ -1 +1 @@
<view class="service-page"><view class="hero"><text class="eyebrow">专项公益服务</text><text class="hero-title">让需要帮助的人被认真看见</text><text class="hero-desc">针对特定群体提供阶段性公益支持,项目条件与服务边界公开透明。</text></view><view class="content"><view class="block"><text class="block-title">青年人才公益交友支持</text><view class="card"><text class="feature-title">服务内容</text><text class="feature-desc">主题活动、资料指导、公益红娘转介与关系成长课堂。</text><text class="feature-title" style="margin-top:24rpx">申请条件</text><text class="feature-desc">以项目当期公布的年龄、工作或居住条件为准,不承诺匹配结果。</text><view><text class="tag">公益免费</text><text class="tag">自愿申请</text><text class="tag">隐私保护</text></view></view></view><view class="notice">项目主办或协作关系不会自动获得用户资料访问权限。</view></view><view class="fixed-action"><view class="primary-btn" bindtap="apply">了解申请方式</view></view></view>
<view class="service-page"><view class="hero"><text class="eyebrow">专项公益服务</text><text class="hero-title">让需要帮助的人被认真看见</text><text class="hero-desc">针对特定群体提供阶段性公益支持,项目条件与服务边界公开透明。</text></view><view class="content"><view wx:if="{{loading}}" class="card empty-panel"><view class="empty-symbol">…</view><text class="empty-title">正在加载</text></view><view wx:elif="{{error}}" class="card empty-panel"><view class="empty-symbol">!</view><text class="empty-title">{{error}}</text></view><view wx:elif="{{programs.length}}" class="block"><view wx:for="{{programs}}" wx:key="id" class="card"><view style="display:flex;justify-content:space-between"><text class="feature-title">{{item.name}}</text><text class="tag">{{item.status}}</text></view><text class="feature-desc" style="margin-top:16rpx">{{item.description}}</text><view><text class="tag">公益免费</text><text class="tag">自愿申请</text><text class="tag">隐私保护</text></view><view wx:if="{{item.applicable}}" class="primary-btn" style="margin-top:24rpx" data-id="{{item.id}}" bindtap="apply">申请加入</view></view></view><view wx:else class="card empty-panel"><view class="empty-symbol">项</view><text class="empty-title">暂无可申请的专项服务</text><text class="meta">新项目上线后会在这里公布。</text></view></view><view class="notice">项目主办或协作关系不会自动获得用户资料访问权限。</view></view></view>
@@ -1,4 +1,4 @@
import api from '../../utils/api'
import { serviceStationService } from '../../services'
interface ServiceLocation {
id: string
@@ -13,15 +13,24 @@ interface ServiceLocation {
longitude?: number | string
}
interface ServiceAnnouncement {
id: string
title: string
content?: string
expiresAt?: string | null
}
interface ServiceStationPageData {
serviceLocation: ServiceLocation | null
announcements: ServiceAnnouncement[]
loading: boolean
loadError: boolean
}
Page({
data: {
serviceLocation: null,
serviceLocation: null as ServiceLocation | null,
announcements: [] as ServiceAnnouncement[],
loading: true,
loadError: false,
},
@@ -33,8 +42,14 @@ Page({
async loadStation() {
this.setData({ loading: true, loadError: false })
try {
const home = await api.get<{ serviceLocation?: ServiceLocation }>('/home')
this.setData({ serviceLocation: home.serviceLocation || null, loading: false })
const station = await serviceStationService.getStation()
if (!station) {
this.setData({ serviceLocation: null, announcements: [], loading: false })
return
}
// 公告是增强信息:失败降级为空数组,不阻断站点主体信息展示
const announcements = await serviceStationService.getVisibleAnnouncements(station.id as string)
this.setData({ serviceLocation: station as ServiceLocation, announcements, loading: false })
} catch (error) {
console.error('加载服务站失败', error)
this.setData({ loading: false, loadError: true })
@@ -81,6 +96,19 @@ Page({
wx.navigateTo({ url: `/pages/service-appointment/service-appointment${query}` })
},
/** 公告详情:临时公告正文较长,用弹窗完整展示,避免卡片被截断 */
onShowAnnouncement(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
const target = (this.data.announcements as ServiceAnnouncement[]).find((item) => item.id === id)
if (!target) return
wx.showModal({
title: target.title || '临时公告',
content: target.content || target.title || '',
showCancel: false,
confirmText: '知道了',
})
},
onRetry() {
this.loadStation()
},
@@ -13,6 +13,22 @@
<view class="station-info-row"><image src="/assets/icons/home/activity.png" mode="aspectFit" /><view><text class="station-info-label">开放时间</text><text class="station-info-value">{{serviceLocation.openingHoursText || '以平台通知为准'}}</text></view></view>
</view>
<view class="block" wx:if="{{announcements.length}}">
<text class="block-title">临时公告</text>
<view class="station-announcements">
<view
wx:for="{{announcements}}"
wx:key="id"
class="station-announcement"
data-id="{{item.id}}"
bindtap="onShowAnnouncement"
>
<view class="station-announcement-head"><text class="station-announcement-dot"></text><text class="station-announcement-title">{{item.title}}</text></view>
<text class="station-announcement-body">{{item.content || item.title}}</text>
</view>
</view>
</view>
<view class="block"><text class="block-title">服务项目</text><view class="station-services"><view wx:for="{{serviceLocation.services}}" wx:key="*this" class="station-service"><image src="/assets/icons/home/matchmaker.png" mode="aspectFit" /><text>{{item}}</text></view></view></view>
<view class="privacy-note"><image src="/assets/icons/home/anti-fraud.png" mode="aspectFit" /><text>服务站人员仅处理现场预约与指定服务,不会浏览无关用户资料。</text></view>
@@ -1,2 +1,9 @@
@import "../../styles/service-page.wxss";
.station-name-card{display:flex;align-items:center;gap:18rpx;margin-top:24rpx;padding:24rpx;border-radius:26rpx;background:linear-gradient(135deg,#F1FFFA,#FFF7F9);box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.station-name-card>image{width:66rpx;height:66rpx}.station-name-card>view{display:flex;flex:1;flex-direction:column}.station-name{color:#283056;font-size:27rpx;font-weight:700}.station-short-name{margin-top:6rpx;color:#7E859B;font-size:19rpx}.station-info-card{overflow:hidden;margin-top:18rpx;border-radius:26rpx;background:#fff;box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.station-info-row{display:flex;align-items:center;gap:16rpx;min-height:104rpx;padding:14rpx 20rpx;border-bottom:1rpx solid #EFF0F5}.station-info-row:last-child{border-bottom:0}.station-info-row>image:first-child{width:42rpx;height:42rpx;flex:none}.station-info-row>view{display:flex;flex:1;flex-direction:column}.station-info-label{color:#303654;font-size:22rpx;font-weight:650}.station-info-value{margin-top:5rpx;color:#858A9E;font-size:19rpx;line-height:1.45}.station-info-row .station-arrow{width:24rpx;height:24rpx}.station-services{display:grid;grid-template-columns:repeat(2,1fr);gap:14rpx;margin-top:18rpx}.station-service{display:flex;align-items:center;gap:12rpx;min-height:74rpx;padding:12rpx 16rpx;border-radius:20rpx;background:#fff;color:#505874;font-size:20rpx;box-shadow:0 6rpx 20rpx rgba(35,35,95,.05)}.station-service image{width:34rpx;height:34rpx}.map-button{display:flex;align-items:center;justify-content:center;gap:10rpx;height:78rpx;margin-top:20rpx;border:1rpx solid #E5E7F0;border-radius:39rpx;background:#fff;color:#53607F;font-size:22rpx}.map-button image{width:34rpx;height:34rpx}
/* 临时公告:仅当服务端返回「已发布且未过期」公告时渲染 */
.station-announcements{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}
.station-announcement{padding:18rpx 20rpx;border-radius:20rpx;background:linear-gradient(135deg,#FFF7EC,#FFF7F9);box-shadow:0 6rpx 20rpx rgba(35,35,95,.05)}
.station-announcement-head{display:flex;align-items:center;gap:10rpx}
.station-announcement-dot{width:12rpx;height:12rpx;flex:none;border-radius:50%;background:#E8A33D}
.station-announcement-title{color:#5A4520;font-size:22rpx;font-weight:700}
.station-announcement-body{display:block;margin-top:8rpx;color:#8A7C63;font-size:19rpx;line-height:1.5}