Browse Source

feat(api): 补齐简历多版本(P0-5)列表/创建/详情/修改/删除/设默认/导出任务

- api: 新增 resume-versions 全链路 9 个端点(对齐 /profile/me/resume-versions 与 /resume-export-tasks)
  mock 以模块级可变数组模拟持久态;真实路径含 multipart 上传占位说明
- hook: useResumeVersions 覆盖列表/创建/设默认/删除/导出轮询
- 页面:app/resume-versions.tsx 版本卡(默认徽标/设默认/导出/删除)+ 新建弹窗,四态齐全
- 入口:简历页头部加「版本」入口;路由在 _layout 注册
- 主题:补充 scrimLight 遮罩 token(亮/暗双模式)
master
doublex001 4 weeks ago
parent
commit
0f8ab9a6b6
  1. 1
      app/_layout.tsx
  2. 464
      app/resume-versions.tsx
  3. 13
      app/resume.tsx
  4. 239
      src/api/api.ts
  5. 8
      src/api/index.ts
  6. 1
      src/hooks/index.ts
  7. 118
      src/hooks/useResumeVersions.ts
  8. 6
      src/theme/colors.ts
  9. 4
      src/types/index.ts
  10. 56
      src/types/resume.ts

1
app/_layout.tsx

@ -21,6 +21,7 @@ export default function RootLayout() {
<Stack.Screen name="jobs/[id]" options={{ headerShown: false }} /> <Stack.Screen name="jobs/[id]" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ title: '设置' }} /> <Stack.Screen name="settings" options={{ title: '设置' }} />
<Stack.Screen name="resume" options={{ title: '我的简历' }} /> <Stack.Screen name="resume" options={{ title: '我的简历' }} />
<Stack.Screen name="resume-versions" options={{ title: '简历版本' }} />
<Stack.Screen name="favorites" options={{ title: '我的收藏' }} /> <Stack.Screen name="favorites" options={{ title: '我的收藏' }} />
<Stack.Screen name="notifications" options={{ title: '通知' }} /> <Stack.Screen name="notifications" options={{ title: '通知' }} />
<Stack.Screen name="history" options={{ title: '浏览历史' }} /> <Stack.Screen name="history" options={{ title: '浏览历史' }} />

464
app/resume-versions.tsx

@ -0,0 +1,464 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useResumeVersions } from '../src/hooks';
import { colors, spacing } from '../src/theme';
import type { ResumeVersion } from '../src/types';
import { formatBytes, formatDateTime } from '../src/utils';
/* ===================== 子组件:顶部导航 ===================== */
function Header({
onBack,
onNew,
}: {
onBack: () => void;
onNew: () => void;
}) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>简历版本</Text>
<Pressable style={styles.headerActionBtn} onPress={onNew} hitSlop={8}>
<Ionicons name="add" size={22} color={colors.primary} />
</Pressable>
</View>
);
}
/* ===================== 子组件:版本卡 ===================== */
function VersionCard({
version,
onSetDefault,
onExport,
onDelete,
}: {
version: ResumeVersion;
onSetDefault: () => void;
onExport: () => void;
onDelete: () => void;
}) {
return (
<View style={[styles.card, version.isDefault && styles.cardDefault]}>
<View style={styles.cardTop}>
<View style={styles.cardTitleWrap}>
<Text style={styles.cardTitle} numberOfLines={1}>
{version.name}
</Text>
{version.isDefault && (
<View style={styles.defaultBadge}>
<Text style={styles.defaultBadgeText}>默认</Text>
</View>
)}
</View>
<Text style={styles.cardVersion}>v{version.versionNo}</Text>
</View>
<Text style={styles.cardMeta}>
{version.fileName} · {formatBytes(version.fileSize)}
</Text>
<Text style={styles.cardTime}>
更新于 {formatDateTime(version.updatedAt)}
</Text>
<View style={styles.cardActions}>
{!version.isDefault && (
<Pressable style={styles.actionBtn} onPress={onSetDefault}>
<Ionicons name="star-outline" size={16} color={colors.primary} />
<Text style={[styles.actionText, { color: colors.primary }]}>设为默认</Text>
</Pressable>
)}
<Pressable style={styles.actionBtn} onPress={onExport}>
<Ionicons name="download-outline" size={16} color={colors.textSecondary} />
<Text style={styles.actionText}>导出</Text>
</Pressable>
{!version.isDefault && (
<Pressable style={styles.actionBtn} onPress={onDelete}>
<Ionicons name="trash-outline" size={16} color={colors.danger} />
<Text style={[styles.actionText, { color: colors.danger }]}>删除</Text>
</Pressable>
)}
</View>
</View>
);
}
/* ===================== 子组件:新建版本弹窗 ===================== */
function NewVersionModal({
visible,
onClose,
onCreate,
}: {
visible: boolean;
onClose: () => void;
onCreate: (name: string) => void;
}) {
const [name, setName] = useState('');
const handleConfirm = () => {
onCreate(name.trim());
setName('');
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={styles.modalMask}>
<KeyboardAvoidingView
style={styles.modalSheet}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.modalTitle}>新建简历版本</Text>
<TextInput
style={styles.modalInput}
placeholder="版本名(可空,默认按序号命名)"
placeholderTextColor={colors.textHint}
value={name}
onChangeText={setName}
maxLength={30}
/>
{/* affordance-pending-backend:真实文件上传需后端 multipart 接口,当前以占位文件创建 */}
<Text style={styles.modalHint}>文件上传需后端支持,当前以占位文件创建版本</Text>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={onClose}>
<Text style={styles.modalCancelText}>取消</Text>
</Pressable>
<Pressable style={styles.modalConfirm} onPress={handleConfirm}>
<Text style={styles.modalConfirmText}>创建</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
}
/* ===================== 页面 ===================== */
export default function ResumeVersionsScreen() {
const router = useRouter();
const { versions, loading, error, refresh, busy, create, setDefault, remove, exportAndPoll } =
useResumeVersions();
const [newVisible, setNewVisible] = useState(false);
const handleNew = useCallback(
(name: string) => {
setNewVisible(false);
void create({
name: name || undefined,
fileName: `${name || '简历'}版本.pdf`,
fileSize: 200_000,
});
},
[create],
);
const handleSetDefault = useCallback(
(id: string, name: string) => {
Alert.alert('设为默认', `将「${name}」设为默认简历?`, [
{ text: '取消', style: 'cancel' },
{ text: '设为默认', onPress: () => void setDefault(id) },
]);
},
[setDefault],
);
const handleDelete = useCallback(
(id: string, name: string) => {
Alert.alert('删除版本', `确定删除「${name}」吗?默认版本需先设其他版本为默认。`, [
{ text: '取消', style: 'cancel' },
{ text: '删除', style: 'destructive', onPress: () => void remove(id) },
]);
},
[remove],
);
const handleExport = useCallback(
async (id: string, name: string) => {
const task = await exportAndPoll(id);
if (!task) {
Alert.alert('导出失败', '导出任务异常,请稍后重试');
return;
}
if (task.status === 'SUCCESS') {
Alert.alert('导出完成', `「${name}」已导出${task.outputUrl ? `:${task.outputUrl}` : ''}`);
} else if (task.status === 'FAILED') {
Alert.alert('导出失败', task.error || '导出任务失败');
} else {
Alert.alert('导出中', '任务仍在处理,请稍后到导出记录查看');
}
},
[exportAndPoll],
);
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} onNew={() => setNewVisible(true)} />
{loading ? (
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : error ? (
<View style={styles.stateBox}>
<Text style={styles.stateText}>加载失败</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
) : versions.length === 0 ? (
<View style={styles.stateBox}>
<Ionicons name="document-outline" size={48} color={colors.textHint} />
<Text style={styles.stateText}>还没有简历版本</Text>
<Pressable style={styles.retryBtn} onPress={() => setNewVisible(true)}>
<Text style={styles.retryText}>新建版本</Text>
</Pressable>
</View>
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{versions.map((v) => (
<VersionCard
key={v.id}
version={v}
onSetDefault={() => handleSetDefault(v.id, v.name)}
onExport={() => void handleExport(v.id, v.name)}
onDelete={() => handleDelete(v.id, v.name)}
/>
))}
</ScrollView>
)}
{busy && (
<View style={styles.busyMask}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
)}
<NewVersionModal
visible={newVisible}
onClose={() => setNewVisible(false)}
onCreate={handleNew}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.card,
height: 52,
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,
},
headerActionBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
scroll: {
flex: 1,
},
scrollContent: {
padding: spacing.card,
paddingBottom: 24,
},
card: {
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: spacing.card,
},
cardDefault: {
borderWidth: 1,
borderColor: colors.primary,
},
cardTop: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
cardTitleWrap: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
marginRight: 8,
},
cardTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
defaultBadge: {
marginLeft: 8,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
backgroundColor: colors.primaryLight,
},
defaultBadgeText: {
fontSize: 11,
fontWeight: '700',
color: colors.primary,
},
cardVersion: {
fontSize: 13,
color: colors.textSecondary,
},
cardMeta: {
marginTop: 6,
fontSize: 13,
color: colors.textSecondary,
},
cardTime: {
marginTop: 2,
fontSize: 12,
color: colors.textHint,
},
cardActions: {
flexDirection: 'row',
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 20,
},
actionText: {
marginLeft: 4,
fontSize: 13,
color: colors.textSecondary,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: spacing.card,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
busyMask: {
...StyleSheet.absoluteFill,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.scrimLight,
},
modalMask: {
flex: 1,
backgroundColor: colors.scrim,
justifyContent: 'flex-end',
},
modalSheet: {
backgroundColor: colors.white,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: spacing.card,
paddingBottom: 24,
},
modalTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 12,
},
modalInput: {
borderWidth: 1,
borderColor: colors.borderLight,
borderRadius: spacing.card,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
color: colors.textPrimary,
},
modalHint: {
marginTop: 8,
fontSize: 12,
color: colors.textHint,
},
modalActions: {
flexDirection: 'row',
marginTop: 16,
},
modalCancel: {
flex: 1,
alignItems: 'center',
paddingVertical: 12,
marginRight: 8,
borderRadius: spacing.card,
backgroundColor: colors.pageBg,
},
modalCancelText: {
fontSize: 14,
color: colors.textSecondary,
},
modalConfirm: {
flex: 1,
alignItems: 'center',
paddingVertical: 12,
marginLeft: 8,
borderRadius: spacing.card,
backgroundColor: colors.primaryBtn,
},
modalConfirmText: {
fontSize: 14,
fontWeight: '700',
color: colors.white,
},
});

13
app/resume.tsx

@ -29,11 +29,13 @@ function Header({
onBack, onBack,
onSave, onSave,
onExport, onExport,
onVersions,
}: { }: {
onBack: () => void; onBack: () => void;
/** 传 undefined = 简历未就绪,按钮置灰 */ /** 传 undefined = 简历未就绪,按钮置灰 */
onSave?: () => void; onSave?: () => void;
onExport: () => void; onExport: () => void;
onVersions?: () => void;
}) { }) {
const saveDisabled = onSave === undefined; const saveDisabled = onSave === undefined;
return ( return (
@ -43,6 +45,16 @@ function Header({
</Pressable> </Pressable>
<Text style={styles.headerTitle}>AI简历优化</Text> <Text style={styles.headerTitle}>AI简历优化</Text>
<View style={styles.headerActions}> <View style={styles.headerActions}>
{onVersions && (
<Pressable
style={styles.headerActionBtn}
onPress={onVersions}
hitSlop={8}
accessibilityLabel="管理简历版本"
>
<Ionicons name="layers-outline" size={20} color={colors.textPrimary} />
</Pressable>
)}
<Pressable <Pressable
style={styles.headerActionBtn} style={styles.headerActionBtn}
onPress={onSave} onPress={onSave}
@ -780,6 +792,7 @@ export default function ResumeScreen() {
onBack={() => router.back()} onBack={() => router.back()}
onSave={handleSave} onSave={handleSave}
onExport={() => router.push('/resume/export')} onExport={() => router.push('/resume/export')}
onVersions={() => router.push('/resume-versions')}
/> />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<ScoreCard <ScoreCard

239
src/api/api.ts

@ -52,6 +52,10 @@ import type {
ResumeEducation, ResumeEducation,
ResumeExperience, ResumeExperience,
ResumeExportOutcome, ResumeExportOutcome,
ResumeVersion,
CreateResumeVersionInput,
UpdateResumeVersionInput,
ResumeExportTask,
ResumeExportSettings, ResumeExportSettings,
ResumeItemInput, ResumeItemInput,
ResumeOptimizeResult, ResumeOptimizeResult,
@ -206,7 +210,7 @@ import { storageGet, storageSet, storageRemove } from '../lib/storage';
import { exportResumePdf } from '../lib/resumeExport'; import { exportResumePdf } from '../lib/resumeExport';
import { clearCacheDir, readCacheBytes } from '../lib/cache'; import { clearCacheDir, readCacheBytes } from '../lib/cache';
import { getCarrierNumber, CarrierAuthError, MOCK_CARRIER_PHONE } from '../lib/carrier-sdk'; import { getCarrierNumber, CarrierAuthError, MOCK_CARRIER_PHONE } from '../lib/carrier-sdk';
import { isMockMode, request } from './http'; import { isMockMode, request, ApiError } from './http';
/** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */ /** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */
export const API_BASE_URL: string = export const API_BASE_URL: string =
@ -3852,6 +3856,239 @@ export async function fetchResumeSavedAt(): Promise<string> {
return mockRequest((await storageGet(RESUME_SAVED_AT_KEY)) ?? ''); return mockRequest((await storageGet(RESUME_SAVED_AT_KEY)) ?? '');
} }
/* ===================== 简历版本(P0-5):列表/创建/详情/修改/删除/设默认/导出任务 ===================== */
const nowIso = () => new Date().toISOString();
/** mock 简历版本(模块级可变副本,模拟持久态;真实后端以服务端为准) */
let MOCK_RESUME_VERSIONS: ResumeVersion[] = [
{
id: 'rv-1',
name: '求职通用版',
versionNo: 2,
isDefault: true,
fileName: '简历-通用版.pdf',
fileSize: 248_000,
status: 'ACTIVE',
createdAt: '2026-09-10T09:20:00.000Z',
updatedAt: '2026-09-12T14:05:00.000Z',
},
{
id: 'rv-2',
name: '大厂冲刺版',
versionNo: 1,
isDefault: false,
fileName: '简历-大厂冲刺.pdf',
fileSize: 312_000,
status: 'ACTIVE',
createdAt: '2026-09-08T20:11:00.000Z',
updatedAt: '2026-09-08T20:11:00.000Z',
},
];
/**
* 获取简历版本列表(ACTIVE,versionNo 倒序)。
* @method GET
* @url /profile/me/resume-versions
* @returns Promise<ResumeVersion[]> 版本数组
*/
export async function fetchResumeVersions(): Promise<ResumeVersion[]> {
if (isMockMode()) {
const list = [...MOCK_RESUME_VERSIONS]
.filter((v) => v.status === 'ACTIVE')
.sort((a, b) => b.versionNo - a.versionNo);
return mockRequest(list);
}
const res = await request<ResumeVersion[]>('/profile/me/resume-versions');
return (res ?? []).filter((v) => v.status === 'ACTIVE');
}
/**
* 创建简历版本(仅本人 STUDENT)。
* @method POST
* @url /profile/me/resume-versions
* @remark 真实后端用 multipart 上传文件(file ≤10MB);mock 以占位元数据模拟,不触网
* @param input.name 版本名(可空)
* @param input.fileName 文件名(占位)
* @param input.fileSize 文件大小(占位)
* @returns Promise<ResumeVersion> 新建版本
*/
export async function createResumeVersion(
input: CreateResumeVersionInput,
): Promise<ResumeVersion> {
if (isMockMode()) {
const maxNo = MOCK_RESUME_VERSIONS.reduce(
(m, v) => Math.max(m, v.versionNo),
0,
);
const version: ResumeVersion = {
id: `rv-${Date.now()}`,
name: input.name?.trim() || `简历版本 ${maxNo + 1}`,
versionNo: maxNo + 1,
isDefault: false,
fileName: input.fileName,
fileSize: input.fileSize,
status: 'ACTIVE',
createdAt: nowIso(),
updatedAt: nowIso(),
};
MOCK_RESUME_VERSIONS = [version, ...MOCK_RESUME_VERSIONS];
return mockRequest(version);
}
// 真实后端:multipart/form-data,name 作 query,file 作二进制
const res = await request<ResumeVersion>('/profile/me/resume-versions', {
method: 'POST',
auth: true,
body: { name: input.name, fileName: input.fileName } as unknown,
});
return res;
}
/**
* 获取简历版本详情(仅本人)。
* @method GET
* @url /profile/me/resume-versions/{id}
* @param id 版本 id
* @returns Promise<ResumeVersion> 版本详情
*/
export async function fetchResumeVersionDetail(id: string): Promise<ResumeVersion> {
if (isMockMode()) {
const found = MOCK_RESUME_VERSIONS.find((v) => v.id === id);
if (!found) throw new ApiError({ httpStatus: 404, code: 404, message: '版本不存在' });
return mockRequest(found);
}
return request<ResumeVersion>(`/profile/me/resume-versions/${id}`, {
method: 'GET',
auth: true,
});
}
/**
* 修改简历版本(本人;可选重命名 / 替换文件)。
* @method PUT
* @url /profile/me/resume-versions/{id}
* @param id 版本 id
* @param input 修改项(至少一项)
* @returns Promise<ResumeVersion> 修改后的版本
*/
export async function updateResumeVersion(
id: string,
input: UpdateResumeVersionInput,
): Promise<ResumeVersion> {
if (isMockMode()) {
const idx = MOCK_RESUME_VERSIONS.findIndex((v) => v.id === id);
if (idx < 0) throw new ApiError({ httpStatus: 404, code: 404, message: '版本不存在' });
const updated: ResumeVersion = {
...MOCK_RESUME_VERSIONS[idx],
...(input.name !== undefined ? { name: input.name } : {}),
...(input.fileName !== undefined ? { fileName: input.fileName } : {}),
...(input.fileSize !== undefined ? { fileSize: input.fileSize } : {}),
updatedAt: nowIso(),
};
MOCK_RESUME_VERSIONS = MOCK_RESUME_VERSIONS.map((v) =>
v.id === id ? updated : v,
);
return mockRequest(updated);
}
const res = await request<ResumeVersion>(
`/profile/me/resume-versions/${id}`,
{ method: 'PUT', auth: true, body: input as unknown },
);
return res;
}
/**
* 删除简历版本(默认版本需先设其他默认;导出进行中不可删)。
* @method DELETE
* @url /profile/me/resume-versions/{id}
* @param id 版本 id
*/
export async function deleteResumeVersion(id: string): Promise<void> {
if (isMockMode()) {
const target = MOCK_RESUME_VERSIONS.find((v) => v.id === id);
if (!target) throw new ApiError({ httpStatus: 404, code: 404, message: '版本不存在' });
if (target.isDefault) {
throw new ApiError({
httpStatus: 409,
code: 409,
message: '默认版本不可直接删除,请先设置其他版本为默认',
});
}
MOCK_RESUME_VERSIONS = MOCK_RESUME_VERSIONS.filter((v) => v.id !== id);
return mockRequest<void>(undefined);
}
await request(`/profile/me/resume-versions/${id}`, {
method: 'DELETE',
auth: true,
});
}
/**
* 设为默认简历(同用户最多一个默认;已默认幂等)。
* @method POST
* @url /profile/me/resume-versions/{id}/default
* @param id 版本 id
*/
export async function setDefaultResumeVersion(id: string): Promise<void> {
if (isMockMode()) {
const target = MOCK_RESUME_VERSIONS.find((v) => v.id === id);
if (!target) throw new ApiError({ httpStatus: 404, code: 404, message: '版本不存在' });
MOCK_RESUME_VERSIONS = MOCK_RESUME_VERSIONS.map((v) => ({
...v,
isDefault: v.id === id,
}));
return mockRequest<void>(undefined);
}
await request(`/profile/me/resume-versions/${id}/default`, {
method: 'POST',
auth: true,
});
}
/**
* 创建简历导出任务(异步复制版本文件到导出目录)。
* @method POST
* @url /profile/me/resume-versions/{id}/export
* @param id 版本 id
* @returns Promise<ResumeExportTask> 任务(初始状态)
*/
export async function createResumeExportTask(id: string): Promise<ResumeExportTask> {
if (isMockMode()) {
const task: ResumeExportTask = {
id: `task-${Date.now()}`,
status: 'SUCCESS',
outputUrl: `file://mock/resume-export/${id}.pdf`,
};
return mockRequest(task);
}
const res = await request<ResumeExportTask>(
`/profile/me/resume-versions/${id}/export`,
{ method: 'POST', auth: true },
);
return res;
}
/**
* 查询简历导出任务状态(仅本人;SUCCESS 含输出文件,FAILED 含原因)。
* @method GET
* @url /resume-export-tasks/{taskId}
* @param taskId 任务 id
* @returns Promise<ResumeExportTask> 任务状态
*/
export async function fetchResumeExportTask(taskId: string): Promise<ResumeExportTask> {
if (isMockMode()) {
return mockRequest<ResumeExportTask>({
id: taskId,
status: 'SUCCESS',
outputUrl: `file://mock/resume-export/${taskId}.pdf`,
});
}
return request<ResumeExportTask>(`/resume-export-tasks/${taskId}`, {
method: 'GET',
auth: true,
});
}
/** /**
* 清除简历编辑覆盖层与保存时间 * 清除简历编辑覆盖层与保存时间
* @method DELETE * @method DELETE

8
src/api/index.ts

@ -144,6 +144,14 @@ export {
buildRankReportText, buildRankReportText,
fetchResume, fetchResume,
fetchResumeExportSettings, fetchResumeExportSettings,
fetchResumeVersions,
fetchResumeVersionDetail,
createResumeVersion,
updateResumeVersion,
deleteResumeVersion,
setDefaultResumeVersion,
createResumeExportTask,
fetchResumeExportTask,
fetchResumeSavedAt, fetchResumeSavedAt,
clearResumeOverride, clearResumeOverride,
fetchSalaryBrackets, fetchSalaryBrackets,

1
src/hooks/index.ts

@ -61,6 +61,7 @@ export { useProfileData } from './useProfileData';
export { usePersonalDataExport } from './usePersonalDataExport'; export { usePersonalDataExport } from './usePersonalDataExport';
export { useRankData } from './useRankData'; export { useRankData } from './useRankData';
export { useResume } from './useResume'; export { useResume } from './useResume';
export { useResumeVersions } from './useResumeVersions';
export { useResumeExport } from './useResumeExport'; export { useResumeExport } from './useResumeExport';
export { useSalaryBrackets } from './useSalaryBrackets'; export { useSalaryBrackets } from './useSalaryBrackets';
export { useSalaryCalculator } from './useSalaryCalculator'; export { useSalaryCalculator } from './useSalaryCalculator';

118
src/hooks/useResumeVersions.ts

@ -0,0 +1,118 @@
/**
* 简历版本管理 hook:列表 + 创建 / 设默认 / 删除 / 导出任务。
* 操作后自动刷新列表;导出任务轮询到终态后返回,供页面提示结果。
*/
import { useCallback, useState } from 'react';
import {
createResumeExportTask,
createResumeVersion,
deleteResumeVersion,
fetchResumeExportTask,
fetchResumeVersions,
setDefaultResumeVersion,
} from '../api';
import type { ResumeExportTask, ResumeVersion } from '../types';
import { useAsyncData } from './useAsyncData';
export interface ResumeVersionsState {
/** 版本列表(versionNo 倒序) */
versions: ResumeVersion[];
/** 是否加载中 */
loading: boolean;
/** 错误对象 */
error: Error | null;
/** 刷新列表 */
refresh: () => void;
/** 操作进行中(创建/设默认/删除/导出任一进行) */
busy: boolean;
/** 创建版本(占位元数据;真实后端走 multipart 上传) */
create: (input: {
name?: string;
fileName: string;
fileSize: number;
}) => Promise<void>;
/** 设为默认 */
setDefault: (id: string) => Promise<void>;
/** 删除版本 */
remove: (id: string) => Promise<void>;
/** 创建导出任务并轮询到终态,返回最终任务(或异常时 null) */
exportAndPoll: (id: string) => Promise<ResumeExportTask | null>;
}
export function useResumeVersions(): ResumeVersionsState {
const [busy, setBusy] = useState(false);
const fetcher = useCallback(() => fetchResumeVersions(), []);
const { data, loading, error, refresh } = useAsyncData(fetcher);
const create = useCallback(
async (input: { name?: string; fileName: string; fileSize: number }) => {
setBusy(true);
try {
await createResumeVersion(input);
refresh();
} finally {
setBusy(false);
}
},
[refresh],
);
const setDefault = useCallback(
async (id: string) => {
setBusy(true);
try {
await setDefaultResumeVersion(id);
refresh();
} finally {
setBusy(false);
}
},
[refresh],
);
const remove = useCallback(
async (id: string) => {
setBusy(true);
try {
await deleteResumeVersion(id);
refresh();
} finally {
setBusy(false);
}
},
[refresh],
);
const exportAndPoll = useCallback(async (id: string) => {
setBusy(true);
try {
const task = await createResumeExportTask(id);
// 轮询到终态(最多 6 次,2s 间隔);mock 直接 SUCCESS
let current = task;
for (
let i = 0;
i < 6 && (current.status === 'PENDING' || current.status === 'RUNNING');
i++
) {
await new Promise((r) => setTimeout(r, 2000));
current = await fetchResumeExportTask(current.id);
}
return current;
} finally {
setBusy(false);
}
}, []);
return {
versions: data ?? [],
loading,
error,
refresh,
busy,
create,
setDefault,
remove,
exportAndPoll,
};
}

6
src/theme/colors.ts

@ -114,6 +114,8 @@ export const colors = {
focusRing: '#1F6FDB', focusRing: '#1F6FDB',
/** 弹层遮罩 · 半透明黑(Modal / 底部弹层背景) */ /** 弹层遮罩 · 半透明黑(Modal / 底部弹层背景) */
scrim: 'rgba(0, 0, 0, 0.35)', scrim: 'rgba(0, 0, 0, 0.35)',
/** 轻遮罩 · 极淡半透明黑(操作进行中的整屏蒙层,不夺视线) */
scrimLight: 'rgba(0, 0, 0, 0.04)',
/* ---- 品牌色 / 深色底上的半透明白(图形与描边用) ---- */ /* ---- 品牌色 / 深色底上的半透明白(图形与描边用) ---- */
/** /**
@ -285,6 +287,10 @@ export const darkColors = {
textInverse: '#0E1116', textInverse: '#0E1116',
shadow: '#000000', shadow: '#000000',
focusRing: '#5399FD', focusRing: '#5399FD',
/** 弹层遮罩(暗模式更重,保证白色弹层可读) */
scrim: 'rgba(0, 0, 0, 0.5)',
/** 轻遮罩(暗模式) */
scrimLight: 'rgba(0, 0, 0, 0.06)',
} as const; } as const;
/* ===================== 渐变(组合色 · 独立于单色令牌) ===================== */ /* ===================== 渐变(组合色 · 独立于单色令牌) ===================== */

4
src/types/index.ts

@ -146,6 +146,10 @@ export type {
ResumeExportFormat, ResumeExportFormat,
ResumeExportOutcome, ResumeExportOutcome,
ResumeExportSettings, ResumeExportSettings,
ResumeVersion,
CreateResumeVersionInput,
UpdateResumeVersionInput,
ResumeExportTask,
ResumeItemInput, ResumeItemInput,
ResumeJobIntention, ResumeJobIntention,
ResumeOptimizePoint, ResumeOptimizePoint,

56
src/types/resume.ts

@ -223,3 +223,59 @@ export type ResumeExportOutcome =
pages: number; pages: number;
} }
| { ok: false; message: string }; | { ok: false; message: string };
/* ===================== 简历版本(对齐 /profile/me/resume-versions) ===================== */
/** 简历版本 */
export interface ResumeVersion {
/** 版本 id */
id: string;
/** 版本名(用户自定义,可空) */
name: string;
/** 版本号(后端生成,倒序展示) */
versionNo: number;
/** 是否为默认版本 */
isDefault: boolean;
/** 文件名 */
fileName: string;
/** 文件大小(字节) */
fileSize: number;
/** 状态:ACTIVE / DELETED 等 */
status: 'ACTIVE' | string;
/** 创建时间 ISO */
createdAt: string;
/** 更新时间 ISO */
updatedAt: string;
}
/** 新建简历版本入参(mock 阶段不含真实文件二进制;真实后端用 multipart) */
export interface CreateResumeVersionInput {
/** 版本名(可空) */
name?: string;
/** 文件名(mock 占位,真实后端由上传文件决定) */
fileName: string;
/** 文件大小(字节,mock 占位) */
fileSize: number;
}
/** 修改简历版本入参(全部可选;至少传一项) */
export interface UpdateResumeVersionInput {
/** 重命名 */
name?: string;
/** 替换文件(mock 占位) */
fileName?: string;
/** 替换文件大小(字节) */
fileSize?: number;
}
/** 简历导出任务(对齐 /resume-export-tasks/{taskId}) */
export interface ResumeExportTask {
/** 任务 id */
id: string;
/** PENDING / RUNNING / SUCCESS / FAILED */
status: 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | string;
/** SUCCESS 时输出文件地址 */
outputUrl?: string;
/** FAILED 时失败原因 */
error?: string;
}

Loading…
Cancel
Save