From cbf38e77452288d859a27f9e08a72b7c33c667ab Mon Sep 17 00:00:00 2001
From: doublex001 <16157008+doublex001@user.noreply.gitee.com>
Date: Thu, 10 Sep 2026 19:53:10 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B3=95=E5=BE=8B=E6=9C=8D=E5=8A=A1?=
=?UTF-8?q?=E4=BB=98=E8=B4=B9=E9=97=AD=E7=8E=AF=E3=80=81=E4=BC=9A=E5=91=98?=
=?UTF-8?q?=E6=9D=83=E7=9B=8A=E5=85=91=E7=8E=B0=E4=B8=8E=E5=B2=97=E4=BD=8D?=
=?UTF-8?q?=E8=AF=84=E4=BB=B7=E5=8F=91=E5=B8=83=EF=BC=882026-09-10?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
按提交纪律精确分阶段 git add(未用 -A / 未用 git rm),tsc --noEmit 0 错误、
eslint .(全量)0 警告。
| 模块 | 主要文件 | 变更要点 | 状态 |
|---|---|---|---|
| 付费支付闭环 | legal/checkout.tsx(新)、useLegalMembership.ts(新)、legal.tsx、upgrade.tsx | 选套餐→结算→选支付方式→模拟支付→会员激活并 AsyncStorage 持久化;套餐卡可点击、返回时 focus 刷新 | 完成·真实SDK仅换 api 函数体 |
| 会员权益兑现 | legal/service.tsx(新)、useLegalServiceRequests.ts(新)、legal.tsx | 修复「付了钱却拿不到服务」:未开通引导开通,已开通进入使用页提交申请并留痕 | 完成 |
| 岗位评价发布 | jobs/[id].tsx、useJobDetail.ts、api.ts | 修复「评价生态却发不出评价」:新增 createJobReview,发布后置顶并持久化 | 完成 |
| 类型与导出 | types/legal.ts、types/index.ts、api/index.ts、hooks/index.ts | 新增订单/会员态/服务申请类型与对应函数、hook 导出 | 完成 |
| 文档 | 项目开发状态.md | 同步三批功能状态与审计结论 | 完成 |
注:mock 阶段持久化统一走 src/lib/storage.ts(AsyncStorage,youzhi: 前缀),
storage 为唯一真相源,真实后端就绪后仅替换 api.ts 对应函数体。
---
app/jobs/[id].tsx | 37 ++-
app/legal.tsx | 101 ++++++-
app/legal/checkout.tsx | 395 ++++++++++++++++++++++++
app/legal/service.tsx | 430 +++++++++++++++++++++++++++
app/legal/upgrade.tsx | 82 ++++-
src/api/api.ts | 246 ++++++++++++++-
src/api/index.ts | 7 +
src/hooks/index.ts | 2 +
src/hooks/useJobDetail.ts | 12 +
src/hooks/useLegalMembership.ts | 92 ++++++
src/hooks/useLegalServiceRequests.ts | 58 ++++
src/types/index.ts | 6 +
src/types/legal.ts | 61 ++++
项目开发状态.md | 5 +-
14 files changed, 1510 insertions(+), 24 deletions(-)
create mode 100644 app/legal/checkout.tsx
create mode 100644 app/legal/service.tsx
create mode 100644 src/hooks/useLegalMembership.ts
create mode 100644 src/hooks/useLegalServiceRequests.ts
diff --git a/app/jobs/[id].tsx b/app/jobs/[id].tsx
index 1400a07..6571257 100644
--- a/app/jobs/[id].tsx
+++ b/app/jobs/[id].tsx
@@ -148,8 +148,28 @@ function SectionTitle({
export default function JobDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
- const { detail, overallScore, salaryData, reviews, loading, error, refresh } = useJobDetail(id);
+ const { detail, overallScore, salaryData, reviews, loading, error, refresh, publishReview } =
+ useJobDetail(id);
const [comment, setComment] = useState('');
+ const [publishing, setPublishing] = useState(false);
+
+ // 发布评价:空内容拦截,成功后清空输入框(列表由 hook 自动刷新)
+ const handlePublish = async () => {
+ const text = comment.trim();
+ if (!text) {
+ Alert.alert('提示', '请先输入你的评价内容');
+ return;
+ }
+ setPublishing(true);
+ try {
+ await publishReview(text);
+ setComment('');
+ } catch (e) {
+ Alert.alert('发布失败', (e as Error).message || '请稍后重试');
+ } finally {
+ setPublishing(false);
+ }
+ };
// 错误态
if (error) {
@@ -285,8 +305,16 @@ export default function JobDetailScreen() {
value={comment}
onChangeText={setComment}
/>
- Alert.alert('发布评论', '评论发布功能开发中,敬请期待')}>
- 发布
+
+ {publishing ? (
+
+ ) : (
+ 发布
+ )}
@@ -604,6 +632,9 @@ const styles = StyleSheet.create({
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
+ publishBtnDisabled: {
+ opacity: 0.6,
+ },
publishText: {
fontSize: 14,
fontWeight: '600',
diff --git a/app/legal.tsx b/app/legal.tsx
index 6044da0..81098b8 100644
--- a/app/legal.tsx
+++ b/app/legal.tsx
@@ -1,6 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
-import { useRouter } from 'expo-router';
-import { useState } from 'react';
+import { useFocusEffect, useRouter } from 'expo-router';
+import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Pressable,
@@ -10,7 +10,7 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { useLegalData } from '../src/hooks';
+import { useLegalData, useLegalMembership } from '../src/hooks';
import { colors } from '../src/theme';
import type { LegalFAQ, LegalPlan, LegalService, LegalTemplate } from '../src/types';
@@ -47,7 +47,16 @@ function BrandCard({ name, company, slogan, logo }: { name: string; company: str
/* ===================== 子组件:服务入口 ===================== */
-function ServiceCard({ service, onPress }: { service: LegalService; onPress: () => void }) {
+function ServiceCard({
+ service,
+ isUnlocked,
+ onPress,
+}: {
+ service: LegalService;
+ /** 会员服务是否已解锁(已开通会员) */
+ isUnlocked: boolean;
+ onPress: () => void;
+}) {
const isMember = service.type === 'member';
return (
{service.title}
{isMember && (
- 会员
+ {isUnlocked ? '已开通' : '会员'}
)}
@@ -107,9 +116,17 @@ function TemplateCard({ template }: { template: LegalTemplate }) {
/* ===================== 子组件:会员套餐 ===================== */
-function PlanCard({ plan }: { plan: LegalPlan }) {
+function PlanCard({ plan, onPress }: { plan: LegalPlan; onPress?: () => void }) {
return (
-
+ [
+ styles.planCard,
+ plan.recommended && styles.planCardRecommended,
+ pressed && styles.planPressed,
+ ]}
+ onPress={onPress}
+ disabled={!onPress}
+ >
{plan.recommended && (
推荐
@@ -129,7 +146,11 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
))}
-
+
+ 立即开通
+
+
+
);
}
@@ -138,6 +159,14 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
export default function LegalScreen() {
const router = useRouter();
const { brand, services, faqs, templates, plans, loading, error, refresh } = useLegalData();
+ const { membership, refresh: refreshMembership } = useLegalMembership();
+
+ // 返回本页时刷新会员态(支付成功后需即时反映)
+ useFocusEffect(
+ useCallback(() => {
+ refreshMembership();
+ }, [refreshMembership]),
+ );
const freeServices = services.filter((s) => s.type === 'free');
const memberServices = services.filter((s) => s.type === 'member');
@@ -150,7 +179,12 @@ export default function LegalScreen() {
} else if (service.action === 'faq') {
router.push('/legal/faq');
} else if (service.action === 'upgrade') {
- router.push('/legal/upgrade');
+ // 已开通会员 → 进入服务使用页(权益兑现);未开通 → 引导开通
+ if (membership) {
+ router.push(`/legal/service?id=${service.id}`);
+ } else {
+ router.push('/legal/upgrade');
+ }
}
};
@@ -190,6 +224,7 @@ export default function LegalScreen() {
handleServicePress(service)}
/>
))}
@@ -201,6 +236,7 @@ export default function LegalScreen() {
handleServicePress(service)}
/>
))}
@@ -231,9 +267,21 @@ export default function LegalScreen() {
{plans.length > 0 && (
<>
+ {membership && (
+
+
+
+ 您已开通{membership.planName},可畅享会员专属服务
+
+
+ )}
{plans.map((plan) => (
-
+ router.push(`/legal/checkout?planId=${plan.id}`)}
+ />
))}
>
@@ -474,6 +522,39 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.border,
},
+ planPressed: {
+ opacity: 0.85,
+ },
+ planCtaRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ marginTop: 12,
+ gap: 2,
+ },
+ planCtaText: {
+ fontSize: 13,
+ fontWeight: '700',
+ color: colors.action,
+ },
+ memberBanner: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderRadius: 8,
+ backgroundColor: colors.actionLight,
+ borderWidth: 1,
+ borderColor: colors.action,
+ marginBottom: 12,
+ },
+ memberBannerText: {
+ flex: 1,
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.actionText,
+ },
planCardRecommended: {
borderColor: colors.action,
backgroundColor: colors.actionLight,
diff --git a/app/legal/checkout.tsx b/app/legal/checkout.tsx
new file mode 100644
index 0000000..2258e81
--- /dev/null
+++ b/app/legal/checkout.tsx
@@ -0,0 +1,395 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { useCallback, useState } from 'react';
+import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useLegalData, useLegalMembership } from '../../src/hooks';
+import { LoadingState, EmptyState } from '../../src/components/StateView';
+import { colors } from '../../src/theme';
+import type { LegalPaymentChannel } from '../../src/types';
+
+/* ===================== 付款方式 ===================== */
+
+const CHANNELS: { id: LegalPaymentChannel; label: string; icon: keyof typeof Ionicons.glyphMap; color: string }[] = [
+ { id: 'wechat', label: '微信支付', icon: 'logo-wechat', color: colors.success },
+ { id: 'alipay', label: '支付宝', icon: 'wallet-outline', color: colors.primary },
+];
+
+/* ===================== 顶部导航 ===================== */
+
+function Header({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
+ 确认支付
+
+
+ );
+}
+
+/* ===================== 页面 ===================== */
+
+export default function LegalCheckoutScreen() {
+ const router = useRouter();
+ const { planId } = useLocalSearchParams();
+ const id = Array.isArray(planId) ? planId[0] : planId;
+ const { plans, loading } = useLegalData();
+ const { createOrder, payOrder, processing } = useLegalMembership();
+
+ const [channel, setChannel] = useState('wechat');
+ const [paid, setPaid] = useState(false);
+
+ const plan = plans.find((p) => p.id === id);
+
+ const handlePay = useCallback(async () => {
+ if (!plan) return;
+ try {
+ const order = await createOrder(plan.id, channel);
+ const result = await payOrder(order.id);
+ if (result.status === 'paid') {
+ setPaid(true);
+ } else {
+ Alert.alert('支付失败', '请稍后重试');
+ }
+ } catch (e) {
+ Alert.alert('支付失败', (e as Error).message || '请稍后重试');
+ }
+ }, [plan, channel, createOrder, payOrder]);
+
+ // 加载套餐中
+ if (loading) {
+ return (
+
+
+ );
+ }
+
+ // 套餐不存在(异常入口)
+ if (!plan) {
+ return (
+
+
+ );
+ }
+
+ // 支付成功
+ if (paid) {
+ return (
+
+ router.replace('/legal')} />
+
+
+
+
+ 支付成功
+
+ 已开通{plan.name},可畅享会员专属法律服务
+
+ router.replace('/legal')}>
+ 返回法律服务
+
+
+
+ );
+ }
+
+ return (
+
+ router.back()} />
+
+ {/* 套餐摘要 */}
+
+ {plan.name}
+
+ ¥
+ {plan.price}
+ {plan.period}
+
+
+ {plan.benefits.map((benefit, idx) => (
+
+
+ {benefit}
+
+ ))}
+
+
+
+ {/* 支付方式 */}
+ 选择支付方式
+
+ {CHANNELS.map((c) => {
+ const active = c.id === channel;
+ return (
+ [
+ styles.channelRow,
+ active && styles.channelRowActive,
+ pressed && styles.channelPressed,
+ ]}
+ onPress={() => setChannel(c.id)}
+ >
+
+ {c.label}
+
+ {active && }
+
+
+ );
+ })}
+
+
+ {/* 实付金额 */}
+
+ 实付金额
+
+ ¥
+ {plan.price}
+
+
+
+
+ {/* 确认支付(底部固定) */}
+
+ [styles.payBtn, pressed && styles.payBtnPressed, processing && styles.payBtnDisabled]}
+ onPress={handlePay}
+ disabled={processing}
+ >
+ {processing ? (
+
+ ) : (
+ 确认支付 ¥{plan.price}
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: colors.pageBg,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 8,
+ height: 48,
+ backgroundColor: colors.white,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderLight,
+ },
+ headerBtn: {
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ headerTitle: {
+ fontSize: 17,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ scroll: {
+ flex: 1,
+ },
+ content: {
+ padding: 12,
+ paddingBottom: 24,
+ },
+ summaryCard: {
+ padding: 16,
+ borderRadius: 8,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ summaryName: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ summaryPriceRow: {
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ marginTop: 8,
+ marginBottom: 12,
+ },
+ summaryCurrency: {
+ fontSize: 15,
+ fontWeight: '700',
+ color: colors.actionText,
+ },
+ summaryPrice: {
+ fontSize: 30,
+ fontWeight: '700',
+ color: colors.actionText,
+ },
+ summaryPeriod: {
+ fontSize: 13,
+ color: colors.textSecondary,
+ marginLeft: 2,
+ },
+ benefits: {
+ gap: 6,
+ },
+ benefitRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ },
+ benefitText: {
+ fontSize: 12,
+ color: colors.textSecondary,
+ },
+ sectionTitle: {
+ marginTop: 20,
+ marginBottom: 10,
+ fontSize: 15,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ channelGroup: {
+ gap: 8,
+ },
+ channelRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: 14,
+ borderRadius: 8,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.border,
+ gap: 12,
+ },
+ channelRowActive: {
+ borderColor: colors.action,
+ backgroundColor: colors.actionLight,
+ },
+ channelPressed: {
+ opacity: 0.85,
+ },
+ channelLabel: {
+ flex: 1,
+ fontSize: 15,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ },
+ radio: {
+ width: 20,
+ height: 20,
+ borderRadius: 10,
+ borderWidth: 2,
+ borderColor: colors.border,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ radioActive: {
+ borderColor: colors.action,
+ },
+ radioDot: {
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ backgroundColor: colors.action,
+ },
+ amountRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginTop: 20,
+ paddingTop: 16,
+ borderTopWidth: 1,
+ borderTopColor: colors.borderLight,
+ },
+ amountLabel: {
+ fontSize: 14,
+ color: colors.textSecondary,
+ },
+ amountValue: {
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ },
+ amountCurrency: {
+ fontSize: 15,
+ fontWeight: '700',
+ color: colors.actionText,
+ },
+ amountPrice: {
+ fontSize: 22,
+ fontWeight: '700',
+ color: colors.actionText,
+ },
+ footer: {
+ padding: 12,
+ backgroundColor: colors.white,
+ borderTopWidth: 1,
+ borderTopColor: colors.borderLight,
+ },
+ payBtn: {
+ height: 48,
+ borderRadius: 8,
+ backgroundColor: colors.primaryBtn,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ payBtnPressed: {
+ opacity: 0.9,
+ },
+ payBtnDisabled: {
+ opacity: 0.6,
+ },
+ payBtnText: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: colors.white,
+ },
+ successBox: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
+ },
+ successIconWrap: {
+ marginBottom: 16,
+ },
+ successTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ successDesc: {
+ marginTop: 8,
+ fontSize: 14,
+ color: colors.textSecondary,
+ textAlign: 'center',
+ },
+ successBtn: {
+ marginTop: 28,
+ width: 200,
+ height: 46,
+ borderRadius: 23,
+ backgroundColor: colors.primaryBtn,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ successBtnText: {
+ fontSize: 15,
+ fontWeight: '700',
+ color: colors.white,
+ },
+});
diff --git a/app/legal/service.tsx b/app/legal/service.tsx
new file mode 100644
index 0000000..580747c
--- /dev/null
+++ b/app/legal/service.tsx
@@ -0,0 +1,430 @@
+import { Ionicons } from '@expo/vector-icons';
+import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
+import { useCallback, useState } from 'react';
+import {
+ ActivityIndicator,
+ Alert,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useLegalData, useLegalMembership, useLegalServiceRequests } from '../../src/hooks';
+import { LoadingState, EmptyState } from '../../src/components/StateView';
+import { colors } from '../../src/theme';
+
+/* ===================== 顶部导航 ===================== */
+
+function Header({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
+ 会员服务
+
+
+ );
+}
+
+/* ===================== 页面 ===================== */
+
+export default function LegalServiceScreen() {
+ const router = useRouter();
+ const { id } = useLocalSearchParams();
+ const serviceId = Array.isArray(id) ? id[0] : id;
+ const { services, loading } = useLegalData();
+ const { membership, refresh: refreshMembership } = useLegalMembership();
+ const {
+ requests,
+ loading: requestsLoading,
+ refresh: refreshRequests,
+ submit,
+ submitting,
+ } = useLegalServiceRequests();
+
+ const [content, setContent] = useState('');
+
+ // 返回本页时刷新会员态与申请记录
+ useFocusEffect(
+ useCallback(() => {
+ refreshMembership();
+ refreshRequests();
+ }, [refreshMembership, refreshRequests]),
+ );
+
+ const service = services.find((s) => s.id === serviceId);
+
+ const handleSubmit = useCallback(async () => {
+ if (!service) return;
+ const text = content.trim();
+ if (!text) {
+ Alert.alert('提示', '请先填写你的需求描述');
+ return;
+ }
+ try {
+ await submit(service.id, text);
+ setContent('');
+ Alert.alert('提交成功', '我们已收到你的申请,将尽快安排专人跟进。');
+ } catch (e) {
+ Alert.alert('提交失败', (e as Error).message || '请稍后重试');
+ }
+ }, [service, content, submit]);
+
+ if (loading) {
+ return (
+
+
+ );
+ }
+
+ if (!service) {
+ return (
+
+
+ );
+ }
+
+ // 未开通会员:拦截并引导开通(付费前不给权益)
+ if (!membership) {
+ return (
+
+ router.back()} />
+
+
+
+
+ {service.title}
+ {service.description}
+ 该服务为会员专享,开通后即可使用
+ router.push('/legal/upgrade')}>
+ 开通会员
+
+
+
+ );
+ }
+
+ const myRequests = requests.filter((r) => r.serviceId === service.id);
+ const canSubmit = content.trim().length > 0 && !submitting;
+
+ return (
+
+ router.back()} />
+
+ {/* 服务说明 */}
+
+
+
+
+
+
+ {service.title}
+ {service.description}
+
+
+ {service.requestHint && {service.requestHint}}
+
+
+ {/* 需求填写 */}
+ 填写你的需求
+
+ {content.length}/500
+
+ [
+ styles.submitBtn,
+ !canSubmit && styles.submitBtnDisabled,
+ pressed && canSubmit && styles.submitBtnPressed,
+ ]}
+ onPress={handleSubmit}
+ disabled={!canSubmit}
+ >
+ {submitting ? (
+
+ ) : (
+ 提交申请
+ )}
+
+
+ {/* 我的申请记录 */}
+ 我的申请记录
+ {requestsLoading ? (
+
+ ) : myRequests.length === 0 ? (
+
+ 暂无申请记录
+
+ ) : (
+
+ {myRequests.map((r) => (
+
+
+ {r.serviceName}
+
+
+ {r.status === 'submitted' ? '已提交' : r.status === 'processing' ? '处理中' : '已完成'}
+
+
+
+ {r.content}
+
+ {new Date(r.createdAt).toLocaleString('zh-CN')}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: colors.pageBg,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 8,
+ height: 48,
+ backgroundColor: colors.white,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderLight,
+ },
+ headerBtn: {
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ headerTitle: {
+ fontSize: 17,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ scroll: {
+ flex: 1,
+ },
+ content: {
+ padding: 12,
+ paddingBottom: 24,
+ },
+ card: {
+ padding: 14,
+ borderRadius: 8,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ serviceHead: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ serviceIconWrap: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ backgroundColor: colors.actionLight,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ serviceHeadBody: {
+ flex: 1,
+ marginLeft: 12,
+ },
+ serviceName: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ serviceDesc: {
+ marginTop: 2,
+ fontSize: 12,
+ color: colors.textSecondary,
+ lineHeight: 17,
+ },
+ hint: {
+ marginTop: 12,
+ paddingTop: 12,
+ borderTopWidth: 1,
+ borderTopColor: colors.borderLight,
+ fontSize: 13,
+ lineHeight: 19,
+ color: colors.textSecondary,
+ },
+ sectionTitle: {
+ marginTop: 20,
+ marginBottom: 10,
+ fontSize: 15,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ input: {
+ minHeight: 120,
+ padding: 12,
+ borderRadius: 8,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.border,
+ fontSize: 14,
+ lineHeight: 20,
+ color: colors.textPrimary,
+ },
+ counter: {
+ marginTop: 6,
+ textAlign: 'right',
+ fontSize: 12,
+ color: colors.textHint,
+ },
+ submitBtn: {
+ marginTop: 16,
+ height: 48,
+ borderRadius: 8,
+ backgroundColor: colors.primaryBtn,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ submitBtnPressed: {
+ opacity: 0.9,
+ },
+ submitBtnDisabled: {
+ opacity: 0.5,
+ },
+ submitBtnText: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: colors.white,
+ },
+ requestsLoading: {
+ marginTop: 16,
+ },
+ emptyCard: {
+ padding: 20,
+ borderRadius: 8,
+ backgroundColor: colors.white,
+ alignItems: 'center',
+ },
+ emptyText: {
+ fontSize: 13,
+ color: colors.textSecondary,
+ },
+ requestList: {
+ gap: 8,
+ },
+ requestCard: {
+ padding: 12,
+ borderRadius: 8,
+ backgroundColor: colors.white,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ requestHead: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ requestService: {
+ flex: 1,
+ fontSize: 14,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ marginRight: 8,
+ },
+ statusTag: {
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 4,
+ backgroundColor: colors.primaryLight,
+ },
+ statusText: {
+ fontSize: 11,
+ fontWeight: '700',
+ color: colors.primaryText,
+ },
+ requestContent: {
+ marginTop: 8,
+ fontSize: 13,
+ lineHeight: 19,
+ color: colors.textSecondary,
+ },
+ requestTime: {
+ marginTop: 8,
+ fontSize: 11,
+ color: colors.textHint,
+ },
+ lockedBox: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
+ },
+ lockedIconWrap: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ backgroundColor: colors.actionLight,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 16,
+ },
+ lockedTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ lockedDesc: {
+ marginTop: 8,
+ fontSize: 14,
+ lineHeight: 20,
+ color: colors.textSecondary,
+ textAlign: 'center',
+ },
+ lockedTip: {
+ marginTop: 12,
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.actionText,
+ },
+ lockedBtn: {
+ marginTop: 28,
+ width: 200,
+ height: 46,
+ borderRadius: 23,
+ backgroundColor: colors.primaryBtn,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ lockedBtnText: {
+ fontSize: 15,
+ fontWeight: '700',
+ color: colors.white,
+ },
+});
diff --git a/app/legal/upgrade.tsx b/app/legal/upgrade.tsx
index 7c94cd5..bc8802d 100644
--- a/app/legal/upgrade.tsx
+++ b/app/legal/upgrade.tsx
@@ -1,8 +1,9 @@
import { Ionicons } from '@expo/vector-icons';
-import { useRouter } from 'expo-router';
+import { useFocusEffect, useRouter } from 'expo-router';
+import { useCallback } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { useLegalData } from '../../src/hooks';
+import { useLegalData, useLegalMembership } from '../../src/hooks';
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme';
import type { LegalPlan } from '../../src/types';
@@ -23,9 +24,17 @@ function Header({ onBack }: { onBack: () => void }) {
/* ===================== 套餐卡片 ===================== */
-function PlanCard({ plan }: { plan: LegalPlan }) {
+function PlanCard({ plan, onPress }: { plan: LegalPlan; onPress?: () => void }) {
return (
-
+ [
+ styles.planCard,
+ plan.recommended && styles.planCardRecommended,
+ pressed && styles.planPressed,
+ ]}
+ onPress={onPress}
+ disabled={!onPress}
+ >
{plan.recommended && (
推荐
@@ -45,7 +54,11 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
))}
-
+
+ 立即开通
+
+
+
);
}
@@ -54,6 +67,15 @@ function PlanCard({ plan }: { plan: LegalPlan }) {
export default function LegalUpgradeScreen() {
const router = useRouter();
const { plans, loading, error, refresh } = useLegalData();
+ const { membership, refresh: refreshMembership } = useLegalMembership();
+
+ useFocusEffect(
+ useCallback(() => {
+ refreshMembership();
+ }, [refreshMembership]),
+ );
+
+ const formatDate = (iso: string) => new Date(iso).toLocaleDateString('zh-CN');
return (
@@ -70,10 +92,23 @@ export default function LegalUpgradeScreen() {
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
- 开通会员后可享受律师咨询、合同审核等专属法律服务
+ {membership ? (
+
+
+
+ 您已开通{membership.planName},有效期至 {formatDate(membership.expiresAt)}
+
+
+ ) : (
+ 开通会员后可享受律师咨询、合同审核等专属法律服务
+ )}
{plans.map((plan) => (
-
+ router.push(`/legal/checkout?planId=${plan.id}`)}
+ />
))}
@@ -130,6 +165,39 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.border,
},
+ planPressed: {
+ opacity: 0.85,
+ },
+ planCtaRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ marginTop: 12,
+ gap: 2,
+ },
+ planCtaText: {
+ fontSize: 13,
+ fontWeight: '700',
+ color: colors.action,
+ },
+ memberBanner: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderRadius: 8,
+ backgroundColor: colors.actionLight,
+ borderWidth: 1,
+ borderColor: colors.action,
+ marginBottom: 12,
+ },
+ memberBannerText: {
+ flex: 1,
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.actionText,
+ },
planCardRecommended: {
borderColor: colors.action,
backgroundColor: colors.actionLight,
diff --git a/src/api/api.ts b/src/api/api.ts
index 15bd0d6..59e9527 100644
--- a/src/api/api.ts
+++ b/src/api/api.ts
@@ -74,8 +74,12 @@ import type {
LegalConversation,
LegalFAQ,
LegalMessage,
+ LegalMembership,
+ LegalOrder,
+ LegalPaymentChannel,
LegalPlan,
LegalService,
+ LegalServiceRequest,
LegalTemplate,
ToggleSetting,
ActionSetting,
@@ -122,6 +126,7 @@ import type {
} from '../types';
import { MOCK_DELAY, SALARY_RANGES, TOKEN_TTL_MS } from '../constants';
import { colors } from '../theme';
+import { storageGet, storageSet, storageRemove } from '../lib/storage';
/** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */
export const API_BASE_URL: string =
@@ -1240,8 +1245,50 @@ export async function fetchSalaryData(id: string): Promise {
*/
export async function fetchEmployeeReviews(id: string): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/reviews`, { method: 'GET' }); return res.json();
- void id;
- return mockRequest(MOCK_EMPLOYEE_REVIEWS);
+ // mock 阶段:内置评价 + 该岗位下本人已发布的评价(本人发布的最新在前)
+ const mine = (await readJobReviews()).filter((r) => r.jobId === id);
+ return mockRequest([...mine, ...MOCK_EMPLOYEE_REVIEWS]);
+}
+
+/* ===================== 岗位评价发布(mock 阶段持久化到 AsyncStorage) ===================== */
+
+const JOB_REVIEWS_KEY = 'job:reviews';
+
+/** 本人发布评价的存储结构:在 EmployeeReview 上补充归属岗位 */
+interface StoredJobReview extends EmployeeReview {
+ jobId: string;
+}
+
+/** 读取本人发布的全部评价 */
+async function readJobReviews(): Promise {
+ const raw = await storageGet(JOB_REVIEWS_KEY);
+ return raw ? (JSON.parse(raw) as StoredJobReview[]) : [];
+}
+
+/**
+ * 发布岗位评价
+ * @method POST
+ * @url /api/jobs/:id/reviews
+ * @param id 岗位 id
+ * @param content 评价内容
+ * @returns Promise 新建的评价
+ * @remark 真实后端下需做敏感词校验与重复提交限制,此处不做
+ */
+export async function createJobReview(id: string, content: string): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/reviews`, { method: 'POST', body: JSON.stringify({ content }) }); return res.json();
+ const review: StoredJobReview = {
+ id: `my-review-${Date.now()}`,
+ jobId: id,
+ userName: '我',
+ userRole: '我的评价',
+ time: '刚刚',
+ content,
+ tags: [],
+ };
+ const all = await readJobReviews();
+ all.unshift(review);
+ await storageSet(JOB_REVIEWS_KEY, JSON.stringify(all));
+ return mockRequest(review);
}
/**
@@ -3813,6 +3860,7 @@ const MOCK_LEGAL_SERVICES: LegalService[] = [
icon: 'people-outline',
type: 'member',
action: 'upgrade',
+ requestHint: '请简要描述你遇到的劳动法律问题,例如试用期被辞退、拖欠工资、未签劳动合同等,律师将在 24 小时内响应。',
},
{
id: 'member-review',
@@ -3821,6 +3869,7 @@ const MOCK_LEGAL_SERVICES: LegalService[] = [
icon: 'reader-outline',
type: 'member',
action: 'upgrade',
+ requestHint: '请说明需要审核的文书类型与你重点关注的风险点,例如劳动合同中的违约金条款、竞业限制范围等。',
},
{
id: 'member-advisor',
@@ -3829,6 +3878,7 @@ const MOCK_LEGAL_SERVICES: LegalService[] = [
icon: 'shield-checkmark-outline',
type: 'member',
action: 'upgrade',
+ requestHint: '请描述你希望顾问长期跟进的事项,例如在职期间的权益保障、跳槽风险把控等。',
},
];
@@ -3973,6 +4023,198 @@ export async function fetchLegalPlans(): Promise {
return mockRequest(MOCK_LEGAL_PLANS);
}
+/* ===================== 付费 / 订单 / 会员态(mock 阶段持久化到 AsyncStorage) ===================== */
+
+const LEGAL_MEMBERSHIP_KEY = 'legal:membership';
+const LEGAL_ORDERS_KEY = 'legal:orders';
+
+/** 读取会员态(storage 为唯一真相源,避免内存态过期) */
+async function readLegalMembership(): Promise {
+ const raw = await storageGet(LEGAL_MEMBERSHIP_KEY);
+ return raw ? (JSON.parse(raw) as LegalMembership) : null;
+}
+
+/** 写入会员态(null 表示清除) */
+async function writeLegalMembership(m: LegalMembership | null): Promise {
+ if (m) {
+ await storageSet(LEGAL_MEMBERSHIP_KEY, JSON.stringify(m));
+ } else {
+ await storageRemove(LEGAL_MEMBERSHIP_KEY);
+ }
+}
+
+/** 读取订单列表 */
+async function readLegalOrders(): Promise {
+ const raw = await storageGet(LEGAL_ORDERS_KEY);
+ return raw ? (JSON.parse(raw) as LegalOrder[]) : [];
+}
+
+/** 写入订单列表 */
+async function writeLegalOrders(orders: LegalOrder[]): Promise {
+ await storageSet(LEGAL_ORDERS_KEY, JSON.stringify(orders));
+}
+
+/**
+ * 获取当前会员态
+ * @method GET
+ * @url /api/legal/membership
+ * @returns Promise 未开通则返回 null
+ */
+export async function fetchLegalMembership(): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/legal/membership`, { method: 'GET' }); return res.json();
+ return mockRequest(await readLegalMembership());
+}
+
+/**
+ * 获取付费订单列表(按创建时间倒序)
+ * @method GET
+ * @url /api/legal/orders
+ * @returns Promise 订单列表
+ */
+export async function fetchLegalOrders(): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/legal/orders`, { method: 'GET' }); return res.json();
+ return mockRequest(await readLegalOrders());
+}
+
+/**
+ * 创建会员订单(mock 阶段仅本地落库,不接真实支付)
+ * @method POST
+ * @url /api/legal/orders
+ * @param planId 套餐 id
+ * @param channel 支付方式
+ * @returns Promise 新建的待支付订单
+ */
+export async function createLegalOrder(
+ planId: string,
+ channel: LegalPaymentChannel,
+): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/legal/orders`, { method: 'POST', body: JSON.stringify({ planId, channel }) }); return res.json();
+ const plans = await fetchLegalPlans();
+ const plan = plans.find((p) => p.id === planId);
+ if (!plan) {
+ throw new Error('套餐不存在');
+ }
+ const order: LegalOrder = {
+ id: `order-${Date.now()}`,
+ planId: plan.id,
+ planName: plan.name,
+ price: plan.price,
+ channel,
+ status: 'pending',
+ createdAt: new Date().toISOString(),
+ };
+ const orders = await readLegalOrders();
+ orders.unshift(order);
+ await writeLegalOrders(orders);
+ return mockRequest(order);
+}
+
+/**
+ * 支付订单(mock 阶段模拟支付处理并激活会员态)
+ * @method POST
+ * @url /api/legal/orders/:id/pay
+ * @param orderId 订单 id
+ * @returns Promise 支付后的订单(status=paid)
+ * @remark 真实接入时,应由后端支付回调确认支付结果后再激活会员态,前端不自行判定成功
+ */
+export async function payLegalOrder(orderId: string): Promise {
+ // TODO: 调用真实支付 SDK → 后端回调确认 → 返回订单
+ const orders = await readLegalOrders();
+ const idx = orders.findIndex((o) => o.id === orderId);
+ if (idx === -1) {
+ throw new Error('订单不存在');
+ }
+ const paid: LegalOrder = {
+ ...orders[idx],
+ status: 'paid',
+ paidAt: new Date().toISOString(),
+ };
+ orders[idx] = paid;
+ await writeLegalOrders(orders);
+
+ // 根据套餐周期计算会员有效期并激活会员态
+ const plans = await fetchLegalPlans();
+ const plan = plans.find((p) => p.id === paid.planId);
+ if (plan) {
+ const now = new Date();
+ const expires = new Date(now.getTime());
+ // period: '/年' 或 '/月'
+ if (plan.period.includes('年')) {
+ expires.setFullYear(now.getFullYear() + 1);
+ } else {
+ expires.setMonth(now.getMonth() + 1);
+ }
+ await writeLegalMembership({
+ planId: plan.id,
+ planName: plan.name,
+ startedAt: now.toISOString(),
+ expiresAt: expires.toISOString(),
+ });
+ }
+
+ // 模拟支付处理延时
+ await new Promise((resolve) => setTimeout(resolve, 800));
+ return mockRequest(paid);
+}
+
+/* ===================== 会员服务使用申请(付费后权益兑现) ===================== */
+
+const LEGAL_REQUESTS_KEY = 'legal:requests';
+
+/** 读取服务申请列表 */
+async function readLegalRequests(): Promise {
+ const raw = await storageGet(LEGAL_REQUESTS_KEY);
+ return raw ? (JSON.parse(raw) as LegalServiceRequest[]) : [];
+}
+
+/** 写入服务申请列表 */
+async function writeLegalRequests(requests: LegalServiceRequest[]): Promise {
+ await storageSet(LEGAL_REQUESTS_KEY, JSON.stringify(requests));
+}
+
+/**
+ * 获取会员服务使用申请列表(按提交时间倒序)
+ * @method GET
+ * @url /api/legal/service-requests
+ * @returns Promise 申请列表
+ */
+export async function fetchLegalServiceRequests(): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/legal/service-requests`, { method: 'GET' }); return res.json();
+ return mockRequest(await readLegalRequests());
+}
+
+/**
+ * 提交会员服务使用申请(仅已开通会员可提交,权限由后端校验)
+ * @method POST
+ * @url /api/legal/service-requests
+ * @param serviceId 服务 id
+ * @param content 用户填写的需求描述
+ * @returns Promise 新建的申请
+ */
+export async function createLegalServiceRequest(
+ serviceId: string,
+ content: string,
+): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/legal/service-requests`, { method: 'POST', body: JSON.stringify({ serviceId, content }) }); return res.json();
+ const services = await fetchLegalServices();
+ const service = services.find((s) => s.id === serviceId);
+ if (!service) {
+ throw new Error('服务不存在');
+ }
+ const request: LegalServiceRequest = {
+ id: `request-${Date.now()}`,
+ serviceId: service.id,
+ serviceName: service.title,
+ content,
+ status: 'submitted',
+ createdAt: new Date().toISOString(),
+ };
+ const requests = await readLegalRequests();
+ requests.unshift(request);
+ await writeLegalRequests(requests);
+ return mockRequest(request);
+}
+
/**
* 获取默认法律咨询服务会话
* @method GET
diff --git a/src/api/index.ts b/src/api/index.ts
index 1ce157e..14458e4 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -38,6 +38,7 @@ export {
fetchLegalServices,
fetchLegalTemplates,
fetchEmployeeReviews,
+ createJobReview,
fetchEnterpriseEntry,
fetchHollandAssessment,
fetchHollandResult,
@@ -75,6 +76,12 @@ export {
fetchDeleteAllData,
submitDeleteData,
fetchLegalDocument,
+ fetchLegalMembership,
+ fetchLegalOrders,
+ createLegalOrder,
+ payLegalOrder,
+ fetchLegalServiceRequests,
+ createLegalServiceRequest,
loginByPhone,
fetchWechatLoginResult,
bindWechatAndRegister,
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 504c7ea..da14531 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -37,6 +37,8 @@ export { useJobPublish } from './useJobPublish';
export { useKeywordBlock } from './useKeywordBlock';
export { useLegalData } from './useLegalData';
export { useLegalDocument } from './useLegalDocument';
+export { useLegalMembership } from './useLegalMembership';
+export { useLegalServiceRequests } from './useLegalServiceRequests';
export { useMessageData } from './useMessageData';
export { useNotificationSettings } from './useNotificationSettings';
export { useOfferCompareData } from './useOfferCompareData';
diff --git a/src/hooks/useJobDetail.ts b/src/hooks/useJobDetail.ts
index b73ef71..71676f2 100644
--- a/src/hooks/useJobDetail.ts
+++ b/src/hooks/useJobDetail.ts
@@ -4,6 +4,7 @@
*/
import { useCallback } from 'react';
import {
+ createJobReview,
fetchEmployeeReviews,
fetchJobDetail,
fetchJobOverallScore,
@@ -38,6 +39,16 @@ export function useJobDetail(id: string) {
const { data, loading, error, refresh } = useAsyncData(fetcher);
+ /** 发布岗位评价,成功后自动刷新评价列表 */
+ const publishReview = useCallback(
+ async (content: string) => {
+ const created = await createJobReview(id, content);
+ refresh();
+ return created;
+ },
+ [id, refresh],
+ );
+
return {
detail: data?.detail ?? null,
overallScore: data?.overallScore ?? null,
@@ -46,5 +57,6 @@ export function useJobDetail(id: string) {
loading,
error,
refresh,
+ publishReview,
};
}
diff --git a/src/hooks/useLegalMembership.ts b/src/hooks/useLegalMembership.ts
new file mode 100644
index 0000000..daf5dda
--- /dev/null
+++ b/src/hooks/useLegalMembership.ts
@@ -0,0 +1,92 @@
+/**
+ * 法律服务会员态 hook:拉取当前会员态与付费订单,并提供
+ * 创建订单 / 模拟支付 的能力。mock 阶段数据持久化在 AsyncStorage。
+ */
+import { useCallback, useEffect, useState } from 'react';
+import {
+ createLegalOrder,
+ fetchLegalMembership,
+ fetchLegalOrders,
+ payLegalOrder,
+} from '../api';
+import type { LegalMembership, LegalOrder, LegalPaymentChannel } from '../types';
+
+export interface LegalMembershipState {
+ /** 当前会员态,未开通为 null */
+ membership: LegalMembership | null;
+ /** 付费订单列表(倒序) */
+ orders: LegalOrder[];
+ /** 首次加载中 */
+ loading: boolean;
+ /** 错误对象 */
+ error: Error | null;
+ /** 支付处理中(创建订单 + 模拟支付) */
+ processing: boolean;
+ /** 刷新会员态与订单 */
+ refresh: () => Promise;
+ /** 创建订单,返回新建的待支付订单 */
+ createOrder: (planId: string, channel: LegalPaymentChannel) => Promise;
+ /** 支付订单,成功后同步会员态 */
+ payOrder: (orderId: string) => Promise;
+}
+
+export function useLegalMembership(): LegalMembershipState {
+ const [membership, setMembership] = useState(null);
+ const [orders, setOrders] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [processing, setProcessing] = useState(false);
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const [m, o] = await Promise.all([fetchLegalMembership(), fetchLegalOrders()]);
+ setMembership(m);
+ setOrders(o);
+ } catch (e) {
+ setError(e as Error);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ const createOrder = useCallback(
+ async (planId: string, channel: LegalPaymentChannel) => {
+ const order = await createLegalOrder(planId, channel);
+ setOrders((prev) => [order, ...prev]);
+ return order;
+ },
+ [],
+ );
+
+ const payOrder = useCallback(async (orderId: string) => {
+ setProcessing(true);
+ try {
+ const order = await payLegalOrder(orderId);
+ setOrders((prev) => prev.map((o) => (o.id === orderId ? order : o)));
+ if (order.status === 'paid') {
+ const m = await fetchLegalMembership();
+ setMembership(m);
+ }
+ return order;
+ } finally {
+ setProcessing(false);
+ }
+ }, []);
+
+ return {
+ membership,
+ orders,
+ loading,
+ error,
+ processing,
+ refresh,
+ createOrder,
+ payOrder,
+ };
+}
diff --git a/src/hooks/useLegalServiceRequests.ts b/src/hooks/useLegalServiceRequests.ts
new file mode 100644
index 0000000..e6aae79
--- /dev/null
+++ b/src/hooks/useLegalServiceRequests.ts
@@ -0,0 +1,58 @@
+/**
+ * 会员服务使用申请 hook:拉取本人的服务申请记录,并提供提交申请的能力。
+ * 权益兑现载体——已开通会员提交后由律师/顾问跟进。
+ */
+import { useCallback, useEffect, useState } from 'react';
+import { createLegalServiceRequest, fetchLegalServiceRequests } from '../api';
+import type { LegalServiceRequest } from '../types';
+
+export interface LegalServiceRequestsState {
+ /** 申请记录(倒序) */
+ requests: LegalServiceRequest[];
+ /** 首次加载中 */
+ loading: boolean;
+ /** 错误对象 */
+ error: Error | null;
+ /** 提交中 */
+ submitting: boolean;
+ /** 刷新申请记录 */
+ refresh: () => Promise;
+ /** 提交申请 */
+ submit: (serviceId: string, content: string) => Promise;
+}
+
+export function useLegalServiceRequests(): LegalServiceRequestsState {
+ const [requests, setRequests] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ setRequests(await fetchLegalServiceRequests());
+ } catch (e) {
+ setError(e as Error);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ const submit = useCallback(async (serviceId: string, content: string) => {
+ setSubmitting(true);
+ try {
+ const created = await createLegalServiceRequest(serviceId, content);
+ setRequests((prev) => [created, ...prev]);
+ return created;
+ } finally {
+ setSubmitting(false);
+ }
+ }, []);
+
+ return { requests, loading, error, submitting, refresh, submit };
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index d1fa7f1..494e4ec 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -192,6 +192,12 @@ export type {
LegalService,
LegalServiceType,
LegalTemplate,
+ LegalPaymentChannel,
+ LegalOrderStatus,
+ LegalOrder,
+ LegalMembership,
+ LegalServiceRequest,
+ LegalServiceRequestStatus,
} from './legal';
export type {
AboutInfo,
diff --git a/src/types/legal.ts b/src/types/legal.ts
index 509cef0..4373d64 100644
--- a/src/types/legal.ts
+++ b/src/types/legal.ts
@@ -20,6 +20,8 @@ export interface LegalService {
type: LegalServiceType;
/** 跳转动作:chat=法律咨询对话 / templates=文书模板 / faq=常见问题 / upgrade=开通会员 */
action: 'chat' | 'templates' | 'faq' | 'upgrade';
+ /** 会员服务使用页的引导提示语(仅 type=member 时配置) */
+ requestHint?: string;
}
/** 法律常见问题 */
@@ -101,3 +103,62 @@ export interface LegalBrand {
/** 品牌 Logo(mock 阶段用 emoji/图标占位) */
logo: string;
}
+
+/* ===================== 付费 / 订单 / 会员态 ===================== */
+
+/** 支付方式(mock 阶段仅作选择,真实支付由后端凭证决定) */
+export type LegalPaymentChannel = 'wechat' | 'alipay';
+
+/** 订单状态:待支付 / 已支付 / 失败 */
+export type LegalOrderStatus = 'pending' | 'paid' | 'failed';
+
+/** 会员订单 */
+export interface LegalOrder {
+ /** 订单 id */
+ id: string;
+ /** 套餐 id */
+ planId: string;
+ /** 套餐名称(下单时快照) */
+ planName: string;
+ /** 实付金额(元) */
+ price: number;
+ /** 支付方式 */
+ channel: LegalPaymentChannel;
+ /** 订单状态 */
+ status: LegalOrderStatus;
+ /** 创建时间 ISO */
+ createdAt: string;
+ /** 支付时间 ISO(status=paid 时存在) */
+ paidAt?: string;
+}
+
+/** 会员态 */
+export interface LegalMembership {
+ /** 套餐 id */
+ planId: string;
+ /** 套餐名称 */
+ planName: string;
+ /** 开通时间 ISO */
+ startedAt: string;
+ /** 到期时间 ISO */
+ expiresAt: string;
+}
+
+/** 会员服务申请状态:已提交 / 处理中 / 已完成 */
+export type LegalServiceRequestStatus = 'submitted' | 'processing' | 'done';
+
+/** 会员服务使用申请(付费后权益兑现的载体) */
+export interface LegalServiceRequest {
+ /** 申请 id */
+ id: string;
+ /** 服务 id */
+ serviceId: string;
+ /** 服务名称(提交时快照) */
+ serviceName: string;
+ /** 用户填写的需求描述 */
+ content: string;
+ /** 申请状态 */
+ status: LegalServiceRequestStatus;
+ /** 提交时间 ISO */
+ createdAt: string;
+}
diff --git a/项目开发状态.md b/项目开发状态.md
index 6de4824..ef8f9c9 100644
--- a/项目开发状态.md
+++ b/项目开发状态.md
@@ -75,7 +75,8 @@
### 4.2 功能完整性缺口(标注【待确认】为推断项,需与产品对齐)
- **通关小游戏(7 关)**:框架页 `game.tsx` + `game/level.tsx` 已建,7 关(认识自己 → 能力 → 岗位 → 企业 → 简历 → 面试 → 签约)**内容与数据已完整**:`MOCK_GAME_LEVELS` 共 22 题(self 4 / 其余各 3),题型含单选/多选/输入,配套解析与 `tags` 用于岗位推荐。评分(≥60 通关)、解锁、进度聚合逻辑均在 `submitGameLevel` 实现。✅ 2026-09-10 完成内容增厚(每关由 1–2 题补至 3 题左右)。
-- **阿布阿诺 AI 法律服务**:免费层(咨询 / 模板 / FAQ)页已建;付费层 `legal/upgrade.tsx` 已建,但付费 / 支付链路 **【待确认】是否接通**。
+- **阿布阿诺 AI 法律服务**:免费层(咨询 / 模板 / FAQ)页已建;付费层 `legal/upgrade.tsx` 已建,**付费 / 支付链路已接通(mock 支付闭环)**——`legal/checkout.tsx` 选套餐→选支付方式→模拟支付→会员态激活并 AsyncStorage 持久化;`legal.tsx` / `upgrade.tsx` 套餐卡可点击进入结算,会员态在返回时即时刷新。真实支付 SDK 后续仅换 `api.ts` 的 `createLegalOrder` / `payLegalOrder` 函数体。**付费后权益已兑现**:新增 `legal/service.tsx` 会员服务使用页——未开通点会员服务仍引导开通,已开通则进入使用页(服务说明 + 引导语 + 需求输入框 + 提交申请 + 申请记录持久化),`legal.tsx` 会员卡标签按态显示「会员 / 已开通」。
+- **岗位评价发布(双向透明评价生态核心)**:✅ 2026-09-10 接通。此前 `jobs/[id].tsx` 底部发布按钮为「功能开发中」占位——产品核心定位即评价体系却无法发布。现已新增 `createJobReview`(AsyncStorage 键 `job:reviews`,按 jobId 归属),`fetchEmployeeReviews` 合并「本人发布(置顶)+ 内置评价」,`useJobDetail` 暴露 `publishReview`,页面接线含空输入拦截、发布中 loading、成功后清空输入框并自动刷新列表。
- **隐私政策 / 用户协议内容**:`src/api/api.ts` 中 `MOCK_PRIVACY_POLICY` / `MOCK_USER_AGREEMENT` 已含完整合规草案(各 9–10 章),**18 岁门槛已落地**(隐私政策第六章 + 用户协议前言)。仅余【待确认】占位待法务/产品替换:公司全称、注册地址、客服邮箱/电话、个人信息保护负责人邮箱、管辖法院。属**上架硬性审查项**,占位填妥即可提交审核。
### 4.3 工程与上线
@@ -90,7 +91,7 @@
## 五、后续增量开发建议(优先级)
- **P0(上架前必做)**:图标替换【待你提供 1024×1024 logo】 · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,待账号注入占位】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】**。
-- **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · 付费法律服务支付 · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。
+- **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · ~~付费法律服务支付~~ **付费法律服务支付【已完成:mock 支付闭环,选套餐→结算→模拟支付→会员激活持久化,tsc/eslint 0 问题】** · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。
- **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · 真实后端对接 · OTA 通道验证。
---