From 0f8ab9a6b60fc45cf14bfc62e34b2b333336ae0d Mon Sep 17 00:00:00 2001
From: doublex001 <16157008+doublex001@user.noreply.gitee.com>
Date: Tue, 29 Sep 2026 16:14:16 +0800
Subject: [PATCH] =?UTF-8?q?feat(api):=20=E8=A1=A5=E9=BD=90=E7=AE=80?=
=?UTF-8?q?=E5=8E=86=E5=A4=9A=E7=89=88=E6=9C=AC=EF=BC=88P0-5=EF=BC=89?=
=?UTF-8?q?=E5=88=97=E8=A1=A8/=E5=88=9B=E5=BB=BA/=E8=AF=A6=E6=83=85/?=
=?UTF-8?q?=E4=BF=AE=E6=94=B9/=E5=88=A0=E9=99=A4/=E8=AE=BE=E9=BB=98?=
=?UTF-8?q?=E8=AE=A4/=E5=AF=BC=E5=87=BA=E4=BB=BB=E5=8A=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- api: 新增 resume-versions 全链路 9 个端点(对齐 /profile/me/resume-versions 与 /resume-export-tasks)
mock 以模块级可变数组模拟持久态;真实路径含 multipart 上传占位说明
- hook: useResumeVersions 覆盖列表/创建/设默认/删除/导出轮询
- 页面:app/resume-versions.tsx 版本卡(默认徽标/设默认/导出/删除)+ 新建弹窗,四态齐全
- 入口:简历页头部加「版本」入口;路由在 _layout 注册
- 主题:补充 scrimLight 遮罩 token(亮/暗双模式)
---
app/_layout.tsx | 1 +
app/resume-versions.tsx | 464 +++++++++++++++++++++++++++++++++
app/resume.tsx | 13 +
src/api/api.ts | 239 ++++++++++++++++-
src/api/index.ts | 8 +
src/hooks/index.ts | 1 +
src/hooks/useResumeVersions.ts | 118 +++++++++
src/theme/colors.ts | 6 +
src/types/index.ts | 4 +
src/types/resume.ts | 56 ++++
10 files changed, 909 insertions(+), 1 deletion(-)
create mode 100644 app/resume-versions.tsx
create mode 100644 src/hooks/useResumeVersions.ts
diff --git a/app/_layout.tsx b/app/_layout.tsx
index e4c775f..46a9640 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -21,6 +21,7 @@ export default function RootLayout() {
+
diff --git a/app/resume-versions.tsx b/app/resume-versions.tsx
new file mode 100644
index 0000000..48aa18a
--- /dev/null
+++ b/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 (
+
+
+
+
+ 简历版本
+
+
+
+
+ );
+}
+
+/* ===================== 子组件:版本卡 ===================== */
+
+function VersionCard({
+ version,
+ onSetDefault,
+ onExport,
+ onDelete,
+}: {
+ version: ResumeVersion;
+ onSetDefault: () => void;
+ onExport: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+
+
+
+ {version.name}
+
+ {version.isDefault && (
+
+ 默认
+
+ )}
+
+ v{version.versionNo}
+
+
+
+ {version.fileName} · {formatBytes(version.fileSize)}
+
+
+ 更新于 {formatDateTime(version.updatedAt)}
+
+
+
+ {!version.isDefault && (
+
+
+ 设为默认
+
+ )}
+
+
+ 导出
+
+ {!version.isDefault && (
+
+
+ 删除
+
+ )}
+
+
+ );
+}
+
+/* ===================== 子组件:新建版本弹窗 ===================== */
+
+function NewVersionModal({
+ visible,
+ onClose,
+ onCreate,
+}: {
+ visible: boolean;
+ onClose: () => void;
+ onCreate: (name: string) => void;
+}) {
+ const [name, setName] = useState('');
+
+ const handleConfirm = () => {
+ onCreate(name.trim());
+ setName('');
+ };
+
+ return (
+
+
+
+ 新建简历版本
+
+ {/* affordance-pending-backend:真实文件上传需后端 multipart 接口,当前以占位文件创建 */}
+ 文件上传需后端支持,当前以占位文件创建版本
+
+
+ 取消
+
+
+ 创建
+
+
+
+
+
+ );
+}
+
+/* ===================== 页面 ===================== */
+
+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 (
+
+ router.back()} onNew={() => setNewVisible(true)} />
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+ 加载失败
+
+ 重试
+
+
+ ) : versions.length === 0 ? (
+
+
+ 还没有简历版本
+ setNewVisible(true)}>
+ 新建版本
+
+
+ ) : (
+
+ {versions.map((v) => (
+ handleSetDefault(v.id, v.name)}
+ onExport={() => void handleExport(v.id, v.name)}
+ onDelete={() => handleDelete(v.id, v.name)}
+ />
+ ))}
+
+ )}
+
+ {busy && (
+
+
+
+ )}
+
+ setNewVisible(false)}
+ onCreate={handleNew}
+ />
+
+ );
+}
+
+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,
+ },
+});
diff --git a/app/resume.tsx b/app/resume.tsx
index 8f7bfe2..fedfabf 100644
--- a/app/resume.tsx
+++ b/app/resume.tsx
@@ -29,11 +29,13 @@ function Header({
onBack,
onSave,
onExport,
+ onVersions,
}: {
onBack: () => void;
/** 传 undefined = 简历未就绪,按钮置灰 */
onSave?: () => void;
onExport: () => void;
+ onVersions?: () => void;
}) {
const saveDisabled = onSave === undefined;
return (
@@ -43,6 +45,16 @@ function Header({
AI简历优化
+ {onVersions && (
+
+
+
+ )}
router.back()}
onSave={handleSave}
onExport={() => router.push('/resume/export')}
+ onVersions={() => router.push('/resume-versions')}
/>
{
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 版本数组
+ */
+export async function fetchResumeVersions(): Promise {
+ 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('/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 新建版本
+ */
+export async function createResumeVersion(
+ input: CreateResumeVersionInput,
+): Promise {
+ 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('/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 版本详情
+ */
+export async function fetchResumeVersionDetail(id: string): Promise {
+ 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(`/profile/me/resume-versions/${id}`, {
+ method: 'GET',
+ auth: true,
+ });
+}
+
+/**
+ * 修改简历版本(本人;可选重命名 / 替换文件)。
+ * @method PUT
+ * @url /profile/me/resume-versions/{id}
+ * @param id 版本 id
+ * @param input 修改项(至少一项)
+ * @returns Promise 修改后的版本
+ */
+export async function updateResumeVersion(
+ id: string,
+ input: UpdateResumeVersionInput,
+): Promise {
+ 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(
+ `/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 {
+ 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(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 {
+ 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(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 任务(初始状态)
+ */
+export async function createResumeExportTask(id: string): Promise {
+ 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(
+ `/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 任务状态
+ */
+export async function fetchResumeExportTask(taskId: string): Promise {
+ if (isMockMode()) {
+ return mockRequest({
+ id: taskId,
+ status: 'SUCCESS',
+ outputUrl: `file://mock/resume-export/${taskId}.pdf`,
+ });
+ }
+ return request(`/resume-export-tasks/${taskId}`, {
+ method: 'GET',
+ auth: true,
+ });
+}
+
/**
* 清除简历编辑覆盖层与保存时间
* @method DELETE
diff --git a/src/api/index.ts b/src/api/index.ts
index 2f792df..d063858 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -144,6 +144,14 @@ export {
buildRankReportText,
fetchResume,
fetchResumeExportSettings,
+ fetchResumeVersions,
+ fetchResumeVersionDetail,
+ createResumeVersion,
+ updateResumeVersion,
+ deleteResumeVersion,
+ setDefaultResumeVersion,
+ createResumeExportTask,
+ fetchResumeExportTask,
fetchResumeSavedAt,
clearResumeOverride,
fetchSalaryBrackets,
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 8913191..95d48a2 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -61,6 +61,7 @@ export { useProfileData } from './useProfileData';
export { usePersonalDataExport } from './usePersonalDataExport';
export { useRankData } from './useRankData';
export { useResume } from './useResume';
+export { useResumeVersions } from './useResumeVersions';
export { useResumeExport } from './useResumeExport';
export { useSalaryBrackets } from './useSalaryBrackets';
export { useSalaryCalculator } from './useSalaryCalculator';
diff --git a/src/hooks/useResumeVersions.ts b/src/hooks/useResumeVersions.ts
new file mode 100644
index 0000000..6ddb617
--- /dev/null
+++ b/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;
+ /** 设为默认 */
+ setDefault: (id: string) => Promise;
+ /** 删除版本 */
+ remove: (id: string) => Promise;
+ /** 创建导出任务并轮询到终态,返回最终任务(或异常时 null) */
+ exportAndPoll: (id: string) => Promise;
+}
+
+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,
+ };
+}
diff --git a/src/theme/colors.ts b/src/theme/colors.ts
index 6481f43..073ea17 100644
--- a/src/theme/colors.ts
+++ b/src/theme/colors.ts
@@ -114,6 +114,8 @@ export const colors = {
focusRing: '#1F6FDB',
/** 弹层遮罩 · 半透明黑(Modal / 底部弹层背景) */
scrim: 'rgba(0, 0, 0, 0.35)',
+ /** 轻遮罩 · 极淡半透明黑(操作进行中的整屏蒙层,不夺视线) */
+ scrimLight: 'rgba(0, 0, 0, 0.04)',
/* ---- 品牌色 / 深色底上的半透明白(图形与描边用) ---- */
/**
@@ -285,6 +287,10 @@ export const darkColors = {
textInverse: '#0E1116',
shadow: '#000000',
focusRing: '#5399FD',
+ /** 弹层遮罩(暗模式更重,保证白色弹层可读) */
+ scrim: 'rgba(0, 0, 0, 0.5)',
+ /** 轻遮罩(暗模式) */
+ scrimLight: 'rgba(0, 0, 0, 0.06)',
} as const;
/* ===================== 渐变(组合色 · 独立于单色令牌) ===================== */
diff --git a/src/types/index.ts b/src/types/index.ts
index 61acbba..def268c 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -146,6 +146,10 @@ export type {
ResumeExportFormat,
ResumeExportOutcome,
ResumeExportSettings,
+ ResumeVersion,
+ CreateResumeVersionInput,
+ UpdateResumeVersionInput,
+ ResumeExportTask,
ResumeItemInput,
ResumeJobIntention,
ResumeOptimizePoint,
diff --git a/src/types/resume.ts b/src/types/resume.ts
index 10edd4a..d0c5053 100644
--- a/src/types/resume.ts
+++ b/src/types/resume.ts
@@ -223,3 +223,59 @@ export type ResumeExportOutcome =
pages: number;
}
| { 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;
+}