feat(user-center): 顶栏用户下拉 + 玻璃个人中心 + 我的报名/任务/园区/认证页
- Header: 登录后头像+用户名玻璃下拉(个人中心/我的报名/我的任务/我的园区/认证中心/退出);未登录显示登录/注册;移动端同步 - Profile: 玻璃用户卡(头像上传/身份标签) + 我的服务宫格(实时状态) + 报名资料 + 登录绑定 - 新增 my-bookings/my-tasks/my-park/my-cert 四页;auth.pickUser 保留 role/account_type;booking/park 新增 myBookings/myParkAdmission - 新增 user-center.css 玻璃样式;vite build 通过
This commit is contained in:
+9
-1
@@ -35,6 +35,10 @@ import Tasks from './user/Tasks';
|
||||
import TaskDetail from './user/TaskDetail';
|
||||
import Enterprise from './platform/Enterprise';
|
||||
import ParkCenter from './platform/ParkCenter';
|
||||
import MyBookings from './user/MyBookings';
|
||||
import MyTasks from './user/MyTasks';
|
||||
import MyPark from './user/MyPark';
|
||||
import MyCert from './user/MyCert';
|
||||
|
||||
/* 公开路由(无需登录) */
|
||||
const PUBLIC_ROUTES = [
|
||||
@@ -54,7 +58,11 @@ const PUBLIC_ROUTES = [
|
||||
{ path: 'task-detail', Page: TaskDetail },
|
||||
{ path: 'enterprise', Page: Enterprise },
|
||||
{ path: 'park-center', Page: ParkCenter },
|
||||
{ path: 'profile', Page: Profile }
|
||||
{ path: 'profile', Page: Profile },
|
||||
{ path: 'my-bookings', Page: MyBookings },
|
||||
{ path: 'my-tasks', Page: MyTasks },
|
||||
{ path: 'my-park', Page: MyPark },
|
||||
{ path: 'my-cert', Page: MyCert }
|
||||
];
|
||||
|
||||
/* 内部路由(/pine,需登录) */
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Logo } from '../atoms';
|
||||
import { NAV, PINE_NAV } from '../../data/site';
|
||||
import { logout, getUser } from '../../services/auth';
|
||||
import { logout, getUser, isAuthed } from '../../services/auth';
|
||||
|
||||
/* ---------- 组织:Header(含移动端汉堡 + 菜单) ---------- */
|
||||
/* 用户下拉菜单项:个人中心 / 我的报名 / 我的任务 / 我的园区 / 认证中心 */
|
||||
const USER_MENU = [
|
||||
['个人中心', '#/profile'],
|
||||
['我的报名', '#/my-bookings'],
|
||||
['我的任务', '#/my-tasks'],
|
||||
['我的园区', '#/my-park'],
|
||||
['认证中心', '#/my-cert'],
|
||||
];
|
||||
|
||||
/* ---------- 组织:Header(含移动端汉堡 + 菜单 + 用户下拉) ---------- */
|
||||
export function Header({ active }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const menuRef = React.useRef(null);
|
||||
const pine = !!active && active.startsWith('pine');
|
||||
const nav = pine ? PINE_NAV : NAV;
|
||||
const user = pine ? null : getUser(); // 公开端显示登录昵称(像素字)
|
||||
const user = pine ? null : getUser(); // 公开端显示登录昵称
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') { setOpen(false); setMenuOpen(false); } };
|
||||
const onResize = () => { if (window.innerWidth > 720) setOpen(false); };
|
||||
const onDown = (e) => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('resize', onResize); };
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('resize', onResize); window.removeEventListener('mousedown', onDown); };
|
||||
}, []);
|
||||
|
||||
const doLogout = () => { setOpen(false); logout(); window.location.hash = '#/'; };
|
||||
const doLogout = () => { setOpen(false); setMenuOpen(false); logout(); window.location.hash = '#/'; };
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -33,13 +46,32 @@ export function Header({ active }) {
|
||||
<div className="header-actions">
|
||||
{pine ? (
|
||||
<button className="btn-sign" onClick={doLogout}>退出</button>
|
||||
) : isAuthed() ? (
|
||||
<div className="user-menu" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="user-menu-trigger"
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="用户菜单"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
>
|
||||
<span className="user-avatar" aria-hidden="true">
|
||||
{user?.avatar ? <img src={user.avatar} alt="" /> : (user?.name || 'U').slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span>{user?.name || '用户'}</span>
|
||||
<span className="cg">▾</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="user-dropdown" role="menu">
|
||||
{USER_MENU.map(([label, href]) => (
|
||||
<a key={href} role="menuitem" href={href} onClick={() => setMenuOpen(false)}>{label}</a>
|
||||
))}
|
||||
<a className="sign" role="menuitem" onClick={doLogout}>退出登录</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{user && user.role === 'service' && <a className="btn-sign" href="#/enterprise">企业中心</a>}
|
||||
{user && user.role === 'carrier' && <a className="btn-sign" href="#/park-center">园区管理</a>}
|
||||
{user && <a className="user-nick" href="#/profile" title={user.username}>{user.name}</a>}
|
||||
<a className="btn-sign" href="#/profile">个人中心</a>
|
||||
</>
|
||||
<a className="btn-sign" href="#/profile">登录 / 注册</a>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -69,15 +101,15 @@ export function Header({ active }) {
|
||||
))}
|
||||
{pine ? (
|
||||
<a className="menu-sign" onClick={doLogout}>退出登录</a>
|
||||
) : (
|
||||
) : isAuthed() ? (
|
||||
<>
|
||||
{user && (
|
||||
<a className="menu-sign" href="#/profile" onClick={() => setOpen(false)}>个人中心({user.name})</a>
|
||||
)}
|
||||
{user && (
|
||||
<a className="menu-sign" onClick={() => { logout(); setOpen(false); window.location.hash = '#/'; }}>退出登录({user.name})</a>
|
||||
)}
|
||||
{USER_MENU.map(([label, href]) => (
|
||||
<a key={href} href={href} onClick={() => setOpen(false)}>{label}</a>
|
||||
))}
|
||||
<a className="menu-sign" onClick={doLogout}>退出登录({user?.name || '用户'})</a>
|
||||
</>
|
||||
) : (
|
||||
<a className="menu-sign" href="#/profile" onClick={() => setOpen(false)}>登录 / 注册</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/tokens.css';
|
||||
import './styles/global.css';
|
||||
import './styles/user-center.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -16,11 +16,17 @@ const USER_KEY = 'pine_user';
|
||||
/** 平台 profile → 本地 user 契约字段(各页面读取 name/status/topics/source/phoneBound 等) */
|
||||
function pickUser(d) {
|
||||
return {
|
||||
id: d.id || '',
|
||||
username: d.username,
|
||||
name: d.name || d.nickname || d.username || '用户',
|
||||
nickname: d.nickname || '',
|
||||
avatar: d.avatar || '',
|
||||
phone: d.phone || '',
|
||||
phoneBound: !!d.phoneBound,
|
||||
role: d.role || '',
|
||||
account_type: d.account_type || '',
|
||||
account_type_label: d.account_type_label || '',
|
||||
company: d.company || '',
|
||||
status: d.status || '',
|
||||
topics: Array.isArray(d.topics) ? d.topics : [],
|
||||
source: d.source || ''
|
||||
|
||||
@@ -48,3 +48,14 @@ export async function submitBooking(data) {
|
||||
if (!res.ok || !r.ok) throw new Error(r.detail || r.error || '报名提交失败,请重试');
|
||||
return r; // { ok, id, createdAt, username, name, auditStatus }
|
||||
}
|
||||
|
||||
/** 我的报名列表(GET /api/bookings/mine,需登录)—— 个人中心「我的报名」 */
|
||||
export async function myBookings() {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/api/bookings/mine`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
const r = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(r.detail || r.error || '获取报名失败');
|
||||
return r.list || []; // [{ id, eventId, eventTitle, eventStart, auditStatus, event_status, createdAt, ... }]
|
||||
}
|
||||
|
||||
@@ -31,6 +31,17 @@ export async function submitParkApplication(payload) {
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 我的园区入驻状态(GET /api/park-admission/mine,需登录)—— 个人中心「我的园区」 */
|
||||
export async function myParkAdmission() {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/api/park-admission/mine`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
const r = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(r.detail || r.error || '获取园区入驻信息失败');
|
||||
return r; // { ok, admission? } —— 含状态/园区/时间等
|
||||
}
|
||||
|
||||
/** 上传一份资料(图片/pdf/doc),返回 url */
|
||||
export async function uploadParkDoc(file) {
|
||||
const token = getToken();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/* ============================================================
|
||||
个人中心 · 玻璃卡片 / 用户菜单 / 我的服务宫格 / 状态徽章
|
||||
浅色玻璃材质(--glass-*),无纯黑背景。
|
||||
============================================================ */
|
||||
|
||||
.uc-page { position: relative; z-index: 1; color: var(--text); }
|
||||
|
||||
/* ---------- 玻璃卡片(统一浅色玻璃) ---------- */
|
||||
.uc-glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(18px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
||||
border-radius: var(--radius-3xl);
|
||||
box-shadow: var(--glass-shadow), 0 0 0 1px var(--glass-ring);
|
||||
border: 1px solid var(--glass-ring);
|
||||
}
|
||||
|
||||
/* ---------- 用户卡 ---------- */
|
||||
.uc-card { padding: clamp(20px, 3vw, 28px); }
|
||||
.uc-user { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
||||
.user-avatar {
|
||||
width: 76px; height: 76px; border-radius: 50%; overflow: hidden; flex: 0 0 auto;
|
||||
background: linear-gradient(135deg, #26c0ff, #06acf1);
|
||||
color: #fff; display: grid; place-items: center;
|
||||
font-size: 30px; font-weight: 700; border: 2px solid var(--glass-ring);
|
||||
}
|
||||
.user-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.uc-user-meta { min-width: 0; flex: 1; }
|
||||
.uc-user-name { font-family: var(--font-display); font-size: clamp(21px, 3vw, 28px); font-weight: 700; color: var(--heading); line-height: 1.1; }
|
||||
.uc-user-sub { color: var(--muted); font-size: 14px; margin-top: 5px; }
|
||||
.uc-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.uc-tag { padding: 3px 12px; border-radius: 999px; font-size: 12px; border: 1px solid var(--glass-ring); background: var(--surface); color: var(--heading); }
|
||||
.uc-avatar-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
|
||||
|
||||
/* ---------- 我的服务宫格 ---------- */
|
||||
.uc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-top: 18px; }
|
||||
.uc-service {
|
||||
padding: 18px; color: var(--heading); text-decoration: none;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
transition: transform .2s var(--ease), box-shadow .2s var(--ease);
|
||||
}
|
||||
.uc-service:hover { transform: translateY(-3px); }
|
||||
.uc-service .i { font-size: 22px; line-height: 1; }
|
||||
.uc-service .t { font-weight: 600; font-size: 16px; }
|
||||
.uc-service .d { font-size: 13px; color: var(--muted); }
|
||||
.uc-service .d .badge { margin-left: 4px; }
|
||||
|
||||
/* ---------- 状态徽章 ---------- */
|
||||
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 12px; border-radius: 999px; font-size: 12px; border: 1px solid; white-space: nowrap; }
|
||||
.badge-ok { color: var(--success); background: var(--success-soft); border-color: rgba(37,189,116,.4); }
|
||||
.badge-warn { color: var(--warning); background: var(--warning-soft); border-color: rgba(250,183,0,.4); }
|
||||
.badge-muted { color: var(--muted); background: var(--panel-2); border-color: var(--line); }
|
||||
.badge-pri { color: var(--primary); background: var(--primary-soft); border-color: rgba(6,172,241,.4); }
|
||||
|
||||
/* ---------- 我的列表项(报名/任务/园区/认证) ---------- */
|
||||
.uc-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.uc-item { padding: 16px 18px; display: flex; align-items: center; gap: 14px; }
|
||||
.uc-item .bd { flex: 1; min-width: 0; }
|
||||
.uc-item .tt { font-weight: 600; color: var(--heading); font-size: 16px; }
|
||||
.uc-item .mt { color: var(--muted); font-size: 13px; margin-top: 3px; }
|
||||
.uc-item .mt .badge { margin-right: 8px; }
|
||||
.uc-item .mt .usc-mt { margin-left: 4px; }
|
||||
.uc-item .dt { flex: 0 0 auto; }
|
||||
|
||||
/* ---------- 标题 / 说明 ---------- */
|
||||
.uc-subhead { font-weight: 600; color: var(--heading); font-size: 18px; margin: 22px 0 12px; }
|
||||
.uc-note { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* ---------- 空态 ---------- */
|
||||
.uc-empty { text-align: center; padding: 48px 20px; color: var(--muted); }
|
||||
.uc-empty .big { font-size: 44px; margin-bottom: 8px; opacity: .5; }
|
||||
|
||||
/* ---------- 顶栏用户菜单(头像+用户名 胶囊 + 下拉) ---------- */
|
||||
.user-menu { position: relative; flex: 0 0 auto; }
|
||||
.user-menu-trigger {
|
||||
display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
padding: 5px 15px 5px 5px; border-radius: 999px;
|
||||
border: 1px solid var(--glass-ring); background: var(--panel);
|
||||
color: var(--heading); font-size: 14px; font-weight: 500;
|
||||
transition: transform .2s ease, background .2s ease;
|
||||
}
|
||||
.user-menu-trigger:hover { transform: translateY(-1px); background: var(--panel-2); }
|
||||
.user-menu-trigger .user-avatar { width: 32px; height: 32px; font-size: 15px; border: none; }
|
||||
.user-menu-trigger .cg { color: var(--muted); font-size: 12px; }
|
||||
.user-dropdown {
|
||||
position: absolute; right: 0; top: calc(100% + 10px); z-index: 60;
|
||||
min-width: 208px; padding: 8px;
|
||||
backdrop-filter: blur(22px) saturate(170%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(170%);
|
||||
background: var(--glass-bg); color: var(--heading);
|
||||
box-shadow: var(--shadow-lg), 0 0 0 1px var(--glass-ring);
|
||||
border-radius: 18px; animation: ucIn .3s var(--ease) both;
|
||||
}
|
||||
.user-dropdown a { display: block; padding: 10px 14px; border-radius: 12px; color: var(--heading); font-size: 14px; text-decoration: none; }
|
||||
.user-dropdown a:hover { background: rgba(0,0,0,.05); }
|
||||
.user-dropdown a.sign { margin-top: 6px; text-align: center; background: #2c2c2c; color: #fff; }
|
||||
.user-dropdown a.sign:hover { background: #141414; }
|
||||
|
||||
@keyframes ucIn {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 桌面端隐藏:下拉为 hover/点击,无额外规则 */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.user-menu { display: none; } /* 移动端走 menu-sheet */
|
||||
.uc-avatar-actions { align-items: flex-start; width: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { ContentTemplate } from '@/components/templates';
|
||||
import AuthGate from '@/components/AuthGate';
|
||||
import { getUser } from '@/services/auth';
|
||||
import { myBookings } from '@/services/booking';
|
||||
|
||||
const AUDIT = {
|
||||
pending: ['待审核', 'badge-warn'],
|
||||
approved: ['已确认', 'badge-ok'],
|
||||
confirmed: ['已确认', 'badge-ok'],
|
||||
rejected: ['已拒绝', 'badge-muted'],
|
||||
canceled: ['已取消', 'badge-muted'],
|
||||
done: ['已完成', 'badge-muted'],
|
||||
full: ['名额已满', 'badge-warn'],
|
||||
};
|
||||
function auditBadge(s) {
|
||||
const [t, c] = AUDIT[String(s || '').toLowerCase()] || ['已报名', 'badge-pri'];
|
||||
return <span className={`badge ${c}`}>{t}</span>;
|
||||
}
|
||||
function fmt(s) {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(+d)) return s;
|
||||
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 [authed, setAuthed] = React.useState(!!getUser());
|
||||
const [lists, setLists] = React.useState([]);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!authed) return;
|
||||
myBookings().then((l) => { setLists(Array.isArray(l) ? l : []); }).catch(() => setLists([])).finally(() => setLoaded(true));
|
||||
}, [authed]);
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<ContentTemplate active="profile" kicker="My" title="我的报名" desc="查看你参加过的公益课 / 活动报名与审核状态。">
|
||||
<section className="section">
|
||||
<AuthGate subtitle="登录后即可查看你的活动报名记录。" onAuthed={() => setAuthed(true)} />
|
||||
</section>
|
||||
</ContentTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentTemplate active="profile" kicker="Bookings" title="我的报名" desc="查看你参加过的公益课 / 活动报名与审核状态。">
|
||||
<section className="section">
|
||||
{lists.length ? (
|
||||
<div className="uc-list">
|
||||
{lists.map((b) => (
|
||||
<div className="uc-item uc-glass" key={b.id}>
|
||||
<div className="bd">
|
||||
<div className="tt">{b.eventTitle || '活动报名'}</div>
|
||||
<div className="mt">
|
||||
{fmt(b.eventStart) ? <span>活动时间 {fmt(b.eventStart)}</span> : null}
|
||||
{b.createdAt ? `${(b.eventStart ? ' · ' : '')}报名于 ${fmt(b.createdAt)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dt">{auditBadge(b.auditStatus)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : loaded ? (
|
||||
<div className="uc-empty uc-glass">
|
||||
<div className="big">📋</div>
|
||||
<p>还没有报名记录,去参加一场公益课或沙龙吧。</p>
|
||||
<a className="btn btn-cta" href="#/events">去看看活动 →</a>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</ContentTemplate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { ContentTemplate } from '@/components/templates';
|
||||
import AuthGate from '@/components/AuthGate';
|
||||
import { getUser } from '@/services/auth';
|
||||
import { getOpCertMine } from '@/services/opcProfile';
|
||||
|
||||
const CERT = {
|
||||
uncertified: ['未认证', 'badge-muted'],
|
||||
pending: ['待提交', 'badge-warn'],
|
||||
reviewing: ['审核中', 'badge-warn'],
|
||||
certified: ['已认证', 'badge-ok'],
|
||||
rejected: ['未通过', 'badge-muted'],
|
||||
};
|
||||
function certBadge(s) {
|
||||
const [t, c] = CERT[String(s || '').toLowerCase()] || [s || '未认证', 'badge-muted'];
|
||||
return <span className={`badge ${c}`}>{t}</span>;
|
||||
}
|
||||
|
||||
export default function MyCert() {
|
||||
const [authed, setAuthed] = React.useState(!!getUser());
|
||||
const [status, setStatus] = React.useState('');
|
||||
const [cert, setCert] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!authed) return;
|
||||
getOpCertMine().then((r) => {
|
||||
setStatus(r?.certification_status || '');
|
||||
setCert(r?.certification || null);
|
||||
}).catch(() => {});
|
||||
}, [authed]);
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<ContentTemplate active="profile" kicker="Cert" title="认证中心" desc="你的 OPC 创业伙伴认证进度。">
|
||||
<section className="section">
|
||||
<AuthGate subtitle="登录后即可查看你的 OPC 认证状态。" onAuthed={() => setAuthed(true)} />
|
||||
</section>
|
||||
</ContentTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
const certified = String(status).toLowerCase() === 'certified';
|
||||
return (
|
||||
<ContentTemplate active="profile" kicker="Cert" title="认证中心" desc="你的 OPC 创业伙伴认证进度。">
|
||||
<section className="section">
|
||||
<div className="uc-list">
|
||||
<div className="uc-item uc-glass">
|
||||
<div className="bd">
|
||||
<div className="tt">OPC 创业伙伴认证</div>
|
||||
{cert?.cert_no ? <div className="mt">证书编号:{cert.cert_no}</div> : null}
|
||||
{cert?.certified_at ? <div className="mt">认证时间:{cert.certified_at}</div> : null}
|
||||
<div className="mt">在平台完成指定指标后即可获得认证,享受政策 / 资源对接权益。</div>
|
||||
</div>
|
||||
<div className="dt">{certBadge(status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!certified && (
|
||||
<div className="uc-empty uc-glass" style={{ marginTop: 20 }}>
|
||||
<div className="big">🏅</div>
|
||||
<p>{status === 'reviewing' ? '认证资料审核中,请耐心等待。' : '完成申请与认证流程,成为 OPC 创业伙伴。'}</p>
|
||||
<a className="btn btn-cta" href="#/cert-apply">去认证 →</a>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ContentTemplate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { ContentTemplate } from '@/components/templates';
|
||||
import AuthGate from '@/components/AuthGate';
|
||||
import { getUser } from '@/services/auth';
|
||||
import { myParkAdmission } from '@/services/park';
|
||||
|
||||
function statusBadge(s) {
|
||||
const k = String(s || '').toLowerCase();
|
||||
if (['approved', 'verified', 'admitted'].includes(k) || s === '已入驻' || s === '通过') return <span className="badge badge-ok">已入驻</span>;
|
||||
if (['pending', 'reviewing'].includes(k) || s === '审核中') return <span className="badge badge-warn">审核中</span>;
|
||||
if (['rejected'].includes(k) || s === '未通过') return <span className="badge badge-muted">未通过</span>;
|
||||
return <span className="badge badge-pri">{s || '待审核'}</span>;
|
||||
}
|
||||
|
||||
function pick(ad, keys) {
|
||||
for (const k of keys) { if (ad && ad[k] != null && ad[k] !== '') return ad[k]; }
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function MyPark() {
|
||||
const [authed, setAuthed] = React.useState(!!getUser());
|
||||
const [ad, setAd] = React.useState(null);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!authed) return;
|
||||
myParkAdmission().then((r) => setAd(r?.admission || r || null)).catch(() => setAd(null)).finally(() => setLoaded(true));
|
||||
}, [authed]);
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<ContentTemplate active="profile" kicker="My" title="我的园区" desc="查看你的园区入驻申请与所属园区。">
|
||||
<section className="section">
|
||||
<AuthGate subtitle="登录后即可查看你的园区入驻信息。" onAuthed={() => setAuthed(true)} />
|
||||
</section>
|
||||
</ContentTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
const status = ad?.status || ad?.audit_status || '';
|
||||
const name = pick(ad, ['tenant_name', 'park_name', 'company_name', 'company', 'name']);
|
||||
const region = pick(ad, ['region', 'region_name', 'zone']);
|
||||
const createdAt = pick(ad, ['created_at', 'createdAt', 'apply_time']);
|
||||
|
||||
return (
|
||||
<ContentTemplate active="profile" kicker="Park" title="我的园区" desc="查看你的园区入驻申请与所属园区。">
|
||||
<section className="section">
|
||||
<div className="uc-list">
|
||||
<div className="uc-item uc-glass">
|
||||
<div className="bd">
|
||||
<div className="tt">{name || '昆明市大学生创业园'}</div>
|
||||
{region ? <div className="mt">区域:{region}</div> : null}
|
||||
{createdAt ? <div className="mt">申请时间:{createdAt}</div> : null}
|
||||
</div>
|
||||
<div className="dt">{statusBadge(status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loaded && !name && (
|
||||
<div className="uc-empty uc-glass" style={{ marginTop: 20 }}>
|
||||
<div className="big">🏢</div>
|
||||
<p>你还没有提交园区入驻申请。</p>
|
||||
<a className="btn btn-cta" href="#/park-apply">去申请入驻 →</a>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ContentTemplate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { ContentTemplate } from '@/components/templates';
|
||||
import AuthGate from '@/components/AuthGate';
|
||||
import { getUser } from '@/services/auth';
|
||||
import { myTasks } from '@/services/tasks';
|
||||
|
||||
function money(min, max) {
|
||||
const f = (n) => (n == null ? '' : `¥${Number(n).toLocaleString('zh-CN')}`);
|
||||
if (min != null && max != null) return `${f(min)} 起`;
|
||||
return f(min || max) || '面议';
|
||||
}
|
||||
|
||||
export default function MyTasks() {
|
||||
const [authed, setAuthed] = React.useState(!!getUser());
|
||||
const [items, setItems] = React.useState([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!authed) return;
|
||||
myTasks().then((r) => setItems(r.ok ? r.items || [] : [])).catch(() => setItems([]));
|
||||
}, [authed]);
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<ContentTemplate active="profile" kicker="My" title="我的任务" desc="你承接 / 参与的任务清单。">
|
||||
<section className="section">
|
||||
<AuthGate subtitle="登录后即可查看我的任务。" onAuthed={() => setAuthed(true)} />
|
||||
</section>
|
||||
</ContentTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentTemplate active="profile" kicker="Tasks" title="我的任务" desc="你承接 / 参与的任务清单。">
|
||||
<section className="section">
|
||||
{items.length ? (
|
||||
<div className="uc-list">
|
||||
{items.map((t) => (
|
||||
<a className="uc-item uc-glass" key={t.id} href={`#/task-detail?id=${t.id}`}>
|
||||
<div className="bd">
|
||||
<div className="tt">{t.title || '任务'}</div>
|
||||
<div className="mt">
|
||||
<span className="badge badge-pri">{t.category || '综合'}</span>
|
||||
{t.deadline ? <span className="usc-mt">截止 {t.deadline}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dt"><span className="badge badge-ok">{money(t.budget_min, t.budget_max)}</span></div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="uc-empty uc-glass">
|
||||
<div className="big">🧩</div>
|
||||
<p>还没有承接的任务,去任务广场看看吧。</p>
|
||||
<a className="btn btn-cta" href="#/tasks">去任务广场 →</a>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ContentTemplate>
|
||||
);
|
||||
}
|
||||
+120
-20
@@ -1,26 +1,63 @@
|
||||
import React from 'react';
|
||||
import { ContentTemplate } from '@/components/templates';
|
||||
import { SectionHead, FieldText, FieldSelect, FieldCheck, Button, Icon } from '@/components/atoms';
|
||||
import { SectionHead, FieldText, FieldSelect, FieldCheck, Icon } from '@/components/atoms';
|
||||
import AuthGate from '@/components/AuthGate';
|
||||
import { getUser, updateProfile, bindPhone, unbind, sendCode, me, mpQrBindStart, mpQrPoll } from '@/services/auth';
|
||||
import {
|
||||
getUser, saveUser, updateProfile, bindPhone, unbind, sendCode, me, mpQrBindStart, mpQrPoll,
|
||||
} from '@/services/auth';
|
||||
import { myBookings } from '@/services/booking';
|
||||
import { myTasks } from '@/services/tasks';
|
||||
import { myParkAdmission, uploadParkDoc } from '@/services/park';
|
||||
import { getOpCertMine } from '@/services/opcProfile';
|
||||
import { BK_STATUS_OPTIONS, BK_TOPIC_OPTIONS, BK_SOURCE_OPTIONS } from '@/data/booking';
|
||||
import '@/styles/booking.css';
|
||||
import '@/styles/user-center.css';
|
||||
|
||||
/* 个人中心:报名资料设置(姓名/状态/主题/来源),报名时自动带入 + 登录方式绑定 */
|
||||
function maskPhone(p) {
|
||||
if (!p) return '';
|
||||
if (/^1\d{10}$/.test(p)) return p.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
return p;
|
||||
}
|
||||
function parkText(s) {
|
||||
const k = String(s || '').toLowerCase();
|
||||
if (['approved', 'verified', 'admitted'].includes(k) || s === '已入驻' || s === '通过') return '已入驻';
|
||||
if (['pending', 'reviewing'].includes(k) || s === '审核中') return '审核中';
|
||||
if (['rejected'].includes(k) || s === '未通过') return '未通过';
|
||||
return s ? String(s) : '未提交';
|
||||
}
|
||||
function certText(s) {
|
||||
const k = String(s || '').toLowerCase();
|
||||
if (k === 'certified' || s === '已认证') return '已认证';
|
||||
if (k === 'reviewing' || s === '审核中') return '审核中';
|
||||
if (k === 'pending' || s === '待提交') return '待提交';
|
||||
return s ? String(s) : '未认证';
|
||||
}
|
||||
|
||||
/* 个人中心:用户卡 + 我的服务 + 报名资料 + 登录绑定(玻璃卡片) */
|
||||
export default function Profile() {
|
||||
const [authed, setAuthed] = React.useState(!!getUser());
|
||||
const user = getUser() || {};
|
||||
const [user, setUser] = React.useState(getUser() || {});
|
||||
const [authed, setAuthed] = React.useState(!!user.username || !!getUser()?.username);
|
||||
const [form, setForm] = React.useState({
|
||||
name: user.name || '',
|
||||
status: user.status || '',
|
||||
topics: user.topics || [],
|
||||
source: user.source || ''
|
||||
name: user.name || '', status: user.status || '', topics: user.topics || [], source: user.source || '',
|
||||
});
|
||||
const [msg, setMsg] = React.useState('');
|
||||
const [err, setErr] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [avBusy, setAvBusy] = React.useState(false);
|
||||
const set = (k) => (v) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
/* 我的服务实时状态 */
|
||||
const [svc, setSvc] = React.useState({ bk: null, tk: null, park: '', cert: '' });
|
||||
React.useEffect(() => {
|
||||
if (!authed) return;
|
||||
Promise.all([
|
||||
myBookings().then((l) => setSvc((s) => ({ ...s, bk: Array.isArray(l) ? l.length : 0 }))).catch(() => {}),
|
||||
myTasks().then((r) => setSvc((s) => ({ ...s, tk: r.ok ? (r.items || []).length : 0 }))).catch(() => {}),
|
||||
myParkAdmission().then((r) => setSvc((s) => ({ ...s, park: parkText(r?.admission?.status || r?.status || '') }))).catch(() => {}),
|
||||
getOpCertMine().then((r) => setSvc((s) => ({ ...s, cert: certText(r?.certification_status || '') }))).catch(() => {}),
|
||||
]);
|
||||
}, [authed]);
|
||||
|
||||
/* 绑定管理 */
|
||||
const [bind, setBind] = React.useState({ phone: '', phoneBound: false, wxBound: false, wxMiniBound: false });
|
||||
const [bphone, setBphone] = React.useState('');
|
||||
@@ -33,12 +70,37 @@ export default function Profile() {
|
||||
const refreshBind = async () => {
|
||||
try {
|
||||
const p = await me();
|
||||
setBind({
|
||||
phone: p.phone || '', phoneBound: !!p.phoneBound, wxBound: !!p.wxBound, wxMiniBound: !!p.wxMiniBound,
|
||||
});
|
||||
setBind({ phone: p.phone || '', phoneBound: !!p.phoneBound, wxBound: !!p.wxBound, wxMiniBound: !!p.wxMiniBound });
|
||||
setUser(getUser() || {});
|
||||
} catch (e) { /* 未登录 */ }
|
||||
};
|
||||
React.useEffect(() => { if (authed) void refreshBind(); }, [authed]);
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault();
|
||||
setErr(''); setMsg('');
|
||||
if (!form.name.trim()) { setErr('请填写姓名 / 称呼'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateProfile(form);
|
||||
setUser(getUser() || {});
|
||||
setMsg('资料已保存');
|
||||
} catch (ex) { setErr((ex && ex.message) || '保存失败'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const onAvatar = async (e) => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
if (!f) return;
|
||||
setErr(''); setAvBusy(true);
|
||||
try {
|
||||
const url = await uploadParkDoc(f);
|
||||
await updateProfile({ avatar: url });
|
||||
saveUser({ ...getUser(), avatar: url });
|
||||
setUser(getUser() || {});
|
||||
setMsg('头像已更新');
|
||||
} catch (ex) { setErr((ex && ex.message) || '头像上传失败'); }
|
||||
finally { setAvBusy(false); e.target.value = ''; }
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
setBindErr('');
|
||||
@@ -60,7 +122,7 @@ export default function Profile() {
|
||||
if (!bcode) { setBindErr('请输入验证码'); return; }
|
||||
setBinding(true);
|
||||
try {
|
||||
await bindPhone(bphone, bcode); // 可能合并成另一账号并切换新令牌
|
||||
await bindPhone(bphone, bcode);
|
||||
setBindMsg('绑定成功'); setBphone(''); setBcode('');
|
||||
setAuthed(true);
|
||||
await refreshBind();
|
||||
@@ -78,7 +140,7 @@ export default function Profile() {
|
||||
finally { setBinding(false); }
|
||||
};
|
||||
|
||||
/* 绑定小程序:发起 bind-start → 展示小程序码 → 轮询确认 */
|
||||
/* 绑定小程序 */
|
||||
const [mpImg, setMpImg] = React.useState('');
|
||||
const [mpStarting, setMpStarting] = React.useState(false);
|
||||
const bindMini = async () => {
|
||||
@@ -91,10 +153,8 @@ export default function Profile() {
|
||||
const t = window.setInterval(async () => {
|
||||
try {
|
||||
const poll = await mpQrPoll(r.scene);
|
||||
if (poll.status === 'done') {
|
||||
window.clearInterval(t); setMpImg(''); setBindMsg('小程序绑定成功');
|
||||
await refreshBind();
|
||||
} else if (poll.status === 'expired') { window.clearInterval(t); setMpImg(''); setBindMsg('二维码已过期,请重试'); }
|
||||
if (poll.status === 'done') { window.clearInterval(t); setMpImg(''); setBindMsg('小程序绑定成功'); await refreshBind(); }
|
||||
else if (poll.status === 'expired') { window.clearInterval(t); setMpImg(''); setBindMsg('二维码已过期,请重试'); }
|
||||
} catch { /* ignore */ }
|
||||
}, 2000);
|
||||
}
|
||||
@@ -113,8 +173,48 @@ export default function Profile() {
|
||||
);
|
||||
}
|
||||
|
||||
const svcGrid = [
|
||||
['📋', '我的报名', svc.bk == null ? '加载中…' : `${svc.bk} 条报名`, '#/my-bookings'],
|
||||
['🧩', '我的任务', svc.tk == null ? '加载中…' : `${svc.tk} 个任务`, '#/my-tasks'],
|
||||
['🏢', '我的园区', svc.park, '#/my-park'],
|
||||
['🏅', '我的认证', svc.cert, '#/my-cert'],
|
||||
];
|
||||
|
||||
return (
|
||||
<ContentTemplate active="profile" kicker="Profile" title="个人中心" desc="完善报名资料,报名活动时自动带入,无需重复填写。">
|
||||
<ContentTemplate active="profile" kicker="Profile" title="个人中心" desc="管理你的资料、报名、任务、园区与认证。">
|
||||
<section className="section" style={{ paddingBottom: 0 }}>
|
||||
{/* 用户卡 */}
|
||||
<div className="uc-card uc-glass">
|
||||
<div className="uc-user">
|
||||
<label className="user-avatar" htmlFor="uc-upload" title="点击更换头像">
|
||||
{user.avatar ? <img src={user.avatar} alt="" /> : (user.name || 'U').slice(0, 1).toUpperCase()}
|
||||
</label>
|
||||
<div className="uc-user-meta">
|
||||
<div className="uc-user-name">{user.name || '用户'}</div>
|
||||
<div className="uc-user-sub">{maskPhone(user.phone) || user.username || '未绑定手机'}</div>
|
||||
{(user.account_type_label || user.role) && (
|
||||
<div className="uc-tags"><span className="uc-tag">{user.account_type_label || user.role}</span></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="uc-avatar-actions">
|
||||
<label className="btn btn-cta btn-sm" htmlFor="uc-upload">{avBusy ? '上传中…' : '更换头像'}</label>
|
||||
<input id="uc-upload" type="file" accept="image/*" hidden onChange={onAvatar} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 我的服务 */}
|
||||
<div className="uc-grid">
|
||||
{svcGrid.map(([icon, t, d, href]) => (
|
||||
<a className="uc-service uc-glass" key={href} href={href}>
|
||||
<span className="i">{icon}</span>
|
||||
<span className="t">{t}</span>
|
||||
<span className="d">{d}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<SectionHead no="01" title="报名资料" en="Profile" />
|
||||
<form className="form-card" onSubmit={save}>
|
||||
|
||||
Reference in New Issue
Block a user