+
{Array.from({ length: leadingDays }, (_, index) => )}
{Array.from({ length: daysInMonth }, (_, index) => {
const day = index + 1;
- const record = month.records[day];
- const isToday = month.key === "2026-07" && day === 20;
+ const record = checkedDays.has(day);
+ const isToday = todayDay === day;
return (
-
{selectedRecord ? "已打卡" : "未打卡"}
-
{month.month + 1} 月 {selectedDay} 日{selectedRecord ? `${selectedRecord[0]} 分钟 · ${selectedRecord[1]} 次练习 · ${selectedRecord[2]} 个学习资产` : "这一天还没有学习记录"}
+
{monthNumber} 月 {selectedDay} 日{selectedRecord ? "已生成五维评分报告,自动打卡完成" : "这一天还没有五维评分报告"}
);
@@ -2164,8 +2224,13 @@ function AchievementSystem() {
);
}
-function Overview() {
- return <>
本周学习时长183 分钟已保存学习资产12 项连续学习天数7 天 LAST SEVEN DAYS
练习节奏
{[38, 62, 78, 26, 92, 70, 48].map((height, index) => {[18, 26, 34, 12, 40, 31, 22][index]}m)}
>;
+function Overview({ calendar, statistics, onMonthChange, onAssets }) {
+ const weeklyMinutes = Math.ceil((statistics?.weeklyPracticeSeconds || 0) / 60);
+ const trainingRecordCount = statistics?.trainingRecordCount || 0;
+ const consecutiveLearningDays = statistics?.consecutiveLearningDays || 0;
+ const days = statistics?.lastSevenDays || [];
+ const maximumSeconds = Math.max(0, ...days.map((day) => day.practiceSeconds || 0));
+ return <>
今日已打卡 : null} />本周学习时长{weeklyMinutes} 分钟已保存学习资产{trainingRecordCount} 项连续学习天数{consecutiveLearningDays} 天 LAST SEVEN DAYS
练习节奏
{days.length ? days.map((day) => { const seconds = day.practiceSeconds || 0; const minutes = Math.ceil(seconds / 60); const height = maximumSeconds ? Math.max(3, (seconds / maximumSeconds) * 100) : 3; const weekday = new Date(`${day.date}T00:00:00+08:00`).toLocaleDateString("zh-CN", { weekday: "short", timeZone: "Asia/Shanghai" }); return
{minutes}m{weekday}; }) :
完成一次至少 30 秒的练习后,这里会显示你的节奏。
}
>;
}
function Membership() {
@@ -2173,14 +2238,39 @@ function Membership() {
return <>{plans.map((plan) =>
{plan.id === "free" &&
当前方案}{plan.id === "pro" &&
推荐}
{plan.name}
{plan.desc}
¥{plan.price}{plan.suffix}
{plan.features.map((feature) => - {feature}
)}
{plan.id === "free" ? 今日自由对话3 / 5 分钟今日普通场景0 / 1 次 : setCheckout(plan)}>升级{plan.name}})}
{checkout && setCheckout(null)}>MOCK PAYMENT
确认升级至{checkout.name}
首版为支付演示。确认后仅展示成功状态,不会产生真实扣款。
- 订阅方案
- {checkout.name}
- 订阅金额
- ¥{checkout.price} / 月
- 生效时间
- 立即生效
setCheckout(null)}>模拟支付并完成}>;
}
-function Settings({ teacher, speed, level, onSettingsChange }) {
+function PasswordChangeModal({ onClose, onSubmit }) {
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState("");
+ const submit = async (event) => {
+ event.preventDefault();
+ if (newPassword !== confirmPassword) {
+ setError("两次输入的新密码不一致");
+ return;
+ }
+ setSubmitting(true);
+ setError("");
+ try {
+ await onSubmit({ currentPassword, newPassword });
+ } catch (submitError) {
+ setError(submitError instanceof Error ? submitError.message : "密码修改失败");
+ setSubmitting(false);
+ }
+ };
+ return ACCOUNT SECURITY
修改密码
修改成功后,所有已登录设备都需要重新登录。
;
+}
+
+function Settings({ teacher, speed, level, onSettingsChange, onPasswordChange }) {
const [syncPulse, setSyncPulse] = useState(0);
+ const [passwordOpen, setPasswordOpen] = useState(false);
const updateSettings = async (next) => {
if (await onSettingsChange(next)) {
setSyncPulse((current) => current + 1);
}
};
- return 设置已同步} /> updateSettings({ speed: nextSpeed })} /> updateSettings({ level: nextLevel })} /> updateSettings({ teacher: nextTeacher })} /> ;
+ return 设置已同步} /> updateSettings({ speed: nextSpeed })} /> updateSettings({ level: nextLevel })} /> updateSettings({ teacher: nextTeacher })} /> setPasswordOpen(true)}>修改密码删除账户 {passwordOpen && setPasswordOpen(false)} onSubmit={onPasswordChange} />} ;
}
function Paywall({ title, onClose, onMembership }) {
@@ -2191,6 +2281,7 @@ export function App() {
const initialRoute = useMemo(() => resolveRoute(window.location), []);
const [authReady, setAuthReady] = useState(false);
const [user, setUser] = useState(null);
+ const [profileOverview, setProfileOverview] = useState(null);
const [flow, setFlow] = useState(initialRoute.flow);
const [authMode, setAuthMode] = useState(initialRoute.authMode);
const [level, setLevel] = useState("");
@@ -2277,12 +2368,14 @@ export function App() {
return;
}
try {
- const [currentUser, preference] = await Promise.all([
+ const [currentUser, preference, profile] = await Promise.all([
getCurrentUser(),
getUserPreference(),
+ getProfileOverview(),
]);
if (cancelled) return;
setUser(currentUser);
+ setProfileOverview(profile);
applyPreference(preference);
if (initialRoute.flow === "app" && !preference.cefrLevel) {
navigate(paths.auth.level, {}, true);
@@ -2311,7 +2404,11 @@ export function App() {
const enterApp = () => setMainPage("conversation");
const completeAuthentication = async (auth, mode) => {
setUser(auth.user);
- const preference = await getUserPreference();
+ const [preference, profile] = await Promise.all([
+ getUserPreference(),
+ getProfileOverview(),
+ ]);
+ setProfileOverview(profile);
applyPreference(preference);
if (mode === "signup" || !preference.cefrLevel) {
goLevel();
@@ -2379,8 +2476,62 @@ export function App() {
const logout = () => {
clearAuthSession();
setUser(null);
+ setProfileOverview(null);
goSplash();
};
+ const updateNickname = async (nickname) => {
+ if (!nickname) {
+ window.alert("用户名不能为空");
+ return false;
+ }
+ try {
+ const updated = await updateProfile({ nickname });
+ setUser((current) => current ? { ...current, nickname: updated.nickname } : current);
+ setProfileOverview((current) => current ? {
+ ...current,
+ account: { ...current.account, nickname: updated.nickname, displayName: updated.displayName },
+ } : current);
+ return true;
+ } catch (error) {
+ window.alert(error instanceof Error ? error.message : "用户名修改失败");
+ return false;
+ }
+ };
+ const loadProfileMonth = async (month) => {
+ try {
+ setProfileOverview(await getProfileOverview(month));
+ } catch (error) {
+ window.alert(error instanceof Error ? error.message : "学习日历加载失败");
+ }
+ };
+ const updateAvatar = async (file) => {
+ if (!["image/jpeg", "image/png"].includes(file.type) || file.size > 2 * 1024 * 1024) {
+ window.alert("请选择不超过 2 MiB 的 JPEG 或 PNG 图片");
+ return false;
+ }
+ try {
+ const updated = await uploadProfileAvatar(file);
+ setProfileOverview((current) => current ? {
+ ...current,
+ account: {
+ ...current.account,
+ avatarUrl: updated.avatarUrl,
+ avatarUrlExpiresAt: updated.avatarUrlExpiresAt,
+ },
+ } : current);
+ return true;
+ } catch (error) {
+ window.alert(error instanceof Error ? error.message : "头像上传失败");
+ return false;
+ }
+ };
+ const updatePassword = async (passwords) => {
+ await changePassword(passwords);
+ clearAuthSession();
+ setUser(null);
+ setProfileOverview(null);
+ navigate(paths.auth.login, { authMode: "login" }, true);
+ };
const startTraining = (sceneOrTitle, initialStep = "learn", options = {}) => {
const scene = typeof sceneOrTitle === "object" ? sceneOrTitle : null;
const title = scene?.title || sceneOrTitle;
@@ -2445,6 +2596,9 @@ export function App() {
},
result: { completed, evaluation },
});
+ if (evaluation) {
+ void getProfileOverview().then(setProfileOverview).catch(() => undefined);
+ }
};
const setMainPage = (next) => navigate(hrefForPage(next), { page: next, authMode, training: null, result: null });
const navigateIelts = (path) => navigate(path, { authMode });
@@ -2470,6 +2624,6 @@ export function App() {
else if (page === "ielts-assets") content = setMainPage("assets")} onInterviewAssets={() => setMainPage("interview-assets")} onTraining={() => navigateIelts(paths.ielts.root)} />;
else if (page === "interview") content = setMainPage("scenes")} onAssets={() => navigateInterview(paths.interview.assets.root)} />;
else if (page === "interview-assets") content = setMainPage("assets")} onIeltsAssets={() => setMainPage("ielts-assets")} onTraining={() => navigateInterview(paths.interview.root)} />;
- else content = ;
- return {content}{paywall && setPaywall(null)} onMembership={() => { setPaywall(null); setMainPage("membership"); }} />};
+ else content = setMainPage("assets")} onLogout={logout} />;
+ return {content}{paywall && setPaywall(null)} onMembership={() => { setPaywall(null); setMainPage("membership"); }} />};
}
diff --git a/frontend/Unispeaking_fronted/src/apiClient.js b/frontend/Unispeaking_fronted/src/apiClient.js
index dbbe14f..8c58d11 100644
--- a/frontend/Unispeaking_fronted/src/apiClient.js
+++ b/frontend/Unispeaking_fronted/src/apiClient.js
@@ -66,6 +66,34 @@ export function getCurrentUser() {
return request("/api/auth/me");
}
+export function getProfileOverview(month) {
+ const query = month ? `?month=${encodeURIComponent(month)}` : "";
+ return request(`/api/profile/overview${query}`);
+}
+
+export function updateProfile({ nickname }) {
+ return request("/api/profile", {
+ method: "PATCH",
+ body: JSON.stringify({ nickname }),
+ });
+}
+
+export function uploadProfileAvatar(file) {
+ const formData = new FormData();
+ formData.append("avatar", file);
+ return request("/api/profile/avatar", {
+ method: "POST",
+ body: formData,
+ });
+}
+
+export function changePassword({ currentPassword, newPassword }) {
+ return request("/api/auth/password", {
+ method: "PUT",
+ body: JSON.stringify({ currentPassword, newPassword }),
+ });
+}
+
export function getUserPreference() {
return request("/api/user-preferences");
}
diff --git a/frontend/Unispeaking_fronted/src/styles.css b/frontend/Unispeaking_fronted/src/styles.css
index 74c32bc..4f3db24 100644
--- a/frontend/Unispeaking_fronted/src/styles.css
+++ b/frontend/Unispeaking_fronted/src/styles.css
@@ -810,7 +810,30 @@ svg { display: block; }
.assets-page:not(.asset-conversation-page):not(.asset-module-placeholder) .page-header > div > p:last-child { font-size: 17px; }
}
-.profile-layout { min-height: 100%; display: grid; grid-template-columns: 250px 1fr; }.profile-nav { border-right: 1px solid var(--line); padding: 38px 22px 28px; display: flex; flex-direction: column; }.profile-user { display: flex; align-items: center; gap: 12px; padding: 0 10px 32px; }.profile-user img { width: 48px; height: 48px; border-radius: 14px; object-fit: cover; object-position: top; }.profile-user strong, .profile-user small { display: block; }.profile-user small { color: var(--muted); margin-top: 5px; font-size: 10px; }.profile-nav nav { display: grid; gap: 7px; }.profile-nav nav button, .logout { height: 46px; padding: 0 13px; border: 0; border-radius: 10px; background: #fff; display: flex; align-items: center; gap: 10px; cursor: pointer; }.profile-nav nav button.is-active { background: var(--soft-2); font-weight: 650; }.profile-nav nav svg, .logout svg { width: 19px; height: 19px; }.logout { margin-top: auto; color: #555; }.profile-content { padding: 52px clamp(42px, 5vw, 84px) 60px; overflow: auto; }.stat-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 16px; }.stat-grid article { min-height: 106px; padding: 20px; border: 1px solid var(--line); border-radius: 14px; display: flex; gap: 14px; align-items: center; }.stat-grid article > svg { width: 22px; height: 22px; }.stat-grid small, .stat-grid strong { display: block; }.stat-grid small { color: var(--muted); }.stat-grid strong { font-size: 26px; margin-top: 7px; }.stat-grid em { font-style: normal; font-size: 11px; }
+.app-main:has(.profile-layout) { overflow: hidden; }
+.profile-layout { height: 100%; min-height: 0; display: grid; grid-template-columns: 280px minmax(0, 1fr); }
+.profile-nav { min-height: 0; height: 100%; border-right: 1px solid var(--line); padding: 38px 22px 28px; display: flex; flex-direction: column; background: #fff; }
+.profile-user { min-height: 112px; display: flex; align-items: center; gap: 17px; padding: 0 7px 30px 10px; }
+.profile-user__avatar { position: relative; flex: 0 0 72px; width: 72px; height: 72px; }
+.profile-user__avatar > img { width: 72px; height: 72px; border-radius: 50%; object-fit: cover; object-position: center; background: var(--soft); }
+.profile-user > span { min-width: 0; flex: 1; }
+.profile-user strong, .profile-user small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.profile-user strong { font-size: 18px; line-height: 1.25; }
+.profile-user small { color: var(--muted); margin-top: 7px; font-size: 11px; }
+.profile-user__edit { position: absolute; top: -3px; right: -3px; width: 28px; height: 28px; display: grid; place-items: center; padding: 0; border: 1px solid rgba(255,255,255,.88); border-radius: 50%; background: rgba(255,255,255,.62); color: rgba(37,37,36,.82); box-shadow: 0 4px 12px rgba(20,20,19,.15); -webkit-backdrop-filter: blur(5px); backdrop-filter: blur(5px); cursor: pointer; transition: transform .2s, background .2s, color .2s; }
+.profile-user__edit:hover { background: rgba(255,255,255,.84); color: #252524; transform: scale(1.08); }
+.profile-user__edit:focus-visible { outline: 2px solid #252524; outline-offset: 2px; }
+.profile-user__edit svg { width: 13px; height: 13px; }
+.profile-nav nav { display: grid; gap: 7px; }
+.profile-nav nav button, .logout { height: 46px; padding: 0 13px; border: 0; border-radius: 10px; background: #fff; display: flex; align-items: center; gap: 10px; cursor: pointer; }
+.profile-nav nav button.is-active { background: var(--soft-2); font-weight: 650; }
+.profile-nav nav svg, .logout svg { width: 19px; height: 19px; }
+.logout { flex: 0 0 46px; margin-top: auto; color: #555; }
+.profile-content { min-width: 0; min-height: 0; padding: 52px clamp(42px, 5vw, 84px) 60px; overflow: auto; }
+.stat-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 16px; }.stat-grid article, .stat-grid > button { min-height: 106px; padding: 20px; border: 1px solid var(--line); border-radius: 14px; display: flex; gap: 14px; align-items: center; }.stat-grid > button { width: 100%; background: #fff; color: var(--ink); font: inherit; text-align: left; cursor: pointer; transition: border-color .2s, box-shadow .2s, transform .2s; }.stat-grid > button:hover { border-color: #b9b9b3; box-shadow: 0 9px 24px rgba(20,20,19,.08); transform: translateY(-2px); }.stat-grid > button:focus-visible { outline: 2px solid #252524; outline-offset: 3px; }.stat-grid article > svg, .stat-grid > button > svg { width: 22px; height: 22px; }.stat-grid small, .stat-grid strong { display: block; }.stat-grid small { color: var(--muted); }.stat-grid strong { font-size: 26px; margin-top: 7px; }.stat-grid em { font-style: normal; font-size: 11px; }
+.today-checkin, .result-checkin { display: inline-flex; align-items: center; gap: 7px; padding: 8px 12px; border-radius: 999px; background: #20201f; color: #fff; font-size: 12px; font-weight: 700; }
+.today-checkin svg, .result-checkin svg { width: 16px; height: 16px; }
+.result-checkin { margin-top: 13px; }
.overview-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(330px, .75fr); gap: 18px; margin-top: 18px; }
.overview-grid > article { min-height: 440px; border: 1px solid var(--line); border-radius: 18px; padding: 26px; }
.overview-grid h2 { margin: 7px 0 0; font-size: 24px; letter-spacing: -.03em; }
@@ -847,7 +870,11 @@ svg { display: block; }
.bars { flex: 1; min-height: 270px; display: flex; align-items: end; justify-content: space-around; gap: 12px; padding: 48px 4px 34px; }
.bars > span { width: 24px; min-height: 12px; background: linear-gradient(180deg, #686863 0%, #d6d6d0 100%); border-radius: 7px 7px 2px 2px; position: relative; transition: filter .2s, transform .2s; }
.bars > span:hover { filter: brightness(.88) contrast(1.04); transform: scaleX(1.08); }
+.bars > span.is-zero { background: #e5e5e0; }
.bars small { position: absolute; bottom: -25px; left: 50%; transform: translateX(-50%); color: #666662; font-size: 9px; }
+.bars em { position: absolute; bottom: -42px; left: 50%; transform: translateX(-50%); color: #aaa9a5; font-size: 8px; font-style: normal; white-space: nowrap; }
+.bars.is-empty { align-items: center; justify-content: center; }
+.bars.is-empty > p { max-width: 210px; margin: 0; color: var(--muted); font-size: 11px; line-height: 1.7; text-align: center; }
.achievement-system { margin-top: 18px; padding: 28px; border: 1px solid var(--line); border-radius: 18px; }
.achievement-system__header { display: flex; justify-content: space-between; align-items: flex-end; gap: 28px; }
.achievement-system__header .eyebrow { margin: 0 0 7px; }
@@ -915,6 +942,24 @@ svg { display: block; }
.account-actions button.danger { color: var(--danger); }
.account-actions svg { width: 19px; height: 19px; }
.settings-list > article:last-child { padding-block: 24px; }
+.password-form { display: grid; gap: 15px; margin-top: 24px; }
+.password-form label { display: grid; gap: 7px; color: #555551; font-size: 13px; font-weight: 650; }
+.password-form input { height: 46px; padding: 0 13px; border: 1px solid var(--line); border-radius: 10px; font: inherit; }
+.password-form input:focus { outline: 2px solid #242423; outline-offset: 1px; }
+.password-form .form-error { margin: 0; color: var(--danger); font-size: 12px; }
+.profile-edit-form { display: grid; gap: 22px; margin-top: 24px; }
+.profile-edit-avatar { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 17px; align-items: center; padding: 17px; border: 1px solid var(--line); border-radius: 14px; background: #fafaf8; }
+.profile-edit-avatar > img { width: 78px; height: 78px; border-radius: 18px; object-fit: cover; object-position: center; background: var(--soft); }
+.profile-edit-avatar strong, .profile-edit-avatar small { display: block; }
+.profile-edit-avatar strong { font-size: 15px; }
+.profile-edit-avatar small { margin-top: 6px; color: var(--muted); font-size: 11px; }
+.profile-avatar-picker { width: fit-content; display: inline-flex; align-items: center; min-height: 34px; margin-top: 12px; padding: 0 13px; border: 1px solid #d3d3ce; border-radius: 999px; background: #fff; color: #444440; font-size: 11px; font-weight: 700; cursor: pointer; }
+.profile-avatar-picker:hover { border-color: #979792; color: var(--ink); }
+.profile-avatar-picker input { display: none; }
+.profile-edit-name { display: grid; gap: 8px; color: #555551; font-size: 13px; font-weight: 650; }
+.profile-edit-name input { height: 46px; padding: 0 13px; border: 1px solid var(--line); border-radius: 10px; font: inherit; }
+.profile-edit-name input:focus { outline: 2px solid #242423; outline-offset: 1px; }
+.profile-edit-form .form-error { margin: 0; color: var(--danger); font-size: 12px; }
@media (max-width: 1250px) {
.page { padding-inline: 48px; }