- Add PUT /api/me/password endpoint in backend - Add changePassword API function in frontend - Add change password form UI in SettingsPanel - Add i18n translations for change password (zh/en) - Fix TypeScript type error in i18n context Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
297 lines
9.9 KiB
TypeScript
297 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useRef } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import Avatar from "@/components/ui/avatar";
|
|
import { api } from "@/lib/api";
|
|
import { useTranslation } from "@/lib/i18n";
|
|
import { useUser } from "@/lib/user-context";
|
|
|
|
type Me = {
|
|
username?: string;
|
|
avatar?: string | null;
|
|
timezone: string;
|
|
barkUrl?: string | null;
|
|
inappEnabled: boolean;
|
|
barkEnabled: boolean;
|
|
};
|
|
|
|
const SettingsPanel = () => {
|
|
const t = useTranslation();
|
|
const { refreshUser } = useUser();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [form, setForm] = useState<Me>({
|
|
timezone: "Asia/Shanghai",
|
|
barkUrl: "",
|
|
avatar: null,
|
|
inappEnabled: true,
|
|
barkEnabled: false,
|
|
});
|
|
const [saved, setSaved] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [passwordForm, setPasswordForm] = useState({
|
|
currentPassword: "",
|
|
newPassword: "",
|
|
confirmPassword: "",
|
|
});
|
|
const [passwordError, setPasswordError] = useState("");
|
|
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
|
const [changingPassword, setChangingPassword] = useState(false);
|
|
|
|
useEffect(() => {
|
|
api
|
|
.getMe()
|
|
.then((data) => setForm(data as Me))
|
|
.catch(() => null);
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
await api.updateSettings({
|
|
avatar: form.avatar || null,
|
|
timezone: form.timezone,
|
|
barkUrl: form.barkUrl || null,
|
|
inappEnabled: form.inappEnabled,
|
|
barkEnabled: form.barkEnabled,
|
|
});
|
|
await refreshUser();
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 1500);
|
|
};
|
|
|
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// 验证文件类型
|
|
const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
if (!allowedTypes.includes(file.type)) {
|
|
alert("不支持的文件格式,请上传 jpg、png、gif 或 webp 格式的图片");
|
|
return;
|
|
}
|
|
|
|
// 验证文件大小 (5MB)
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
alert("文件大小不能超过 5MB");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setUploading(true);
|
|
const result = await api.uploadAvatar(file);
|
|
setForm({ ...form, avatar: result.avatarUrl });
|
|
await refreshUser();
|
|
} catch (error) {
|
|
console.error("上传头像失败:", error);
|
|
alert("上传头像失败,请重试");
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveAvatar = () => {
|
|
setForm({ ...form, avatar: null });
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
const handleChangePassword = async () => {
|
|
setPasswordError("");
|
|
setPasswordSuccess(false);
|
|
|
|
if (!passwordForm.currentPassword) {
|
|
setPasswordError(t("currentPasswordRequired"));
|
|
return;
|
|
}
|
|
if (!passwordForm.newPassword) {
|
|
setPasswordError(t("newPasswordRequired"));
|
|
return;
|
|
}
|
|
if (passwordForm.newPassword.length < 6) {
|
|
setPasswordError(t("passwordTooShort"));
|
|
return;
|
|
}
|
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
|
setPasswordError(t("passwordMismatch"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setChangingPassword(true);
|
|
await api.changePassword({
|
|
currentPassword: passwordForm.currentPassword,
|
|
newPassword: passwordForm.newPassword,
|
|
});
|
|
setPasswordSuccess(true);
|
|
setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
|
setTimeout(() => setPasswordSuccess(false), 3000);
|
|
} catch (error) {
|
|
setPasswordError(error instanceof Error ? error.message : "Failed to change password");
|
|
} finally {
|
|
setChangingPassword(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className="bg-white">
|
|
<CardHeader>
|
|
<CardTitle>{t("settings")}</CardTitle>
|
|
<CardDescription>{t("settingsDesc")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{/* Avatar Section */}
|
|
<div className="space-y-2">
|
|
<Label>{t("avatar")}</Label>
|
|
<div className="flex items-center gap-4">
|
|
<Avatar username={form.username} src={form.avatar} size="lg" />
|
|
<div className="flex flex-col gap-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
id="avatar-upload"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
{uploading ? t("uploading") : t("uploadAvatar")}
|
|
</Button>
|
|
{form.avatar && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleRemoveAvatar}
|
|
>
|
|
{t("removeAvatar")}
|
|
</Button>
|
|
)}
|
|
<p className="text-xs text-slate-500">{t("avatarHint")}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="barkUrl">{t("barkPushUrl")}</Label>
|
|
<Input
|
|
id="barkUrl"
|
|
value={form.barkUrl ?? ""}
|
|
onChange={(event) => setForm({ ...form, barkUrl: event.target.value })}
|
|
placeholder="https://your.bark.server"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="timezone">{t("timezone")}</Label>
|
|
<Input
|
|
id="timezone"
|
|
value={form.timezone}
|
|
onChange={(event) => setForm({ ...form, timezone: event.target.value })}
|
|
placeholder="Asia/Shanghai"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{t("notificationChannels")}</Label>
|
|
<div className="rounded-lg bg-slate-50/80">
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<div>
|
|
<div className="text-sm font-medium text-slate-800">{t("webNotifications")}</div>
|
|
<div className="text-xs text-slate-500">{t("webNotificationsDesc")}</div>
|
|
</div>
|
|
<Checkbox
|
|
id="inapp"
|
|
checked={form.inappEnabled}
|
|
onCheckedChange={(checked) =>
|
|
setForm({ ...form, inappEnabled: checked === true })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="mx-4 h-px bg-slate-200/50" />
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<div>
|
|
<div className="text-sm font-medium text-slate-800">{t("barkAlerts")}</div>
|
|
<div className="text-xs text-slate-500">{t("barkAlertsDesc")}</div>
|
|
</div>
|
|
<Checkbox
|
|
id="bark"
|
|
checked={form.barkEnabled}
|
|
onCheckedChange={(checked) =>
|
|
setForm({ ...form, barkEnabled: checked === true })
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="outline" onClick={save}>
|
|
{t("save")}
|
|
</Button>
|
|
<Button variant="outline" type="button">
|
|
{t("cancel")}
|
|
</Button>
|
|
{saved && <div className="text-sm text-primary">{t("saved")}</div>}
|
|
</div>
|
|
|
|
{/* Change Password Section */}
|
|
<div className="mt-8 pt-6 border-t border-slate-200">
|
|
<h3 className="text-lg font-medium mb-4">{t("changePassword")}</h3>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="currentPassword">{t("currentPassword")}</Label>
|
|
<Input
|
|
id="currentPassword"
|
|
type="password"
|
|
value={passwordForm.currentPassword}
|
|
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="newPassword">{t("newPassword")}</Label>
|
|
<Input
|
|
id="newPassword"
|
|
type="password"
|
|
value={passwordForm.newPassword}
|
|
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirmPassword">{t("confirmNewPassword")}</Label>
|
|
<Input
|
|
id="confirmPassword"
|
|
type="password"
|
|
value={passwordForm.confirmPassword}
|
|
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
|
/>
|
|
</div>
|
|
{passwordError && (
|
|
<div className="text-sm text-red-500">{passwordError}</div>
|
|
)}
|
|
{passwordSuccess && (
|
|
<div className="text-sm text-green-600">{t("passwordChanged")}</div>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleChangePassword}
|
|
disabled={changingPassword}
|
|
>
|
|
{changingPassword ? t("loading") : t("changePassword")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default SettingsPanel;
|