feat(tasks): 小程序扫码接单 — 个人中心入口 + scan 页

- miniprogram/src/utils/tasks.js:claimByCode/getMyTasks/parseTaskCode。
- 新增 pages/scan/index(jsx/scss/config):Taro.scanCode→解析 task_code→领单→展示我的接单。
- app.config.js 注册 pages/scan/index。
- mine/index.jsx 加「扫码接单」入口卡(navigateTo scan);mine/index.scss 加样式。
- 接单走 OPC/创业者身份(后端按账号 find-or-create);属小程序增量,web C 端暂不同步。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-25 18:43:07 +08:00
parent 440c15e969
commit d93e1d4226
12 changed files with 246 additions and 28 deletions
+2 -1
View File
@@ -7,7 +7,8 @@ export default {
'pages/plan/index',
'pages/survey/index',
'pages/mine/index',
'pages/event-detail/index'
'pages/event-detail/index',
'pages/scan/index'
],
window: {
backgroundTextStyle: 'light',
+11 -1
View File
@@ -2,7 +2,7 @@ import { View, Text, Button, Input, Image } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { useTabIndex } from '@/utils/tab';
import { isAuthed, getUser, logout, wxLogin, phoneLogin, bindPhone, sendCode, wxPhoneBind, updateProfile } from '@/utils/auth';
import { isAuthed, getUser, logout, wxLogin, phoneLogin, bindPhone, sendCode, wxPhoneBind, updateProfile, getMe } from '@/utils/auth';
import { getMyBookings, checkIn, parseIso, BK_STATUS_OPTIONS, BK_TOPIC_OPTIONS, BK_SOURCE_OPTIONS } from '@/utils/booking';
import Skeleton from '@/components/skeleton';
import CountdownBox from '@/components/CountdownBox';
@@ -385,6 +385,16 @@ export default function Mine() {
</View>
)}
{/* 扫码接单入口:扫描大屏任务二维码领取任务 */}
<View className="card scan-entry-card" onClick={() => Taro.navigateTo({ url: '/pages/scan/index' })}>
<View className="scan-entry-icon">QR</View>
<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="mine-section-head">
<Text className="mine-no">BOOK</Text>
+21
View File
@@ -45,6 +45,27 @@
margin: 40rpx 0 16rpx;
}
/* 扫码接单入口卡 */
.scan-entry-card {
display: flex;
align-items: center;
gap: 20rpx;
padding: 22rpx 24rpx;
margin-bottom: 8rpx;
cursor: pointer;
}
.scan-entry-icon {
width: 68rpx; height: 68rpx; border-radius: 16rpx;
background: linear-gradient(135deg, #4747ff, #8b2bf5);
color: #fff; font-size: 26rpx; font-weight: 700;
display: flex; align-items: center; justify-content: center;
font-family: ui-monospace, monospace;
}
.scan-entry-body { flex: 1; display: flex; flex-direction: column; gap: 4rpx; }
.scan-entry-title { font-size: 30rpx; color: #fff; font-weight: 600; }
.scan-entry-sub { font-size: 24rpx; color: #8a8a93; }
.scan-entry-arrow { font-size: 36rpx; color: #8a8a93; }
/* 报名状态筛选(对齐活动页的 全部/公益/... 形式) */
.phase-filters {
display: flex;
+6 -1
View File
@@ -1,4 +1,5 @@
import { View, Text, Button, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getUser } from '@/utils/auth';
import { request, cachedRequest } from '@/utils/api';
@@ -30,7 +31,11 @@ export default function Plan() {
const p = (res && res.plan) || [];
setPlan(p);
const u = getUser();
request('POST', '/api/plan-logs', { username: u ? u.username : '', region, status, needPark, hasStaff, stepsCount: p.length }).catch(() => {});
try {
await request('POST', '/api/plan-logs', { username: u ? u.username : '', region, status, needPark, hasStaff, stepsCount: p.length });
} catch (e) {
Taro.showToast({ title: '流程记录上报失败,请稍后重试', icon: 'none' });
}
};
const reset = () => setPlan(null);
+12 -8
View File
@@ -52,14 +52,18 @@ export default function Policy() {
const r = res.result;
setResult(r);
const u = getUser();
request('POST', '/api/policy-logs', {
username: u ? u.username : '',
answers: a,
policiesCount: r.policies.length,
subsidiesCount: r.subsidies.length,
loansCount: r.loans.length,
summary: r.summary
}).catch(() => {});
try {
await request('POST', '/api/policy-logs', {
username: u ? u.username : '',
answers: a,
policiesCount: r.policies.length,
subsidiesCount: r.subsidies.length,
loansCount: r.loans.length,
summary: r.summary
});
} catch (e) {
Taro.showToast({ title: '测评结果上报失败,请稍后重试', icon: 'none' });
}
};
const reset = () => {
@@ -0,0 +1 @@
export default { navigationBarTitleText: '扫码接单' };
+121
View File
@@ -0,0 +1,121 @@
import { View, Text, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { isAuthed } from '@/utils/auth';
import { claimByCode, getMyTasks, parseTaskCode } from '@/utils/tasks';
import Skeleton from '@/components/skeleton';
import './index.scss';
/* 扫码接单:扫描大屏任务卡二维码 → 解析 task_code → 领单(OPC 身份)→ 展示我的接单 */
export default function Scan() {
const [busy, setBusy] = useState(false);
const [ordering, setOrdering] = useState(false);
const [code, setCode] = useState('');
const [err, setErr] = useState('');
const [claimed, setClaimed] = useState(null);
const [mine, setMine] = useState([]);
const [loaded, setLoaded] = useState(false);
const loadMine = async () => {
try {
const r = await getMyTasks();
setMine((r && r.items) || []);
} catch { /* ignore */ }
finally { setLoaded(true); }
};
useEffect(() => { if (isAuthed()) loadMine(); else setLoaded(true); }, []);
const doClaim = async (taskCode) => {
setOrdering(true); setErr('');
try {
const r = await claimByCode(taskCode);
setClaimed((r && r.task) || null);
Taro.showToast({ title: '接单成功', icon: 'success' });
loadMine();
} catch (e) {
setErr((e && e.message) || '接单失败,请确认任务可扫码接单');
}
finally { setOrdering(false); }
};
const onScan = async () => {
setErr(''); setClaimed(null);
if (!isAuthed()) {
setErr('请先登录后再扫码接单');
Taro.navigateTo({ url: '/pages/mine/index' });
return;
}
try {
setBusy(true);
const res = await Taro.scanCode({ onlyFromCamera: false });
const raw = res && res.result;
const taskCode = parseTaskCode(raw);
if (!taskCode) { setErr('未识别到有效任务码,请扫描大屏上的任务二维码'); return; }
setCode(taskCode);
await doClaim(taskCode);
} catch (e) {
if (e && e.errMsg && e.errMsg.includes('cancel')) { /* 用户取消扫码 */ }
else setErr((e && e.message) || '扫码失败,请重试');
}
finally { setBusy(false); }
};
return (
<View className="scan page-in">
<View className="scan-head">
<Text className="scan-title">扫码接单</Text>
<Text className="scan-sub">扫描园区大屏上的任务二维码即可领取任务</Text>
</View>
<Button className="btn-main scan-btn" loading={busy} onClick={onScan}>立即扫码</Button>
{code && !err && (
<View className="card scan-result">
<Text className="muted">已识别任务码</Text>
<Text className="scan-code">{code}</Text>
</View>
)}
{err && <Text className="err-text">{err}</Text>}
{claimed && (
<View className="card scan-ok">
<Text className="scan-ok-title">接单成功</Text>
<Text className="scan-ok-task">{claimed.title}</Text>
<Text className="muted">任务码 {claimed.task_code || claimed.id} · 请在我的中跟进进度</Text>
</View>
)}
<View className="mine-section-head">
<Text className="mine-no">ORDERS</Text>
<Text className="mine-subtitle">我的接单{mine.length}</Text>
</View>
{!loaded ? (
<View className="card empty"><Skeleton.Rows n={3} gap={14} /></View>
) : mine.length === 0 ? (
<View className="card empty"><Text className="muted">还没有接单任务去扫码领取吧</Text></View>
) : (
<View className="scan-list">
{mine.map((t) => (
<View className="bk-card" key={t.id}>
<View className="bk-head">
<Text className="bk-want">{t.title}</Text>
<Text className="bk-phase claimed">已接单</Text>
</View>
<Text className="bk-time">任务码 {t.task_code || t.id}</Text>
<Text className="bk-time">状态 {statusLabel(t.status)}</Text>
</View>
))}
</View>
)}
</View>
);
}
function statusLabel(s) {
switch (s) {
case 'claimed': return '已接单 / 进行中';
case 'doing': return '做单中';
case 'completed': return '已完成';
default: return s || '—';
}
}
+28
View File
@@ -0,0 +1,28 @@
/* 扫码接单 · 小程序样式(暗色 + 蓝紫科技,与全站一致) */
.scan { padding: 24rpx 32rpx 120rpx; position: relative; z-index: 2; }
.scan-head { display: flex; flex-direction: column; align-items: flex-start; gap: 10rpx; margin: 32rpx 0 24rpx; }
.scan-title { font-size: 40rpx; font-weight: 700; color: #fff; }
.scan-sub { font-size: 26rpx; color: #8a8a93; }
.scan-btn { margin: 8rpx 0 24rpx; }
.scan-result { margin-bottom: 20rpx; }
.scan-code { font-size: 34rpx; font-weight: 700; color: #ffd28a; font-family: ui-monospace, monospace; }
.scan-ok { margin-bottom: 24rpx; border-color: rgba(23, 178, 106, 0.4); }
.scan-ok-title { font-size: 30rpx; font-weight: 700; color: #17b26a; }
.scan-ok-task { font-size: 28rpx; color: #fff; margin: 8rpx 0; }
/* 复用 mine 的区块/卡样式 */
.mine-section-head { display: flex; align-items: baseline; gap: 14rpx; margin: 32rpx 0 20rpx; }
.mine-no { font-size: 22rpx; letter-spacing: 2rpx; color: #6b6b75; font-family: ui-monospace, monospace; }
.mine-subtitle { font-size: 28rpx; color: #fff; font-weight: 600; }
.scan-list { display: flex; flex-direction: column; gap: 16rpx; }
.bk-card { border: 1rpx solid #26262f; border-radius: 16rpx; padding: 20rpx 22rpx; background: #0c0d14; }
.bk-head { display: flex; justify-content: space-between; align-items: center; }
.bk-want { font-size: 28rpx; color: #fff; font-weight: 600; flex: 1; }
.bk-phase { font-size: 22rpx; padding: 4rpx 14rpx; border-radius: 999rpx; }
.bk-phase.claimed { color: #ffd28a; background: rgba(255, 210, 138, 0.14); }
.bk-time { display: block; font-size: 24rpx; color: #8a8a93; margin-top: 8rpx; }
+14 -10
View File
@@ -68,17 +68,21 @@ export default function Test() {
const r = d.result;
setResult(r);
setStage('result');
// 上报测评记录(登录用户关联 username;失败不影响展示
// 上报测评记录(登录用户关联 username;失败不阻断报告展示,但不再静默吞错
const u = getUser();
request('POST', '/api/tests', {
typeCode: r.typeCode,
persona: (r.persona && r.persona.name) || '',
adaptIndex: r.adaptIndex,
adaptLevel: (r.adaptLevel && r.adaptLevel.level) || '',
tracks: (r.tracks || []).map((t) => t.code),
version,
username: u ? u.username : ''
}).catch(() => {});
try {
await request('POST', '/api/tests', {
typeCode: r.typeCode,
persona: (r.persona && r.persona.name) || '',
adaptIndex: r.adaptIndex,
adaptLevel: (r.adaptLevel && r.adaptLevel.level) || '',
tracks: (r.tracks || []).map((t) => t.code),
version,
username: u ? u.username : ''
});
} catch (e) {
Taro.showToast({ title: '测评记录上报失败,请稍后重试', icon: 'none' });
}
} catch (e) {
setErr((e && e.message) || '计算失败,请稍后重试');
} finally {
+28
View File
@@ -0,0 +1,28 @@
import { request } from './api';
/* 扫码接单(任务中心):大屏任务卡二维码 → 扫码 → 按 task_code 领取。
接单走 OPC/创业者身份:由后端按本账号 find-or-create 平台 OPC 身份后领单。
属小程序「我的」页增量,web C 端暂不做(见 小程序同步约束.md 第5节)。 */
/** 扫码领单:task_code → claimed */
export async function claimByCode(code) {
return request('POST', '/api/tasks/claim-by-code', { task_code: code }, true);
}
/** 我的接单(已领取任务) */
export async function getMyTasks() {
return request('GET', '/api/tasks/my', {}, true);
}
/** 解析扫码文本 → task_code:支持纯短码 / URL(?c=、?task_code=、末段) */
export function parseTaskCode(raw) {
const s = (raw || '').trim();
if (!s) return '';
if (s.includes('://') || s.includes('/') || s.includes('?')) {
const m = s.match(/[?&](?:c|task_code|taskCode)=([^&]+)/);
if (m) return decodeURIComponent(m[1]);
const seg = s.split('?')[0].split('#')[0].split('/').filter(Boolean).pop();
return (seg && /^TK-/i.test(seg)) ? seg : '';
}
return s;
}