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:
@@ -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>
|
||||
|
||||
@@ -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 }); },
|
||||
|
||||
// ============ 其他 ============
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
},
|
||||
"useCompilerPlugins": false,
|
||||
"useCompilerPlugins": [
|
||||
"typescript"
|
||||
],
|
||||
"minifyWXML": true
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 公益内容(U53 婚恋课堂 / U54 内容详情)
|
||||
*
|
||||
* 边界(Phase 19):用户端**只做文章列表与详情**——不做 Feed、不做评论、不做点赞。
|
||||
* 因此本层只提供分类 / 列表 / 详情三个只读方法。
|
||||
*/
|
||||
const api = require('../utils/api');
|
||||
|
||||
/** 内容分类 */
|
||||
function getCategories() {
|
||||
return api.get('/content/categories');
|
||||
}
|
||||
|
||||
/** 文章列表(可按分类过滤) */
|
||||
function getArticles(params = {}) {
|
||||
return api.get('/content/articles', params);
|
||||
}
|
||||
|
||||
/** 文章详情 */
|
||||
function getArticleDetail(id) {
|
||||
return api.get(`/content/articles/${id}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCategories,
|
||||
getArticles,
|
||||
getArticleDetail,
|
||||
};
|
||||
@@ -20,6 +20,13 @@ const matchmakerService = require('./matchmakerService');
|
||||
const safetyService = require('./safetyService');
|
||||
const appealService = require('./appealService');
|
||||
const appConfigService = require('./appConfigService');
|
||||
// Phase 19:公益服务链路(服务预约 / 专项服务 / 内容 / 服务记录)
|
||||
const serviceAppointmentService = require('./serviceAppointmentService');
|
||||
const serviceProgramService = require('./serviceProgramService');
|
||||
const contentService = require('./contentService');
|
||||
const serviceHistoryService = require('./serviceHistoryService');
|
||||
// Phase 19:服务站(U50)—— 临时公告只读接入
|
||||
const serviceStationService = require('./serviceStationService');
|
||||
|
||||
module.exports = {
|
||||
authService,
|
||||
@@ -34,4 +41,9 @@ module.exports = {
|
||||
safetyService,
|
||||
appealService,
|
||||
appConfigService,
|
||||
serviceAppointmentService,
|
||||
serviceProgramService,
|
||||
contentService,
|
||||
serviceHistoryService,
|
||||
serviceStationService,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 服务预约(U51 服务预约 / U52 我的预约)
|
||||
*
|
||||
* 归属与契约边界(Phase 19):
|
||||
* - 用户端只能读写本人预约:详情/列表/取消全部由服务端按登录主体过滤,
|
||||
* 越权访问一律返回空或 404,前端不得以任何参数指定他人 userId。
|
||||
* - 提交预约**只传服务内容**。来源(source)由服务端强制为 user_self,
|
||||
* 前端禁止传 requestSource / createdByOperatorId / userId(伪造运营代登记身份)。
|
||||
* - 运营代登记走独立端点(后端 /service-appointments/operator/register),前端不在本层暴露。
|
||||
*/
|
||||
const api = require('../utils/api');
|
||||
|
||||
/**
|
||||
* 读取当前服务站点(U51 预约上下文)
|
||||
*
|
||||
* 服务站页(U50)跳转时会带 `?serviceLocationId=xxx`,本方法用于把该 ID 还原成
|
||||
* 可展示的站点名。仅**展示用途**:真正的归属由服务端按登录主体校验,
|
||||
* 展示失败(无站点 / 接口异常)不影响提交,返回 null。
|
||||
* @returns {Promise<{id: string, name: string} | null>}
|
||||
*/
|
||||
async function getCurrentServiceLocation() {
|
||||
try {
|
||||
const home = await api.get('/home');
|
||||
const location = home && home.serviceLocation;
|
||||
if (!location || !location.id) {
|
||||
return null;
|
||||
}
|
||||
return { id: location.id, name: location.name || '已选服务站点' };
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的预约列表
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
function getMyAppointments() {
|
||||
return api.get('/service-appointments');
|
||||
}
|
||||
|
||||
/**
|
||||
* 预约详情(越权由服务端返回 404)
|
||||
* @param {string} id
|
||||
*/
|
||||
function getAppointmentDetail(id) {
|
||||
return api.get(`/service-appointments/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交预约
|
||||
* @param {{ serviceLocationId?: string, serviceType: string, preferredDate: string,
|
||||
* preferredTimeSlot?: string, userNote?: string }} payload
|
||||
*/
|
||||
function createAppointment(payload) {
|
||||
return api.post('/service-appointments', {
|
||||
serviceLocationId: payload.serviceLocationId,
|
||||
serviceType: payload.serviceType,
|
||||
preferredDate: payload.preferredDate,
|
||||
preferredTimeSlot: payload.preferredTimeSlot,
|
||||
userNote: payload.userNote,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消我的预约
|
||||
* @param {string} id
|
||||
* @param {string} [reason]
|
||||
*/
|
||||
function cancelAppointment(id, reason) {
|
||||
return api.post(`/service-appointments/${id}/cancel`, { reason: reason || '' });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCurrentServiceLocation,
|
||||
getMyAppointments,
|
||||
getAppointmentDetail,
|
||||
createAppointment,
|
||||
cancelAppointment,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 公益服务记录(U64)
|
||||
*
|
||||
* 只返回当前用户本人的服务时间线摘要(类别/标题/状态/时间)。
|
||||
* 服务端已排除咨询正文与内部备注(INV-013 最小可见),前端不得展示任何内部字段。
|
||||
*/
|
||||
const api = require('../utils/api');
|
||||
|
||||
function getMyServiceHistory() {
|
||||
return api.get('/me/service-history');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMyServiceHistory,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 专项公益服务项目(U61 专项公益服务介绍)
|
||||
*
|
||||
* 边界:项目成员身份只用于服务办理,**不得**成为用户价值标签(Phase 19 / INV 语义)。
|
||||
* 因此本层只暴露项目信息与本人申请状态,不提供"按项目成员筛选用户"之类能力。
|
||||
*/
|
||||
const api = require('../utils/api');
|
||||
|
||||
/** 可申请/进行中的专项服务项目列表 */
|
||||
function getPrograms(params = {}) {
|
||||
return api.get('/service-programs', params);
|
||||
}
|
||||
|
||||
/** 项目详情 */
|
||||
function getProgramDetail(id) {
|
||||
return api.get(`/service-programs/${id}`);
|
||||
}
|
||||
|
||||
/** 申请加入项目 */
|
||||
function applyToProgram(id) {
|
||||
return api.post(`/service-programs/${id}/apply`, {});
|
||||
}
|
||||
|
||||
/** 我的项目申请 */
|
||||
function getMyApplications() {
|
||||
return api.get('/service-programs/my');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPrograms,
|
||||
getProgramDetail,
|
||||
applyToProgram,
|
||||
getMyApplications,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 善缘服务站(U50)服务层。
|
||||
*
|
||||
* Phase 19:U50 的内容清单包含「临时公告 / 开放时间 / 服务项目 / 预约到站」,
|
||||
* 但公告此前**全仓没有任何存储与接口**,页面也无落点。本轮补齐后端
|
||||
* (V042 + 运营端五个端点 + 用户端只读端点)后由此接入。
|
||||
*
|
||||
* 分层:按 services/index.js 的约定,页面不直接 require utils/api,
|
||||
* 统一从本模块取数据(本站点主体信息来自首页聚合,公告来自服务站公告端点)。
|
||||
*
|
||||
* 契约:
|
||||
* GET /home → 当前服务站主体信息
|
||||
* GET /service-locations/{id}/announcements → 仅 status=published 且未过期
|
||||
* 草稿/归档/过期由服务端过滤,前端不得再做"猜测式"过滤,
|
||||
* 否则等于把未定稿内容拿到客户端。
|
||||
*/
|
||||
const api = require('../utils/api');
|
||||
|
||||
/**
|
||||
* 当前展示的服务站(首页聚合给出的唯一站点)。
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
async function getStation() {
|
||||
const home = await api.get('/home');
|
||||
return (home && home.serviceLocation) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某服务站的可见临时公告。
|
||||
*
|
||||
* 公告属**增强信息**:请求失败时返回空数组而不抛出,
|
||||
* 避免"公告接口挂了导致整个服务站详情页变成错误页"。
|
||||
* @param {string} locationId
|
||||
* @returns {Promise<Array<{id: string, title: string, content?: string}>>}
|
||||
*/
|
||||
async function getVisibleAnnouncements(locationId) {
|
||||
if (!locationId) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const list = await api.get(`/service-locations/${encodeURIComponent(locationId)}/announcements`);
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch (err) {
|
||||
console.warn('加载服务站公告失败', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStation,
|
||||
getVisibleAnnouncements,
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
page{background:var(--color-bg-page)}.service-page{min-height:100vh;padding-bottom:calc(140rpx + env(safe-area-inset-bottom))}.hero{padding:44rpx 32rpx 54rpx;background:linear-gradient(140deg,var(--color-primary-lighter),#FFF8EE);position:relative;overflow:hidden}.eyebrow{display:inline-flex;padding:7rpx 14rpx;border-radius:999rpx;background:rgba(255,255,255,.72);color:var(--color-primary);font-size:21rpx}.hero-title{display:block;margin-top:18rpx;font-size:40rpx;font-weight:700;line-height:1.35}.hero-desc{display:block;width:82%;margin-top:14rpx;font-size:25rpx;color:var(--color-text-secondary);line-height:1.7}.content{padding:0 var(--spacing-page-x)}.block{margin-top:var(--spacing-section)}.block-title{display:block;font-size:31rpx;font-weight:650}.block-subtitle{display:block;margin-top:8rpx;font-size:23rpx;color:var(--color-text-tertiary)}.card{margin-top:18rpx;padding:28rpx;background:var(--color-bg-card);border:1rpx solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-card)}.feature-row,.menu-row,.record-row{display:flex;align-items:center;gap:20rpx;min-height:112rpx;padding:18rpx 0;border-bottom:1rpx solid var(--color-divider)}.feature-row:last-child,.menu-row:last-child,.record-row:last-child{border-bottom:0}.feature-icon{width:72rpx;height:72rpx;display:flex;align-items:center;justify-content:center;flex:none;border-radius:22rpx;background:var(--color-primary-light);color:var(--color-primary);font-weight:700}.feature-copy{flex:1;min-width:0}.feature-title{display:block;font-size:28rpx;font-weight:600}.feature-desc,.meta{display:block;margin-top:7rpx;font-size:23rpx;color:var(--color-text-tertiary);line-height:1.55}.arrow{font-size:40rpx;color:var(--color-text-disabled)}.tag{display:inline-flex;margin-right:10rpx;margin-top:10rpx;padding:6rpx 13rpx;border-radius:999rpx;background:var(--color-success-light);color:var(--color-success);font-size:20rpx}.tag.warning{background:var(--color-warning-light);color:var(--color-warning)}.primary-btn,.secondary-btn{height:88rpx;display:flex;align-items:center;justify-content:center;border-radius:44rpx;font-size:28rpx;font-weight:600}.primary-btn{background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:var(--color-text-inverse);box-shadow:0 10rpx 24rpx rgba(255,92,130,.22)}.primary-btn.disabled{background:#E6E8EF;color:#989DAF;box-shadow:none}.secondary-btn{background:var(--color-primary-light);color:var(--color-primary)}.fixed-action{position:fixed;left:0;right:0;bottom:0;padding:18rpx var(--spacing-page-x) calc(18rpx + env(safe-area-inset-bottom));background:rgba(255,255,255,.96);border-top:1rpx solid var(--color-divider);z-index:100}.field{margin-top:26rpx}.label{display:block;margin-bottom:12rpx;font-size:25rpx;font-weight:600}.input,.select-box,.textarea{width:100%;box-sizing:border-box;background:#FCFAF8;border:1rpx solid var(--color-border);border-radius:var(--radius-md);font-size:26rpx}.input,.select-box{height:88rpx;padding:0 24rpx;display:flex;align-items:center}.textarea{min-height:180rpx;padding:22rpx}.notice{margin-top:20rpx;padding:22rpx;border-radius:var(--radius-md);background:#F3F7FC;color:var(--color-text-secondary);font-size:23rpx;line-height:1.65}.empty-panel{text-align:center;padding:70rpx 30rpx}.empty-symbol{width:96rpx;height:96rpx;margin:auto;display:flex;align-items:center;justify-content:center;border-radius:30rpx;background:var(--color-primary-light);color:var(--color-primary);font-size:36rpx;font-weight:700}.empty-title{display:block;margin-top:22rpx;font-size:30rpx;font-weight:600}.article-card{margin-top:18rpx;background:var(--color-bg-card);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-card)}.article-cover{height:220rpx;padding:24rpx;background:linear-gradient(135deg,#F5E5DE,#F9F1E8);display:flex;align-items:flex-end}.article-cover text{font-size:42rpx;color:rgba(125,76,63,.52);font-weight:700}.article-body{padding:24rpx}.article-title{display:block;font-size:29rpx;font-weight:650;line-height:1.45}.article-meta{display:block;margin-top:10rpx;font-size:22rpx;color:var(--color-text-tertiary)}.article-content{padding:32rpx;background:var(--color-bg-card);font-size:28rpx;line-height:1.9;color:var(--color-text-secondary)}.article-content .lead{display:block;font-size:31rpx;color:var(--color-text-primary);font-weight:600;margin-bottom:24rpx}.danger-card{background:#FFF8F4;border-color:#F5DED2}.danger-card .feature-icon{background:#FFF0EA;color:#C85D43}@media(max-width:340px){.hero-title{font-size:36rpx}.hero-desc{width:92%}}
|
||||
page{background:var(--color-bg-page)}.service-page{min-height:100vh;padding-bottom:calc(140rpx + env(safe-area-inset-bottom))}.hero{padding:44rpx 32rpx 54rpx;background:linear-gradient(140deg,var(--color-primary-lighter),#FFF8EE);position:relative;overflow:hidden}.eyebrow{display:inline-flex;padding:7rpx 14rpx;border-radius:999rpx;background:rgba(255,255,255,.72);color:var(--color-primary);font-size:21rpx}.hero-title{display:block;margin-top:18rpx;font-size:40rpx;font-weight:700;line-height:1.35}.hero-desc{display:block;width:82%;margin-top:14rpx;font-size:25rpx;color:var(--color-text-secondary);line-height:1.7}.content{padding:0 var(--spacing-page-x)}.block{margin-top:var(--spacing-section)}.block-title{display:block;font-size:31rpx;font-weight:650}.block-subtitle{display:block;margin-top:8rpx;font-size:23rpx;color:var(--color-text-tertiary)}.card{margin-top:18rpx;padding:28rpx;background:var(--color-bg-card);border:1rpx solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-card)}.feature-row,.menu-row,.record-row{display:flex;align-items:center;gap:20rpx;min-height:112rpx;padding:18rpx 0;border-bottom:1rpx solid var(--color-divider)}.feature-row:last-child,.menu-row:last-child,.record-row:last-child{border-bottom:0}.feature-icon{width:72rpx;height:72rpx;display:flex;align-items:center;justify-content:center;flex:none;border-radius:22rpx;background:var(--color-primary-light);color:var(--color-primary);font-weight:700}.feature-copy{flex:1;min-width:0}.feature-title{display:block;font-size:28rpx;font-weight:600}.feature-desc,.meta{display:block;margin-top:7rpx;font-size:23rpx;color:var(--color-text-tertiary);line-height:1.55}.arrow{font-size:40rpx;color:var(--color-text-disabled)}.tag{display:inline-flex;margin-right:10rpx;margin-top:10rpx;padding:6rpx 13rpx;border-radius:999rpx;background:var(--color-success-light);color:var(--color-success);font-size:20rpx}.tag.warning{background:var(--color-warning-light);color:var(--color-warning)}.primary-btn,.secondary-btn{height:88rpx;display:flex;align-items:center;justify-content:center;border-radius:44rpx;font-size:28rpx;font-weight:600}.primary-btn{background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:var(--color-text-inverse);box-shadow:0 10rpx 24rpx rgba(255,92,130,.22)}.primary-btn.disabled{background:#E6E8EF;color:#989DAF;box-shadow:none}.secondary-btn{background:var(--color-primary-light);color:var(--color-primary)}.fixed-action{position:fixed;left:0;right:0;bottom:0;padding:18rpx var(--spacing-page-x) calc(18rpx + env(safe-area-inset-bottom));background:rgba(255,255,255,.96);border-top:1rpx solid var(--color-divider);z-index:100}.field{margin-top:26rpx}.label{display:block;margin-bottom:12rpx;font-size:25rpx;font-weight:600}.input,.select-box,.textarea{width:100%;box-sizing:border-box;background:#FCFAF8;border:1rpx solid var(--color-border);border-radius:var(--radius-md);font-size:26rpx}.input,.select-box{height:88rpx;padding:0 24rpx;display:flex;align-items:center}.textarea{min-height:180rpx;padding:22rpx}.notice{margin-top:20rpx;padding:22rpx;border-radius:var(--radius-md);background:#F3F7FC;color:var(--color-text-secondary);font-size:23rpx;line-height:1.65}.select-box.is-readonly{background:#F4F2F0;color:var(--color-text-secondary)}.empty-panel{text-align:center;padding:70rpx 30rpx}.empty-symbol{width:96rpx;height:96rpx;margin:auto;display:flex;align-items:center;justify-content:center;border-radius:30rpx;background:var(--color-primary-light);color:var(--color-primary);font-size:36rpx;font-weight:700}.empty-title{display:block;margin-top:22rpx;font-size:30rpx;font-weight:600}.article-card{margin-top:18rpx;background:var(--color-bg-card);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-card)}.article-cover{height:220rpx;padding:24rpx;background:linear-gradient(135deg,#F5E5DE,#F9F1E8);display:flex;align-items:flex-end}.article-cover text{font-size:42rpx;color:rgba(125,76,63,.52);font-weight:700}.article-body{padding:24rpx}.article-title{display:block;font-size:29rpx;font-weight:650;line-height:1.45}.article-meta{display:block;margin-top:10rpx;font-size:22rpx;color:var(--color-text-tertiary)}.article-content{padding:32rpx;background:var(--color-bg-card);font-size:28rpx;line-height:1.9;color:var(--color-text-secondary)}.article-content .lead{display:block;font-size:31rpx;color:var(--color-text-primary);font-weight:600;margin-bottom:24rpx}.danger-card{background:#FFF8F4;border-color:#F5DED2}.danger-card .feature-icon{background:#FFF0EA;color:#C85D43}@media(max-width:340px){.hero-title{font-size:36rpx}.hero-desc{width:92%}}
|
||||
|
||||
.service-shell{min-height:100vh;padding-bottom:calc(136rpx + env(safe-area-inset-bottom));background:#F4F5FA}.service-content{padding:8rpx 24rpx 36rpx}.service-status{display:flex;align-items:center;justify-content:space-between;margin-top:24rpx;padding:20rpx 24rpx;border-radius:22rpx;background:#fff;box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.service-status__label{color:#7B8198;font-size:21rpx}.service-status__value{padding:6rpx 14rpx;border-radius:18rpx;background:#EAF8F2;color:#329472;font-size:19rpx;font-weight:650}.service-status__value.disabled{background:#FFF0F0;color:#D45A60}.service-topics{display:grid;grid-template-columns:repeat(2,1fr);gap:16rpx;margin-top:18rpx}.service-topic{min-height:148rpx;padding:20rpx;border-radius:24rpx;background:#fff;box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.service-topic__icon{display:flex;align-items:center;justify-content:center;width:54rpx;height:54rpx;border-radius:17rpx;background:#FFF0F5}.service-topic__icon image{width:34rpx;height:34rpx}.service-topic__title{display:block;margin-top:13rpx;color:#283056;font-size:24rpx;font-weight:650}.service-topic__desc{display:block;margin-top:6rpx;color:#858A9E;font-size:19rpx;line-height:1.45}.service-actions{display:flex;gap:14rpx}.service-actions .secondary-btn{width:190rpx;flex:none}.service-actions .primary-btn{flex:1}.privacy-note{display:flex;align-items:flex-start;gap:14rpx;margin-top:22rpx;padding:20rpx;border-radius:22rpx;background:#F1F4FF;color:#69718E;font-size:20rpx;line-height:1.55}.privacy-note image{width:34rpx;height:34rpx;flex:none}
|
||||
|
||||
Reference in New Issue
Block a user