feat(web): add settings sub-pages and connect to backend APIs

- Add GeneralSettingsPage for language, privacy, and notification settings
- Add FeedbackPage for user feedback submission with image upload
- Connect settings to backend PATCH /users/me/settings API
- Implement language preference sync between frontend and backend
- Update login flow to pass language preference and redirect based on user settings
- Add Astro entry pages for /settings/general and /settings/feedback routes
- Update sidebar navigation: language button links to general settings
- Fix account data card to link to profile page
- Remove "deletion" text from account data description
This commit is contained in:
zl-q
2026-05-09 18:23:21 +08:00
parent 5aa46d3311
commit 1fbb07f692
21 changed files with 1621 additions and 118 deletions
+11
View File
@@ -7,14 +7,25 @@ export const API_ROUTES = {
},
users: {
profile: '/api/v1/users/me/profile',
updateProfile: '/api/v1/users/me/profile',
updateSettings: '/api/v1/users/me/settings',
avatarUploadUrl: '/api/v1/users/me/avatar/upload-url',
uploadAvatar: '/api/v1/users/me/avatar',
},
points: {
balance: '/api/v1/points/balance',
packages: '/api/v1/points/packages',
},
notifications: {
list: '/api/v1/notifications',
unreadCount: '/api/v1/notifications/unread-count',
markRead: (id: string) => `/api/v1/notifications/${id}/read`,
markAllRead: '/api/v1/notifications/mark-all-read',
},
agent: {
history: '/api/v1/agent/history',
},
feedback: {
submit: '/api/v1/feedback',
},
} as const;
+182 -3
View File
@@ -11,22 +11,110 @@ import { API_ROUTES } from './api-routes';
export interface UserProfile {
user_id: string;
display_name: string;
bio: string;
bio: string | null;
avatar_path: string | null;
avatar_url: string | null;
settings: {
version: number;
preferences: {
language: string;
timezone: string;
};
privacy: {
can_sell: boolean;
profile_visibility: string;
};
notification: {
allow_notifications: boolean;
allow_vibration: boolean;
};
divination_tutorial: {
divination_entry_shown: boolean;
auto_divination_shown: boolean;
manual_divination_shown: boolean;
};
};
updated_at: string;
}
export interface UpdateProfileRequest {
display_name?: string;
bio?: string;
avatar_path?: string;
}
export interface ProfileSettings {
version: number;
preferences: {
language: string;
timezone: string;
};
privacy: {
can_sell: boolean;
profile_visibility: string;
};
notification: {
allow_notifications: boolean;
allow_vibration: boolean;
};
divination_tutorial: {
divination_entry_shown: boolean;
auto_divination_shown: boolean;
manual_divination_shown: boolean;
};
}
export interface UpdateSettingsRequest {
settings: ProfileSettings;
}
export interface AvatarUploadUrlRequest {
mime_type: string;
file_size: number;
ext: string;
}
export interface AvatarUploadUrlResponse {
bucket: string;
path: string;
upload_url: string;
expires_in: number;
}
export function getUserProfile(): Promise<UserProfile> {
return authFetch<UserProfile>(API_ROUTES.users.profile);
}
export function updateUserProfile(data: UpdateProfileRequest): Promise<UserProfile> {
return authFetch<UserProfile>(API_ROUTES.users.updateProfile, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
export function getAvatarUploadUrl(data: AvatarUploadUrlRequest): Promise<AvatarUploadUrlResponse> {
return authFetch<AvatarUploadUrlResponse>(API_ROUTES.users.avatarUploadUrl, {
method: 'POST',
body: JSON.stringify(data),
});
}
export function uploadAvatar(file: File): Promise<UserProfile> {
const formData = new FormData();
formData.append('file', file);
return authFetch<UserProfile>(API_ROUTES.users.uploadAvatar, {
method: 'POST',
body: formData,
});
}
export function updateUserSettings(data: UpdateSettingsRequest): Promise<UserProfile> {
return authFetch<UserProfile>(API_ROUTES.users.updateSettings, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
// --- Points ---
export interface PointsBalance {
@@ -37,20 +125,111 @@ export interface PointsBalance {
canRun: boolean;
}
export function getPointsBalance(): Promise<PointsBalance> {
return authFetch<PointsBalance>(API_ROUTES.points.balance);
export interface PackageInfo {
productCode: string;
appStoreProductId: string;
type: 'starter' | 'regular';
credits: number;
isStarter: boolean;
starterEligible: boolean;
sortOrder: number;
}
export interface PackagesResponse {
packages: PackageInfo[];
}
// Points cache with TTL
let pointsCache: { data: PointsBalance; expiry: number } | null = null;
const POINTS_CACHE_TTL = 60 * 1000; // 1 minute
export function getPointsBalance(useCache = true): Promise<PointsBalance> {
const now = Date.now();
if (useCache && pointsCache && pointsCache.expiry > now) {
return Promise.resolve(pointsCache.data);
}
return authFetch<PointsBalance>(API_ROUTES.points.balance).then((data) => {
pointsCache = { data, expiry: now + POINTS_CACHE_TTL };
return data;
});
}
export function invalidatePointsCache(): void {
pointsCache = null;
}
export function getPackages(): Promise<PackagesResponse> {
return authFetch<PackagesResponse>(API_ROUTES.points.packages);
}
// --- Notifications ---
export interface NotificationPayloadNone {
action: 'none';
}
export interface NotificationPayloadRoute {
action: 'open_route';
route: string;
entity_id?: string | null;
tab?: string | null;
}
export interface NotificationPayloadUrl {
action: 'open_url';
url: string;
}
export type NotificationPayload = NotificationPayloadNone | NotificationPayloadRoute | NotificationPayloadUrl;
export interface NotificationItem {
id: string;
notificationId: string;
type: string;
title: string;
body: string;
payload: NotificationPayload;
isRead: boolean;
readAt: string | null;
createdAt: string;
}
export interface NotificationListResponse {
items: NotificationItem[];
nextCursor: string | null;
hasMore: boolean;
}
export interface UnreadCount {
count: number;
}
export function getNotifications(locale?: string, limit = 20, cursor?: string): Promise<NotificationListResponse> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (locale) params.set('locale', locale);
if (cursor) params.set('cursor', cursor);
const query = params.toString();
return authFetch<NotificationListResponse>(`${API_ROUTES.notifications.list}?${query}`);
}
export function getUnreadNotificationCount(): Promise<UnreadCount> {
return authFetch<UnreadCount>(API_ROUTES.notifications.unreadCount);
}
export function markNotificationRead(id: string, locale?: string): Promise<NotificationItem> {
const params = locale ? `?locale=${locale}` : '';
return authFetch<NotificationItem>(API_ROUTES.notifications.markRead(id) + params, {
method: 'PATCH',
});
}
export function markAllNotificationsRead(): Promise<{ updatedCount: number }> {
return authFetch<{ updatedCount: number }>(API_ROUTES.notifications.markAllRead, {
method: 'PATCH',
});
}
// --- Agent History ---
export interface HistoryAgentOutput {
+28
View File
@@ -30,6 +30,34 @@ interface SessionResponse {
user: { id: string; email: string };
}
// --- Language mapping ---
/**
* Map frontend locale to backend BCP-47 language tag
*/
export function localeToBackendLanguage(locale: string): string {
const mapping: Record<string, string> = {
'zh': 'zh-CN',
'zh_Hant': 'zh-TW',
'en': 'en-US',
};
return mapping[locale] || 'zh-CN';
}
/**
* Map backend BCP-47 language tag to frontend locale
*/
export function backendLanguageToLocale(lang: string): string {
const mapping: Record<string, string> = {
'zh-CN': 'zh',
'zh-TW': 'zh_Hant',
'zh-Hant': 'zh_Hant',
'en-US': 'en',
'en': 'en',
};
return mapping[lang] || 'zh';
}
// --- Storage ---
export function getAuth(): AuthData | null {