feat: 微信分享图回退 + 活动时间区间 + 发布方权限 + 体验优化

- 活动/资讯分享图双通道回退(朋友圈shareImgTimeline/会话shareImgMessage,未设置回退封面)
- 资讯详情页新增朋友圈分享(enableShareTimeline)
- 活动分享标题去掉「· 云超服活动」后缀,只保留活动标题
- 活动时间渲染改为开始-结束区间(同一天只显示一次日期,跨天显示完整两端)
- 活动列表时间过长时回退只显示开始时间
- 活动详情页副标题改为活动标题
- 报名截止行强制单行不换行(标题+内容均nowrap)
- 默认问卷「最想解决的问题」admin可控显示(showDefaultQuestion!==false时渲染)
- 发布方/管理员权限拆分:报名管理入口用canReview||canManage(发布方+管理员均可进入审核报名)
This commit is contained in:
Pine
2026-09-09 21:53:02 +08:00
parent 001441505c
commit 0643cd1a0b
9 changed files with 72 additions and 27 deletions
@@ -1 +1,5 @@
export default { navigationStyle: 'custom' };
export default {
navigationStyle: 'custom',
enableShareAppMessage: true,
enableShareTimeline: true,
};
@@ -1,5 +1,5 @@
import { View, Text, Image, Video, ScrollView, Input, Button, RichText } from '@tarojs/components';
import Taro, { useShareAppMessage } from '@tarojs/taro';
import Taro, { useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getContent, getComments, addComment, toggleLike, reportShare } from '@/utils/content';
import { isAuthed } from '@/utils/auth';
@@ -40,16 +40,22 @@ export default function ContentDetail() {
const [replyTo, setReplyTo] = useState(null); // 被回复的评论 {id, name}hooks 必须在提前 return 之前)
const [expanded, setExpanded] = useState({}); // 展开全部回复的评论 id
// 微信分享:转发标题/封面/路径(配合 Button openType="share");转发落库 share_count +1
// 微信分享:会话(好友/群聊 5:4)+ 朋友圈(1:1),分享图 admin 端可单独配置,未配置回退封面;
// 转发落库 share_count +1(仅会话转发计数)
useShareAppMessage(() => {
const sid = (item && item.id) || '';
if (sid) reportShare(sid).then((r) => { if (r && r.share_count) setShareCount(r.share_count); }).catch(() => {});
return {
title: (item && item.title) || '云超服 · 资讯',
path: `/pages-extra/content-detail/index?id=${sid}`,
imageUrl: (item && (item.cover || item.video_cover)) || '',
imageUrl: (item && (item.share_img_message || item.cover || item.video_cover)) || '',
};
});
useShareTimeline(() => ({
title: (item && item.title) || '云超服 · 资讯',
query: (item && item.id) ? `id=${item.id}` : '',
imageUrl: (item && (item.share_img_timeline || item.cover || item.video_cover)) || '',
}));
useEffect(() => {
const inst = Taro.getCurrentInstance();
@@ -1,7 +1,7 @@
import { View, Text, Input, Textarea, Picker, Button, Image, RichText } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getEventDetail, submitBooking, getMyBookingForEvent, fmtEvt, payBooking, bookingPayStatus, requestWxPay, priceText } from '@/utils/booking';
import { getEventDetail, submitBooking, getMyBookingForEvent, fmtEvt, fmtEvtRange, payBooking, bookingPayStatus, requestWxPay, priceText } from '@/utils/booking';
/* rich-text 子树不吃外部 CSS:传入前对 HTML 内联约束,杜绝图片/表格横向溢出 */
const sanitizeRichHtml = (html) => String(html || '')
@@ -141,6 +141,12 @@ export default function EventDetail() {
finally { setBusy(false); }
};
const refreshMyBook = () => {
if (isAuthed() && id) {
getMyBookingForEvent(id).then((d) => setMyBook(d || null)).catch(() => {});
}
};
/* 微信一键登录:wx.login + 昵称头像 → 后端建/查 wxid 账号 */
const onWxLogin = async () => {
if (!agree) { Taro.showToast({ title: '请先阅读并同意《用户服务协议》和《隐私政策》', icon: 'none' }); return; }
@@ -152,6 +158,7 @@ export default function EventDetail() {
const d = await wxLogin({ code: loginCode, nickName, avatarUrl });
setAuthed(true);
setPhoneBound(!!d.phoneBound);
refreshMyBook(); // 登录成功立即刷新「我的报名」状态,已报名用户直接展示报名卡
} catch (e) { setErr((e && e.message) || '微信登录失败'); }
finally { setBusy(false); }
};
@@ -166,6 +173,7 @@ export default function EventDetail() {
const d = await wxPhoneBind(pcode);
setAuthed(true);
setPhoneBound(!!d.phoneBound);
refreshMyBook(); // 绑号成功刷新报名状态(已报名用户直接进入「我的报名」卡)
} catch (err) { setErr((err && err.message) || '绑定失败'); }
finally { setBusy(false); }
};
@@ -186,6 +194,7 @@ export default function EventDetail() {
}
setAuthed(true);
setPhoneBound(true);
refreshMyBook(); // 登录/绑号成功后同步报名状态
} catch (e) { setErr((e && e.message) || '登录失败'); }
finally { setBusy(false); }
};
@@ -366,12 +375,12 @@ export default function EventDetail() {
const manual = ev.auditMode === 'manual';
return (
<View className="ed page-in">
<PageHeader back no="EVENT" title={manual ? '报名已提交' : '报名成功'} sub="线下 · 线上 公益课与沙龙" />
<PageHeader back no="EVENT" title={manual ? '报名已提交' : '报名成功'} sub={ev.title} />
<View className="ok-icon" />
<Text className="ok-title">{manual ? '报名已提交' : '报名成功'}</Text>
{paid && ev.priceFen > 0 && <Text className="ok-desc">已支付 {priceText(ev.priceFen)}</Text>}
<Text className="ok-desc">场次{ev.title}</Text>
<Text className="ok-desc">时间{fmtEvt(ev.startAt)}</Text>
<Text className="ok-desc">时间{fmtEvtRange(ev.startAt, ev.endAt)}</Text>
<Text className="ok-desc">{ev.mode === 'online' ? `线上 · ${ev.location}` : `线下 · ${ev.location}`}</Text>
<Text className="ok-desc">预约编号{done.id}</Text>
<Text className="ok-desc">
@@ -390,7 +399,7 @@ export default function EventDetail() {
return (
<View className="ed page-in">
<PageHeader back no="EVENT" title="活动详情" sub="线下 · 线上 公益课与沙龙" />
<PageHeader back no="EVENT" title="活动详情" sub={ev.title} />
{/* 宣传图 / 头图 */}
<View className="ed-hero">
<Image className="ed-img" src={ev.image || coverWide} mode="aspectFill"
@@ -414,12 +423,12 @@ export default function EventDetail() {
{ev.subtitle && <Text className="ed-sub">{ev.subtitle}</Text>}
<View className="ed-info">
<View className="ed-line"><Text className="ed-k">时间</Text><Text className="ed-v">{fmtEvt(ev.startAt)}</Text></View>
<View className="ed-line"><Text className="ed-k">时间</Text><Text className="ed-v">{fmtEvtRange(ev.startAt, ev.endAt)}</Text></View>
<View className="ed-line"><Text className="ed-k">费用</Text><Text className="ed-v ed-price">{priceText(ev.priceFen)}</Text></View>
<View className="ed-line"><Text className="ed-k">{ev.mode === 'online' ? '入口' : '地点'}</Text><Text className="ed-v">{ev.location}</Text></View>
{ev.host && <View className="ed-line"><Text className="ed-k">主办</Text><Text className="ed-v">{ev.host}</Text></View>}
{ev.signupDeadline && (
<View className="ed-line"><Text className="ed-k">报名截止</Text>
<View className="ed-line deadline"><Text className="ed-k">报名截止</Text>
<Text className={`ed-v${signupClosed ? ' closed' : ''}`}>{fmtDeadline(ev.signupDeadline)}{signupClosed ? '(已截止)' : ''}</Text>
</View>
)}
@@ -428,7 +437,7 @@ export default function EventDetail() {
)}
</View>
{ev.canManage && (
{(ev.canReview || ev.canManage) && (
<View className="ed-manage">
<Button className="btn-ghost manage-btn" onClick={goManage}>报名管理审核/编辑名单</Button>
</View>
@@ -564,16 +573,19 @@ export default function EventDetail() {
)}
{authed && phoneBound && (
<View>
<Text className="ed-form-title">报名本场</Text>
{/* <Text className="ed-form-title">报名本场</Text> */}
<Text className="muted bind-tip">
将使用你的个人资料报名{user && user.name ? user.name : '已登录账号'}
{user && user.status ? ` · ${user.status}` : ''}可在我的页修改
</Text>
{/* 自定义报名表单(按活动定义渲染;必填/选填/隐藏字段均需填写) */}
{(ev.formFields || []).map((f) => renderField(f))}
<View className="field"><Text className="label">最想解决的问题选填</Text>
<Input value={question} onInput={(e) => setQuestion(e.detail.value)} placeholder="例:有设计技能但接单不稳定,想把它做成产品" className="input" />
</View>
{/* 默认问卷「最想解决的问题」:admin 可控制是否显示(默认显示,兼容旧数据) */}
{ev.showDefaultQuestion !== false && (
<View className="field"><Text className="label">最想解决的问题选填</Text>
<Input value={question} onInput={(e) => setQuestion(e.detail.value)} placeholder="例:有设计技能但接单不稳定,想把它做成产品" className="input" />
</View>
)}
</View>
)}
{debug && <Text className="ok-text">{debug}</Text>}
@@ -154,6 +154,9 @@
/* ── 报名体系升级:自定义表单 / 截止 / 隐藏信息 / 报名管理入口 ── */
.ed-v.closed { color: #f07575; }
/* 报名截止行:标题与内容均强制单行,不换行 */
.ed-line.deadline .ed-k { width: auto; flex-shrink: 0; white-space: nowrap; }
.ed-line.deadline .ed-v { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ed-manage { margin-top: 20rpx; }
.manage-btn { width: 100%; font-size: 26rpx; }
@@ -2,7 +2,7 @@ import { View, Text, Button, Image } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState } from 'react';
import { isAuthed } from '@/utils/auth';
import { getMyBookings, payBooking, requestWxPay, bookingPayStatus, priceText, cancelBooking, deleteBooking } from '@/utils/booking';
import { getMyBookings, payBooking, requestWxPay, bookingPayStatus, priceText, cancelBooking, deleteBooking, fmtEvtRange } from '@/utils/booking';
import { PageHeader } from '@/components/TopInset';
import Skeleton from '@/components/skeleton';
import './index.scss';
@@ -199,7 +199,7 @@ export default function MyBookings() {
<Text className="bk-want">{b.eventTitle || b.want || '报名'}</Text>
<Text className={`bk-phase ${p.cls}`}>{p.text}</Text>
</View>
{b.eventStart && <Text className="bk-time">场次时间{fmtTime(b.eventStart)}</Text>}
{b.eventStart && <Text className="bk-time">场次时间{fmtEvtRange(b.eventStart, b.eventEnd)}</Text>}
<Text className="bk-time">报名时间{fmtTime(b.createdAt)}</Text>
<Text className="bk-extra">编号 {b.id} · {b.name || '—'}</Text>
{renderFormData(b)}
@@ -1,7 +1,7 @@
import { View, Text, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState } from 'react';
import { myEvents, managedEvents, priceText, fmtEvt } from '@/utils/booking';
import { myEvents, managedEvents, priceText, fmtEvt, fmtEvtRange } from '@/utils/booking';
import { PageHeader } from '@/components/TopInset';
import './index.scss';
@@ -74,7 +74,7 @@ export default function MyEvents() {
<Text className="adm-meta">{e.category || '未分类'} · {e.region || '其他'} · {e.mode === 'online' ? '线上' : e.mode === 'both' ? '线上/线下' : '线下'}</Text>
</View>
<View className="adm-meta-row">
<Text className="adm-meta">{fmtEvt(e.startAt)}</Text>
<Text className="adm-meta">{fmtEvtRange(e.startAt, e.endAt)}</Text>
<Text className="adm-meta">{priceText(e.priceFen)}</Text>
</View>
{e.reviewStatus === 'rejected' && e.reviewComment ? <Text className="adm-note">驳回原因{e.reviewComment}</Text> : null}
+2 -2
View File
@@ -1,7 +1,7 @@
import { View, Text, Button, Image, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getBookableEvents, fmtEvt, priceText } from '@/utils/booking';
import { getBookableEvents, fmtEvt, fmtEvtRange, priceText } from '@/utils/booking';
import { cachedRequest } from '@/utils/api';
import { useTabIndex } from '@/utils/tab';
import Skeleton from '@/components/skeleton';
@@ -78,7 +78,7 @@ export default function Booking() {
<View className="cell-body">
<Text className="cell-name">{e.title}</Text>
<Text className="cell-desc">{e.desc}</Text>
<Text className="cell-meta">{fmtEvt(e.startAt)}</Text>
<Text className="cell-meta">{(() => { const t = fmtEvtRange(e.startAt, e.endAt); return t.length > 22 ? fmtEvt(e.startAt) : t; })()}</Text>
<Text className="cell-loc">{e.location}</Text>
{e.showCapacity && e.capacity > 0 && (
<Text className="cell-cap">名额 {e.enrolled || 0} / {e.capacity}</Text>
+17
View File
@@ -86,6 +86,23 @@ export function fmtEvt(iso) {
return `${d.getMonth() + 1}${d.getDate()}日 周${week} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 活动时间区间:开始-结束(同一天只显示一次日期;无结束时间则只显示开始)。 */
export function fmtEvtRange(start, end) {
const s = parseIso(start);
if (!s) return '';
const e = parseIso(end);
const p = (n) => String(n).padStart(2, '0');
const week = ['日', '一', '二', '三', '四', '五', '六'][s.getDay()];
const dateStr = `${s.getMonth() + 1}${s.getDate()}日 周${week}`;
const startStr = `${p(s.getHours())}:${p(s.getMinutes())}`;
if (!e) return `${dateStr} ${startStr}`;
if (s.getFullYear() === e.getFullYear() && s.getMonth() === e.getMonth() && s.getDate() === e.getDate()) {
return `${dateStr} ${startStr}-${p(e.getHours())}:${p(e.getMinutes())}`;
}
const eweek = ['日', '一', '二', '三', '四', '五', '六'][e.getDay()];
return `${dateStr} ${startStr} - ${e.getMonth() + 1}${e.getDate()}日 周${eweek} ${p(e.getHours())}:${p(e.getMinutes())}`;
}
/** 查当前登录用户是否已报名某活动及其审核状态(详情页判断用) */
export async function getMyBookingForEvent(eventId) {
return request('GET', `/api/bookings/by-event/${eventId}`, {}, true);
+9 -6
View File
@@ -3,22 +3,25 @@ import Taro, { useShareAppMessage, useShareTimeline } from '@tarojs/taro';
/**
* 活动双通道分享:对话(onShareAppMessage+ 朋友圈(onShareTimeline)。
*
* - 对话分享:标题 + 活动封面 + 落地页(带 id),群聊/单聊均可直达活动详情。
* - 朋友圈分享:标题 + 活动封面 + query 带 id(需页面 config 开启 enableShareTimeline)。
* - 对话分享:标题 + 分享图 + 落地页(带 id),群聊/单聊均可直达活动详情。
* - 朋友圈分享:标题 + 分享图 + query 带 id(需页面 config 开启 enableShareTimeline)。
* - 分享图规范(admin 端可单独配置,未配置自动回退活动封面):
* 朋友圈 1:1ev.shareImgTimeline)| 微信好友/群聊 5:4ev.shareImgMessage)。
* 活动封面为 3.35:1 宽幅,直接使用会被微信按 1:1/5:4 裁剪导致展示不完整。
*
* 用法:在组件内先持有 ev 状态,再调用 useEventShare(() => ev)。
*/
export function useEventShare(getEv) {
const ev = (typeof getEv === 'function' && getEv()) || null;
useShareAppMessage(() => ({
title: ev ? `${ev.title} · 云超服活动` : '云超服活动',
title: ev ? ev.title : '',
path: `/pages-extra/event-detail/index?id=${ev ? ev.id : ''}`,
imageUrl: ev && ev.image ? ev.image : '',
imageUrl: ev ? (ev.shareImgMessage || ev.image || '') : '',
}));
useShareTimeline(() => ({
title: ev ? ev.title : '云超服活动',
title: ev ? ev.title : '',
query: `id=${ev ? ev.id : ''}`,
imageUrl: ev && ev.image ? ev.image : '',
imageUrl: ev ? (ev.shareImgTimeline || ev.image || '') : '',
}));
}