168 lines
8.1 KiB
TypeScript
168 lines
8.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Pencil, Upload, User as UserIcon } from 'lucide-react';
|
|
import { profileGet, profilePhotoUpload, profileUpsert } from '@/lib/api';
|
|
import type { ProfileInfo } from '@/lib/types';
|
|
import { Button, Dialog, Field, Input, Select, Textarea, useToast } from '@/components/ui';
|
|
|
|
const EMPTY: ProfileInfo = {
|
|
name: '', gender: '', birth: '', nation: '', native: '', political: '',
|
|
school: '', college: '', major: '', className: '', studentId: '', degree: '',
|
|
phone: '', email: '', homepage: '', photoUrl: '', selfIntro: '',
|
|
eduLength: '', eduStart: '', eduEnd: '', majorCourses: ''
|
|
};
|
|
|
|
const FIELD_GROUPS: { title: string; fields: { key: keyof ProfileInfo; label: string; type?: string; options?: string[] }[] }[] = [
|
|
{ title: '基本信息', fields: [
|
|
{ key: 'name', label: '姓名' },
|
|
{ key: 'gender', label: '性别', options: ['', '男', '女'] },
|
|
{ key: 'birth', label: '出生日期', type: 'date' },
|
|
{ key: 'nation', label: '民族' },
|
|
{ key: 'native', label: '籍贯' },
|
|
{ key: 'political', label: '政治面貌' },
|
|
]},
|
|
{ title: '学校信息', fields: [
|
|
{ key: 'school', label: '学校' },
|
|
{ key: 'college', label: '学院' },
|
|
{ key: 'major', label: '专业' },
|
|
{ key: 'className', label: '班级' },
|
|
{ key: 'studentId', label: '学号' },
|
|
{ key: 'degree', label: '学历层次', options: ['', '专科', '本科', '硕士研究生', '博士研究生'] },
|
|
{ key: 'eduLength', label: '学制', options: ['', '两年', '三年', '四年', '五年'] },
|
|
{ key: 'eduStart', label: '入学时间', type: 'month' },
|
|
{ key: 'eduEnd', label: '毕业时间', type: 'month' },
|
|
]},
|
|
{ title: '主修课程', fields: [
|
|
{ key: 'majorCourses', label: '主修课程' },
|
|
]},
|
|
{ title: '联系方式', fields: [
|
|
{ key: 'phone', label: '手机' },
|
|
{ key: 'email', label: '邮箱' },
|
|
{ key: 'homepage', label: '个人主页 / 博客' },
|
|
]},
|
|
];
|
|
|
|
export function ProfilePage() {
|
|
const qc = useQueryClient();
|
|
const toast = useToast();
|
|
const [editing, setEditing] = useState(false);
|
|
const [form, setForm] = useState<ProfileInfo>(EMPTY);
|
|
const [photoBusy, setPhotoBusy] = useState(false);
|
|
|
|
const q = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
|
const info: Partial<ProfileInfo> = q.data?.content?.info ?? {};
|
|
const set = (patch: Partial<ProfileInfo>) => setForm(f => ({ ...EMPTY, ...f, ...patch }));
|
|
|
|
const openEdit = () => { setForm({ ...EMPTY, ...info }); setEditing(true); };
|
|
const saveMut = useMutation({
|
|
mutationFn: () => profileUpsert({ info: form }),
|
|
onSuccess: () => { toast('success', '个人信息已保存'); setEditing(false); void qc.invalidateQueries({ queryKey: ['profile'] }); },
|
|
onError: (e: Error) => toast('error', e.message)
|
|
});
|
|
|
|
const onPhoto = async (f: File | undefined) => {
|
|
if (!f) return;
|
|
setPhotoBusy(true);
|
|
try {
|
|
const url = await profilePhotoUpload(f);
|
|
// 以已保存档案为底,只更新 photoUrl —— 避免覆盖其他字段
|
|
const next = { ...EMPTY, ...info, photoUrl: url };
|
|
await profileUpsert({ info: next });
|
|
setForm(prev => ({ ...EMPTY, ...prev, photoUrl: url }));
|
|
void qc.invalidateQueries({ queryKey: ['profile'] });
|
|
toast('success', '证件照已上传');
|
|
} catch (e) { toast('error', (e as Error).message); }
|
|
setPhotoBusy(false);
|
|
};
|
|
|
|
const photo = info.photoUrl;
|
|
|
|
return (
|
|
<div className="mx-auto max-w-[880px] px-3 py-4 sm:px-6 sm:py-5">
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
|
<h2 className="text-[15px] font-semibold text-ink-1">个人信息</h2>
|
|
<Button variant="primary" onClick={openEdit}><Pencil size={13} /> 编辑</Button>
|
|
</div>
|
|
|
|
{q.isLoading ? (
|
|
<div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>
|
|
) : (
|
|
<div className="rounded-lg border border-line bg-surface shadow-card">
|
|
<div className="flex flex-col gap-4 border-b border-line p-4 sm:flex-row sm:items-center">
|
|
<div className="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-full border border-line bg-surface-2">
|
|
{photo ? <img src={photo} alt="证件照" className="h-full w-full object-cover" />
|
|
: <UserIcon size={32} className="text-ink-3" />}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[17px] font-semibold text-ink-1">{info.name || '未填写姓名'}</div>
|
|
<div className="mt-0.5 text-[12px] text-ink-3">
|
|
{[info.school, info.college, info.major].filter(Boolean).join(' · ') || '完善学校信息后,简历将自动填充'}
|
|
</div>
|
|
</div>
|
|
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-line px-2.5 py-1.5 text-[12px] text-ink-2 transition-colors hover:border-line-strong">
|
|
<Upload size={13} />
|
|
{photoBusy ? '上传中…' : (photo ? '更换证件照' : '上传证件照')}
|
|
<input type="file" accept="image/*" className="hidden" disabled={photoBusy}
|
|
onChange={e => { void onPhoto(e.target.files?.[0]); e.target.value = ''; }} />
|
|
</label>
|
|
</div>
|
|
|
|
{FIELD_GROUPS.map(g => (
|
|
<div key={g.title} className="border-b border-line px-4 py-3 last:border-b-0">
|
|
<div className="mb-2 text-[12px] font-semibold text-ink-2">{g.title}</div>
|
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
|
{g.fields.map(f => {
|
|
const v = (info[f.key] as string) || '';
|
|
return (
|
|
<div key={f.key} className="grid min-w-0 grid-cols-[84px_1fr] gap-x-2.5 py-0.5">
|
|
<dt className="shrink-0 text-right text-[12px] text-ink-3">{f.label}</dt>
|
|
<dd className={'min-w-0 truncate text-[12.5px] ' + (v ? 'text-ink-1' : 'text-ink-3/60')} title={v}>{v || '未填写'}</dd>
|
|
</div>
|
|
);
|
|
})}
|
|
</dl>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!info.selfIntro && (
|
|
<div className="mt-3 rounded-md border border-dashed border-line px-4 py-2.5 text-[12px] text-ink-3">
|
|
提示:编辑里填写「自我评价」后,可一键用于简历生成。
|
|
</div>
|
|
)}
|
|
|
|
<Dialog open={editing} onClose={() => setEditing(false)} width={720} title="编辑个人信息"
|
|
footer={<>
|
|
<span className="mr-auto text-[12px] text-ink-3">填写一次,填表与简历自动复用</span>
|
|
<Button variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>保存</Button>
|
|
</>}>
|
|
<div className="flex flex-col gap-4">
|
|
{FIELD_GROUPS.map(g => (
|
|
<div key={g.title}>
|
|
<div className="mb-2 text-[12px] font-semibold text-ink-2">{g.title}</div>
|
|
<div className="grid grid-cols-1 gap-x-3 gap-y-3 sm:grid-cols-2">
|
|
{g.fields.map(f => (
|
|
<Field key={f.key} label={f.label}>
|
|
{f.options ? (
|
|
<Select value={form[f.key] as string || ''} onChange={e => set({ [f.key]: e.target.value } as Partial<ProfileInfo>)}
|
|
options={f.options.map(o => ({ value: o, label: o || '未选择' }))} />
|
|
) : (
|
|
<Input type={f.type || 'text'} value={form[f.key] as string || ''} onChange={e => set({ [f.key]: e.target.value } as Partial<ProfileInfo>)} />
|
|
)}
|
|
</Field>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="border-t border-line pt-3">
|
|
<Field label="自我评价" hint="简历「自我评价」板块使用">
|
|
<Textarea rows={4} value={form.selfIntro} onChange={e => set({ selfIntro: e.target.value })} placeholder="简述性格、能力、方向…" />
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|