feat(zen-box): 新增云端配置同步功能,完善应用构建与配置
本次提交实现了观空禅音应用的核心云端同步功能,包括: 1. 添加用户登录验证接口与本地存储管理 2. 新增云端配置存储与拉取接口,支持跨设备同步歌单、播放进度等设置 3. 新增登录页面与设置页账号管理模块 4. 更新依赖包版本与安卓、iOS打包配置 5. 优化缓存下载逻辑,修复OSS链接编码问题 6. 新增离线模式支持,自动过滤无缓存歌单 7. 新增歌曲排序偏好本地与云端同步功能
This commit is contained in:
+160
-1
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Track } from './types';
|
||||
import { Track, UserConfig, SortMode } from './types';
|
||||
|
||||
const API_BASE_URL = 'https://traesite.umersoft.com:8975';
|
||||
const PLAYLIST_LIST_PATH = '/api/zen_box/playlists';
|
||||
@@ -9,6 +10,9 @@ const PLAYLIST_SNAPSHOT_KEY_PREFIX = '@zen_box_playlist_snapshot:';
|
||||
const SELECTED_PLAYLIST_KEY = '@zen_box_selected_playlist';
|
||||
const RESUME_ENABLED_KEY = '@zen_box_resume_enabled';
|
||||
const PLAYBACK_POSITION_KEY_PREFIX = '@zen_box_playback_position:';
|
||||
const LOGGED_IN_USER_KEY = '@zen_box_logged_in_user';
|
||||
const CLOUD_CONFIG_KEY = '@zen_box_cloud_config';
|
||||
const SORT_PREF_KEY = '@zen_box_sort_pref';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -18,6 +22,16 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// 网络状态检测
|
||||
export const isNetworkConnected = async (): Promise<boolean> => {
|
||||
try {
|
||||
const state = await NetInfo.fetch();
|
||||
return state.isConnected ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatError = (error: any): string => {
|
||||
if (error.response) {
|
||||
return `服务器响应 ${error.response.status}: ${error.response.statusText}`;
|
||||
@@ -46,8 +60,86 @@ const getPlaybackPositionKey = (playlistFile?: string, trackSrc?: string): strin
|
||||
return `${PLAYBACK_POSITION_KEY_PREFIX}${normalizePlaylistFile(playlistFile)}::${String(trackSrc || '').trim()}`;
|
||||
};
|
||||
|
||||
// ========== 登录相关 ==========
|
||||
|
||||
export const login = async (username: string, password: string): Promise<{ ok: boolean; username?: string; error?: string }> => {
|
||||
try {
|
||||
const response = await api.post('/api/zen_box/auth/login', { username, password });
|
||||
if (response.data?.ok) {
|
||||
await AsyncStorage.setItem(LOGGED_IN_USER_KEY, String(response.data.username || username));
|
||||
return { ok: true, username: response.data.username || username };
|
||||
}
|
||||
return { ok: false, error: '登录失败' };
|
||||
} catch (error: any) {
|
||||
const msg = error.response?.data?.error || formatError(error);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(LOGGED_IN_USER_KEY);
|
||||
await AsyncStorage.removeItem(CLOUD_CONFIG_KEY);
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getLoggedInUser = async (): Promise<string | null> => {
|
||||
try {
|
||||
return await AsyncStorage.getItem(LOGGED_IN_USER_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 云端配置同步 ==========
|
||||
|
||||
export const pushCloudConfig = async (config: UserConfig): Promise<void> => {
|
||||
try {
|
||||
const user = await getLoggedInUser();
|
||||
if (!user) return;
|
||||
const connected = await isNetworkConnected();
|
||||
if (!connected) {
|
||||
await AsyncStorage.setItem(CLOUD_CONFIG_KEY, JSON.stringify(config));
|
||||
return;
|
||||
}
|
||||
await api.post('/api/zen_box/config', { username: user, config });
|
||||
} catch (error) {
|
||||
console.error('上传云端配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const pullCloudConfig = async (): Promise<UserConfig | null> => {
|
||||
try {
|
||||
const user = await getLoggedInUser();
|
||||
if (!user) return null;
|
||||
const connected = await isNetworkConnected();
|
||||
if (!connected) {
|
||||
const cached = await AsyncStorage.getItem(CLOUD_CONFIG_KEY);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
}
|
||||
const response = await api.get('/api/zen_box/config', { params: { username: user } });
|
||||
if (response.data?.ok && response.data?.config) {
|
||||
await AsyncStorage.setItem(CLOUD_CONFIG_KEY, JSON.stringify(response.data.config));
|
||||
return response.data.config as UserConfig;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('拉取云端配置失败:', error);
|
||||
const cached = await AsyncStorage.getItem(CLOUD_CONFIG_KEY);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 歌单相关 ==========
|
||||
|
||||
export const getPlaylistFiles = async (): Promise<string[]> => {
|
||||
try {
|
||||
const connected = await isNetworkConnected();
|
||||
if (!connected) {
|
||||
return await getLocalPlaylistFiles();
|
||||
}
|
||||
const response = await api.get<{ ok?: boolean; playlists?: string[] }>(PLAYLIST_LIST_PATH);
|
||||
const raw = Array.isArray(response.data?.playlists) ? response.data.playlists : [];
|
||||
const files = raw.map(item => String(item || '')).filter(Boolean);
|
||||
@@ -57,6 +149,20 @@ export const getPlaylistFiles = async (): Promise<string[]> => {
|
||||
return files;
|
||||
} catch (error) {
|
||||
console.error('读取歌单列表失败:', error);
|
||||
return await getLocalPlaylistFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const getLocalPlaylistFiles = async (): Promise<string[]> => {
|
||||
try {
|
||||
const allKeys = await AsyncStorage.getAllKeys();
|
||||
const snapshotKeys = allKeys.filter(k => k.startsWith(PLAYLIST_SNAPSHOT_KEY_PREFIX));
|
||||
const files = snapshotKeys.map(k => k.replace(PLAYLIST_SNAPSHOT_KEY_PREFIX, ''));
|
||||
if (!files.includes(DEFAULT_PLAYLIST_FILE)) {
|
||||
files.unshift(DEFAULT_PLAYLIST_FILE);
|
||||
}
|
||||
return files;
|
||||
} catch {
|
||||
return [DEFAULT_PLAYLIST_FILE];
|
||||
}
|
||||
};
|
||||
@@ -137,6 +243,10 @@ export const clearPlaybackPosition = async (playlistFile: string, trackSrc: stri
|
||||
|
||||
export const getPlaylist = async (fileName?: string): Promise<Track[]> => {
|
||||
try {
|
||||
const connected = await isNetworkConnected();
|
||||
if (!connected) {
|
||||
throw new Error('网络不可用');
|
||||
}
|
||||
const response = await api.get<Track[]>(buildPlaylistPath(fileName));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -203,3 +313,52 @@ export const getPlaylistWithFallback = async (fileName?: string): Promise<{
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 离线歌单反推 ==========
|
||||
|
||||
export const getOfflineAvailablePlaylists = async (
|
||||
isCached: (src: string) => boolean
|
||||
): Promise<string[]> => {
|
||||
const allKeys = await AsyncStorage.getAllKeys();
|
||||
const snapshotKeys = allKeys.filter(k => k.startsWith(PLAYLIST_SNAPSHOT_KEY_PREFIX));
|
||||
const available: string[] = [];
|
||||
|
||||
for (const key of snapshotKeys) {
|
||||
const fileName = key.replace(PLAYLIST_SNAPSHOT_KEY_PREFIX, '');
|
||||
const snapshot = await getPlaylistSnapshot(fileName);
|
||||
if (!snapshot.length) continue;
|
||||
|
||||
const cachedCount = snapshot.filter(track => isCached(track.src)).length;
|
||||
if (cachedCount > 0) {
|
||||
available.push(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!available.includes(DEFAULT_PLAYLIST_FILE)) {
|
||||
available.unshift(DEFAULT_PLAYLIST_FILE);
|
||||
}
|
||||
|
||||
return available;
|
||||
};
|
||||
|
||||
// ========== 排序偏好 ==========
|
||||
|
||||
export const getSortPreference = async (): Promise<SortMode> => {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(SORT_PREF_KEY);
|
||||
if (saved && ['original', 'asc', 'desc'].includes(saved)) {
|
||||
return saved as SortMode;
|
||||
}
|
||||
return 'original';
|
||||
} catch {
|
||||
return 'original';
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSortPreference = async (mode: SortMode): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(SORT_PREF_KEY, mode);
|
||||
} catch (error) {
|
||||
console.error('保存排序偏好失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user