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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '扫码接单' };
|
||||
@@ -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 || '—';
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user