feat(c端): 补齐 OPC认证申请 + 转园申请发起页(小程序+网站)

- 小程序:新增 pages/cert-apply(OPC认证)、pages/park-transfer(转园);utils/profile.js 封装 /opc/certification/apply|mine、/opc/park-transfer/apply|mine;app.config 注册;首页/我的加入口
- 网站:新增 services/opcProfile.js、user/CertApply.jsx、user/ParkTransfer.jsx;App.jsx 路由 + Home.jsx 申请区入口
- 登录门复用 auth(phoneLogin/bindPhone/isAuthed);重复提交/已认证前端禁用;build:weapp + website build 通过
- 至此 申请入驻/OPC认证/转园 三条流:C端发起 → 园区端|平台端审核 → 回填用户 全链路打通

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-26 19:37:01 +08:00
parent 600f4fc5a9
commit 35e87889db
15 changed files with 735 additions and 0 deletions
+2
View File
@@ -10,6 +10,8 @@ export default {
'pages/event-detail/index',
'pages/scan/index',
'pages/park-apply/index',
'pages/cert-apply/index',
'pages/park-transfer/index',
'pages/scan-login/index'
],
window: {
@@ -0,0 +1 @@
export default { navigationBarTitleText: 'OPC 认证' };
+184
View File
@@ -0,0 +1,184 @@
import { View, Text, Input, Textarea, Button, Radio, RadioGroup } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { phoneLogin, bindPhone, isAuthed } from '@/utils/auth';
import { submitOpCert, getOpCertMine } from '@/utils/profile';
import { uploadParkDoc } from '@/utils/parks';
import './index.scss';
const EMPTY = () => ({ real_name: '', gender: '', address: '', industry: '', ability: '', phone: '' });
const GENDER_OPTS = ['男', '女'];
/** OPC 认证申请(C端):提交认证资料 → 平台运营方审核。 */
export default function CertApply() {
const [authed, setAuthed] = useState(isAuthed());
const [gatePhone, setGatePhone] = useState('');
const [gateCode, setGateCode] = useState('');
const [gateErr, setGateErr] = useState('');
const [busy, setBusy] = useState(false);
const [form, setForm] = useState(EMPTY());
const [docs, setDocs] = useState({});
const [mine, setMine] = useState(null); // {certification_status, certification}
const [err, setErr] = useState('');
const [done, setDone] = useState(false);
const [loaded, setLoaded] = useState(false);
const up = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const reloadMine = async () => {
try {
const m = await getOpCertMine();
setMine(m);
} catch { /* ignore */ }
finally { setLoaded(true); }
};
useEffect(() => { if (isAuthed()) reloadMine(); else setLoaded(true); }, []);
const gateSmsLogin = async () => {
setGateErr('');
if (!/^1\d{10}$/.test(gatePhone)) { setGateErr('请输入正确的 11 位手机号'); return; }
if (!gateCode) { setGateErr('请输入验证码'); return; }
setBusy(true);
try {
if (authed) {
const b = await bindPhone(gatePhone, gateCode);
if (!b.ok) { setGateErr(b.error || '绑定失败'); return; }
} else {
await phoneLogin(gatePhone, gateCode);
}
setAuthed(true);
reloadMine();
} catch (e) { setGateErr((e && e.message) || '登录失败'); }
finally { setBusy(false); }
};
const pickAndUpload = async (key) => {
let filePath = null;
try {
const res = await Taro.chooseMessageFile({ count: 1, type: 'file' });
filePath = res.tempFiles && res.tempFiles[0] && res.tempFiles[0].path;
} catch (e) {
try {
const img = await Taro.chooseImage({ count: 1, sizeType: ['compressed'] });
filePath = img.tempFilePaths && img.tempFilePaths[0];
} catch (_) { return; }
}
if (!filePath) return;
setBusy(true); setErr('');
try {
const url = await uploadParkDoc(filePath);
setDocs((d) => ({ ...d, [key]: url }));
} catch (e) { setErr((e && e.message) || '文件上传失败'); }
finally { setBusy(false); }
};
const statusText = (s) => ({ uncertified: '未认证', pending: '审核中', reviewing: '审核中', certified: '已认证', rejected: '未通过' }[s] || s || '未认证');
const submit = async () => {
setErr('');
if (!form.real_name || !form.phone) { setErr('请填写真实姓名与联系电话'); return; }
const payload = { ...form, docs_json: JSON.stringify(docs) };
setBusy(true);
try {
await submitOpCert(payload);
setDone(true);
await reloadMine();
} catch (e) { setErr((e && e.message) || '提交失败,请稍后重试'); }
finally { setBusy(false); }
};
if (done) {
return (
<View className="ca page-in">
<View className="ok-icon" />
<Text className="ok-title">认证申请已提交</Text>
<Text className="ok-desc">平台将对您的资料进行审核请留意认证状态</Text>
<Button className="btn btn-cta" onClick={() => Taro.navigateBack()}>返回</Button>
</View>
);
}
if (!loaded) return <View className="ca page-in"><Text className="err-text">加载中</Text></View>;
// 未登录/未绑手机 → 快捷门
if (!authed) {
return (
<View className="ca page-in">
<View className="ca-card">
<Text className="ca-title">OPC 认证申请</Text>
<Text className="ca-sub">请先用手机号登录验证码 123456</Text>
<View className="field"><Input className="input" placeholder="手机号" value={gatePhone} onChange={(e) => setGatePhone(e.detail.value)} /></View>
<View className="field"><Input className="input" placeholder="验证码" value={gateCode} onChange={(e) => setGateCode(e.detail.value)} /></View>
{gateErr && <Text className="err-text">{gateErr}</Text>}
<Button className="btn btn-cta" loading={busy} onClick={gateSmsLogin}>登录 / 绑定</Button>
</View>
</View>
);
}
const st = mine?.certification_status || 'uncertified';
const disabled = st === 'certified' || st === 'pending' || st === 'reviewing';
return (
<View className="ca page-in">
<View className="ca-head">
<Text className="ca-title">OPC 认证申请</Text>
<Text className="ca-sub">当前认证状态<Text className="ca-status">{statusText(st)}</Text></Text>
</View>
<View className="ca-card">
<Text className="ca-sec"> · 基本信息</Text>
<Field label="真实姓名 *" value={form.real_name} ph="" onChange={(v) => up('real_name', v)} />
<View className="field"><Text className="ca-label">性别</Text>
<RadioGroup onChange={(e) => up('gender', e.detail.value)}><View className="ca-radios">
{GENDER_OPTS.map((g) => <Radio key={g} value={g} checked={form.gender === g}>{g}</Radio>)}
</View></RadioGroup>
</View>
<Field label="联系地址" value={form.address} ph="常住地/通讯地址" onChange={(v) => up('address', v)} />
<Field label="所属行业" value={form.industry} ph="如 人工智能、文化创意、电商" onChange={(v) => up('industry', v)} />
<Field label="联系电话 *" value={form.phone} ph="" onChange={(v) => up('phone', v)} />
</View>
<View className="ca-card">
<Text className="ca-sec"> · 能力与成果</Text>
<Text className="ca-label">专业技能 / 代表作品 / 成果</Text>
<Textarea className="ca-textarea" placeholder="描述你的核心能力、代表作品或已取得的成果" value={form.ability}
onChange={(e) => up('ability', e.detail.value)} />
</View>
<View className="ca-card">
<Text className="ca-sec"> · 佐证材料</Text>
{DOC_ITEMS.map((d) => (
<View className="ca-doc" key={d.key}>
<Text className="ca-label">{d.label}</Text>
<Button className="btn btn-ghost" disabled={busy || disabled} onClick={() => pickAndUpload(d.key)}>
{docs[d.key] ? '已上传 ✓' : '选择文件'}
</Button>
{docs[d.key] && <Text className="ca-uploaded">{docs[d.key].split('/').pop()}</Text>}
</View>
))}
</View>
{disabled && <Text className="err-text">{st === 'certified' ? '您已是认证 OPC,无需重复申请' : '已有认证申请审核中,请勿重复提交'}</Text>}
{err && <Text className="err-text">{err}</Text>}
<Button className="btn btn-cta" loading={busy || disabled} disabled={disabled} onClick={submit}>提交认证申请</Button>
<View className="ca-foot">提交即授权平台审核材料结果可在我的查看</View>
</View>
);
}
const DOC_ITEMS = [
{ key: 'id_card_doc', label: '身份证(正面)' },
{ key: 'achievement', label: '成果 / 资质证明材料' },
{ key: 'other', label: '其他佐证材料' },
];
function Field({ label, value, ph, onChange }) {
return (
<View className="field">
<Text className="ca-label">{label}</Text>
<Input className="input" placeholder={ph} value={value} onInput={(e) => onChange(e.detail.value)} />
</View>
);
}
@@ -0,0 +1,35 @@
/* OPC 认证申请 · 小程序样式(暗色令牌一致) */
.ca { position: relative; z-index: 2; padding: 0 0 60rpx; }
.ca-head { padding: 24rpx 16rpx 8rpx; }
.ca-title { font-size: 40rpx; font-weight: 700; color: #fff; }
.ca-sub { display: block; margin-top: 8rpx; font-size: 25rpx; color: #8e8e8e; }
.ca-status { color: #ffd28a; }
.ca-card {
margin: 24rpx 16rpx 0;
padding: 28rpx 24rpx;
background: linear-gradient(180deg, #0e0e12, #0a0a0d);
border: 1px solid rgba(255,255,255,.12);
border-radius: 20rpx;
}
.ca-sec { display: block; font-size: 26rpx; color: #ffd28a; margin-bottom: 16rpx; }
.ca-label { display: block; font-size: 25rpx; color: #c4c2c3; margin: 14rpx 0 8rpx; }
.ca-radios { display: flex; gap: 24rpx; font-size: 26rpx; color: #eaeaea; }
.field { margin-top: 12rpx; }
.input {
background: #17171b; border: 1rpx solid #2a2a2d; border-radius: 10rpx;
color: #eaeaea; padding: 14rpx 16rpx; font-size: 27rpx; width: 100%;
}
.ca-textarea {
width: 100%; min-height: 140rpx; background: #17171b; border: 1rpx solid #2a2a2d;
border-radius: 10rpx; color: #eaeaea; padding: 14rpx 16rpx; font-size: 27rpx;
}
.ca-doc { display: flex; align-items: center; gap: 16rpx; margin-top: 12rpx; }
.ca-uploaded { font-size: 23rpx; color: #8e8e8e; flex: 1; }
.ca-foot { margin-top: 20rpx; text-align: center; font-size: 23rpx; color: #8e8e8e; }
.ok-icon { width: 96rpx; height: 96rpx; margin: 120rpx auto 24rpx; border-radius: 50%; background: #26a69a; }
.ok-title { display: block; text-align: center; font-size: 36rpx; color: #fff; }
.ok-desc { display: block; text-align: center; margin-top: 12rpx; font-size: 26rpx; color: #8e8e8e; }
.btn { margin-top: 32rpx; }
.err-text { display: block; margin-top: 16rpx; text-align: center; font-size: 25rpx; color: #f0b8b8; }
+2
View File
@@ -26,6 +26,8 @@ export default function Index() {
<Button className="btn btn-cta" onClick={goTab(MK_HERO.ctaPrimary.tab)}>{MK_HERO.ctaPrimary.label} </Button>
<Button className="btn btn-ghost" onClick={goTab(MK_HERO.ctaSecondary.tab)}>{MK_HERO.ctaSecondary.label}</Button>
<Button className="btn btn-ghost" onClick={() => Taro.navigateTo({ url: '/pages/park-apply/index' })}>申请入驻</Button>
<Button className="btn btn-ghost" onClick={() => Taro.navigateTo({ url: '/pages/cert-apply/index' })}>OPC认证</Button>
<Button className="btn btn-ghost" onClick={() => Taro.navigateTo({ url: '/pages/park-transfer/index' })}>转园申请</Button>
</View>
</View>
+18
View File
@@ -401,6 +401,24 @@ export default function Mine() {
<Text className="scan-entry-arrow"></Text>
</View>
{/* 申请/认证入口:入驻 / OPC认证 / 转园 */}
<View className="mine-section-head">
<Text className="mine-no">APPLY</Text>
<Text className="mine-section-title">申请与认证</Text>
</View>
<View className="card scan-entry-card" onClick={() => Taro.navigateTo({ url: '/pages/park-apply/index' })}>
<View className="scan-entry-body"><Text className="scan-entry-title">园区入驻申请</Text><Text className="scan-entry-sub">填写入园申请表入驻创业园</Text></View>
<Text className="scan-entry-arrow"></Text>
</View>
<View className="card scan-entry-card" onClick={() => Taro.navigateTo({ url: '/pages/cert-apply/index' })}>
<View className="scan-entry-body"><Text className="scan-entry-title">OPC 认证</Text><Text className="scan-entry-sub">提交认证资料认证 OPC 超级个体</Text></View>
<Text className="scan-entry-arrow"></Text>
</View>
<View className="card scan-entry-card" onClick={() => Taro.navigateTo({ url: '/pages/park-transfer/index' })}>
<View className="scan-entry-body"><Text className="scan-entry-title">转园申请</Text><Text className="scan-entry-sub">园区 OPC 申请转入其它园区</Text></View>
<Text className="scan-entry-arrow"></Text>
</View>
{/* 我的报名 */}
<View className="mine-section-head">
<Text className="mine-no">BOOK</Text>
@@ -0,0 +1 @@
export default { navigationBarTitleText: '转园申请' };
@@ -0,0 +1,133 @@
import { View, Text, Input, Textarea, Button, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { phoneLogin, bindPhone, isAuthed } from '@/utils/auth';
import { submitParkTransfer, getParkTransferMine } from '@/utils/profile';
import { getParks } from '@/utils/parks';
import './index.scss';
/** 转园申请(C端):园区 OPC 申请转入目标园区 → 园区/平台审核。 */
export default function ParkTransfer() {
const [authed, setAuthed] = useState(isAuthed());
const [gatePhone, setGatePhone] = useState('');
const [gateCode, setGateCode] = useState('');
const [gateErr, setGateErr] = useState('');
const [busy, setBusy] = useState(false);
const [parks, setParks] = useState([]);
const [toParkId, setToParkId] = useState('');
const [reason, setReason] = useState('');
const [mine, setMine] = useState(null); // {transfer}
const [err, setErr] = useState('');
const [done, setDone] = useState(false);
const [loaded, setLoaded] = useState(false);
const reload = async () => {
try {
const [t, m] = await Promise.all([getParks(), getParkTransferMine()]);
setParks(t);
setMine(m);
} catch { /* ignore */ }
finally { setLoaded(true); }
};
useEffect(() => { if (isAuthed()) reload(); else setLoaded(true); }, []);
const gateSmsLogin = async () => {
setGateErr('');
if (!/^1\d{10}$/.test(gatePhone)) { setGateErr('请输入正确的 11 位手机号'); return; }
if (!gateCode) { setGateErr('请输入验证码'); return; }
setBusy(true);
try {
if (authed) {
const b = await bindPhone(gatePhone, gateCode);
if (!b.ok) { setGateErr(b.error || '绑定失败'); return; }
} else {
await phoneLogin(gatePhone, gateCode);
}
setAuthed(true);
reload();
} catch (e) { setGateErr((e && e.message) || '登录失败'); }
finally { setBusy(false); }
};
const submit = async () => {
setErr('');
if (!toParkId) { setErr('请选择目标园区'); return; }
setBusy(true);
try {
const park = parks.find((p) => p.id === toParkId);
await submitParkTransfer({ to_park_id: toParkId, to_park_name: park?.name || '', reason });
setDone(true);
} catch (e) { setErr((e && e.message) || '提交失败,请稍后重试'); }
finally { setBusy(false); }
};
const cur = mine?.transfer;
const curSt = cur?.status;
if (done) {
return (
<View className="pt page-in">
<View className="ok-icon" />
<Text className="ok-title">转园申请已提交</Text>
<Text className="ok-desc">园区与平台将对您的转园申请进行审核</Text>
<Button className="btn btn-cta" onClick={() => Taro.navigateBack()}>返回</Button>
</View>
);
}
if (!loaded) return <View className="pt page-in"><Text className="err-text">加载中</Text></View>;
if (!authed) {
return (
<View className="pt page-in">
<View className="pt-card">
<Text className="pt-title">转园申请</Text>
<Text className="pt-sub">请先用手机号登录验证码 123456</Text>
<View className="field"><Input className="input" placeholder="手机号" value={gatePhone} onChange={(e) => setGatePhone(e.detail.value)} /></View>
<View className="field"><Input className="input" placeholder="验证码" value={gateCode} onChange={(e) => setGateCode(e.detail.value)} /></View>
{gateErr && <Text className="err-text">{gateErr}</Text>}
<Button className="btn btn-cta" loading={busy} onClick={gateSmsLogin}>登录 / 绑定</Button>
</View>
</View>
);
}
const disabled = curSt === 'pending' || curSt === 'reviewing';
return (
<View className="pt page-in">
<View className="pt-head">
<Text className="pt-title">转园申请</Text>
<Text className="pt-sub">当前园区 OPC 可申请转入其它园区需园区/平台审核</Text>
</View>
{cur && (
<View className="pt-card">
<Text className="pt-sec">当前申请</Text>
<Text className="pt-line">
目标{cur.to_park_name || cur.to_park_id || '-'} · 状态
<Text className="pt-status">{{ pending: '审核中', reviewing: '审核中', approved: '已通过', rejected: '未通过' }[curSt] || curSt}</Text>
</Text>
{cur.review_comment && <Text className="pt-line">审核意见{cur.review_comment}</Text>}
</View>
)}
<View className="pt-card">
<Text className="pt-sec">选择目标园区</Text>
<Picker range={parks.map((p) => p.name)} value={Math.max(0, parks.findIndex((p) => p.id === toParkId))}
onChange={(e) => setToParkId(parks[Number(e.detail.value)]?.id || '')}>
<View className="pt-picker">{parks.find((p) => p.id === toParkId)?.name || '请选择目标园区'} </View>
</Picker>
<Text className="pt-label">转园理由</Text>
<Textarea className="pt-textarea" placeholder="请说明转园原因" value={reason}
onChange={(e) => setReason(e.detail.value)} />
</View>
{disabled && <Text className="err-text">已有转园申请审核中请勿重复提交</Text>}
{err && <Text className="err-text">{err}</Text>}
<Button className="btn btn-cta" loading={busy || disabled} disabled={disabled} onClick={submit}>提交转园申请</Button>
<View className="pt-foot">仅园区 OPC 可发起转园审核结果可在我的查看</View>
</View>
);
}
@@ -0,0 +1,37 @@
/* 转园申请 · 小程序样式(暗色令牌一致) */
.pt { position: relative; z-index: 2; padding: 0 0 60rpx; }
.pt-head { padding: 24rpx 16rpx 8rpx; }
.pt-title { font-size: 40rpx; font-weight: 700; color: #fff; }
.pt-sub { display: block; margin-top: 8rpx; font-size: 25rpx; color: #8e8e8e; }
.pt-card {
margin: 24rpx 16rpx 0;
padding: 28rpx 24rpx;
background: linear-gradient(180deg, #0e0e12, #0a0a0d);
border: 1px solid rgba(255,255,255,.12);
border-radius: 20rpx;
}
.pt-sec { display: block; font-size: 26rpx; color: #ffd28a; margin-bottom: 16rpx; }
.pt-label { display: block; font-size: 25rpx; color: #c4c2c3; margin: 14rpx 0 8rpx; }
.pt-line { display: block; font-size: 25rpx; color: #eaeaea; margin-top: 8rpx; }
.pt-status { color: #ffd28a; }
.pt-picker {
background: #17171b; border: 1rpx solid #2a2a2d; border-radius: 10rpx;
color: #eaeaea; padding: 14rpx 16rpx; font-size: 27rpx;
}
.pt-textarea {
width: 100%; min-height: 120rpx; background: #17171b; border: 1rpx solid #2a2a2d;
border-radius: 10rpx; color: #eaeaea; padding: 14rpx 16rpx; font-size: 27rpx;
}
.field { margin-top: 12rpx; }
.input {
background: #17171b; border: 1rpx solid #2a2a2d; border-radius: 10rpx;
color: #eaeaea; padding: 14rpx 16rpx; font-size: 27rpx; width: 100%;
}
.pt-foot { margin-top: 20rpx; text-align: center; font-size: 23rpx; color: #8e8e8e; }
.ok-icon { width: 96rpx; height: 96rpx; margin: 120rpx auto 24rpx; border-radius: 50%; background: #26a69a; }
.ok-title { display: block; text-align: center; font-size: 36rpx; color: #fff; }
.ok-desc { display: block; text-align: center; margin-top: 12rpx; font-size: 26rpx; color: #8e8e8e; }
.btn { margin-top: 32rpx; }
.err-text { display: block; margin-top: 16rpx; text-align: center; font-size: 25rpx; color: #f0b8b8; }
+27
View File
@@ -0,0 +1,27 @@
import { request } from './api';
/** OPC 认证:提交认证申请(需登录,opc_member */
export async function submitOpCert(payload) {
const r = await request('POST', '/opc/certification/apply', payload, true);
if (!r?.ok) throw new Error(r?.detail || r?.error || '提交失败');
return r;
}
/** OPC 认证:我的认证状态 {certification_status, certification} */
export async function getOpCertMine() {
const r = await request('GET', '/opc/certification/mine', {}, true);
return r || {};
}
/** 转园:提交转园申请(需登录,园区 OPC) */
export async function submitParkTransfer(payload) {
const r = await request('POST', '/opc/park-transfer/apply', payload, true);
if (!r?.ok) throw new Error(r?.detail || r?.error || '提交失败');
return r;
}
/** 转园:我的转园申请 {transfer} */
export async function getParkTransferMine() {
const r = await request('GET', '/opc/park-transfer/mine', {}, true);
return r || {};
}