feat(mine): 小程序「我的」页对齐 Web 个人中心 + 我的报名/任务/园区/认证子页

- mine 页:我的服务宫格(报名/任务/园区/认证)+账号绑定状态卡(手机号/微信/小程序)+身份标签;pickUser 持久化 wxBound/wxMiniBound/account_type_label
- 新增子页 my-bookings/my-tasks/my-park/my-cert(复用 getMyBookings/getMyTasks/getMyAdmission/getOpCertMine)
- app.config 注册 4 子页;build:weapp 通过
This commit is contained in:
Pine
2026-08-27 20:31:04 +08:00
parent 3216e2399b
commit 67f66abbc4
16 changed files with 393 additions and 1 deletions
+5 -1
View File
@@ -16,7 +16,11 @@ export default {
'pages/content-detail/index',
'pages/tasks/index',
'pages/task-detail/index',
'pages/scan-login/index'
'pages/scan-login/index',
'pages/my-bookings/index',
'pages/my-tasks/index',
'pages/my-park/index',
'pages/my-cert/index'
],
window: {
backgroundTextStyle: 'light',
+36
View File
@@ -41,6 +41,15 @@ const fmtTime = (iso) => {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}${week}`;
};
/** 我的服务宫格项:label → 子页 */
const SVC = [
['报名', '/pages/my-bookings/index'],
['任务', '/pages/my-tasks/index'],
['园区', '/pages/my-park/index'],
['认证', '/pages/my-cert/index'],
];
const maskPhone = (p) => (/^1\d{10}$/.test(p || '') ? p.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : (p || ''));
export default function Mine() {
useTabIndex(4);
const [user, setUser] = useState(getUser());
@@ -328,6 +337,9 @@ export default function Mine() {
<Text className={`phone-tag${user.phoneBound ? ' bound' : ''}`}>
{user.phoneBound ? (user.phone ? `已绑定 ${user.phone}` : '已绑定手机') : '未绑定手机'}
</Text>
{user.account_type_label || user.role ? (
<Text className="role-tag">{user.account_type_label || user.role}</Text>
) : null}
{nickDraft && nickDraft !== ((user && user.name) || '') && (
<Button className="btn-ghost save-nick" onClick={onSaveNick}>保存昵称</Button>
)}
@@ -335,6 +347,30 @@ export default function Mine() {
<Button className="btn-ghost logout-btn" onClick={onLogout}>退出</Button>
</View>
{/* 我的服务宫格:报名 / 任务 / 园区 / 认证 */}
<View className="mk-grid">
{SVC.map(([label, url]) => (
<View className="mk-grid-item" key={label} onClick={() => Taro.navigateTo({ url })}>
<View className="mk-grid-ico"><Text>{label.slice(0, 1)}</Text></View>
<Text className="mk-grid-label">我的{label}</Text>
</View>
))}
</View>
{/* 账号绑定状态 */}
<View className="card bind-state">
<Text className="bind-title">账号绑定</Text>
<View className="bind-line"><Text className="bind-key">手机号</Text>
<Text className={`bind-val${user.phoneBound ? ' on' : ''}`}>{user.phoneBound ? maskPhone(user.phone) : '未绑定'}</Text>
</View>
<View className="bind-line"><Text className="bind-key">微信(开放平台)</Text>
<Text className={`bind-val${user.wxBound ? ' on' : ''}`}>{user.wxBound ? '已绑定' : '未绑定'}</Text>
</View>
<View className="bind-line"><Text className="bind-key">小程序</Text>
<Text className={`bind-val${user.wxMiniBound ? ' on' : ''}`}>{user.wxMiniBound ? '已绑定' : '未绑定'}</Text>
</View>
</View>
{/* 跨端口扫码登录:由电脑/网页登录页展示「小程序码」,微信扫码自动进入小程序授权页确认 */}
<View className="card next-card">
<Text className="next-lbl">登录其它端</Text>
+15
View File
@@ -378,3 +378,18 @@
.save-nick { margin-top: 12rpx; align-self: flex-start; line-height: 2.2; padding: 0 28rpx; font-size: 24rpx; }
.scan-err { display:block; margin-top:12rpx; font-size:24rpx; color:#f0b8b8; text-align:center; }
/* ---- 我的服务宫格 ---- */
.role-tag { display:inline-block; margin-top:10rpx; padding:2rpx 20rpx; border-radius:999px; font-size:24rpx; color:#1f8ce0; background:#eaf6ff; align-self:flex-start; }
.mk-grid { display:flex; gap:16rpx; padding:0 24rpx; margin-top:24rpx; }
.mk-grid-item { flex:1; background:#fff; border-radius:24rpx; padding:24rpx 8rpx; display:flex; flex-direction:column; align-items:center; gap:10rpx; box-shadow:0 8rpx 22rpx -12rpx rgba(13,47,78,.16); }
.mk-grid-ico { width:72rpx; height:72rpx; border-radius:50%; display:flex; align-items:center; justify-content:center; background:#f2f8ff; color:#06acf1; font-size:30rpx; font-weight:700; }
.mk-grid-label { font-size:24rpx; color:#23315c; font-weight:600; }
/* ---- 账号绑定状态 ---- */
.bind-state { margin-top:24rpx; }
.bind-line { display:flex; align-items:center; justify-content:space-between; padding:16rpx 2rpx; border-bottom:1px solid #f0f2f6; }
.bind-line:last-child { border-bottom:none; }
.bind-key { font-size:26rpx; color:#6a6a73; }
.bind-val { font-size:26rpx; color:#98a0ab; }
.bind-val.on { color:#00a06e; font-weight:600; }
@@ -0,0 +1 @@
export default { navigationBarTitleText: '我的报名' };
@@ -0,0 +1,92 @@
import { View, Text, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { isAuthed } from '@/utils/auth';
import { getMyBookings, checkIn, parseIso } from '@/utils/booking';
import './index.scss';
const PHASE_META = {
pending: { text: '审核中', cls: 'pending' },
rejected: { text: '未通过', cls: 'rejected' },
done: { text: '已结束', cls: 'done' },
upcoming: { text: '待参加', cls: 'upcoming' }
};
const phaseOf = (b) => {
const audit = b.auditStatus !== undefined ? b.auditStatus : b.audit_status;
const evStatus = b.event_status !== undefined ? b.event_status : b.eventStatus;
if (audit === 'pending') return PHASE_META.pending;
if (audit === 'rejected') return PHASE_META.rejected;
if (evStatus === 'done') return PHASE_META.done;
return PHASE_META.upcoming;
};
const fmtTime = (iso) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(+d)) return iso;
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
};
export default function MyBookings() {
const [bookings, setBookings] = useState([]);
const [loaded, setLoaded] = useState(false);
const [err, setErr] = useState('');
const [busyId, setBusyId] = useState('');
const load = async () => {
try {
const d = await getMyBookings();
setBookings((d && d.list) || []);
} catch (e) { setErr((e && e.message) || '加载失败'); }
finally { setLoaded(true); }
};
useEffect(() => { load(); }, []);
const onCheckin = async (id) => {
setBusyId(id); setErr('');
try {
const r = await checkIn(id);
if (r.ok) Taro.showToast({ title: '签到成功', icon: 'success' });
else Taro.showToast({ title: r.error || '签到失败', icon: 'none' });
load();
} catch (e) { Taro.showToast({ title: (e && e.message) || '签到失败', icon: 'none' }); }
finally { setBusyId(''); }
};
const goActivities = () => Taro.switchTab({ url: '/pages/booking/index' });
return (
<View className="page-in">
<View className="mk-head">
<Text className="mk-title">我的报名</Text>
<Text className="mk-sub">你参加过的公益课 / 活动报名与审核状态</Text>
</View>
{!isAuthed() && <View className="card"><Text className="muted">请先登录我的 登录绑定</Text></View>}
{err && <Text className="mk-err">{err}</Text>}
{!loaded ? <Text className="muted">加载中</Text>
: bookings.length === 0 ? (
<View className="card mk-empty">
<Text className="muted">还没有报名记录</Text>
<Text className="link" onClick={goActivities}>去看活动 </Text>
</View>
) : bookings.map((b) => {
const p = phaseOf(b);
const ended = b.event_status === 'done';
const ready = b.can_checkin;
return (
<View className="mk-card" key={b.id}>
<View className="mk-row">
<Text className="mk-name">{b.eventTitle || b.want || '报名'}</Text>
<Text className={`mk-phase ${p.cls}`}>{p.text}</Text>
</View>
{b.eventStart ? <Text className="mk-meta">场次 {fmtTime(b.eventStart)}</Text> : null}
<Text className="mk-meta">报名于 {fmtTime(b.createdAt)}</Text>
{b.checkinAt ? <Text className="mk-checked">已于 {fmtTime(b.checkinAt)} 签到</Text>
: ended ? <Text className="mk-checked done">本场已结束</Text>
: ready ? <Button className="btn-main mk-checkin" loading={busyId === b.id} onClick={() => onCheckin(b.id)}>活动签到</Button>
: <Text className="mk-checked">未到签到时间活动开始后即可签到</Text>}
</View>
);
})}
</View>
);
}
@@ -0,0 +1,17 @@
.mk-head { padding: 18px 20px 6px; }
.mk-title { font-size: 24px; font-weight: 700; color: #0d2f4e; display: block; }
.mk-sub { font-size: 13px; color: #8a8a93; margin-top: 4px; display: block; }
.mk-err { color: #e0544c; font-size: 13px; padding: 0 20px; }
.mk-card { background: #fff; border-radius: 18px; padding: 16px 18px; margin: 10px 20px; box-shadow: 0 6px 18px -8px rgba(13,47,78,.12); }
.mk-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.mk-name { font-size: 16px; font-weight: 600; color: #12233b; flex: 1; }
.mk-phase { flex: 0 0 auto; font-size: 12px; padding: 2px 12px; border-radius: 999px; }
.mk-phase.upcoming { color: #1f8ce0; background: #eaf6ff; }
.mk-phase.pending { color: #e6a400; background: #fff6e2; }
.mk-phase.rejected { color: #98a0ab; background: #f2f2f6; }
.mk-phase.done { color: #98a0ab; background: #f2f2f6; }
.mk-meta { display: block; font-size: 12px; color: #98a0ab; margin-top: 6px; }
.mk-checked { display: block; margin-top: 10px; font-size: 13px; color: #00a06e; }
.mk-checked.done { color: #98a0ab; }
.mk-checkin { margin-top: 12px; }
.mk-empty { text-align: center; padding: 40px 20px; }
@@ -0,0 +1 @@
export default { navigationBarTitleText: '认证中心' };
+59
View File
@@ -0,0 +1,59 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { isAuthed } from '@/utils/auth';
import { getOpCertMine } from '@/utils/profile';
import './index.scss';
const CERT = {
uncertified: ['未认证', 'muted'],
pending: ['待提交', 'pending'],
reviewing: ['审核中', 'pending'],
certified: ['已认证', 'ok'],
rejected: ['未通过', 'rejected'],
};
const badge = (s) => CERT[String(s || '').toLowerCase()] || [s || '未认证', 'muted'];
export default function MyCert() {
const [r, setR] = useState({});
const [loaded, setLoaded] = useState(false);
const [err, setErr] = useState('');
const load = async () => {
try {
const d = await getOpCertMine();
setR(d || {});
} catch (e) { setErr((e && e.message) || '加载失败'); }
finally { setLoaded(true); }
};
useEffect(() => { load(); }, []);
const status = r.certification_status || '';
const cert = r.certification || {};
const [txt, cls] = badge(status);
const certified = String(status).toLowerCase() === 'certified';
const goApply = () => Taro.navigateTo({ url: '/pages/cert-apply/index' });
return (
<View className="page-in">
<View className="mk-head">
<Text className="mk-title">认证中心</Text>
<Text className="mk-sub">你的 OPC 创业伙伴认证进度</Text>
</View>
{!isAuthed() && <View className="card"><Text className="muted">请先登录我的 登录绑定</Text></View>}
{err && <Text className="mk-err">{err}</Text>}
{!loaded ? <Text className="muted">加载中</Text> : (
<View className="mk-card">
<View className="mk-row">
<Text className="mk-name">OPC 创业伙伴认证</Text>
<Text className={`mk-phase ${cls}`}>{txt}</Text>
</View>
{cert.cert_no ? <Text className="mk-meta">证书编号{cert.cert_no}</Text> : null}
{cert.certified_at ? <Text className="mk-meta">认证时间{cert.certified_at}</Text> : null}
<Text className="mk-meta">在平台完成指定指标后即可获得认证享受政策 / 资源对接权益</Text>
{!certified && <Text className="link" onClick={goApply}>去认证 </Text>}
</View>
)}
</View>
);
}
+13
View File
@@ -0,0 +1,13 @@
.mk-head { padding: 18px 20px 6px; }
.mk-title { font-size: 24px; font-weight: 700; color: #0d2f4e; display: block; }
.mk-sub { font-size: 13px; color: #8a8a93; margin-top: 4px; display: block; }
.mk-err { color: #e0544c; font-size: 13px; padding: 0 20px; }
.mk-card { background: #fff; border-radius: 18px; padding: 18px; margin: 12px 20px; box-shadow: 0 6px 18px -8px rgba(13,47,78,.12); }
.mk-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.mk-name { font-size: 16px; font-weight: 600; color: #12233b; flex: 1; }
.mk-phase { flex: 0 0 auto; font-size: 12px; padding: 2px 12px; border-radius: 999px; }
.mk-phase.ok { color: #00a06e; background: #e6f8f0; }
.mk-phase.pending { color: #e6a400; background: #fff6e2; }
.mk-phase.rejected { color: #98a0ab; background: #f2f2f6; }
.mk-phase.muted { color: #98a0ab; background: #f2f2f6; }
.mk-meta { display: block; font-size: 13px; color: #98a0ab; margin-top: 8px; }
@@ -0,0 +1 @@
export default { navigationBarTitleText: '我的园区' };
+63
View File
@@ -0,0 +1,63 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { isAuthed } from '@/utils/auth';
import { getMyAdmission } from '@/utils/parks';
import './index.scss';
function statusText(s) {
const k = String(s || '').toLowerCase();
if (['approved', 'verified', 'admitted'].includes(k) || s === '已入驻' || s === '通过') return ['已入驻', 'ok'];
if (['pending', 'reviewing'].includes(k) || s === '审核中') return ['审核中', 'pending'];
if (['rejected'].includes(k) || s === '未通过') return ['未通过', 'rejected'];
return [s || '待审核', 'pending'];
}
export default function MyPark() {
const [items, setItems] = useState([]);
const [loaded, setLoaded] = useState(false);
const [err, setErr] = useState('');
const load = async () => {
try {
const r = await getMyAdmission();
setItems(Array.isArray(r) ? r : []);
} catch (e) { setErr((e && e.message) || '加载失败'); }
finally { setLoaded(true); }
};
useEffect(() => { load(); }, []);
const goApply = () => Taro.navigateTo({ url: '/pages/park-apply/index' });
const ad = items[0] || {};
return (
<View className="page-in">
<View className="mk-head">
<Text className="mk-title">我的园区</Text>
<Text className="mk-sub">查看你的园区入驻申请与所属园区</Text>
</View>
{!isAuthed() && <View className="card"><Text className="muted">请先登录我的 登录绑定</Text></View>}
{err && <Text className="mk-err">{err}</Text>}
{!loaded ? <Text className="muted">加载中</Text>
: items.length === 0 ? (
<View className="card mk-empty">
<Text className="muted">你还没有提交园区入驻申请</Text>
<Text className="link" onClick={goApply}>去申请入驻 </Text>
</View>
) : (() => {
const [txt, cls] = statusText(ad.status || ad.audit_status);
const name = ad.tenant_name || ad.park_name || ad.company_name || ad.company || '昆明市大学生创业园';
return (
<View className="mk-card">
<View className="mk-row">
<Text className="mk-name">{name}</Text>
<Text className={`mk-phase ${cls}`}>{txt}</Text>
</View>
{ad.region ? <Text className="mk-meta">区域{ad.region}</Text> : null}
{ad.created_at ? <Text className="mk-meta">申请时间{ad.created_at}</Text> : null}
</View>
);
})()}
</View>
);
}
+13
View File
@@ -0,0 +1,13 @@
.mk-head { padding: 18px 20px 6px; }
.mk-title { font-size: 24px; font-weight: 700; color: #0d2f4e; display: block; }
.mk-sub { font-size: 13px; color: #8a8a93; margin-top: 4px; display: block; }
.mk-err { color: #e0544c; font-size: 13px; padding: 0 20px; }
.mk-card { background: #fff; border-radius: 18px; padding: 18px; margin: 12px 20px; box-shadow: 0 6px 18px -8px rgba(13,47,78,.12); }
.mk-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.mk-name { font-size: 16px; font-weight: 600; color: #12233b; flex: 1; }
.mk-phase { flex: 0 0 auto; font-size: 12px; padding: 2px 12px; border-radius: 999px; }
.mk-phase.ok { color: #00a06e; background: #e6f8f0; }
.mk-phase.pending { color: #e6a400; background: #fff6e2; }
.mk-phase.rejected { color: #98a0ab; background: #f2f2f6; }
.mk-meta { display: block; font-size: 13px; color: #98a0ab; margin-top: 6px; }
.mk-empty { text-align: center; padding: 40px 20px; }
@@ -0,0 +1 @@
export default { navigationBarTitleText: '我的任务' };
+59
View File
@@ -0,0 +1,59 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { isAuthed } from '@/utils/auth';
import { getMyTasks } from '@/utils/tasks';
import './index.scss';
const MODE = { grab: '抢单', bid: '竞标', assign: '指派', recommend: '推荐' };
const money = (a, b) => (a || b) ? (a && b && a !== b ? `¥${a}${b}` : `¥${a || b}`) : '面议';
const itemsOf = (r) => (r && r.items) || (r && r.list) || (Array.isArray(r) ? r : []);
export default function MyTasks() {
const [items, setItems] = useState([]);
const [loaded, setLoaded] = useState(false);
const [err, setErr] = useState('');
const load = async () => {
try {
const r = await getMyTasks();
setItems(itemsOf(r));
} catch (e) {
setErr((e && e.message) || '加载失败');
} finally { setLoaded(true); }
};
useEffect(() => { load(); }, []);
const open = (id) => () => Taro.navigateTo({ url: `/pages/task-detail/index?id=${id}` });
const goSquare = () => Taro.navigateTo({ url: '/pages/tasks/index' });
return (
<View className="page-in">
<View className="mk-head">
<Text className="mk-title">我的任务</Text>
<Text className="mk-sub">你承接 / 参与的任务点击查看详情</Text>
</View>
{!isAuthed() && <View className="card"><Text className="muted">请先登录我的 登录绑定</Text></View>}
{err && <Text className="mk-err">{err}</Text>}
{!loaded ? <Text className="muted">加载中</Text>
: items.length === 0 ? (
<View className="card mk-empty">
<Text className="muted">还没有承接的任务</Text>
<Text className="link" onClick={goSquare}>去任务广场 </Text>
</View>
) : items.map((t) => (
<View className="mk-card" key={t.id} onClick={open(t.id)}>
<View className="mk-row">
<Text className="mk-tag">{t.category || '综合'}</Text>
<Text className="mk-mode">{MODE[t.mode] || '抢单'}</Text>
</View>
<Text className="mk-name">{t.title}</Text>
<View className="mk-meta">
<Text className="mk-price">{money(t.budget_min, t.budget_max)}</Text>
{t.deadline ? <Text className="mk-dot">截止 {t.deadline}</Text> : null}
</View>
</View>
))}
</View>
);
}
+13
View File
@@ -0,0 +1,13 @@
.mk-head { padding: 18px 20px 6px; }
.mk-title { font-size: 24px; font-weight: 700; color: #0d2f4e; display: block; }
.mk-sub { font-size: 13px; color: #8a8a93; margin-top: 4px; display: block; }
.mk-err { color: #e0544c; font-size: 13px; padding: 0 20px; }
.mk-card { background: #fff; border-radius: 18px; padding: 16px 18px; margin: 10px 20px; box-shadow: 0 6px 18px -8px rgba(13,47,78,.12); }
.mk-row { display: flex; gap: 8px; }
.mk-tag { background: #eaf6ff; color: #1f8ce0; border-radius: 999px; padding: 2px 12px; font-size: 12px; }
.mk-mode { background: #f2f2f6; color: #6a6a73; border-radius: 999px; padding: 2px 12px; font-size: 12px; }
.mk-name { display: block; font-size: 16px; font-weight: 600; color: #12233b; margin: 10px 0 6px; }
.mk-meta { display: flex; gap: 12px; }
.mk-price { color: #06acf1; font-weight: 600; font-size: 14px; }
.mk-dot { color: #98a0ab; font-size: 13px; }
.mk-empty { text-align: center; padding: 40px 20px; }
+4
View File
@@ -13,6 +13,10 @@ function pickUser(d) {
avatar: d.avatar || '',
phone: d.phone || '',
phoneBound: !!d.phoneBound,
wxBound: !!d.wxBound,
wxMiniBound: !!d.wxMiniBound,
account_type_label: d.account_type_label || d.role || '',
role: d.role || '',
status: d.status || '',
topics: Array.isArray(d.topics) ? d.topics : [],
source: d.source || ''